redis底层设计(五)——内部运作机制
5.1 数据库
5.1.1 数据库的结构:
Redis 中的每个数据库,都由一个redis.h/redisDb 结构表示:
typedef struct redisDb { // 保存着数据库以整数表示的号码 int id; // 保存着数据库中的所有键值对数据 // 这个属性也被称为键空间(key space) dict *dict; // 保存着键的过期信息 dict *expires; // 实现列表阻塞原语,如BLPOP // 在列表类型一章有详细的讨论 dict *blocking_keys; dict *ready_keys; // 用于实现WATCH 命令 // 在事务章节有详细的讨论 dict *watched_keys; } redisDb;
5.1.2 数据库的切换:
redisDb 结构的id 域保存着数据库的号码。这个号码很容易让人将它和切换数据库的SELECT 命令联系在一起,但是,实际上,id 属性并不是用来实现SELECT 命令,而是给Redis 内部程序使用的。当Redis 服务器初始化时, 它会创建出 redis.h/REDIS_DEFAULT_DBNUM 个数据库, 并将所有数据库保存到redis.h/redisServer.db 数组中, 每个数据库的id 为从0 到REDIS_DEFAULT_DBNUM - 1 的值。当执行SELECT number 命令时,程序直接使用redisServer.db[number] 来切换数据库。但是,一些内部程序,比如AOF 程序、复制程序和RDB 程序,需要知道当前数据库的号码,如果没有id 域的话,程序就只能在当前使用的数据库的指针,和redisServer.db 数组中所有数据库的指针进行对比,以此来弄清楚自己正在使用的是那个数据库。有了id 域的话,程序就可以通过读取id 域来了解自己正在使用的是哪个数据库,这样就不用对比指针那么麻烦了。
5.1.3 数据库键空间:
因为Redis 是一个键值对数据库(key-value pairs database),所以它的数据库本身也是一个字典(俗称key space):
• 字典的键是一个字符串对象。
• 字典的值则可以是包括字符串、列表、哈希表、集合或有序集在内的任意一种Redis 类型对象。
在redisDb 结构的dict 属性中,保存着数据库的所有键值对数据。
下图展示了一个包含number 、book 、message 三个键的数据库——其中number 键是一个列表,列表中包含三个整数值;book 键是一个哈希表,表中包含三个键值对;而message 键则指向另一个字符串:
5.1.4 键空间的操作:
因为数据库本身是一个字典,所以对数据库的操作基本上都是对字典的操作,加上以下一些维护操作:
• 更新键的命中率和不命中率,这个值可以用INFO 命令查看;
• 更新键的LRU 时间,这个值可以用OBJECT 命令来查看;
• 删除过期键(稍后会详细说明);
• 如果键被修改了的话,那么将键设为脏(用于事务监视),并将服务器设为脏(等待RDB保存);
• 将对键的修改发送到AOF 文件和附属节点,保持数据库状态的一致;
比如刚开始的数据库存储结构如下:
那么在客户端执行SET date 2018-12-7 的命令之后,数据库更新为下图状态:
删除和修改都差不多,这里就不一一展示了。
当执行查询操作时实际上就是在字典空间中取值,再加上一些额外的类型检查:
• 键不存在,返回空回复;
• 键存在,且类型正确,按照通讯协议返回值对象;
• 键存在,但类型不正确,返回类型错误。
举个例子,当前redis数据存储结构如下:
* 当客户端执行GET message 时,服务器返回"hello moto" 。
* 当客户端执行GET not-exists-key 时,服务器返回空回复。
* 当服务器执行GET book 时,服务器返回类型错误。
除了上面基本的对数据信息的增删改查之外,还有很多对数据库本身的命令,也是通过对键空间进行处理来完成的:
• FLUSHDB 命令:删除键空间中的所有键值对。
• RANDOMKEY 命令:从键空间中随机返回一个键。
• DBSIZE 命令:返回键空间中键值对的数量。
• EXISTS 命令:检查给定键是否存在于键空间中。
• RENAME 命令:在键空间中,对给定键进行改名。
5.1.5 键的过期时间
通过EXPIRE 、PEXPIRE 、EXPIREAT 和PEXPIREAT 四个命令,客户端可以给某个存在的键设置过期时间,当键的过期时间到达时,键就不再可用:
redis> SETEX key 5 value OK redis> GET key "value" redis> GET key // 5 秒过后 (nil) //命令TTL 和PTTL 则用于返回给定键距离过期还有多长时间: redis> SETEX key 10086 value OK redis> TTL key (integer) 10082 redis> PTTL key (integer) 10068998
5.1.6 过期时间的保存;
在数据库中,所有键的过期时间都被保存在redisDb 结构的expires 字典里:
typedef struct redisDb { // ... dict *expires; // ... } redisDb;
expires 字典的键是一个指向dict 字典(键空间)里某个键的指针,而字典的值则是键所指向的数据库键的到期时间,这个值以long long 类型表示。下图展示了一个含有三个键的数据库,其中number 和book 两个键带有过期时间:
5.1.7 设置生存时间:
Redis 有四个命令可以设置键的生存时间(可以存活多久)和过期时间(什么时候到期):
• EXPIRE 以秒为单位设置键的生存时间;
• PEXPIRE 以毫秒为单位设置键的生存时间;
• EXPIREAT 以秒为单位,设置键的过期UNIX 时间戳;
• PEXPIREAT 以毫秒为单位,设置键的过期UNIX 时间戳。
虽然有那么多种不同单位和不同形式的设置方式,但是expires 字典的值只保存“以毫秒为单位的过期UNIX 时间戳” ,这就是说,通过进行转换,所有命令的效果最后都和PEXPIREAT命令的效果一样。
5.1.8 过期键的判断
通过expire字典,可以用以下步骤检查某个键是否过期:
1.检查键是否存在于expires字典中:如果存在,那么取出键的过期时间;
2.检查当前Unix时间戳是否大于键的过期时间,如果是的话,那么键已经过期;否则键未过期。
5.1.9 过期键的清除
一个键已经过期,删除的机制是什么呢:
1.定时删除:在设置键的过期时间时,创建一个定时事件,当过期时间到达时,由事件处理器来自动执行键的删除操作;
2.惰性删除:放任键过期不管,但是在每次 从dict字典中取出键值时,要检查是否过期,如果过期的话,删除它,并返回空;如果没过期,就返回键值;
3.定期删除:每隔一段时间,对expires字典进行检查,删除里面的过期键;
定时删除:
定时删除策略对内存是最友好的:因为它保证过期键会在第一时间被删除,过期键所消耗的内存会立即被释放。这种策略的缺点是,它对CPU时间是最不友好的:因为删除操作可能会占用大量的CPU时间——在内存不紧张但CPU时间非常紧张的时候(比如说进行交集计算和排序的时候),将CPU时间花在删除那些和当前任务无关的过期键上,这种做法毫无疑问会是低效的。除此之外,目前redis事件处理器对时间事件的实现方式——无序链表,查找一个时间复杂度为O(N)——并不适合用来处理大量时间事件。
惰性删除:
惰性删除对CPU时间来说是最有好的:它只会在取出键时进行检查,这可以保证删除操作只会在非做不可的情况下进行——并且删除的目标仅限于当前处理的键,这个策略不会再删除其他无关的过期键上花费任何CPU时间。它的缺点是对内存最不友好:如果一个键已经过期,而这个键又任然保留在数据库中,那么dict字典和expires字典都需要继续保存这个件的信息,只要这个过期键不被删除,它占用的内存就不会被释放。
定期删除:
通过上面对定时删除和惰性删除的介绍,我们可以知道这两种方式都存在明显的缺陷:定时删除占用太多CPU时间,惰性删除浪费太多内存。而定期删除是这两种策略的折中:
1.它每隔一段时间执行一次删除操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
2.通过定期删除过期键,有效的减少了因惰性删除而带来的内存浪费。
以上是介绍的三种删除策略,而redis对于过期键的删除策略是惰性删除加上定期删除,这两个策略相互配合,可以很好地在合理利用CPU时间和节约内存空间之间取得平衡。
5.1.10 过期键的惰性删除策略
实现过期键惰性删除策略的核心是:expireIfNeeded函数——所有命令在读取(get、lrange、smembers)和写入(set、lpush、sadd)数据库之前,程序都会调用expireIfNeeded对输入的键进行检查,并将过期键删除:
expireIfNeeded 的作用是:如果输入键已经过期,那么将键、值和保存在expires字典中的过期时间都删除掉。
5.1.11 过期键的定期删除策略
对过期键的定期删除由activeExpireCycle函数执行:每当redis的例行处理程序serverCron执行时activeExpireCycle 都会被调用——这个函数在规定的时间限制内,尽可能地遍历各个数据库的expires字典,随机地检查一部分键的过期时间,并删除其中的过期键。
5.1.12 过期键对AOF、RDB和辅助的影响
更新后的RDB文件
在创建新的RDB文件时,程序会对键进行检查,过期的键不会被写到更新后的RDB文件中,所以过期键对更新后的RDB文件没有任何影响。
更新后的AOF文件
在键已经过期,但是还没有被惰性删除或者定期删除之前,这个键不会产生任何影响,AOF文件也不会因为这个键而被修改;当过期键被惰性删除或者定期删除之后,程序会向AOF文件发送一条DEL命令,来显式地记录该键已被删除。
比如客户端使用GET message 命令时,message已经过期,那么服务器将会执行以下3个动作:
1)从数据库删除message;
2)追加一条DEL message 命令到AOF文件;
3)向客户端返回NIL。
AOF重写
和RDB文件类似,进行AOF 文件重写时,程序会对键进行检查,过期的键不会被保存到重写后的AOF文件中。所以过期键对重写后的AOF文件没有影响。
复制
当服务器带有附属节点时,过期键的删除由主节点统一控制:
* 如果服务器是主节点,那么它在删除一个过期键之后,会显式地向附属节点发送一个DEL命令;
* 如果服务器是附属节点,那么当它在删除一个过期键之后,他会想程序返回键已过期的回复,但并不真正的删除过期键。因为程序只根据键是否过期而不是键是否已经被删除来决定执行流程,所以这种处理并不影响程序的正确执行结果。当接到从主节点传来的DEL命令之后附属节点才会真正的将过期键删除。 附属节点不自主对键进行删除是为了和主节点的数据保持绝对一致,因为这个原因,当一个过期键还存在于主节点时,这个键在所有附属节点的副本也不会被删除。这种处理机制对那些使用大量附属节点,并且带有大量过期键的应用来说,可能会造成一部分内存不能立即被释放,但是,因为过期键通常很快会被主节点发现并删除,所以这实际上也算不上什么大问题。
5.1.13 小结
* 数据库主要是由dict和expires两个字典构成,起重工dict保存键值对,而expires则保存键的过期时间;
* 数据库的键总是一个字符串对象,而值可以是任意一种redis数据类型,包括字符串、哈希、集合、列表和有序集;
* expires的某个键和dict的某个键共同指向同一个字符串对象,而expires键的值则是该键以毫秒计算的Unix过期时间戳;
* redis使用惰性删除和定期删除两种策略来删除过期键;
* 更新后的RDB文件和重新后的AOF文件都不会保存已经过期的键;
* 当一个过期键被删除之后,程序会追加一条新的DEL命令到现有AOF文件末尾;
* 当主节点删除一个过期键之后,它会显式地发送一条DEL命令道所有附属节点;
* 附属节点即使发现过期键,也不会自作主张的删除它,而是等待主节点发来DEL命令,这样可以保证主节点和附属节点的数据保持绝对一致;
* 数据库的dict字典和expires字典的扩展策略和普通字典的一样,他们的收缩策略是:当节点的填充百分比不足10%时,将可用节点数量减少至大于等于当前已用节点数量;
5.2 RDB
在运行情况下,redis以数据结构的形式将数据存储在内存中,为了将这些数据在redis重启之后任然可以使用,redis提供了RDB和AOF两种持久化模式。
在redis运行时,RDB程序将当前内存中的数据库快照保存到磁盘文件中,在redis重新启动时,RDB程序可以通过载入RDB文件来还原数据库的状态。
RDB功能最核心的是RDBSave和RDBLoad两个函数,前者用于生成RDB文件到磁盘,而后者则将RDB文件中的数据重新载入到内存中。
5.2.1 保存
RDBSave函数负责将内存中的数据库数据以RDB格式保存到磁盘中,如果RDB文件已经存在,那么新的RDB文件将替换已有的RDB文件。在保存RDB文件期间,主进程会被阻塞,直到保存完为止。
SAVE和BGSAVE两个命令都会调用RDBSave函数,但它们的保存方式不同:
SAVE直接调用RDBSave,阻塞redis主进程,直到保存完成为止。在主进程阻塞期间,服务器不能处理任何客户端的请求;
BGSAVE则fork出一个子进程,子进程负责调用RDBSave,并保存完成后想主进程发送信号,通知保存已完成,因为RDBSave在子进程被调用,所以redis服务器在BGSAVE执行期间任然可以处理客户端的请求。
5.2.2 SAVE、BGSAVE、AOF写入和BGREWRITEAOF
SAVE
前面提到过,当SAVE 执行时,Redis 服务器是阻塞的,所以当SAVE 正在执行时,新的SAVE 、BGSAVE 或BGREWRITEAOF 调用都不会产生任何作用。只有在上一个SAVE 执行完毕、Redis 重新开始接受请求之后,新的SAVE 、BGSAVE 或 BGREWRITEAOF 命令才会被处理。另外,因为AOF 写入由后台线程完成,而BGREWRITEAOF 则由子进程完成,所以在SAVE执行的过程中,AOF 写入和BGREWRITEAOF 可以同时进行。
BGSAVE
在执行SAVE 命令之前,服务器会检查BGSAVE 是否正在执行当中,如果是的话,服务器就不调用rdbSave ,而是向客户端返回一个出错信息,告知在BGSAVE 执行期间,不能执行SAVE 。这样做可以避免SAVE 和BGSAVE 调用的两个rdbSave 交叉执行, 造成竞争条件。另一方面,当BGSAVE 正在执行时,调用新BGSAVE 命令的客户端会收到一个出错信息,告知BGSAVE 已经在执行当中。
BGREWRITEAOF 和BGSAVE 不能同时执行:
• 如果BGSAVE 正在执行,那么BGREWRITEAOF 的重写请求会被延迟到BGSAVE 执行完毕之后进行,执行BGREWRITEAOF 命令的客户端会收到请求被延迟的回复。
• 如果BGREWRITEAOF 正在执行,那么调用BGSAVE 的客户端将收到出错信息,表示这两个命令不能同时执行。
BGREWRITEAOF 和BGSAVE 两个命令在操作方面并没有什么冲突的地方,不能同时执行它们只是一个性能方面的考虑:并发出两个子进程,并且两个子进程都同时进行大量的磁盘写入操作,这怎么想都不会是一个好主意。
5.2.3 载入
当Redis 服务器启动时,rdbLoad 函数就会被执行,它读取RDB 文件,并将文件中的数据库数据载入到内存中。在载入期间,服务器每载入1000 个键就处理一次所有已到达的请求,不过只有PUBLISH 、SUBSCRIBE 、PSUBSCRIBE 、UNSUBSCRIBE 、 PUNSUBSCRIBE 五个命令的请求会被正确地处理,其他命令一律返回错误。等到载入完成之后,服务器才会开始正常处理所有命令。
注意: 发布与订阅功能和其他数据库功能是完全隔离的,前者不写入也不读取数据库,所以在服务器载入期间,订阅与发布功能仍然可以正常使用,而不必担心对载入数据的完整性产生影响。另外,因为AOF 文件的保存频率通常要高于RDB 文件保存的频率,所以 一般来说,AOF 文件中的数据会比RDB 文件中的数据要新。因此,如果服务器在启动时,打开了AOF 功能,那么程序优先使用AOF 文件来还原数据。只有在AOF 功能未打开的情况下,Redis 才会使用RDB 文件来还原数据。
5.2.4 RDB文件结构
一个RDB文件可以分为以下几个部分:
REDIS:文件的最开头保存着REDIS 五个字符,标识着一个RDB 文件的开始。在读入文件的时候,程序可以通过检查一个文件的前五个字节,来快速地判断该文件是否有可能是RDB 文件。
RDB-VERSION:一个四字节长的以字符表示的整数,记录了该文件所使用的RDB 版本号。目前的RDB 文件版本为0006 。因为不同版本的RDB 文件互不兼容,所以在读入程序时,需要根据版本来选择不同的读入方式。
DB-DATA:这个部分在一个RDB 文件中会出现任意多次,每个DB-DATA 部分保存着服务器上一个非空数据库的所有数据。
SELECT-DB:这域保存着跟在后面的键值对所属的数据库号码。在读入RDB 文件时,程序会根据这个域的值来切换数据库,确保数据被还原到正确的数据库上。
KEY-VALUE-PAIRS:因为空的数据库不会被保存到RDB 文件,所以这个部分至少会包含一个键值对的数据。每个键值对的数据使用以下结构来保存:
OPTIONAL-EXPIRE-TIME 域是可选的,如果键没有设置过期时间,那么这个域就不会出现;反之,如果这个域出现的话,那么它记录着键的过期时间,在当前版本的RDB 中,过期时间是一个以毫秒为单位的UNIX 时间戳。KEY 域保存着键,格式和 REDIS_ENCODING_RAW 编码的字符串对象一样。TYPE-OF-VALUE 域记录着VALUE 域的值所使用的编码,根据这个域的指示,程序会使用不同的方式来保存和读取VALUE 的值。
EOF:标志着数据库内容的结尾(不是文件的结尾)。
CHECK-SUM:RDB 文件所有内容的校验和,一个uint_64t 类型值。REDIS 在写入RDB 文件时将校验和保存在RDB 文件的末尾,当读取时,根据它的值对内容进行校验。如果这个域的值为0 ,那么表示Redis 关闭了校验和功能。
5.2.5 小结
• rdbSave 会将数据库数据保存到RDB 文件,并在保存完成之前阻塞调用者。
• SAVE 命令直接调用rdbSave ,阻塞Redis 主进程;BGSAVE 用子进程调用rdbSave ,主进程仍可继续处理命令请求。
• SAVE 执行期间,AOF 写入可以在后台线程进行,BGREWRITEAOF 可以在子进程进行,所以这三种操作可以同时进行。
• 为了避免产生竞争条件,BGSAVE 执行时,SAVE 命令不能执行。
• 为了避免性能问题,BGSAVE 和BGREWRITEAOF 不能同时执行。
• 调用rdbLoad 函数载入RDB 文件时,不能进行任何和数据库相关的操作,不过订阅与发布方面的命令可以正常执行,因为它们和数据库不相关联。
5.3 AOF
redis分别提供了RDB和AOF两种持久化机制:
* RDB将数据库的快照以二进制的方式保存到磁盘中;
* AOF则以协议文本的方式,将所有对数据库进行过写入的命令(及其参数)记录到AOF文件,以此达到记录数据库状态的目的:
5.3.1 AOF命令同步
同步命令到AOF 文件的整个过程可以分为三个阶段:
1. 命令传播:Redis 将执行完的命令、命令的参数、命令的参数个数等信息发送到AOF 程序中。
2. 缓存追加:AOF 程序根据接收到的命令数据,将命令转换为网络通讯协议的格式,然后将协议内容追加到服务器的AOF 缓存中。
3. 文件写入和保存:AOF 缓存中的内容被写入到AOF 文件末尾,如果设定的AOF 保存条件被满足的话,fsync 函数或者fdatasync 函数会被调用,将写入的内容真正地保存到磁盘中。
5.3.2 命令传播
当一个Redis 客户端需要执行命令时,它通过网络连接,将协议文本发送给Redis 服务器。比如说, 要执行命令SET KEY VALUE , 客户端将向服务器发送文本"*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n" 。服务器在接到客户端的请求之后,它会根据协议 文本的内容,选择适当的命令函数,并将各个参数从字符串文本转换为Redis 字符串对象(StringObject)。比如说,针对上面的SET 命令例子,Redis 将客户端的命令指针指向实现SET 命令的setCommand 函数,并创建三个Redis 字符串对象,分别保存SET 、KEY 和VALUE 三个参数(命令也算作参数)。每当命令函数成功执行之后,命令参数都会被传播到AOF 程序,以及REPLICATION 程序。
5.3.3 缓存追加
当命令被传播到AOF 程序之后,程序会根据命令以及命令的参数,将命令从字符串对象转换回原来的协议文本。比如说,如果AOF 程序接受到的三个参数分别保存着SET 、KEY 和VALUE 三个字符串,那么它将生成协议文 本"*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n" 。协议文本生成之后,它会被追加到redis.h/redisServer 结构的aof_buf 末尾。redisServer 结构维持着Redis 服务器的状态,aof_buf 域则保存着所有等待写入到AOF 文件的协议文本。
综合起来,整个缓存追加过程可以分为以下三步:
1. 接受命令、命令的参数、以及参数的个数、所使用的数据库等信息。
2. 将命令还原成Redis 网络通讯协议。
3. 将协议文本追加到aof_buf 末尾。
5.3.4 文件写入和保存
每当服务器常规任务函数被执行、或者事件处理器被执行时,aof.c/flushAppendOnlyFile 函数都会被调用,这个函数执行以下两个工作:
WRITE:根据条件,将aof_buf 中的缓存写入到AOF 文件。
SAVE:根据条件,调用fsync 或fdatasync 函数,将AOF 文件保存到磁盘中。
两个步骤都需要根据一定的条件来执行,而这些条件由AOF 所使用的保存模式来决定
5.3.5 AOF保存模式
Redis 目前支持三种AOF 保存模式,它们分别是:
1. AOF_FSYNC_NO :不保存。
2. AOF_FSYNC_EVERYSEC :每一秒钟保存一次。
3. AOF_FSYNC_ALWAYS :每执行一个命令保存一次。
AOF_FSYNC_NO:
在这种模式下,每次调用flushAppendOnlyFile 函数,WRITE 都会被执行,但SAVE 会被略过。在这种模式下,SAVE 只会在以下任意一种情况中被执行:
• Redis 被关闭
• AOF 功能被关闭
• 系统的写缓存被刷新(可能是缓存已经被写满,或者定期保存操作被执行)
这三种情况下的SAVE 操作都会引起Redis 主进程阻塞。
AOF_FSYNC_EVERYSEC:
在这种模式中,SAVE 原则上每隔一秒钟就会执行一次,因为SAVE 操作是由后台子线程调用的,所以它不会引起服务器主进程阻塞。
注意,在上一句的说明里面使用了词语“原则上” ,在实际运行中,程序在这种模式下对fsync或fdatasync 的调用并不是每秒一次,它和调用flushAppendOnlyFile 函数时Redis 所处的状态有关。
每当flushAppendOnlyFile 函数被调用时,可能会出现以下四种情况:
• 子线程正在执行SAVE ,并且:
1. 这个SAVE 的执行时间未超过2 秒,那么程序直接返回,并不执行WRITE 或新的SAVE 。
2. 这个SAVE 已经执行超过2 秒,那么程序执行WRITE ,但不执行新的SAVE 。
注意,因为这时WRITE 的写入必须等待子线程先完成(旧的)SAVE ,因此这里WRITE 会比平时阻塞更长时间。
• 子线程没有在执行SAVE ,并且:
3. 上次成功执行SAVE 距今不超过1 秒,那么程序执行WRITE ,但不执行SAVE 。
4. 上次成功执行SAVE 距今已经超过1 秒,那么程序执行WRITE 和SAVE 。
根据以上说明可以知道,在“每一秒钟保存一次”模式下,如果在情况1 中发生故障停机,那么用户最多损失小于2 秒内所产生的所有数据。如果在情况2 中发生故障停机,那么用户损失的数据是可以超过2 秒的。Redis 官网上所说的,AOF 在“每一秒钟保存一 次”时发生故障,只丢失1 秒钟数据的说法,实际上并不准确。
AOF_FSYNC_ALWAYS:
在这种模式下,每次执行完一个命令之后,WRITE 和SAVE 都会被执行。另外,因为SAVE 是由Redis 主进程执行的,所以在SAVE 执行期间,主进程会被阻塞,不能接受命令请求。
5.3.6 AOF 保存模式对性能和安全性的影响
对于三种AOF 保存模式,它们对服务器主进程的阻塞情况如下:
1. 不保存(AOF_FSYNC_NO):写入和保存都由主进程执行,两个操作都会阻塞主进程。
2. 每一秒钟保存一次(AOF_FSYNC_EVERYSEC):写入操作由主进程执行,阻塞主进程。保存操作由子线程执行,不直接阻塞主进程,但保存操作完成的快慢会影响写入操作的阻塞时长。
3. 每执行一个命令保存一次(AOF_FSYNC_ALWAYS):和模式1 一样。
因为阻塞操作会让Redis 主进程无法持续处理请求,所以一般说来,阻塞操作执行得越少、完成得越快,Redis 的性能就越好。
模式1 的保存操作只会在AOF 关闭或Redis 关闭时执行,或者由操作系统触发,在一般情况下,这种模式只需要为写入阻塞,因此它的写入性能要比后面两种模式要高,当然,这种性能的提高是以降低安全性为代价的:在这种模式下,如果运行的中途发生停机, 那么丢失数据的数量由操作系统的缓存冲洗策略决定。
模式2 在性能方面要优于模式3 ,并且在通常情况下,这种模式最多丢失不多于2 秒的数据,所以它的安全性要高于模式1 ,这是一种兼顾性能和安全性的保存方案。
模式3 的安全性是最高的,但性能也是最差的,因为服务器必须阻塞直到命令信息被写入并保存到磁盘之后,才能继续处理请求。
综合起来,三种AOF 模式的操作特性可以总结如下:
5.3.7 AOF文件的读取和数据还原
AOF 文件保存了Redis 的数据库状态,而文件里面包含的都是符合Redis 通讯协议格式的命令文本。
这也就是说,只要根据AOF 文件里的协议,重新执行一遍里面指示的所有命令,就可以还原Redis 的数据库状态了。
Redis 读取AOF 文件并还原数据库的详细步骤如下:
1. 创建一个不带网络连接的伪客户端(fake client)。
2. 读取AOF 所保存的文本,并根据内容还原出命令、命令的参数以及命令的个数。
3. 根据命令、命令的参数和命令的个数,使用伪客户端执行该命令。
4. 执行2 和3 ,直到AOF 文件中的所有命令执行完毕。
完成第4 步之后,AOF 文件所保存的数据库就会被完整地还原出来。
注意,因为Redis 的命令只能在客户端的上下文中被执行,而AOF 还原时所使用的命令来自于AOF 文件,而不是网络,所以程序使用了一个没有网络连接的伪客户端来执行命令。伪客户端执行命令的效果,和带网络连接的客户端执行命令的效果,完全一样。
5.3.8 AOF 重写
AOF文件通过同步redis服务器所执行的命令,从而实现了数据库状态的记录,但是随着运行时间的流逝,AOF文件将会越来越大。所以redis需要对 AOF文件进行重写,创建一个新的AOF文件来代替原来的文件,新的AOF文件和原来保存的AOF文件保存的数据库状态完全一样,但体积比原来的要小。
5.3.9 AOF重写的实现
当redis服务器执行上面的的4个命令后,在AOF中将会保存上面4条记录状态的数据记录信息,若现在触发了AOF的重写,那么现在的AOF文件只是记录RPUSH 1 2 3 这一条数据记录信息就行了,然后将原先的AOF文件覆盖,所以,AOF的重写大大缩小了AOF文件的占用空间。
5.3.10 AOF 后台重写
作为一种辅佐性的维护手段,Redis 不希望AOF 重写造成服务器无法处理请求,所以Redis 决定将AOF 重写程序放到(后台)子进程里执行,这样处理的最大好处是:
1. 子进程进行AOF 重写期间,主进程可以继续处理命令请求。
2. 子进程带有主进程的数据副本,使用子进程而不是线程,可以在避免锁的情况下,保证数据的安全性。
不过,使用子进程也有一个问题需要解决:因为子进程在进行AOF 重写期间,主进程还需要继续处理命令,而新的命令可能对现有的数据进行修改,这会让当前数据库的数据和重写后的AOF 文件中的数据不一致。
为了解决这个问题,Redis 增加了一个AOF 重写缓存,这个缓存在fork 出子进程之后开始启用,Redis 主进程在接到新的写命令之后,除了会将这个写命令的协议内容追加到现有的AOF文件之外,还会追加到这个缓存中:
换言之,当子进程在执行AOF 重写时,主进程需要执行以下三个工作:
1. 处理命令请求。
2. 将写命令追加到现有的AOF 文件中。
3. 将写命令追加到AOF 重写缓存中。
这样一来可以保证:
1. 现有的AOF 功能会继续执行,即使在AOF 重写期间发生停机,也不会有任何数据丢失。
2. 所有对数据库进行修改的命令都会被记录到AOF 重写缓存中。
当子进程完成AOF 重写之后,它会向父进程发送一个完成信号,父进程在接到完成信号之后,会调用一个信号处理函数,并完成以下工作:
1. 将AOF 重写缓存中的内容全部写入到新AOF 文件中。
2. 对新的AOF 文件进行改名,覆盖原有的AOF 文件。
当步骤1 执行完毕之后,现有AOF 文件、新AOF 文件和数据库三者的状态就完全一致了。
当步骤2 执行完毕之后,程序就完成了新旧两个AOF 文件的交替。
这个信号处理函数执行完毕之后,主进程就可以继续像往常一样接受命令请求了。在整个AOF后台重写过程中,只有最后的写入缓存和改名操作会造成主进程阻塞,在其他时候,AOF 后台重写都不会对主进程造成阻塞,这将AOF 重写对性能造成的影响降到了最 低。以上就是AOF 后台重写,也即是BGREWRITEAOF 命令的工作原理。
5.3.11 小结
* AOF 文件通过保存所有修改数据库的命令来记录数据库的状态;
* AOF 文件中的所有命令都以redis通讯协议的格式保存;
* 不同的AOF 保存模式对数据的安全性、以及redis的性能有很大的影响;
* AOF 重写的目的是用更小的体积来保存数据库的状态,整个重写的过程基本上不影响redis 主进程处理命令请求;
* AOF 可以由用户手动触发,也可以由服务器自动触发。
5.4 事件
事件是redis服务器的核心,主要处理两项重要的任务:
* 处理文件事件:在多个客户端中实现多路复用,接收它们发来的请求,并将命令的执行结果返回给客户端;
* 时间事件:实现服务器常规操作。
5.4.1 文件事件
Redis 服务器通过在多个客户端之间进行多路复用,从而实现高效的命令请求处理:多个客户端通过套接字连接到Redis 服务器中,但只有在套接字可以无阻塞地进行读或者写时,服务器才会和这些客户端进行交互。
Redis 将这类因为对套接字进行多路复用而产生的事件称为文件事件(file event),文件事件可以分为读事件和写事件两类。
读事件:
当一个新的客户端连接到服务器时,服务器会给为该客户端绑定读事件,直到客户端断开连接之后,这个读事件才会被移除。
读事件在整个网络连接的生命期内,都会在等待和就绪两种状态之间切换:
• 当客户端只是连接到服务器,但并没有向服务器发送命令时,该客户端的读事件就处于等待状态。
• 当客户端给服务器发送命令请求,并且请求已到达时(相应的套接字可以无阻塞地执行读操作),该客户端的读事件处于就绪状态。
当事件处理器被执行时,就绪的文件事件会被识别到,相应的命令请求会被发送到命令执行器,并对命令进行求值。
写事件:
写事件标志着客户端对命令结果的接收状态。和客户端自始至终都关联着读事件不同,服务器只会在有命令结果要传回给客户端时,才会为客户端关联写事件,并且在命令结果传送完毕之后,客户端和写事件的关联就会被移除。
一个写事件会在两种状态之间切换:
• 当服务器有命令结果需要返回给客户端,但客户端还未能执行无阻塞写,那么写事件处于等待状态。
• 当服务器有命令结果需要返回给客户端,并且客户端可以进行无阻塞写,那么写事件处于就绪状态。
当客户端向服务器发送命令请求,并且请求被接受并执行之后,服务器就需要将保存在缓存内的命令执行结果返回给客户端,这时服务器就会为客户端关联写事件。
注意: 读事件只有在客户端断开和服务器的连接时,才会被移除。这也就是说,当客户端关联写事件的时候,实际上它在同时关联读/写两种事件。因为在同一次文件事件处理器的调用中,单个客户端只能执行其中一种事件(要么读,要么写,但不能又读又 写),当出现读事件和写事件同时就绪的情况时,事件处理器优先处理读事件。这也就是说,当服务器有命令结果要返回客户端,而客户端又有新命令请求进入时,服务器先处理新命令请求。
5.4.2 时间事件
时间事件记录着那些要在指定时间点运行的事件,多个时间事件以无序链表的形式保存在服务器状态中。
每个时间事件主要由三个属性组成:
• when :以毫秒格式的UNIX 时间戳为单位,记录了应该在什么时间点执行事件处理函数。
• timeProc :事件处理函数。
• next 指向下一个时间事件,形成链表。
根据timeProc 函数的返回值,可以将时间事件划分为两类:
• 如果事件处理函数返回ae.h/AE_NOMORE ,那么这个事件为单次执行事件:该事件会在指定的时间被处理一次,之后该事件就会被删除,不再执行。
• 如果事件处理函数返回一个非AE_NOMORE 的整数值,那么这个事件为循环执行事件:该事件会在指定的时间被处理,之后它会按照事件处理函数的返回值,更新事件的when 属性,让这个事件在之后的某个时间点再次运行,并以这种方式一直更新并运行 下去。
5.4.3 时间事件应用实例:服务器常规操作
对于持续运行的服务器来说,服务器需要定期对自身的资源和状态进行必要的检查和整理,从而让服务器维持在一个健康稳定的状态,这类操作被统称为常规操作(cron job)。在Redis 中,常规操作由redis.c/serverCron 实现,它主要执行以下操作:
• 更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况等。
• 清理数据库中的过期键值对。
• 对不合理的数据库进行大小调整。
• 关闭和清理连接失效的客户端。
• 尝试进行AOF 或RDB 持久化操作。
• 如果服务器是主节点的话,对附属节点进行定期同步。
• 如果处于集群模式的话,对集群进行定期同步和连接测试。
Redis 将serverCron 作为时间事件来运行,从而确保它每隔一段时间就会自动运行一次,又因为serverCron 需要在Redis 服务器运行期间一直定期运行,所以它是一个循环时间事件:serverCron 会一直定期执行,直到服务器关闭为止。
在Redis 2.6 版本中,程序规定serverCron 每隔10 毫秒就会被运行一次。从Redis 2.8 开始,10 毫秒是serverCron 运行的默认间隔,而具体的间隔可以由用户自己调整。
5.4.4 事件的执行与调度
既然Redis 里面既有文件事件,又有时间事件,那么如何调度这两种事件就成了一个关键问题。简单地说,Redis 里面的两种事件呈合作关系,它们之间包含以下三种属性:
1. 一种事件会等待另一种事件执行完毕之后,才开始执行,事件之间不会出现抢占。
2. 事件处理器先处理文件事件(处理命令请求),再执行时间事件(调用serverCron)
3. 文件事件的等待时间(类poll 函数的最大阻塞时间),由距离到达时间最短的时间事件决定。
这些属性表明,实际处理时间事件的时间,通常会比时间事件所预定的时间要晚,至于延迟的时间有多长,取决于时间事件执行之前,执行文件事件所消耗的时间。
5.4.5 小结
* redis分为时间事件和文件事件两类;
* 文件事件分为读事件和写事件:读事件实现了命令请求的接收,写事件实现了命令结果的返回;
* 时间事件分为单次执行事件和循环执行事件;
* 文件事件和时间事件之间是合作关系;一般是服务器先执行文件事件,再执行时间事件;
* 时间事件的实际执行时间通常比约定的时间要晚一点。