Redis 是一个开源的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件,支持多种类型的数据结构和范围查询。 Redis 内置了复制(replication)、事务(transactions)和不同级别的磁盘持久化(persistence),并通过 Redis哨兵(Sentinel)和自动分区(Cluster)提供高可用性(high availability)。

本篇将结合 《Redis 设计与实现》 讲解 Redis 的内部机制、单机特性以及多机特性。

Redis 数据结构

Redis 有五种不同的 对象类型,包括字符串、列表、哈希、集合和有序集合,每种对象都用到了至少一种数据结构,这样就可以根据不同的使用场景,为对象设置不同的数据结构(内部编码)实现,从而优化对象在不同场景下的使用效率。

如上图所示,可以看到每种数据结构都有 2 种以上的 内部编码实现,例如 String 数据结构就包含了 raw、int 和 embstr 三种内部编码。同时,有些内部编码可以作为多种外部数据结构的内部实现,例如 ziplist 就是 hash、list 和 zset 共有的内部编码,而 set 的内部编码可能是 hashtable 或者 intset。

Redis 使用对象来表示数据库中的键和值,每次当我们在 Redis 数据库中新创建一个键值对时,我们至少会创建两个对象,一个键对象,一个值对象。每个对象都由一个 redisObject 结构表示:

typedef struct redisObject {
	unsigned type : 4;        // 类型
	unsigned encoding : 4;    // 编码
	void *ptr;                // 指向底层实现数据结构的指针
	// ...
} robj;

因为 C 语言并不具备自动内存回收功能,所以 Redis 在自己的对象系统中构建了一个 引用计数 技术实现的内存回收机制。对象的引用计数属性还带有 对象共享 的作用,不仅字符串键可以使用共享对象,那么在数据结构中嵌套了字符串对象的对象都可以使用这些共享对象。

字符串

Redis 中的字符串对象是 可以修改 的,称为 SDS Simple Dynamic String,简单动态字符串。它有三种不同的编码实现:int、raw、embstr。当一个 key 的 value 是整型时,Redis 就将其编码为 int 类型,这种编码类型节省了内存。Redis 默认会缓存 10000 个整型值(#define OBJSHAREDINTEGERS 10000),这就意味着,如果有 10 个不同的 KEY,其 value 都是 10000 以内的值,事实上全部都是 共享同一个对象

127.0.0.1:6379> set msg 9999
OK
127.0.0.1:6379> object encoding msg
"int"
127.0.0.1:6379> set msg hello
OK
127.0.0.1:6379> object encoding msg
"embstr"
127.0.0.1:6379> set story "long, long, long ago there lived a king."
OK
127.0.0.1:6379> strlen story
(integer) 40
127.0.0.1:6379> object encoding story
"raw"

embstr 和 raw 编码的长度界限是 39,长度超过 39 字节以后,就是 raw 编码类型。embstr 编码将创建字符串对象所需的 空间分配的次数 从 raw 编码的两次降低为一次。因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面,所以这种编码的字符串对象比起 raw 编码的字符串对象能更好地利用缓存带来的优势,并且释放 embstr 编码的字符串对象只需要调用一次内存释放函数,而释放 raw 编码对象的字符串对象需要调用两次内存释放函数。

raw 编码的 SDS 数据结构如下:

struct sdshdr {
 int len;    // buf 数组中已使用的字节数量
 int free;   // buf 数组中未使用的字节数量
 char buf[];
}

相比 C 字符串,SDS 具有以下优点:

  1. O(1) 复杂度获取字符串长度,C 字符串需要遍历获取,复杂度为 0(N)
  2. 杜绝了因忘记重分配内存而导致的缓冲区溢出
  3. 通过 空间预分配(小于 1MB,分配 2 * len + 1,大于等于 1MB 分配 1MB 的未使用空间) 和 惰性空间释放(free 记录未使用空间代替内存回收)减少了修改字符串时所需的 内存重分配 次数
  4. 使用 len 属性值而不是空字符判断结尾,可以保存任意格式的二进制数据
  5. 兼容部分 C 字符串函数

列表

列表对象的编码可以是 ziplist 或者 linkedlist。linkedlist 就是我们非常熟悉的双向链表,特征有:双端、无环、带表头和表尾指针、带长度计数器、多态。当列表保存的所有字符串元素长度都小于 64 字节,并且列表长度小于 512 时,列表使用 ziplist 编码:

127.0.0.1:6379> rpush numbers 1 three 5
(integer) 3
127.0.0.1:6379> type numbers
list
127.0.0.1:6379> object encoding numbers
"ziplist"

压缩列表是 Redis 为了 节约内存 而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,其结构为:

<zlbytes><zltail><zllen><entry><entry> ... <entry><zlend>

属性 长度(字节) 用途
zlbytes 4 表示这个 ziplist 占用了多少字节,其中包括了 zlbytes 本身占用的 4 个字节:在对压缩列表进行内存重分配,或者计算 zlend 的位置时使用
zltail 4 表示到 ziplist 中最后一个元素的偏移量,有了这个值,pop 操作的时间复杂度就是 O(1) 了,即不需要遍历整个 ziplist
zllen 2 表示 ziplist 中有多少个 entry,即保存了多少个元素。由于这个字段占用 16 位,所以最大值是2^16-1,当这个值等于 UNIT16_MAX(65535) 时,节点的真实数量需要遍历整个压缩列表才能计算得出
entryX 不定 压缩列表包含的各个节点,节点的长度由保存的内存决定
zlend 1 特殊值 0xFF(255),用于标记压缩列表的末端

每个压缩列表节点都是由 previous_entry_length、encoding、content 三个部分组成,previous_entry_length 记录了前一个节点的长度,程序可以通过指针运算,根据当前节点的起始地址来 计算出前一个节点的起始地址,从而实现从表尾向表头遍历。如果前一个节点的长度小于 254 字节,那么 previous_entry_length 会使用 1 字节记录它的长度,如果前一节点的长度大于等于 254 字节,那么 previous_entry_length 的长度为 5 字节,且属性的第一个字节会被设置为 0xFE(254),之后四个字节则用于保存前一节点的长度。encoding 属性记录了节点的 content 所存数据的类型及长度,节点值 content 可以是一个字节数据或者整数。

如果在一个压缩列表中,有多个连续的、长度介于 250 字节到 253 字节之前的节点 e1 至 eN,这时将一个长度大于等于 254 字节的新节点 new 设置为压缩列表的表头节点就会触发 连锁更新。如下图所示,因为 e1 的 previous_entry_length 属性仅长 1 字节,它没办法保存新节点 new 的长度,所以程序将对压缩列表执行空间重分配操作,并将 e1 的 previous_entry_length属性从原来的 1 字节长扩展为 5 字节长。但这又导致了 e2 的 previous_entry_length 无法记录 e1 的长度,导致需要再次执行空间重分配操作…

除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。在最坏的情况下需要对压缩列表执行 N 次空间重分配操作,而每次空间重分配的最坏复杂度为 O(N),所以连锁更新的最坏复杂度为 O(N^2)。尽管连锁更新的复杂度较高,但它真正造成性能问题的 几率是很低的,压缩列表里要恰好有多个连续的、长度介于 250 字节至 253 字节之间的节点,才有可能引发连锁更新;即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响,所以 ziplistPush 等命令的平均复杂度仅为O(N)。

哈希

哈希对象的编码可以是 ziplist 或者 hashtable。当哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节,且保存的键值对数量小于 512 个,哈希对象就使用 ziplist 编码,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的节点推入压缩列表表尾,再将保存了值的节点推入到压缩列表表尾,如下图所示:

当上述的两个条件不满足时,哈希对象就会使用 hashtable 编码,由于 Redis 所使用的 C 语言 没有内置 字典这种数据结构,因此 Redis 构建了自己的字典实现,其结构如下图所示:

dicht 中的 table 指向一个数组,数组中的每个元素都指向一个 dictEntry 结构,它保存着一个键值对。dictEntry 包含指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决 键冲突 的问题。ht 是一个包含两个项的数组,数组中的每个项都是一个 dicht 哈希表,一般情况下,字典只使用 ht[0] 哈希表,ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。

Redis 中字典的 rehash 动作并不是一次性、集中式地完成的,而是 分多次、渐进式 地完成的。因为如果哈希表里保存的键值对数量较多,要一次性将这些键值对全部 rehash 到 ht[1] 的话,庞大的计算量可能会导致服务器在一段时间内 停止服务。渐进式 rehash 时会在字典内维护一个索引计数器变量 rehashidx,在 rehash 进行期间,程序会将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当本次 rehash 完成后,程序将 rehashidx 属性的值加一。

在进行渐进式 rehash 的过程中,字典会同时使用 ht[0] 和 ht[1] 两个哈希表,删除、查找、更新等操作会在两个哈希表上进行。例如要在字典里面查找一个键的话,程序会先在 ht[0] 里面进行查找,如果没找到的话,就会继续到 ht[1] 里面进行查找,诸如此类。新添加到字典的键值对一律会被保存到 ht[1] 里面,而 ht[0] 则不再进行任何添加操作,这一措施保证了 ht[0] 包含的键值对数量会 只减不增,并随着 rehash 操作的执行而最终变成空表。

集合

集合对象的编码可以是 intset 或者 hashtable,当集合对象保存的元素都是整数值且元素数量不超过 512 个时将会使用整数集合编码,不满足这两个条件就使用 hashtable 编码,字典的每个键都是一个集合元素,而字典的值则全部被设置为 NULL。

Contents 数组的每一项都是整数集合的一个元素,各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。intset 的结构如下图所示:

其中的 encoding 决定了集合中整数的类型,它可以是 INTSET_ENC_INT8、INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64,每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比集合中的现有类型长时,整数集合需要先进行 升级。升级时会先扩展整数集合底层数组的空间大小,然后将现有元素都转换成与新元素相同的类型,最后将新元素添加到底层数组里面。

因为 C 语言是静态类型语言,为了避免 类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。整数集合通过自动升级底层数据来适应新元素,所以我们可以随意地将 int16_t、int32_t 或者 int64_t 类型的整数添加到集合中,而不必担心出现类型错误。整数集合的实现可以让集合可以尽量节省内存,又可以确保升级操作只会在有需要的时候进行。

有序集合

有序集合 Sorted Set 的编码可以是 zipllist 或 skiplist,当有序集合保存的所有元素成员的长度都小于 64 字节且元素数量小于 128 个时会使用 ziplist 编码,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个元素保存元素的分值,如下图所示:

跳跃表 skiplist 是一种有序的数据结构,它通过在每个节点中维持 多个指向其他节点的指针,从而达到快速访问节点的目的。其结构如下图所示,header 和 tail 为支持快速访问的表头节点和表尾节点,level 用于获取跳跃表中层高最大的那个节点的数量(表头节点的层高并不计算在内)。表中的节点按各自所保存的分值从小到大排列,节点的 level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度。

除此之外,有序集合还同时使用字典来保存从成员到分值的映射,这样就可以以 O(1) 复杂度查找给定成员的分值。值得一提的是,虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来 共享 相同元素的成员和分值,不会产生任何重复成员或者分值,也不会因此而 浪费额外的内存

单机数据库的实现

数据库

Redis 使用字典类型的 键空间 dict 保存数据库中的键和值,其结构如下图所示。每次当我们在 Redis 数据库中新创建一个键值对时,我们至少会创建两个对象,一个字符串键对象,一个值对象,它可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的一种。

因为 Redis 的键空间是一个字典,所以所有针对数据库的操作,比如添加或删除一个键值对,又或者在数据库中获取某个键值对等,实际上都是通过对键空间进行操作来实现的。

通过 EXPIRE 或者 PEXPIRE 命令可以以秒或者毫秒精度为某个键设置生存时间,或者通过 EXPIREATPEXPIREAT 命令给某个键设置过期时间(UNIX 时间戳)。Redis 通过 expires 字典 保存数据库中所有键的过期时间,它的键指向间空间中的某个键对象(对象共享),值为一个毫秒精度的 long long 类型的 UNIX 时间戳。

通过过期字典,程序可以检查某个键是否存在于过期字典,如果存在,可以检查当前 UNIX 时间错是否大于键的过期时间,如果是的话,那么键已经过期;否则键未过期。如果一个键过期了,那么它什么时候会被删除呢?

  • 定时删除:设置键的过期时间的同时,创建一个定时器,在键的过期时间来临时立即执行对键的删除操作。定时删除策略是 对内存最友好 的,可以保证过期键会尽可能快地被删除,并释放过期键所占用的内存,但它 对 CPU 时间是最不友好 的,在 CPU 时间非常紧张的情况下,这无疑会对服务器的响应时间和吞吐量造成影响。

  • 惰性删除:每次从键空间获取键时,都检查键是否过期,如果过期就删除。这样对 CPU 时间来说是最友好的,因为删除过期键的操作只会在非做不可的情况下进行,且只删除当前处理的键;但它 对内存是最不友好的,有些过期键如果没被访问到的话可能永远也不会被删除(内存泄漏),这对于运行状态非常依赖于内存的 Redis 服务器来说,肯定不是一个好消息。

  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。定期删除策略是前两种策略的一种整合和折中,每隔一段时间执行一次删除过期键操作,并通过限制操作执行的时长和频率来减少删除操作对 CPU 时间的影响,此外,定期删除有效地减少了因为过期键而带来的内存浪费。

Redis 服务器实际使用的是惰性删除和定期删除两种策略:通过配合使用这两种删除策略,服务器可以很好地在合理使用 CPU 时间和避免浪费内存空间之间取得 平衡

RDB 持久化

Redis 提供了 RDB 持久化功能将内存中的数据库状态保存到磁盘里面,来避免进程退出时数据库状态的意外丢失。RDB 可以手动执行,也可以根据服务器配置定期执行,它会生成一个经过压缩的二进制文件,在 Redis 启动时可以用 RDB 文件来还原数据库状态。

SAVE 命令会 阻塞 服务器进程,直到 RDB 文件创建完毕,在这期间服务器不能处理任何命令请求。BGSAVE 则会在派生出的子进程中创建 RDB 文件,服务器进程(父进程)继续处理命令请求。

save 选项的默认条件为:

save 900 1

save 300 10

save 60 10000

那么只要满足以下三个条件中的任意一个,BGSAVE 命令就会被执行:

  • 服务器在 900 秒之内,对数据库进行了至少 1 次修改
  • 服务器在 300 秒之内,对数据库进行了至少 10 次修改
  • 服务器在 60 秒之内,对数据库进行了至少 10000 次修改

Redis 通过其周期性操作函数 serverCron 默认每隔 100 毫秒执行一次检查,检查 dirty 计数器并计算距离上次执行保存操作有多少秒,当满足保存条件中的任意一条时便执行 BGSAVE 命令。

在创建新的 RDB 文件时,程序会对数据库中的键进行检查,已过期的键 不会被保存到新创建的 RDB 文件中。在 Redis 启动载入 RDB 文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入 RDB 文件的服务器不会造成影响。

Redis 在启动时会自动检测,如果存在 RDB 文件就会自动载入。值得一提的是,因为 AOF 文件的 更新频率 通常比 RDB 文件的要高,所以如果服务器开启了 AOF 持久化功能,那 Redis 会优先使用 AOF 文件来还原数据库状态。只有在 AOF 持久化功能处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态。

AOF 持久化

除了 RDB 之外,Redis 还提供了 AOF Append Only File 持久化功能。AOF 通过保存 Redis 服务器所执行的 写命令 来记录数据库状态的,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾,由服务器配置的 appendfsync(默认为 everysec)来决定不同的持久化行为:

appendfsync 持久化行为
always 将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件中
everysec 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,如果距上次同步超过一秒,就进行同步
no 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,不进行同步,何时同步由操作系统来决定

当 appendfsync 的值为 always 时,服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,并且同步 AOF 文件,效率最慢但也 最安全,即使出现故障停机,AOF 持久化也只会丢失一个事件循环中所产生的命令数据。Redis 在载入 AOF 文件进行数据还原时会创建一个不带网络连接的伪客户端,逐条读取其中的写命令并执行。

随着服务器的运行,AOF 文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的 AOF文件很可能对 Redis 服务器、甚至整个宿主计算器造成影响,并且 AOF 文件的体积越大,进行数据还原所需的时间就越多。

为此,Redis 提供了 AOF 文件重写 功能,通过创建一个不包含任何浪费空间的冗余命令的新的 AOF 文件来替换原有的 AOF 文件。AOF 文件重写并不需要对现有的 AOF 文件进行任何读取、分析或者写入操作,而是通过读取服务器当前的数据库状态来实现的。因为新的 AOF 文件只包含还原当前数据库状态所必须的命令,所以新的 AOF 文件不会浪费任何硬盘空间。

Redis 将 AOF 重写放到子进程中执行,这样服务器进程(父进程)可以在重写期间继续处理命令请求。在此期间客户端对数据库的修改会被同时记录到 AOF 重写缓冲区 中,当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,最后使用原子替换完成 AOF 重写操作。

对于 过期键,当过期键被惰性删除或者定期删除之后,程序会向 AOF 文件追加一条 DEL 命令,来显式地记录该键已被删除。在 AOF 重写的过程中,已过期的键不会被保存到重写后的 AOF 文件中,因此数据库中包含过期键不会对 AOF 产生影响。

事件

Redis 服务器是一个 事件驱动程序,服务器通过处理文件事件(监听客户端)和时间事件(serverCron 函数)来实现功能。

文件事件 使用 I/O 多路复用 来同时监听多个套接字,并且根据套接字目前执行的任务来为套接字关联不同的事件处理器。文件事件处理器以 单线程 方式运行,但通过使用 I/O 多路复用程序来监听多个套接字(参考:关于 Redis 单线程的解释),这样既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他以单线程方式运行的模块进行对接,以保持 Redis 内存单线程设计的简单性。

文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答、写入、读取、关闭等操作时,就会产生一个文件事件,所以多个文件事件有可能会 并发地出现。但 I/O 多路复用程序总是将所有产生事件的套接字都放到一个队列里面,然后以 有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。

时间事件 分为只执行一次的定时事件和周期性事件。服务器将所有的时间事件都放在一个 无序链表 中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达(时间事件的 when 属性记录的 UNIX 时间戳等于或小于当前时间的 UNIX 时间戳)的时间事件,并调用相应的事件处理器。正常模式下的 Redis 服务器只使用 serverCron 一个时间事件,所以使用无序链表来保存并不影响事件执行的性能。

服务器默认规定 serverCron 每秒运行 10 次以实现对自身资源和状态的定期检查和调整,确保服务可以长期、稳定地运行,它的主要工作包括:

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

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

def aeProcessEvents():
 // 获取到达时间离当前时间最接近的时间事件
 time_event = aeSearchNearestTimer()
 
 // 计算最接近的时间事件距离到达还有多少毫秒
 remaind_ms = time_event.when - unix_ts_now()
 
 // 如果事件已到达,那么 remaind_ms 的值可能为负数,将它设定为 0
 if remaind_ms < 0:
  remaind_ms = 0
  
 // 根据 remaind_ms 的值,创建 timeval 结构
 timeval = create_timeval_with_ms(remaind_ms)
 
 // 阻塞并等待文件事件产生,最大阻塞时间由传入的 timeval 结构决定
 // 如果 remaind_ms 的值为 0,那么 aeApiPoll 调用之后马上返回,不阻塞
 aeApiPoll(timeval)

aeApiPoll 函数的最大阻塞时间由距当前时间最接近的时间事件决定,这个方法既可以 避免 服务器对事件事件进行频繁的轮询(忙等待),也可以确保 aeApiPoll 函数不会阻塞过长时间。函数会处理随机出现的文件事件,当最终来到时间事件设置的到达时间时,服务器就可以开始处理到达的时间事件了。

Redis 服务器的主函数会将 asProcessEvents 置于一个循环里面,一直处理事件,直到服务器关闭为止。Redis 对文件事件和时间事件的处理都是 同步、有序、原子 地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占。因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会 尽可能地减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低事件饥饿的可能性。比如说,在命令回复处理器将一个命令回复写入到客户端套接字时,如果写入字节数超过了一个预设常量的话,命令回复处理器就会主动用 break 跳出写入循环,将余下的数据留到下次再写;另外,时间事件也会将非常耗时的持久化操作放到子线程中执行。

多机数据库的实现

复制

Redis 的复制功能分为同步和命令传播两个操作。当客户端向从服务器发送 SLAVEOF 命令,要求从服务器复制主服务器时,从服务器会首先向主服务器发送 SYNC 命令执行同步操作,收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB文件,并使用一个 缓冲区 记录从现在开始执行的所有写命令。主服务器发送生成的 RDB 文件,在从服务器载入后将记录在缓冲区中的所有写命令发送给从服务器,从服务器通过执行这些写命令,将自己的数据库状态更新至主服务器数据库当前的状态。

之后每当主服务器执行客户端发送的写命令时,主服务器的数据库就有可能被修改,并导致主从服务器状态不再一致。为了让主从服务器再次回到一致状态,主服务器需要对从服务器执行 命令传播 操作:主服务器将自己执行的写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器再次回到一致状态。

Redis 2.8 之前,从服务器对主服务器的复制无论是初次复制还是断线后重复制都是发送 SYNC 命令,主服务器将通过BGSAVE 命令生成 RDB 文件并发送给从服务器,这个操作会 耗费 服务器大量的 CPU、内存、磁盘 I/O 资源以及网络资源,无疑是非常 低效 的,因为断线重连后生成 RDB 文件包含了大多数从服务器已有的数据。

为了解决旧版复制功能在处理断线重复制情况时的低效问题,Redis 从 2.8 版本开始使用 PSYNC 命令代替 SYNC 命令来执行复制时的同步操作。PSYNC 会在初次复制的时候使用完整重同步,而在断线后重复制时使用 部分重同步(如果条件允许)。相比 SYNC 命令在断线重复制时需要生成、传送和载入整个 RDB 文件,而部分重同步只需要将从服务器缺少的写命令发送给从服务器执行就可以了,这将使用更少的资源,同步也会更快。

部分重同步的实现

部分重同步功能主要由以下三个部分构成,复制偏移量、主服务器的复制积压缓冲区、服务器的运行 ID。PSYNC 命令执行完整重同步和部分重同步的流程如下图所示:

执行复制的双方——主服务器和从服务器会分别维护一个 复制偏移量,主服务器每次向从服务器传播 N 个字节的数据时,就将自己的复制偏移量加上 N,从服务器每次收到主服务器传播来的 N 个字节的数据时,就将自己的复制偏移量的值加上 N。通过 对比 主从服务器的复制偏移量就很容易地知道主从服务器是否处于一致状态。

复制积压缓冲区 是由主服务器维护的一个固定长度、先进先出队列,默认大小为 1 MB。当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里面,缓冲区会为队列中的每个字节记录相应的复制偏移量。当从服务器重新连上主服务器时,从服务器会通过 PYSNC 命令将自己的复制偏移量 offset 发送给主服务器,如果 offset 偏移量之后的数据 仍在 复制积压缓冲区里面,那么主服务器就执行部分重同步操作。

每个 Redis 服务器都有一个自己的 运行 ID,运行 ID 是服务器启动时自动生成的 40 位随机的十六进制字符,当从服务器对主服务器进行初次复制时,主服务器会将自己的运行 ID 传送给从服务器,从服务器会将这个运行 ID 保存起来。当从服务器断线重连后,通过将从服务器保存的运行 ID 与当前连接的主服务器的运行 ID 对比 来判断,断线之前复制的主服务器是否是当前连接的这个主服务器,如果是,主服务器可以尝试执行部分重同步操作,若不是则执行完成重同步操作。

心跳检测

在命令传播阶段,从服务器默认会以 每秒一次 的频率,向主服务器发送命令 REPLCONF ACK <replication_offset>这个命令对于主从服务器有三个作用:

  • 检测主从服务器的网络连接状态:如果主服务器超过一秒钟没有收到从服务器发来的 REPLCONF ACK 命令,那么就知道主从服务器之间的连接出现问题了。
  • 辅助实现 min-slaves 选项:可以防止主服务器在不安全的情况下执行写命令,例如下主服务器配置 min-slaves-to-write 3 min-slaves-max-lag 10 的情况下,如果从服务器数量少于 3 个,或者从服务器的延迟 lag 都大于等于 10 秒,主服务器将拒绝执行写命令。
  • 检测命令丢失:如果主服务器传播给从服务器的写命令因为网络故障在半路丢失,主服务器可以通过 REPLCONF ACK 命令中的偏移量 发觉从服务器缺少部分数据,并将这些数据重新发送给从服务器(Redis 2.8 之前无法通过这一点保证主从服务器数据一致性)。

Sentinel

Sentinel(哨兵)是 Redis 高可用性的解决方案:由一个或多个 Sentinel 实例组成的 Sentinel 系统可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器 升级 为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

Sentinel 服务器

Sentinel 本质上只是一个运行在特殊模式下的 Redis 服务器,因为 Sentinel 执行的工作和普通 Redis 服务器执行的工作不同,所以 Sentinel 的初始化过程和普通 Redis 不尽相同。比如 Sentinel 在初始化时不会载入 RDB 或者 AOF 文件,启动 Sentinel 时会将一部分普通 Redis 服务器使用的代码替换成 Sentinel 的 专用代码,并载入 Sentinel 专用的命令表

所有被 Sentinel 监视的主服务器的相关信息会被保存到 sentinelState 中的 masters 字典中,Sentinel 启动时会载入 Sentinel 的配置文件来初始化这个 masters 字典。随后 Sentinel 会创建两个连向主服务器的异步网络连接:一个是命令连接,专用用于向主服务器发送命令,并接收命令回复;另一个是订阅连接,专门用于订阅主服务器的 \_sentinel\_:hello 频道。如果在信息发送时,想要接收信息的客户端不在线或者断线,那么这个客户端就会 丢失 这条信息。为了不丢失 _sentinel_:hello 频道的任何信息,Sentinel 必须专门用一个 订阅连接 来接收该频道的信息。

Sentinel 会默认 每十秒一次 的频率通过命令连接向被监视的主服务器发送 INFO 命令,通过分析回复来获取主服务器的当前信息,其得到的回复类似以下内容:

# Server
...
run_id:7611c59dc3a29aa6fa0609f841bb6a1019008a9c
...
# Replication
role:master
...
slave0:ip=127.0.0.1,port=11111,state=online,offset=43,lag=0
slave1:ip=127.0.0.1,port=22222,state=online,offset=43,lag=0
slave2:ip=127.0.0.1,port=33333,state=online,offset=43,lag=0
...
# Other sections
...

除了得到关于主服务器本身的信息(会根据回复对 masters 字典进行更新)外,还得到了主服务器属下所有从服务器的信息,因此 Sentinel 无须用户提供从服务器的地址信息(并以此更新 masters 字典中的 slaves 字典),就可以 自动发现从服务器。同样地,Sentinel 会创建连接到从服务器的命令连接和订阅连接,并以默认 每十秒一次 的频率向从服务器发送 INFO 命令,根据回复内容更新从服务器的实例结构。

Sentinel 会以默认 每两秒一次 的频率,通过命令连接向所有被监视的主服务器和从服务器的 _sentinel_:hello 频道发送 PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>" 其中以 s_ 开头的参数记录的是 Sentinel 本身的信息,以 m_ 开头的则是主服务器的信息,如果发送对象为从服务器,那么这些参数记录的就是从服务器正在复制的主服务器的信息。

当 Sentinel 与一个主/从服务器建立起订阅连接后,会发送 SUBSCRIBE __sentinel__:hello 订阅频道。也就是说,Sentinel 既通过命令连接向每个连接的服务器的 __sentinel__:hello 频道发送信息,又通过订阅连接从该频道接收信息。对于监视同一个服务器的多个 Sentinel 来说,一个 Sentinel 发送的信息会被其他 Sentinel 接收到,这些信息会被用于 更新其他 Sentinel 对发送信息 Sentinel 的认知,也会被用于更新其他 Sentinel 对被监视服务器的认知。因为一个 Sentinel 可以通过分析接收到的频道信息来获知其他 Sentinel 的存在,并通过发送频道信息让其他 Sentinel 知道自己的存在,所以用户在使用 Sentinel 的时候并不需要提供各个 Sentinel 的地址信息,监视同一个主服务器的多个 Sentinel 可以 自动发现 对方。

当 Sentinel 通过频道信息发现一个新的 Sentinel 时,它会创建一个连向新 Sentinel 的命令连接,相连的 Sentinel 可以通过命令连接向其他 Sentinel 发送命令请求来进行 信息交换(在对服务器的下线检测时进行彼此通信)。Sentinel 之间 不会 创建订阅连接,因为 Sentinel 通过接收主/从服务器的频道信息来发现未知的新 Sentinel,而相互已知的 Sentinel 只要使用命令连接来进行通信就足够了。

检测下线与故障转移

Sentinel 会在默认情况下以 每秒一次 的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他 Sentinel)发送 PING 命令,如果一个实例在 down-after-milliseconds 毫秒内,连续向 Sentinel 返回无效回复,那么 Sentinel 就会修改这个实例状态为 主观下线状态。需要注意的是,对于监视同一主服务器的多个 Sentinel 来说,这些 Sentinel 所设置的 down-after-milliseconds 值可能不同,所以当一个 Sentinel 将主服务器判断为主观下线时,其他 Sentinel 可能仍然会认为主服务器处于在线状态。

当 Sentinel 将一个主服务器判断为主观下线后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他 Sentinel发送 SENTINEL is-master-down-by-addr <ip><port><current_epoch><runid> 进行询问,看它们是否也认为主服务器已经进入了下线状态(主观/客观)。当目标 Sentinel 接收到源 Sentinel 发来的 SENTINEL is-master-down-by-addr 命令时,目标 Sentinel 会检查请求中的主服务器是否下线,然后向源 Sentinel 返回三个参数:<down_state>、<leader_runid>、<leader_epoch>。当 Sentinel 从其他 Sentinel 那里接收到足够数量的已下线判断之后(不同 Sentinel 设置的客观下线状态的判断条件可不同),Sentinel 就会将服务器判定为 客观下线,并对主服务器执行故障转移操作。

每个发现主服务器进入客观下线的 Sentinel 都会要求其他 Sentinel 将自己设置为局部 领头 Sentinel,当源 Sentinel 向目标 Sentinel 发送 SENTINEL is-master-down-by-addr 命令,命令中的 runid 不是 * 符号而是源 Sentinel 的运行 ID 时,这表示源 Sentinel 要求目标 Sentinel 将前者设置为后者的局部领头 Sentinel。根据 先到先得 的原则,在一个配置纪元里面,目标 Sentinel 会设置最先发送命令的 Sentinel 为局部领头 Sentinel,拒绝之后接收到的请求。如果某个 Sentinel 被 半数以上 的 Sentinel 设置成了局部领头 Sentinel,那么这个 Sentinel 成为领头 Sentinel,如果在给定时限内,没有一个 Sentinel 被选举为领头 Sentinel,那么各个 Sentinel 将在一段时间之后再次进行选举,直到选出领头 Sentinel 为止。

领头 Sentinel 将对已下线的主服务器执行故障转移操作,先从已下线主服务器属下的所有从服务器里面,选出新的主服务器(在没有过早地与主服务器断开连接的从服务器中,选出优先级最高、偏移量最大、运行ID最小的从服务器),让已下线主服务器属下的所有从服务器改为复制新的主服务器,最后将已下线主服务器设置为新主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器。

集群

槽指派

一个 Redis 集群通常由多个节点组成,在刚开始的时候,每个节点都是相互独立的,处于一个只包含自己的集群当中,一个真正可工作的集群是通过 CLUSTER MEET <ip><port> 将各个独立的节点连接起来,构成一个包含多个节点的集群。Redis 服务器会根据 cluster-enabled 配置选择作为节点运行在集群模式下,它会继续使用所有在单机模式中使用的服务器组件,比如文件/时间事件处理器、serverCrom 函数、RDB、AOF、复制功能等。

节点使用 clusterState 来保存只有在集群模式下才会用到的数据,集群中的每个节点都将使用一个 clusterNode 记录其状态,具体存储结构如下图所示,结构的 nodes 字典记录了集群目前包含的三个节点,这三个节点分别由三个 clusterNode 结构表示,其中 myself 指针指向代表节点 7000 的 clusterNode 结构,而字典中的另外两个指针分别指向代表节点 7001 和 7002 的 clusterNode 结构,这两个节点是已知的在集群中的其他节点。

Redis 集群通过分片的方式来保存数据库中的键值对,集群的整个数据库被分为 16384 个槽 slot,数据库的每个键都属于这 16384 个槽的其中一个。可以使用 CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000 将槽指派给某个节点负责,当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态,返回则处于下线 fail 状态。

clusterNode 结构中的 slots 属性和 numslot 属性记录了节点负责处理哪些槽:

struct clusterNode {
	// ...
	unsigned char slots[15384/8];
	int numslot;
	// ...
}

slots 属性是一个二进制数组,长度为 16384/8 = 2048 个字节,共包含 16384 个二进制位。如果 slots 数组在索引 i 上的二进制位的值为 1,那么表示节点负责处理槽 i,否则就是不负责处理。因为取出和设置 slots 数组中任意一个二进制位的值复杂度仅为 0(1),所以对于一个给定节点的 slots 数组来说,程序检查节点是否复杂处理某个槽,又或者将某个槽指派给节点复杂,这两个动作的复杂度都是 0(1)。

此外,节点还会将自己的 slots 数组通过消息发给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。clusterState结构中的 slots 数组记录了集群中所有 16384 个槽的指派信息,如果 slots[i] 指向 NULL,表示槽 i 尚未指派给任何节点,如果指向一个 clusterNode 结构,那么表示槽 i 已经指派给了 clusterNode 结构所代表的节点。

typedef struct clusterState {
	// ...
	clusterNode *slots[16384];
	// ...
}

要说明的一点是,虽然 clusterState.slots 数据记录了集群中所有槽的指派信息,但使用 clusterNode 结构的 slots 数组来记录单个节点的槽指派信息仍然是 必要的。如果单独使用 clusterState.slots 数据的话,那么每次要将节点 A 的槽指派信息 传播 给其他节点时,程序必须先遍历整个 clusterState.slots 数组,记录节点 A 负责处理哪些槽,然后才能发送节点 A 的槽指派信息,这比直接发送 clusterNode.slots 数组要麻烦和低效的多。

当客户端向集群中的节点发送数据命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己,具体的流程如下图所示:

如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个 MOVED 错误,指引客户端转向 redirect 至正确的节点,并再次发送之前想要执行的命令。因此一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是 换一个套接字 来发送命令。

复制与故障转移

Redis 集群中,主节点用于处理槽,从节点用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。集群从节点的复制功能和单机 Redis 服务器的复制功能使用了相同的代码,一个节点成为从节点,并开始复制某个主节点这一信息会 通过消息发送 给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点。

集群中的每个节点会定期地向集群中的其他节点发送 PING 消息,如果接收 PING 消息的节点没有在规定的时间内返回 PONG 消息,那么发送 PING 消息的节点就会将接收 PING 消息的节点标记为 疑似下线。如果在一个集群中 半数以上 负责处理槽的主节点都将某个主节点 x 报告为疑似下线时,那么这个主节点 x 将被标记为 已下线 FAIL

这时从节点将开始对下线主节点进行故障转移,从该主节点的所有从节点里面,选举出新的主节点,撤销所有对已下线主节点的槽指派,全部指派给新的主节点,并通知集群中的其他节点。集群选举新的主节点的方法为:

  1. 在每个配置纪元中,已下线主节点的从节点会向集群广播一条消息,向主节点要求投票,而第一个向集群中主节点要求投票的从节点将获得投票。
  2. 当一个从节点收集到大于等于 N/2+1 张支持票时,这个从节点就会当选新的主节点,确保了新的主节点只会有一个。
  3. 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群将进入一个新的配置纪元,并再次进行选择,直到选出新的主节点为止。

Redis 功能

发布与订阅

客户端可以订阅一个或多个频道,每当有其他客户端向被订阅的频道发送消息时,频道的所有订阅者都会收到这条消息。此外客户端还可以通过 PSUBSCRIBE 订阅一个或多个模式,从而接收所有与模式匹配的频道的消息。如下图所示:

Redis 将所有频道的订阅关系都保存在服务器状态的 pubsub_channels 字典里面,键为某个被订阅的频道,值是一个链表,里面记录了所有订阅这个频道的客户端。类似地,所有模式的订阅关系都保存在服务器状态的 pubsub_patterns 属性里面,它也是一个链表,里面记录了客户端与其订阅的模式,如下图所示:

当一个 Redis 客户端执行 PUBLISH <channel> <message> 命令将消息发送给频道时,服务器会将消息 message 发送给频道所有的订阅者,并发送给所有与 channel 匹配的模式的订阅者。PUBLISH 命令将在 pubsub_channels 字典中查找键为 channel 对应的链表值,然后遍历链表将消息发送给频道所有的订阅者。

至于模式订阅者,PUBLISH 命令会遍历整个 pubsub_patterns 链表,查找那些与 channel 频道相匹配的模式, 并将消息发送给订阅了这些模式的客户端,其伪代码如下所示:

def pattern_public(channel, message) :

 # 遍历所有模式订阅消息
 for pubsubPattern in server.pubsub_patterns:
 
  # 如果频道和模式相匹配
  if match (channel, pubsubPattern.pattern):
  
   # 那么将消息发送给订阅该模式的客户端
   send_message(pubsubPattern.client, message)

事务

Redis 事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器 不会中断事务 而改去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。以下是一个 Redis 事务执行的过程:

redis> MULTI
OK

redis> SET "name" "Practical Common Lisp"
QUEUED

redis> GET "name"
QUEUED

redis> SET "author" "Peter Seibel"
QUEUED

redis> GET "auther"
QUEUED

redis> EXEC
1) OK
2) "Practical Common Lisp"
3) OK
4) "Peter Seibel"

MULTI 命令的执行标志着事务的开始,当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:如果客户端发送的命令为 EXEC、DISCARD、WATCH、MULTI 四个命令的其中一个,那么服务器立即执行这个命令;相反,如果是这四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个 FIFO 事务队列里面,然后向客户端返回 QUEUED 回复,其具体逻辑如下图所示:

当一个处于事务状态的客户端向服务器发送 EXEC 命令时,这个 EXEC 命令将被立即执行,服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。

WATCH 命令的实现

WATCH 命令是一个乐观锁的实现,它可以在 EXEC 命令执行之前监视任意数量的数据库键,并在 EXEC 命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将 拒绝执行事务,并向客户端返回代表执行失败的空回复。

每个 Redis 数据库都保存着一个 watched_keys 字典,字典的键是被 WATCH 监视的数据库键,而字典的值是一个链表,记录了所有监视相应数据库键的客户端。所有对数据库进行修改的命令,比如 SET、LPUSH、SADD、ZREM、DEL、FLUSHDB 等等,在执行之后都会调用 multi.c/touchWatchKey 函数对 watched_keys 字典进行检查,如果有客户端正在监视刚刚被命令修改过的数据库键,那么 touchWatchKey 函数会将链表中客户端的 REDIS_DIRTY_CAS 标识打开,标识该客户端的事务安全性已经被破坏。

当接收到客户端发来的 EXEC 命令时,服务器会根据这个客户端是否打开了 REDIS_DIRTY_CAS 标识来决定是否执行事务:

事务的 ACID 性质

Redis 的事务具有原子性,可以将事务中的多个操作当做一个整体来执行,服务器要么执行事务中的所有操作,要么就一个操作也不执行。与传统关系型数据库的最大区别是,Redis 不支持事务回滚 机制,即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下午,直到将事务队列中的所有命令都执行完毕为止。

Redis 通过谨慎的错误检测和简单的设计来保证事务的一致性,比如在事务入队命令的过程中,如果出现命令不存在或者命令格式不正确等,错误的命令不会被入队;事务在执行的过程中发生错误,出错的命令 不会 对数据库做任何修改,也不会中断事务的执行,服务器会继续执行事务中余下的其他命令,并且已执行的命令不会被出错的命令影响。

因为 Redis 使用 单线程 的方式来执行事务(以及事务队列中的命令),并且服务器保证,在执行事务期间不会对事务进行中断,因此,Redis 的事务总是以串行的方式运行的,并且事务也总是具有隔离性的。

当服务器在无持久化的内存模式下运行时,事务不具有耐久性;当运行在 RDB 模式下时,服务器只会在特定的保存条件被满足时,才会执行 BGSAVE 命令,因此也不具有耐久性;当运行在 AOF 模式下时,并且 appendfsync 选项的值为 always 时,事务才是具有耐久性的。虽然可以在事务的最后加上 SAVE 命令来保证事务的耐久性,但这种做法效率太低,并不具有实用性。