Redis从了解到掌握(三):Redis单机数据库、持久化机制与事件

384 阅读35分钟

数据库

服务器中的数据库

Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,db数组的每一个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库。在初始化服务器时,程序会根据服务器状态的dbnum属性来决定应该创建多少个数据库,默认情况下为16。

服务器数据库示例

切换数据库

每个Redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者读命令时,目标数据库就会成为这些命令操作的对象。默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库,如下图所示。

切换数据库

在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisDb结构的指针。

客户端指向服务端的数据库

数据库键空间

Redis是一个键值对数据库服务器,服务器中的每个数据库都由一个redis.h/redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间。

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP) */
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;
    long long avg_ttl;          /* Average TTL, just for stats */
} redisDb;

键空间和用户所见的数据库是直接对应的:

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象。
  • 键空间的值也就是数据库的值,每个值可以使字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种Redis对象。

键空间图

对键值对进行增删改查询操作,就是在键空间字典里面,通过key来进行操作,找到key指向的value,进行对应的增删改查操作。

除了键的增删改查,还有其他针对数据库本身的命令也是通过对键空间进行处理来完成的,如清空整个数据库的FLUSHDB命令,就是通过删除键空间的所有键值对来实现的,还有其他的RANDOMKEY、DESIZE、EXISTS、RENAME、KEYS等操作都是通过键空间进行操作来实现的。

当使用Redis命令对数据库进行读写时,服务器不仅会对键空间执行指定的读写操作,还会执行一些额外的维护操作,包括:

  • 读取一个键(读写操作都需要对键进行读取)后,服务器会根据键是否存在来更新服务器的键空间命中次数(hit)或者键空间不命中(miss)次数。
  • 在读取一个键后,服务器会更细键的LRU时间,这个值可以计算键的闲置时间。
  • 如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作。
  • 如果有客户端使用WATCH命令监视某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为脏(dirty),从而让事务程序注意到这个程序已经被修改。
  • 服务器每次修改一个键之后,都会对脏键计数器的值增1,这个计数器会触发服务器的持久化以及复制操作。
  • 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知。

设置键的生存时间或过期时间

通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间(TTL, Time To Live),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键。

EXPIRE key seconds #设置key的过期时间,超过时间后,将会自动删除该key
EXPIREAT key timestamp #和EXPIRE类似,都用于为key设置生存时间。不同在于EXPIREAT命令接受的时间参数是UNIX时间戳Unix-timestamp
PERSIST key #移除给定key的生存时间,将这个key从带生存时间key转换成不带生存时间、永不过期的key
PEXPIRE key milliseconds #和EXPIRE命令的作用类似,但是它以毫秒为单位设置key的生存时间
PEXPIREAT key milliseconds-timestamp #和EXPIREAT命令类似,但它以毫秒为单位设置key的过期unix时间戳
PTTL key #类似于TTL命令,但它以毫秒为单位返回key的剩余生存时间
TTL key #返回key剩余的过期时间

虽然有多重不同单位和不同形式的设置过期时间命令,但最终都是用过PEXPIREAT命令实现的。

redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典。

过期键删除策略

如果一个键过期了,它的删除策略有如下三种:

  • 定时删除:主动删除,在设置键的过期时间的同时,创建一个定时器,让定时器在键过期的时间来临时,立即执行对键的删除操作。优劣:对内存友好,可以保证过期键被尽可能快的删除释放过期键占用的内存空间;对CPU不友好,若有大量请求在等待服务器处理,CPU时间用在删除和当前任务无关的过期键上,影响服务器的响应时间和吞吐量。
  • 惰性删除:被动删除,放任键过期不管,但每次从键空间获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没过期,就返回该键。优劣:对CPU友好,取键时才过期检查;对内存不友好库中可能存在大量没被访问到的过期键,它们可能永远不会被删除。
  • 定期删除:主动删除,每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则有算法决定。是前两种删除策略的折中,难点:删除太频繁执行时长过长,定期删除就会退化成定时删除策略;删除执行过少或执行时长过短,定期删除又会和惰性删除一样。

Redis的过期键删除策略:Redis服务器实际使用的是惰性删除和定期删除两种策略。

惰性删除策略的实现在db.c/expireIfNeeded中,定期删除策略的实现在redis.c/activeExpireCycle中。

惰性删除策略

  • 如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除。
  • 如果输入键未过期,那么expireIfNeeded函数不做动作。

定期删除策略

  • 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键。
  • 全局遍历current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理。
  • 随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作。

内存淘汰策略

有了以上过期策略的说明后,就很容易理解为什么需要淘汰策略了,因为不管是定期采样删除还是惰性删除都不是一种完全精准的删除,就还是会存在key没有被删除掉的场景,所以就需要内存淘汰策略进行补充。当Redis的内存使用达到设置的内存上限,触发内存淘汰机制,根据淘汰规则释放内存:

  1. volatile-lru:使用lru算法(Least Recently Used,最近最久未使用),从已设置过期时间的数据集中挑选最近最少使用的淘汰;
  2. volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰;
  3. volatile-random:从已设置过期时间的数据集中任意选择数据淘汰;
  4. allkeys-lru:使用lru算法,从数据集中选择最近最少使用的淘汰;
  5. allkeys-random:从数据集中选择任意数据淘汰;
  6. noenviction(驱逐):禁止淘汰数据;当内存不足以写入新数据时,直接报异常,Redis只响应读操作。

默认使用noenviction内存淘汰策略。

AOF、RDB和复制功能对过期键的处理

生成RDB文件:在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。因此,数据库中包含过期键不会对生成新的RDB文件造成影响。

载入RDB文件:在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:

  • 如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库,而过期键则会被忽略,所以过期键对载入RDB文件的主服务器不会造成影响。
  • 如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响。

AOF文件写入:当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定时删除之后,程序会向AOF文件追加一条DEL命令,来显式地记录该键已被删除。

AOF重写:和生成RDB文件类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,已经过期的键不会被保存到重写后的AOF文件中。因此,数据库中包含过期键不会对AOF重写造成影响。

主从复制:当服务器运行在主从复制模式下时,从服务器的过期键删除动作由主服务器控制:

  • 主服务器在删除一个过期键时,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键。
  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键。
  • 从服务器只有在接到主服务器发送过来的DEL命令之后,才会删除过期键。

通知由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,也正是因为这个原因,当一个过期键仍然存在于主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在。

数据库通知

数据库通知是Redis2.8版本新增加的功能,这个功能可以让客户端通过定于给定的频道或者模式,来获知数据库中键的变化,以及数据库中的命令执行情况。

键通知由两种:分别是键空间通知,即某个键执行了什么命令;和键事件通知,即某个命令被什么键执行了。

这部分功能在后续的发布订阅功能中会细讲,这里简略提及。

发送通知的功能是由notify.c/notifyKeyspaceEvent实现的,步骤如下:

  1. server.notify_keyspace_events属性就是服务器配置notify_keyspace_events选项所设置的值,如果给定的通知类型type不是服务器允许发送的通知类型,那么函数会直接返回,不做任何操作。
  2. 如果给定的通知是服务器允许发送的通知,那么下一步函数会检测服务器是否允许发送键空间通知,如果允许的话,程序就会构建并发送事件通知。
  3. 最后,函数检测服务器是否允许发送键事件通知,如果允许的话,程序就会构建并发送事件通知。

RDB持久化

因为Redis是内存数据库,它将自己的数据库状态存储在内存里面,所以如果不想办法将存储在内存的数据库状态保存到磁盘里面,那么一旦服务器进程退出,服务器中的状态也会消失不见。

为了解决这个问题,Redis提供了RDB持久化功能,这个功能可以将Redis在内存的数据库状态保存到磁盘里面,避免数据意外丢失。

将数据库状态保存为RDB文件:

将数据库状态保存为RDB文件

用RDB文件来还原数据库状态:

用RDB文件来还原数据库状态

RDB文件的创建与载入

可以通过SAVE命令或者BGSAVE命令来生成RDB文件。

SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求;而BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程继续处理命令请求。

RDB文件的载入工作是在服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时自动检测到RDB文件存在,它就会自动载入RDB文件。另外,因为AOF文件的更新频率通常比RDB文件更新频率高,所以如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。

SAVE命令执行过程中,Redis服务器会被阻塞,客户端发送的所有命令请求都会被拒绝。

BGSAVE命令执行过程中,Redis服务器仍然可以继续处理客户端的命令请求,而SAVE、BGSAVE、BGREWRITEAOF命令都会被拒绝。

服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止。

自动间隔性保存

Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令,用户可以通过save选项设置多个保存条件,但只要其中任意一个条件被满足,服务器就会执行BGSAVE操作。

save time times,即在多少秒内,对数据库进行了至少多少次操作,就会触发BGSAVE命令执行操作。

save 900 1
save 300 10
save 60 10000

这里的实现是通过redisServer结构的saveparams属性来实现的:

struct saveparam {
    time_t seconds;//秒数
    int changes;//修改数
};

持久化部分保存参数条件

除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性:

  • dirty计数器记录距离上一次执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态进行了多少次修改(写入、删除、更新等)操作。
  • lastsave属性是一个unix时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间。

dirty+lastsave参数

在有了saveparams数组和dirty+lastsave属性组合的前提下,Redis的服务器周期性操作函数serverCron默认每隔100毫米就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否满足,如果满足的话,就会执行BGSAVE命令。

RDB文件结构

RDB文件图

RDB文件的最开头是REDIS部分,这个部分长度为5字节,保存着"REDIS"五个字符,类似于java文件的CAFEBABE魔数。通过这五个字符,程序可以在载入文件时,快速检查所载入的文件是否是RDB文件。

db_version长度为4个字节,它的值是一个字符串表示的整数,这个整数记录了RDB文件的版本号。这篇文章值介绍第六版RDB文件的结构。

databases部分包含着零个到任意多个数据库,以及各个数据库中的键值对数据。

EOF常量的长度为1字节,这个常量标志着RDB文件正文内容的结束,当读入成功遇到这个值得时候,它就知道数据库的所有键值对都已经载入完毕了。

check_sum是一个8字节长的无符号整数,保存着一个校验和,这个校验和是通过对前面四部分内容进行计算得出的。服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比,一次来检查RDB文件是否有出错或者损坏的情况出现。

databases部分

一个RDB文件的databases部分可以保存任意多个非空数据库,每个非空数据库在RDB文件中都可以保存为SELECTDBdb_numberkey_value_pairs三个部分。

databases图

SELECTDB:长度为1字节,当读入程序遇到这个值时,它知道接下来要读入的将是一个数据库号码。

db_number:保存着一个数据库号码,根据号码的大小不同,这个部分的长度可以使1字节、2字节或者5字节。

key_value_pairs:保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。

RDB文件中的数据库结构示例:

RDB文件中的数据库结构示例

key_value_pairs部分

RDB文件中的每个key_value_pairs部分都保存了一个或以上数量的键值对,如果键值对带有过期时间的话,那么键值对的过期时间也会被保存在内。

不带过期时间的键值对在RDB文件中由TYPEkeyvalue三部分组成。

RDB键值对图-不带过期时间

TYPE记录了value的类型,长度为1字节,值可以是以下常量的其中一个:

  • REDIS_RDB_TYPE_STRING
  • REDIS_RDB_TYPE_LIST
  • REDIS_RDB_TYPE_SET
  • REDIS_RDB_TYPE_ZSET
  • REDIS_RDB_TYPE_HASH
  • REDIS_RDB_TYPE_LIST_ZIPLIST
  • REDIS_RDB_TYPE_SET_INTSET
  • REDIS_RDB_TYPE_ZSET_ZIPLIST
  • REDIS_RDB_TYPE_HASH_ZIPLIST

以上列出的每个TYPE常量都代表了一种对象类型或者底层编码,这部分已经在上一篇文章内详细学习过了。

key和value分别保存了键值对的键对象和值对象。其中key总是一个字符串对象,而value对象会有不同的结构保存方式。

带有过期时间的键值对在RDB文件中的结构如下图:

带有过期时间的键值对在RDB文件中的结构

  • EXPIRETIME_AT常量的长度为1自己诶,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间。
  • ms是一个8字节长的带符号整数,记录着一个以毫秒为单位的UNIX时间戳,这个时间戳就是键值对的过期时间。

后面value部分就不详细记录了,具体内容可以参考书上内容,只需要记字符串大于20字节会被压缩后再保存,其他的对象的存储结构都类似于压缩列表的存储方式,紧密挨在一起。

AOF持久化

除了RDB持久化功能外,Redis还提供了AOF(Append Only File)持久化功能,与RDB持久化通过保存数据库中的键值对状态来记录数据库状态不同,AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态的。

AOF流程图

AOF文件实图

AOF持久化的实现

AOF持久化功能的实现可以分为命令追加、文件写入和文件同步三个步骤。

命令追加

当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾。

举个例子,客户端向服务器发送redis> set key value这个命令,然后打开aof文件:

aof文件,追加命令

可以看到,命令被追加到aof文件中去了。

AOF文件的写入和同步

前面说过,客户端发出的写命令是被写入到缓冲区,那是如何落盘到磁盘里的?这就要提AOF文件的写入和同步功能了。

Redis的服务器进程就是一个时间循环,这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责像serverCron函数这样需要定时运行的函数。

因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到aof_buf缓冲区里面,所以服务器每次结束一个事件循环之前,它都会调用flushAppendOnlyFile函数,考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF文件里面。

flushAppendOnlyFile函数的行为由服务器配置的appendfsync选项的值来决定,各个不同值产生的行为如下:

选项函数行为
always将缓冲区所有内容写入并同步到AOF文件
everysec每隔一秒,对缓冲区文件进行写入并同步,由一个线程专门负责执行
no将缓冲区所有内容写入到AOF文件,但不对AOF文件进行同步,何时同步由操作系统决定

AOF文件的载入与数据还原

因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。

Redis读取AOF文件并还原数据库状态的详细步骤如下:

  1. 创建一个不带网络连接的伪客户端,因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。
  2. 从AOF文件中分析并读出一条写命令。
  3. 使用伪客户端执行被读出的写命令。
  4. 一直执行步骤2和步骤3,知道AOF文件中的所有写命令都被处理完毕为止。

完整过程如下图:

AOF文件载入流程图

AOF重写

因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的AOF文件很可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间越多。

为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写功能。通过该功能,Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个AOF文件保存的数据库状态相同,但新的AOF文件不会包含任何浪费空间的冗余命令,所以新的AOF文件的体积通常会比旧AOF文件的体积要小得多。

AOF文件重写的实现

虽然Redis将生成新AOF文件替换旧AOF文件的功能命名为“AOF文件重写”,但实际上,AOF文件重写并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。即新生成的AOF文件不需要考虑旧AOF文件的数据,直接通过读取现有的Redis服务器数据库状态,来写入命令,从而实现去除浪费空间的冗余命令。

AOF后台重写

上面介绍的AOF重写程序aof_rewrite函数可以很好地完成创建一个新AOF文件的任务,但是,因为这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,因为Redis服务器使用单个县城来处理命令请求,所以如果由服务器直接调用aof_rewrite函数的话,那么在重写AOF文件期间,服务器将无法处理客户端发来的命令请求。

很明显,作为一个辅佐性的维护手段,Redis不希望AOF重写造成服务器无法处理请求,所以Redis决定将AOF重写程序放到子进程里执行,这样做可以同时达到两个目的:

  • 子进程进行AOF重写期间,服务器进程可以继续处理命令请求。
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以避免使用锁的情况,保证数据的安全性。

不过,使用子进程也有一个问题需要解决,因为子进程在进行AOF重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的AOF文件所保存的数据库状态不一致。

为了解决这种数据不一致问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程后开始使用,当Redis服务器执行完一个写命令之后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区。

这样一来,可以保证:

  • AOF缓冲区的内容会被定时写入和同步到AOF文件,对现有AOF文件的处理工作会如常进行。
  • 从创建子进程开始,服务器执行的偶有写命令都会被记录到AOF重写缓冲区里面。

当子进程完成AOF文件重写工作之后,它会向父进程发出一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:

  1. 将AOF重写缓冲区的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致。
  2. 对新的AOF文件进行改名,原子地覆盖现有的AOF文件,完成新旧两个AOF文件的替换。

这个信号处理函数执行完毕后,父进程就可以继续像往常一样接收命令请求了。

在整个AOF后台重写过程中,只有信号处理函数执行时会对服务器进程造成阻塞,在其他时候,AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到了最低。

以下举个例子

时间父进程子进程
T1执行命令 SET k1 v1
T2执行命令 SET k2 v2
T3执行命令 SET k3 v3
T4创建子进程,执行AOF文件重写开始AOF文件重写
T5执行命令 SET k2 10086执行重写操作
T6执行命令 SET k3 12345执行重写操作
T7执行命令 SET k4 22222完成AOF文件重写,向父进程发送信号
T8接收到子进程发来的信号,将 T5、T6、T7执行的
写命令追加到新AOF文件的末尾
T9用新AOF文件覆盖旧AOF文件

以上就是AOF后台重写,即BGREWRITEAOF命令的实现原理。

事件

Redis服务器是一个事件驱动程序,服务器需要处理以下两类事件:

文件事件:Redis服务器通过socket与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对socket操作的抽象。服务器与客户端的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。

时间事件:Redis服务器的一些操作(比如serverCron函数)需要在给定的时间点进行,而时间事件就是服务器对这类定时操作的抽象。

文件事件

Redis基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器:

  • 文件事件处理器使用IO多路复用程序来同时监听多个socket,并根据socket目前执行的任务来为socket关联不同的事件处理器。
  • 当被监听的socket准备好执行连接应答、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用socket之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过IO多路复用程序来监听多个socket,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与Redis服务器中其他同样以单线程方式运行的模块进行对接,这保持了Redis内部单线程设计的简单性。

文件事件处理器的构成

文件事件处理器的四个组成部分

文件事件处理器的四个组成部分,分别是socket、IO多路复用程序、文件事件分派器和事件处理器。

文件事件是对socket操作的抽象,每当一个socket准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个socket,所以多个文件事件可能会并发地出现。

IO多路复用程序负责监听多个socket,并向文件事件分派器传送那些产生了事件的socket。

尽管多个文件事件可能会并发地出现,但IO多路复用程序总是会将所有产生事件的socket都放在一个队列里面,然后通过这个队列,以有序同步每次一个socket的方式向文件事件分派器传送socket。当一个socket产生的事件被处理完毕之后,IO多路复用程序才会继续向文件事件分派器传送下一个socket。

IO多路复用程序通过队列想文件事件分派器传送socket

文件事件分派器接受IO多路复用程序传来的socket,并根据socket产生的事件的类型,调用相应的事件处理器。

服务器会执行不同人物的socket关联不同的事件处理器,这些处理器是一个个函数,它们定义了某个事件发生时,服务器应该执行的操作。

IO多路复用程序的实现

Redis的IO多路复用程序的所有功能都是通过包装常见的selectepollevportkqueue这些IO多路复用函数库来实现的。因为Redis为每个IO多路复用函数库都实现了相同的API,所以IO多路复用程序的底层实现是可以互换的。

底层实现多个库可选

事件的类型

IO多路复用程序下可以监听多个socket的ae.h/AR_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和socket操作之间的对应关系如下:

  • 当socket变得可读时(客户端对socket执行write操作,或者执行close操作),或者有新的可应答(acceptable)socket出现时(客户端对服务器的监听socket执行connect操作),socket产生AE_READABLE事件。
  • 当socket变得可写时(客户端对socket执行read操作),socket产生AE_WRITABLE事件。

IO多路复用程序允许服务器同时监听socket的ae.h/AR_READABLE事件和ae.h/AE_WRITABLE事件,如果一个socket同时产生了这两个事件,那么文件事件分派器会优先处理ae.h/AR_READABLE事件,等到ae.h/AR_READABLE事件处理完之后,才处理ae.h/AE_WRITABLE事件。

这也就是说,如果一个socket可读又可写时的话,那么服务器将先读后写。

文件事件的处理器

Redis为文件事件编写了多个处理器,这些处理器分别用于实现不同的网络通信需求,在这些事件处理器里面,服务器最常用的要数与客户端进行通信的连接应答处理器、命令请求处理器和命令回复处理器。

  1. 连接应答处理器:在networking.c/acceptTcpHandler函数中实现,这个处理器用于对接服务器监听socket的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听socket的AE_READABLE事件关联起来,当有客户端用sys/socket.h/connect函数链接服务器监听socket时,socket就会产生AE_READABLE事件,引发连接应答处理器执行,并执行相应的socket应答操作。
  2. 命令请求处理器:在networking.c/readQueryFromClient函数中实现,是Redis的命令请求处理器,这个处理器负责从scoket中读入客户端发送的命令请求内容,具体实现为unistd.h/read函数的包装。当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端的socket的AE_READABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,socket就会产生AE_READABLE事件,引发命令请求处理器执行,并执行相应的socket读入操作。在客户端连接服务器的整个过程中,服务器都会一直未客户端socket的AE_READABLE事件关联命令请求处理器。
  3. 命令回复处理器:在networking.c/sendReplyToClient函数中实现,是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过socket返回给客户的,具体实现为unistd.h/write函数的包装。当服务器有命令回复需要传送给客户端时,服务器会将客户端socket的AE_WRITABLE事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的socket写入操作,当命令回复发送完毕之后,服务器就会解除命令回复处理器与客户端socket的AE_WRITABLE事件之间的关联。

3种文件事件处理器

时间事件

Redis的时间事件分为以下两类:

  • 定时事件:让一段程序在指定的时间之后执行一次。
  • 周期性事件:让一段程序每隔指定时间就执行一次。

一个时间事件主要由以下三个属性组成:

  • id:服务器为时间事件创建的全局唯一ID,ID号按从小到大的顺序递增,新事件的ID号比旧事件的ID号要大。
  • when:毫秒精度的UNIX时间戳,记录了时间事件的到达时间。
  • timeProc:时间事件处理器,一个函数,当时间事件到达时,服务器就会调用相应的处理器来处理事件。

一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值:

  • 如果事件处理器返回ae.h/AE_NOMORE,那么这个事件定义为定时事件:该事件在达到一次之后就会被删除,之后不再道道。
  • 如果事件处理器返回一个非AE_NOMORE的整数值,那么这个事件为周期性事件:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。

目前版本的Redis只是用周期性事件,而没有使用定时事件。

实现

服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。

如图所示:

链表-时间事件

无序链表指的是不按照时间事件的到达时间when进行排序。

时间事件应用实例:serverCron函数

持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:

  • 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
  • 清理数据库中的过期键值对。
  • 关闭和清理连接失效的客户端。
  • 尝试进行AOF或RDB持久化操作。
  • 如果服务器是主服务器,那么对从服务器进行定期同步。
  • 如果处于集群模式,对集群进行定期同步和连接测试。

Redis服务器以周期性事件的方式来运行serverCron函数,在服务器运行期间,每隔一段时间,serverCron就会执行一次,直到服务器关闭为止。

事件的调度与执行

因为服务器中同时存在文件事件和时间事件两种事件类型,所以服务器必须对这两种事件进行调度,决定何时应该处理文件事件,何时2又该处理时间事件,以及花多少时间来处理它们等等。

服务器事件处理流程

事件的调度和执行规则

  1. aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这个方法既可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间。
  2. 因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐渐向时间事件所设置的到达时间逼近,并最终来到到达时间,这时服务器就可以开始处理到达的时间事件了。
  3. 对文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可能减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低造成事件饥饿的可能性。
  4. 因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的的到达时间稍晚一些。

总结

在这篇文章中,学习了单机数据库的原理,包括键空间、过期键的实现、过期键删除策略、最大内存淘汰策略,然后是持久化,持久化分为RDB和AOF,AOF持久化还有AOF重写,然后是事件,事件分为文件事件和时间事件,文件事件实际上是客户端和服务器之间通过socket连接,服务器应答、处理、回复客户端的一系列事件,而时间事件是定时的或者周期性的到达某个时间点Redis需要进行的事件。

但是单机数据库面临着四个无法解决的问题:读压力、写压力、数据备份、故障自愈,下一篇文章将学习多机数据库及其原理,来看看不同的玩法是通过何种方式来如何解决单机Redis无法解决的问题。