《Redis设计与实现》读书笔记 第二部分

第二部分主要解决下面的问题:

  • 数据库在服务器中是如何保存的
  • 过期键是如何保存的以及如何被清理的
  • RDBAOF的原理,以及AOF的重写
  • redis是如何实现单线程下的事件驱动的
  • redis客户端是如何保存的,有哪些属性,在什么情况下会被关闭
  • 当接收到一个请求时,redis服务器是如何处理的
  • serverCron是如何管理服务器资源的
  • redis服务器是如何启动及初始化的

服务器和客户端的数据结构

服务器:

struct redisServer {
  // 第9章,保存服务器中所有数据库的数组,服务器数据库默认数量(16个)
  redisDb * db;
  int dbnum;
  // 第10章,记录save选项的数组
  struct saveparam * saveparam;
  // 距离上一次成功执行save或者bgsave,服务器经过的修改次数
  long long dirty;
  // 上一次成功执行save或者bgsave的时间
  time_t lastsave;
  // 第11章,AOF缓冲区
  sds aof_buf;
  // 第13章,所有客户端状态/LUA脚本伪客户端
  list * clients;
  redisClient * lua_client;

  // 第14章,服务器当前时间戳/10s更新一次的时钟缓存 
  time_t unixtime; 
  long long mstime; 
  unsigned lruclock:22; 
  // 上一次抽样时间/已执行命令数量/抽样结果/环形数组索引值 
  long long ops_sec_last_sample_time; 
  long long ops_sec_lst_sample_ops; 
  long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES]; 
  int ops_sec_idx; 
  // 已使用内存峰值/关闭服务器标识/BGREWRITEAOF延迟 
  size_t stat_peak_memory; 
  int shutdown_asap; 
  int aof_rewrite_scheduled; 
  // 记录执行BGSAVE/BGREWRITEAOF的子进程ID/serverCron计数器 
  pid_t rdb_child_pid; 
  pid_t aof_cild_pid; 
  int cronloops; 
  ...
} 

客户端:

struct redisClient{ 
  // 第9章,记录客户端当前使用的数据库
  redisDb *db;

  // 第13章,客户端socket描述符/名字/role/输入缓冲区/命令与命令参数/命令实现函数指针
  int fd;
  robj * name;
  int flags;
  sds querybuf;
  robj ** argv; 
  int argc;
  struct redisCommand * cmd;
  // 输出缓冲区/当前已使用字节数量
  char buf[REDIS_REPLY_CHUNK_BYTES];
  int bufpos;
  list * reply;
  // 身份验证,通过验证为1
  int authenticated;
  // 客户端创建时间/最后一次互动时间/缓冲区到达软性限制时间
  time_t ctime;
  time_t lastinteraction;
  time_t obuf_soft_limit_reached_time;
  ...
} 

Redis对象:


struct redisObject{ 
  // 第14章,对象最后一次被命令访问的时间 
  unsigned lru:22; 
  ...
} 

第九章 数据库

数据库键空间

redisClient中的db指针指向redisServerdbselect命令可以修改该指针,从而指向服务器中的不同数据库。

每一个数据库都由redisDb对象表示:

struct redisDb{
  dict * dict;
  dict * expires;
  ...
}

dict字典保存了数据库中的所有键值对(键空间)。每个键都是一个字符串对象,值可以是五种对象中的任意一种。flushdb就是通过删除所有键值对实现的。

键的过期设置原理

设置键的过期时间可以通过expirepexpire,expireat三个命令,但最终都是通过pexpireat实现的。

expires字典保存了数据库的所有键的过期时间,其中键指向指向键空间的某个对象 ,值是一个long long类型的整数,保存了毫秒精度的unix时间戳。使用persist可以从expires字典中解除键和值的关联。

过期键的删除策略

过期键的删除策略有三种:

  • 定时删除:为每一个过期键创建一个定时器,在过期时间来临时,执行对键的删除。
  • 惰性删除:每次从键空间中获取键时,检查是否过期,过期了进行删除,否则返回该键。
  • 定期删除:每隔一段时间,对数据库进行检查,删除过期键。

其中定时删除和定期删除是主动删除,惰性删除是被动删除。每种方式都有优缺点:

  • 定时删除可以尽快删除过期键,释放内存。但是会占用大量CPU,对服务器响应时间和吞吐量造成影响。同时,创建定时器需要使用时间事件,它的实现方式是无序链表,查找复杂度为o(n)
  • 惰性删除对CPU是最后好的,但是对内存是最不友好的。
  • 定期删除是以上两种的折中,但是难以确定操作的时长和频率。否则容易退化成上面两种。

综上

redis服务器实际采用的是惰性删除和定期删除。定期删除由serverCron执行调用activeExpireCycle。它在每次执行中,都从一定量的数据库中随机抽取键进行检查,并对过期键进行删除,在下一次调用时接着上一次的进度进行处理。

持久化和复制对过期键的处理

RDB:

  • 使用save或者bgsave创建新的RDB时,过期键并不会保存到新的RDB文件中。

  • 当以主服务器模式载入RDB时,会忽略过期键。而从服务器载入RDB时,并不会对过期键进行检查。

AOF:

  • 写入AOF时,如果键已经过期,但还未被删除,AOF并不会产生任何影响。因为当该键被删除后,会向AOF追加一条del命令。
  • 重写AOF时,并不会写入过期键。

复制:

  • 主服务器删除过期键会通知从服务器删除。
  • 从服务器收到客户端的读命令时,即使键已经过期,也不会删除;它只在从服务器发送的del命令后才会删除过期键。

数据库通知

2.8之后,客户端可以通过订阅给定的频道或者模式,获知数据库中键的变化。每当redis命令需要发送数据库通知时,该命令中包含的实现函数会调用notify-KeyspaceEvent,根据type值判断通知是否是服务器配置中notify-keyspace-events的类型,从而决定是否要发送通知。

第十章 RDB持久化

RDB可以将redis在内存中的数据库状态保存到磁盘中,生成的RDB文件是一个经过压缩的二进制文件。

通过savebgsave可以生成RDB文件,不同的是,前者将会阻塞服务器,执行期间,服务器不能处理任何请求;后者会派生出一个新的子进程,由子进程负责创建,父进程继续处理命令请求。

只要启动时检测到有RDB的存在,就会自动载入。服务器在载入时,一直处于阻塞状态。

由于AOF的更新频率更高一些,如果开启了AOF持久化,会优先使用AOF文件还原数据库。

通过配置服务器的save选项,可以让服务器每隔一段时间自动执行bgsave(可以有多个选项,只要满足其中一个便开始执行,根据serverdirtylastsave来判断)。

第十一章 AOF持久化

RDB是通过保存数据库中的键值对来记录数据库的状态,AOF是通过保存服务器执行的写命令来记录的。AOF持久化包括命令追加,文件写入和文件同步。

命令追加是将服务器锁执行的命令追加到serveraof_buf缓冲区。在每一次事件循环中,redis都会考虑是否要(由appendfsync决定)将缓冲区的内容写入(写入到内存缓冲区)和同步到AOF里(写入到磁盘中)。

当系统载入AOF时,会创建一个不带网络连接的伪客户端,来执行所有AOF中的命令,从而还原数据库。

AOF重写

AOF文件过大时,系统采用重写创建一个新的AOF文件来替代原有的文件,不会包含浪费空间的冗余命令。

事实上,重写的过程不需要对现有的AOF文件进行分析,它是通过读取服务器当前数据库状态来实现的:读取数据库中键现在的值,然后使用一条命令代替原先的多条命令(如果元素数量太多,为了避免缓冲区溢出,还是会采取多条记录的~)。

为了避免重写时阻塞服务器,redis采用后台重写的方式,即在子进程中执行重写。但这样会带来的问题是,服务器仍然在接受请求,重写的文件所保存的数据库与当前数据并不一致。为了解决这个问题,redis设置了AOF重写缓冲区,当redis执行完写命令,它会将这条命令同时追加到AOF缓冲区和AOF重写缓冲区中。

从创建子进程开始,服务器的所有写命令都会追加到AOF重写缓冲区中。

当重写完成之后,会将所有AOF重写缓冲区的命令写入到新AOF中,并对新文件进行改名,覆盖原来的AOF文件。

第十二章 事件

redis服务器是一个事件驱动程序,需要处理的有:

  • 文件事件,即socket
  • 时间事件,即定时

文件事件

文件事件处理器由四个部分组成:套接字,I/O多路复用程序,文件事件分派器和事件处理器。

I/O多路复用监听多个套接字,并且将产生事件的套接字放入队列之中,以有序、同步,每次一个的方式向文件事件分派器传送套接字。服务器会为不同任务的套接字关联不同的事件处理器。

只有上一个套接字产生的事件被所关联的事件处理器处理完毕,才会传送下一个套接字。

I/O多路复用的功能通过包装常见的selectepollkqueue来实现,程序会在编译时自动选择性能最高的函数库作为底层实现。它负责监听套接字的AE_READABLE(当客户端对socket进行写操作,或者close操作,或者有新的客户端连接)和AE_WRITABLE(客户端对socket进行读操作)事件。如果同时产生了这两种事件,即socket既可读又可写,将首先处理读事件。

事件处理器是如何工作的:

当一个客户端与服务器进行连接时(connect),监听socket会产生AE_READABLE事件,此时与此socket关联连接应答处理器会进行处理;

接下来,客户端通过该处理器成功连接到服务器之后(accept),服务器会产生客户端socket,并关联到命令请求处理器上。当客户端向服务器发送命令请求的时候,产生AE_READABLE事件引发命令请求处理器执行,处理器读取命令内容,然后交由其他程序执行;

当服务器有命令恢复需要传送给客户端时,会将该socketAE_WRITABLE事件和命令回复器关联,当客户端准备好接收回复时,引发命令回复器执行。当命令回复发送完毕后,移除可写事件与套接字的关联。

时间事件

时间事件包括了定时事件和周期性事件,而目前只采用了后者(正常模式下只使用一个serverCron)。

服务器将所有的时间事件都放在一个无序链表(新来的事件总是插入到表头,但每个节点的when值并不是有序的)中,每当时间事件执行器运行时,就遍历整个列表,查找已到达的时间事件,并调用相应的处理器。

因为在无序链表中查找一个元素的复杂度为o(n),所以对过期键采用定时删除并不合理。

事件调度

事件调度与执行可以表述如下:

def aeProcessEvents():
  ...
  # 略去对阻塞时间的计算与修改
  processFileEvents()
  processTimeEvents()
  
 
def main():
  init_server()
  while server_is_not_shutdown():
    aeProcessEvents()
  clean_server()
  • 服务器的最大阻塞时间由到达时间最接近当前时间的时间事件决定,可以避免频繁轮询,可以保证不会阻塞过长(tornado也有类似的处理);
  • 如果处理完文件事件,仍未有时间事件到达,则会回到继续等待并处理文件事件的状态;
  • 不管文件事件处理或者时间事件的处理,都会尽可能减少对程序的阻塞,降低造成事件饥饿。比如命令回复处理器会将较长的回复截断,将余下数据留到下次继续处理,而时间事件处理器会将耗时的操作放到子线程或者子进程处理;
  • 因为总是先处理文件事件,因此时间事件的实际处理时间可能会比设定时间稍晚一些。

第十三章 客户端

对于每一个与服务器连接的客户端,服务器都建立了相应的redisClient,并使用链表进行保存。每当新来一个客户端,都会保存到链表的尾部。每个客户端数据结构保存了下面一些属性:

fd: 前面提到,当载入AOF时,会生成一个伪客户端,采用fd值为-1来指示(执行LUA脚本时也会生成伪客户端)。

flags:采用诸如REDIS_MASTER/REDIS_SLAVE等记录当前客户端的角色。

querybuf: 缓冲区大小虽然可以动态变化,但是如果超过了1G,会被服务器关闭。

argv/argcargv是一个数组,每一项都是字符串对象,argv[0]是要执行的命令,其余为参数。argc用以记录命令参数的长度。

cmd:根据argv[0]成功在命令表中找到对应的redisCommand结构时,就将cmd指向它。不分大小写。

buf/reply:客户端有两个缓冲区,固定长度缓冲区保存较短回复(默认16K),可变大小缓冲区保存长度较大的回复。

客户端的连接在上一章有介绍,关闭有以下几种情况:

  • 客户端退出或被kill
  • 客户端发送非法命令请求
  • 客户端成为CLIENT KILL目标
  • 如果服务器设置了timeout,将会将空转超时的客户端关闭
  • 客户端发送命令请求超过querybuf限制大小
  • 服务器要发送给客户端的命令回复大小超过输出缓冲区限制大小

虽然输出缓冲区有两种,但是为了避免过多占用服务器资源,仍然具有限制:

  • 硬性限制:一旦超出,客户端会被关闭
  • 软性限制:如果在指定时间内一直超出软性限制,也会关闭客户端

除此之外,服务器初始化时会创建一个Lua脚本的伪客户端,会一直运行到服务器结束;而执行AOF载入的伪客户端,一旦执行完毕就会被关闭。

十四章 服务器

服务器负责处理客户端发来的请求,以及通过周期时间事件serverCron进行资源管理,包括服务器的启动以及初始化。

处理客户端请求

当 接收到客户端发送的命令请求后,客户端与服务器之间的套接字可读时,服务器使用命令请求处理器进行以下操作

  • 读取套接字中协议格式的命令请求,保存到客户端状态的输入缓冲区

  • 分析命令请求,提取命令参数以及参数的个数,分别保存到客户端里的argvargc

  • 调用命令执行器,执行命令

命令执行器的主要工作有:

  • 查找命令实现:命令执行器根据argv[0]参数在命令表中查找指定命令,使得客户端的cmd指针指向对应的redisCommand结构。

  • 检查是否能执行命令:

    • 命令不存在,说明用户命令非法,服务器将向客户端返回一个错误。

    • 参数不一致,服务器将向客户端返回一个错误。

    • 未通过身份验证,服务器将向客户端返回一个错误。

    • 检查服务器的内存占用情况,以及上一次BGSAVE功能是否正常执行/客户端是否处在订阅模式/服务器正在执行数据载入/执行LUA脚本并超时/客户端执行事务/服务器打开了监视器功能等情况的存在。(以上皆为单机模式)

  • 执行命令:根据cmd指针指向的命令函数,将客户端中的argvargc传入进行指定的操作,对应的命令回复会被保存在客户端的输出缓冲区里(bufreply)。

  • 执行后续工作:判断是否要新增慢查询日志/更新redisCommand结构体/判断是否要写入AOF缓冲区/是否要复制给其他从服务器

  • 回复客户端:当客户端与服务器之间套接字可写时,将缓冲区中的回复内容发送给客户端

serverCron函数

serverCron主要负责管理服务器的资源(更新时间,内存等属性,拦截SIGTERM信号,管理客户端,处理持久化),默认每隔100ms执行一次。它包括:

  • 更新服务器的时间缓存(unixtimemstime),因此这两个时间精度并不高,只用在打印日志/更新服务器LRU时钟/是否执行持久化任务/计算服务器上线时间等功能上。

  • 更新LRU时钟,用于减去对象的lru属性记录的时间,得出对象的空转时间,因此得出的空转时间也是模糊的。

  • 更新服务器每秒执行命令次数,通过抽样计算的方式,计算出两次抽样之间服务器能处理多少个命令的估计值,放在环形数组里,最后利用环形数组的值计算一个平均值作为结果。

  • 更新服务器内存峰值记录。

  • 处理SIGTERM信号,服务器会为这个信号关联处理器,打开服务器的shutdown_asap标识,serverCron根据这个标识符决定是否关闭服务器,服务器拦截该信号的原因是要在关闭之前执行RDB持久化操作。

  • 管理客户端资源,关闭超时的客户端/重新给用户分配输入缓冲区/关闭输出缓冲区大小超过限制的客户端。

  • 管理数据库资源,对部分数据库进行检查,删除过期键,对字典进行收缩操作。

  • 执行被延迟的BGREWRITEAOF,当服务器执行BGSAVE命令期间,如果收到BGREWRITEAOF命令,将会延迟到BGSAVE执行完毕后。

  • 检查持久化操作的运行状态,判断是否需要执行持久化操作。

  • AOF缓冲区的内容写入AOF文件。

  • 增加cronloops的值。

启动过程

服务器的启动需要经过一系列的初始化和设置:

  • 初始化过程即创建redisServer的结构体,使用initServerConfig函数初始化一般属性,除了命令表,没有创建服务器的其他数据结构,只进行了默认端口号/默认数据库个数/文件路径等属性的设置。

  • 我们可以通过redis-server --port 10086或者指定conf文件启动redis server,即此时开始载入用户给定的配置参数和配置文件,覆盖系统默认的对应参数。

    • 利用initServerclients链表/db数组/保存订阅信息的字典与链表/LUA环境/慢查询日志数据结构分配内存,设置或者关联初始化值。除了初始化数据结构之外,initServer还有一些操作:
    • 为服务器设置进程信号处理器
    • 创建常用的值作为共享对象,避免反复创建
    • 打开监听窗口,等待接受连接
    • serverCron创建时间事件
    • 如果打开了AOF持久化功能,打开现有的AOF文件,如果不存在则创建
    • 初始化后台I/O操作
  • 完成server的初始化后,利用RDB或者AOF文件还原数据库操作。

  • 启动事件循环。

posted @ 2020-10-25 15:35  yuyinzi  阅读(223)  评论(0)    收藏  举报