减少修改字符串时带来的内存重分配次数
因为 C 字符串并不记录自身的长度, 所以对于一个包含了 N 个字符的 C 字符串来说, 这个 C 字符串的底层实现总是一个 N+1 个字符长的数组(额外的一个字符空间用于保存空字符)。
因为 C 字符串的长度和底层数组的长度之间存在着这种关联性, 所以每次增长或者缩短一个 C 字符串, 程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:
如果程序执行的是增长字符串的操作, 比如拼接操作(append), 那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。
如果程序执行的是缩短字符串的操作, 比如截断操作(trim), 那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。
举个例子, 如果我们持有一个值为 "Redis" 的 C 字符串 s , 那么为了将 s 的值改为 "Redis Cluster" , 在执行:
strcat(s, " Cluster");
# 键为字符串对象,值为字符串对象
redis> SET msg "hello world"
OK
redis> TYPE msg
string
# 键为字符串对象,值为列表对象
redis> RPUSH numbers 1 3 5
(integer) 6
redis> TYPE numbers
list
# 键为字符串对象,值为哈希对象
redis> HMSET profile name Tome age 25 career Programmer
OK
redis> TYPE profile
hash
# 键为字符串对象,值为集合对象
redis> SADD fruits apple banana cherry
(integer) 3
redis> TYPE fruits
set
# 键为字符串对象,值为有序集合对象
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3
redis> TYPE price
zset
对象
对象 type 属性的值
TYPE 命令的输出
字符串对象
REDIS_STRING
"string"
列表对象
REDIS_LIST
"list"
哈希对象
REDIS_HASH
"hash"
集合对象
REDIS_SET
"set"
有序集合对象
REDIS_ZSET
"zset"
编码和底层实现
编码常量
编码所对应的底层数据结构
REDIS_ENCODING_INT
long 类型的整数
REDIS_ENCODING_EMBSTR
embstr 编码的简单动态字符串
REDIS_ENCODING_RAW
简单动态字符串
REDIS_ENCODING_HT
字典
REDIS_ENCODING_LINKEDLIST
双端链表
REDIS_ENCODING_ZIPLIST
压缩列表
REDIS_ENCODING_INTSET
整数集合
REDIS_ENCODING_SKIPLIST
跳跃表和字典
类型
编码
对象
REDIS_STRING
REDIS_ENCODING_INT
使用整数值实现的字符串对象。
REDIS_STRING
REDIS_ENCODING_EMBSTR
使用 embstr 编码的简单动态字符串实现的字符串对象。
REDIS_STRING
REDIS_ENCODING_RAW
使用简单动态字符串实现的字符串对象。
REDIS_LIST
REDIS_ENCODING_ZIPLIST
使用压缩列表实现的列表对象。
REDIS_LIST
REDIS_ENCODING_LINKEDLIST
使用双端链表实现的列表对象。
REDIS_HASH
REDIS_ENCODING_ZIPLIST
使用压缩列表实现的哈希对象。
REDIS_HASH
REDIS_ENCODING_HT
使用字典实现的哈希对象。
REDIS_SET
REDIS_ENCODING_INTSET
使用整数集合实现的集合对象。
REDIS_SET
REDIS_ENCODING_HT
使用字典实现的集合对象。
REDIS_ZSET
REDIS_ENCODING_ZIPLIST
使用压缩列表实现的有序集合对象。
REDIS_ZSET
REDIS_ENCODING_SKIPLIST
使用跳跃表和字典实现的有序集合对象。
redis> SET msg "hello wrold"
OK
redis> OBJECT ENCODING msg
"embstr"
redis> SET story "long long long long long long ago ..."
OK
redis> OBJECT ENCODING story
"raw"
redis> SADD numbers 1 3 5
(integer) 3
redis> OBJECT ENCODING numbers
"intset"
redis> SADD numbers "seven"
(integer) 1
redis> OBJECT ENCODING numbers
"hashtable"
redis> SET message "hello world"
OK
redis> RPUSH alphabet "a" "b" "c"
(integer) 3
redis> HSET book name "Redis in Action"
(integer) 1
redis> HSET book author "Josiah L. Carlson"
(integer) 1
redis> HSET book publisher "Manning"
(integer) 1
1.redis数据结构
简单字符串
redis专门封装了一个叫SDS的数据结构
优点:
char *strcat(char *dest, const char *src);
因为 C 字符串不记录自身的长度, 所以 strcat 假定用户在执行这个函数时, 已经为 dest 分配了足够多的内存, 可以容纳 src 字符串中的所有内容, 而一旦这个假定不成立时, 就会产生缓冲区溢出。 举个例子, 假设程序里有两个在内存中紧邻着的 C 字符串 s1 和 s2 , 其中 s1 保存了字符串 "Redis" , 而 s2 则保存了字符串 "MongoDB", 如图 2-7 所示。
strcat(s1, " Cluster");
将 s1 的内容修改为 "Redis Cluster" , 但粗心的他却忘了在执行 strcat 之前为 s1 分配足够的空间, 那么在 strcat 函数执行之后, s1 的数据将溢出到 s2 所在的空间中, 导致 s2 保存的内容被意外地修改, 如图 2-8 所示。
与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性: 当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求, 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小, 然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小, 也不会出现前面所说的缓冲区溢出问题。
举个例子, SDS 的 API 里面也有一个用于执行拼接操作的 sdscat 函数, 它可以将一个 C 字符串拼接到给定 SDS 所保存的字符串的后面, 但是在执行拼接操作之前, sdscat 会先检查给定 SDS 的空间是否足够, 如果不够的话, sdscat 就会先扩展 SDS 的空间, 然后才执行拼接操作。
如果程序执行的是增长字符串的操作, 比如拼接操作(append), 那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。 如果程序执行的是缩短字符串的操作, 比如截断操作(trim), 那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。 举个例子, 如果我们持有一个值为 "Redis" 的 C 字符串 s , 那么为了将 s 的值改为 "Redis Cluster" , 在执行:
strcat(s, " Cluster");
为了避免 C 字符串的这种缺陷, SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联: 在 SDS 中, buf 数组的长度不一定就是字符数量加一, 数组里面可以包含未使用的字节, 而这些字节的数量就由 SDS 的 free 属性记录。 通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。
其中, 额外分配的未使用空间数量由以下公式决定:
如果对 SDS 进行修改之后, SDS 的长度(也即是 len 属性的值)将小于 1 MB , 那么程序分配和 len 属性同样大小的未使用空间, 这时 SDS len 属性的值将和 free 属性的值相同。 举个例子, 如果进行修改之后, SDS 的 len 将变成 13 字节, 那么程序也会分配13 字节的未使用空间, SDS 的 buf 数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符)。
如果对 SDS 进行修改之后, SDS 的长度将大于等于 1 MB , 那么程序会分配 1 MB 的未使用空间。 举个例子, 如果进行修改之后, SDS 的 len 将变成 30 MB , 那么程序会分配 1 MB 的未使用空间, SDS 的 buf 数组的实际长度将为 30 MB + 1 MB + 1 byte 。
sdscat(s, " Cluster");
那么 sdscat 将执行一次内存重分配操作, 将 SDS 的长度修改为 13 字节, 并将 SDS 的未使用空间同样修改为 13 字节, 如图 2-12 所示。
如果这时, 我们再次对 s 执行:
sdscat(s, " Tutorial");
那么这次 sdscat 将不需要执行内存重分配: 因为未使用空间里面的 13 字节足以保存 9 字节的 " Tutorial" , 执行 sdscat 之后的 SDS 如图 2-13 所示。在扩展 SDS 空间之前, SDS API 会先检查未使用空间是否足够, 如果足够的话, API 就会直接使用未使用空间, 而无须执行内存重分配。
通过这种预分配策略, SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。
惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。 举个例子, sdstrim 函数接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符。
sdstrim(s, "XY"); // 移除 SDS 字符串中的所有 'X' 和 'Y'
会将 SDS 修改成图 2-15 所示的样子。
注意执行 sdstrim 之后的 SDS 并没有释放多出来的 8 字节空间, 而是将这 8 字节空间作为未使用空间保留在了 SDS 里面, 如果将来要对 SDS 进行增长操作的话, 这些未使用空间就可能会派上用场。
二进制安全 SDS 的 API 都是二进制安全的(binary-safe): 所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据, 程序不会对其中的数据做任何限制、过滤、或者假设 —— 数据在写入时是什么样的, 它被读取时就是什么样。
兼容部分 C 字符串函数
链表
多个 listNode 可以通过 prev 和 next 指针组成双端链表, 如图 3-1 所示。
list 结构为链表提供了表头指针 head 、表尾指针 tail , 以及链表长度计数器 len , 而 dup 、 free 和 match 成员则是用于实现多态链表所需的类型特定函数:
dup 函数用于复制链表节点所保存的值; free 函数用于释放链表节点所保存的值; match 函数则用于对比链表节点所保存的值和另一个输入值是否相等。
Redis 的链表实现的特性可以总结如下:
字典
字典, 又称符号表(symbol table)、关联数组(associative array)或者映射(map), 是一种用于保存键值对(key-value pair)的抽象数据结构。
其中一个键值对的键为 "Redis" , 值为 "Redis.io" 。 另一个键值对的键为 "MariaDB" , 值为 "MariaDB.org" ; 还有一个键值对的键为 "MongoDB" , 值为 "MongoDB.org" ;
table 属性是一个数组, 数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针, 每个 dictEntry 结构保存着一个键值对。 size 属性记录了哈希表的大小, 也即是 table 数组的大小, 而 used 属性则记录了哈希表目前已有节点(键值对)的数量。 sizemask 属性的值总是等于 size - 1 , 这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。
key 属性保存着键值对中的键, 而 v 属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数。
next 属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。
举个例子, 图 4-2 就展示了如何通过 next 指针, 将两个索引值相同的键 k1 和 k0 连接在一起。
type 属性和 privdata 属性是针对不同类型的键值对, 为创建多态字典而设置的:
type 属性是一个指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数, Redis 会为用途不同的字典设置不同的类型特定函数。
而 privdata 属性则保存了需要传给那些类型特定函数的可选参数。
ht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。
除了 ht[1] 之外, 另一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。
图 4-3 展示了一个普通状态下(没有进行 rehash)的字典:
当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
Redis 计算哈希值和索引值的方法如下:
举个例子, 对于图 4-4 所示的字典来说, 如果我们要将一个键值对 k0 和 v0 添加到字典里面, 那么程序会先使用语句:
hash = dict->type->hashFunction(k0);
计算键 k0 的哈希值。 假设计算得出的哈希值为 8 , 那么程序会继续使用语句:index = hash & dict->ht[0].sizemask = 8 & 3 = 0;
计算出键 k0 的索引值 0 , 这表示包含键值对 k0 和 v0 的节点应该被放置到哈希表数组的索引 0 位置上, 如图 4-5 所示。当字典被用作数据库的底层实现, 或者哈希键的底层实现时, Redis 使用 MurmurHash2 算法来计算键的哈希值。
MurmurHash 算法最初由 Austin Appleby 于 2008 年发明, 这种算法的优点在于, 即使输入的键是有规律的, 算法仍能给出一个很好的随机分布性, 并且算法的计算速度也非常快。
MurmurHash 算法目前的最新版本为 MurmurHash3 , 而 Redis 使用的是 MurmurHash2 , 关于 MurmurHash 算法的更多信息可以参考该算法的主页: http://code.google.com/p/smhasher/
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时, 我们称这些键发生了冲突(collision)。
Redis 的哈希表使用链地址法(separate chaining)来解决键冲突: 每个哈希表节点都有一个 next 指针, 多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用这个单向链表连接起来, 这就解决了键冲突的问题。 举个例子, 假设程序要将键值对 k2 和 v2 添加到图 4-6 所示的哈希表里面, 并且计算得出 k2 的索引值为 2 , 那么键 k1 和 k2 将产生冲突, 而解决冲突的办法就是使用 next 指针将键 k2 和 k1 所在的节点连接起来, 如图 4-7 所示。
因为 dictEntry 节点组成的链表没有指向链表表尾的指针, 所以为了速度考虑, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1)), 排在其他已有节点的前面。
扩展和收缩哈希表的工作可以通过执行 rehash (重新散列)操作来完成, Redis 对字典的哈希表执行 rehash 的步骤如下:
为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及 ht[0] 当前包含的键值对数量 (也即是ht[0].used 属性的值): 如果执行的是扩展操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n (2 的 n 次方幂); 如果执行的是收缩操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n 。 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上。
当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。
举个例子, 假设程序要对图 4-8 所示字典的 ht[0] 进行扩展操作, 那么程序将执行以下步骤: ht[0].used 当前的值为 4 , 4 * 2 = 8 , 而 8 (2^3)恰好是第一个大于等于 4 的 2 的 n 次方, 所以程序会将 ht[1] 哈希表的大小设置为 8 。 图 4-9 展示了 ht[1] 在分配空间之后, 字典的样子。 将 ht[0] 包含的四个键值对都 rehash 到 ht[1] , 如图 4-10 所示。 释放 ht[0] ,并将 ht[1] 设置为 ht[0] ,然后为 ht[1] 分配一个空白哈希表,如图 4-11 所示。 至此, 对哈希表的扩展操作执行完毕, 程序成功将哈希表的大小从原来的 4 改为了现在的 8 。
当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩展操作:
服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 1 ; 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 5 ; 其中哈希表的负载因子可以通过公式:
计算得出。
比如说, 对于一个大小为 4 , 包含 4 个键值对的哈希表来说, 这个哈希表的负载因子为:
load_factor = 4 / 4 = 1
又比如说, 对于一个大小为 512 , 包含 256 个键值对的哈希表来说, 这个哈希表的负载因子为:
load_factor = 256 / 512 = 0.5
根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行, 服务器执行扩展操作所需的负载因子并不相同, 这是因为在执行 BGSAVE 命令或BGREWRITEAOF 命令的过程中, Redis 需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率, 所以在子进程存在期间, 服务器会提高执行扩展操作所需的负载因子, 从而尽可能地避免在子进程存在期间进行哈希表扩展操作, 这可以避免不必要的内存写入操作, 最大限度地节约内存。 另一方面, 当哈希表的负载因子小于 0.1 时, 程序自动开始对哈希表执行收缩操作。
这样做的原因在于, 如果 ht[0] 里只保存着四个键值对, 那么服务器可以在瞬间就将这些键值对全部 rehash 到 ht[1] ; 但是, 如果哈希表里保存的键值对数量不是四个, 而是四百万、四千万甚至四亿个键值对, 那么要一次性将这些键值对全部 rehash 到 ht[1] 的话, 庞大的计算量可能会导致服务器在一段时间内停止服务。
因此, 为了避免 rehash 对服务器性能造成影响, 服务器不是一次性将 ht[0] 里面的所有键值对全部 rehash 到 ht[1] , 而是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1] 。 以下是哈希表渐进式 rehash 的详细步骤:
https://www.kancloud.cn/kancloud/redisbook/63842
跳跃表
跳跃表(skiplist)是一种有序数据结构, 它通过在每个节点中维持多个指向其他节点的指针, 从而达到快速访问节点的目的。
跳跃表支持平均 O(\log N) 最坏 O(N) 复杂度的节点查找, 还可以通过顺序性操作来批量处理节点。 在大部分情况下, 跳跃表的效率可以和平衡树相媲美, 并且因为跳跃表的实现比平衡树要来得更为简单, 所以有不少程序都使用跳跃表来代替平衡树。
Redis 使用跳跃表作为有序集合键的底层实现之一: 如果一个有序集合包含的元素数量比较多, 又或者有序集合中元素的成员(member)是比较长的字符串时, Redis 就会使用跳跃表来作为有序集合键的底层实现。
举个例子, fruit-price 是一个有序集合键, 这个有序集合以水果名为成员, 水果价钱为分值, 保存了 130 款水果的价钱:
fruit-price 有序集合的所有数据都保存在一个跳跃表里面, 其中每个跳跃表节点(node)都保存了一款水果的价钱信息, 所有水果按价钱的高低从低到高在跳跃表里面排序:
和链表、字典等数据结构被广泛地应用在 Redis 内部不同, Redis 只在两个地方用到了跳跃表, 一个是实现有序集合键, 另一个是在集群节点中用作内部数据结构, 除此之外, 跳跃表在 Redis 里面没有其他用途。
Redis 的跳跃表由 redis.h/zskiplistNode 和 redis.h/zskiplist 两个结构定义, 其中 zskiplistNode 结构用于表示跳跃表节点, 而 zskiplist结构则用于保存跳跃表节点的相关信息, 比如节点的数量, 以及指向表头节点和表尾节点的指针, 等等。
图 5-1 展示了一个跳跃表示例, 位于图片最左边的是 zskiplist 结构, 该结构包含以下属性: header :指向跳跃表的表头节点。 tail :指向跳跃表的表尾节点。 level :记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。 length :记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。 位于 zskiplist 结构右方的是四个 zskiplistNode 结构, 该结构包含以下属性: 层(level):节点中用 L1 、 L2 、 L3 等字样标记节点的各个层, L1 代表第一层, L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。 后退(backward)指针:节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。 分值(score):各个节点中的 1.0 、 2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列。 成员对象(obj):各个节点中的 o1 、 o2 和 o3 是节点所保存的成员对象。 注意表头节点和其他节点的构造是一样的: 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到, 所以图中省略了这些部分, 只显示了表头节点的各个层。
每次创建一个新跳跃表节点的时候, 程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小, 这个大小就是层的“高度”。
每个层都有一个指向表尾方向的前进指针(level[i].forward 属性), 用于从表头向表尾方向访问节点。 图 5-3 用虚线表示出了程序从表头向表尾方向, 遍历跳跃表中所有节点的路径:
1.迭代程序首先访问跳跃表的第一个节点(表头), 然后从第四层的前进指针移动到表中的第二个节点。 2.在第二个节点时, 程序沿着第二层的前进指针移动到表中的第三个节点。 3.在第三个节点时, 程序同样沿着第二层的前进指针移动到表中的第四个节点。 4.当程序再次沿着第四个节点的前进指针移动时, 它碰到一个 NULL , 程序知道这时已经到达了跳跃表的表尾, 于是结束这次遍历。
举个例子, 图 5-4 用虚线标记了在跳跃表中查找分值为 3.0 、 成员对象为 o3 的节点时, 沿途经历的层: 查找的过程只经过了一个层, 并且层的跨度为 3 , 所以目标节点在跳跃表中的排位为 3 。
节点的后退指针(backward 属性)用于从表尾向表头方向访问节点: 跟可以一次跳过多个节点的前进指针不同, 因为每个节点只有一个后退指针, 所以每次只能后退至前一个节点。 图 5-6 用虚线展示了如果从表尾向表头遍历跳跃表中的所有节点: 程序首先通过跳跃表的 tail 指针访问表尾节点, 然后通过后退指针访问倒数第二个节点, 之后再沿着后退指针访问倒数第三个节点, 再之后遇到指向 NULL 的后退指针, 于是访问结束。
举个例子, 在图 5-7 所示的跳跃表中, 三个跳跃表节点都保存了相同的分值 10086.0 , 但保存成员对象 o1 的节点却排在保存成员对象 o2和 o3 的节点之前, 而保存成员对象 o2 的节点又排在保存成员对象 o3 的节点之前, 由此可见, o1 、 o2 、 o3 三个成员对象在字典中的排序为 o1 <= o2 <= o3 。
整数集合
整数集合(intset)是集合键的底层实现之一: 当一个集合只包含整数值元素, 并且这个集合的元素数量不多时, Redis 就会使用整数集合作为集合键的底层实现。
contents 数组是整数集合的底层实现: 整数集合的每个元素都是 contents 数组的一个数组项(item), 各个项在数组中按值的大小从小到大有序地排列, 并且数组中不包含任何重复项。
length 属性记录了整数集合包含的元素数量, 也即是 contents 数组的长度。
虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组, 但实际上 contents 数组并不保存任何 int8_t 类型的值 —— contents 数组的真正类型取决于 encoding 属性的值:
如果 encoding 属性的值为 INTSET_ENC_INT16 , 那么 contents 就是一个 int16_t 类型的数组, 数组里的每个项都是一个 int16_t 类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )。
如果 encoding 属性的值为 INTSET_ENC_INT32 , 那么 contents 就是一个 int32_t 类型的数组, 数组里的每个项都是一个 int32_t 类型的整数值 (最小值为 -2,147,483,648 ,最大值为 2,147,483,647 )。
如果 encoding 属性的值为 INTSET_ENC_INT64 , 那么 contents 就是一个 int64_t 类型的数组, 数组里的每个项都是一个 int64_t 类型的整数值 (最小值为 -9,223,372,036,854,775,808 ,最大值为 9,223,372,036,854,775,807 )。
encoding 属性的值为 INTSET_ENC_INT16 , 表示整数集合的底层实现为 int16_t 类型的数组, 而集合保存的都是 int16_t 类型的整数值。
length 属性的值为 5 , 表示整数集合包含五个元素。
contents 数组按从小到大的顺序保存着集合中的五个元素。
因为每个集合元素都是 int16_t 类型的整数值, 所以 contents 数组的大小等于 sizeof(int16_t) 5 = 16 5 = 80 位。
虽然 contents 数组保存的四个整数值中, 只有 -2675256175807981027 是真正需要用 int64_t 类型来保存的, 而其他的 1 、 3 、 5 三个值都可以用 int16_t 类型来保存, 不过根据整数集合的升级规则, 当向一个底层为 int16_t 数组的整数集合添加一个 int64_t 类型的整数值时, 整数集合已有的所有元素都会被转换成 int64_t 类型, 所以 contents 数组保存的四个整数值都是 int64_t 类型的, 不仅仅是-2675256175807981027 。
升级整数集合并添加新元素共分为三步进行:
举个例子, 假设现在有一个 INTSET_ENC_INT16 编码的整数集合, 集合中包含三个 int16_t 类型的元素, 如图 6-3 所示。
因为每个元素都占用 16 位空间, 所以整数集合底层数组的大小为 3 * 16 = 48 位, 图 6-4 展示了整数集合的三个元素在这 48 位里的位置。
现在, 假设我们要将类型为 int32_t 的整数值 65535 添加到整数集合里面, 因为 65535 的类型 int32_t 比整数集合当前所有元素的类型都要长, 所以在将 65535 添加到整数集合之前, 程序需要先对整数集合进行升级。 升级首先要做的是, 根据新类型的长度, 以及集合元素的数量(包括要添加的新元素在内), 对底层数组进行空间重分配。
整数集合目前有三个元素, 再加上新元素 65535 , 整数集合需要分配四个元素的空间, 因为每个 int32_t 整数值需要占用 32 位空间, 所以在空间重分配之后, 底层数组的大小将是 32 * 4 = 128 位, 如图 6-5 所示
虽然程序对底层数组进行了空间重分配, 但数组原有的三个元素 1 、 2 、 3 仍然是 int16_t 类型, 这些元素还保存在数组的前 48 位里面, 所以程序接下来要做的就是将这三个元素转换成 int32_t 类型, 并将转换后的元素放置到正确的位上面, 而且在放置元素的过程中, 需要维持底层数组的有序性质不变。 首先, 因为元素 3 在 1 、 2 、 3 、 65535 四个元素中排名第三, 所以它将被移动到 contents 数组的索引 2 位置上, 也即是数组 64 位至 95 位的空间内, 如图 6-6 所示。
接着, 因为元素 2 在 1 、 2 、 3 、 65535 四个元素中排名第二, 所以它将被移动到 contents 数组的索引 1 位置上, 也即是数组的 32位至 63 位的空间内, 如图 6-7 所示。
之后, 因为元素 1 在 1 、 2 、 3 、 65535 四个元素中排名第一, 所以它将被移动到 contents 数组的索引 0 位置上, 也即是数组的 0 位至 31 位的空间内, 如图 6-8 所示。
然后, 因为元素 65535 在 1 、 2 、 3 、 65535 四个元素中排名第四, 所以它将被添加到 contents 数组的索引 3 位置上, 也即是数组的96 位至 127 位的空间内, 如图 6-9 所示。
最后, 程序将整数集合 encoding 属性的值从 INTSET_ENC_INT16 改为 INTSET_ENC_INT32 , 并将 length 属性的值从 3 改为 4 , 设置完成之后的整数集合如图 6-10 所示。
因为每次向整数集合添加新元素都可能会引起升级, 而每次升级都需要对底层数组中已有的所有元素进行类型转换, 所以向整数集合添加新元素的时间复杂度为 O(N)
其他类型的升级操作, 比如从 INTSET_ENC_INT16 编码升级为 INTSET_ENC_INT64 编码, 或者从 INTSET_ENC_INT32 编码升级为 INTSET_ENC_INT64 编码, 升级的过程都和上面展示的升级过程类似。
升级之后新元素的摆放位置
因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大, 所以这个新元素的值要么就大于所有现有元素, 要么就小于所有现有元素:
在新元素小于所有现有元素的情况下, 新元素会被放置在底层数组的最开头(索引 0 ); 在新元素大于所有现有元素的情况下, 新元素会被放置在底层数组的最末尾(索引 length-1 )。
压缩列表
当一个列表键只包含少量列表项, 并且每个列表项要么就是小整数值, 要么就是长度比较短的字符串, 那么 Redis 就会使用压缩列表来做列表键的底层实现。
另外, 当一个哈希键只包含少量键值对, 并且每个键值对的键和值要么就是小整数值, 要么就是长度比较短的字符串, 那么 Redis 就会使用压缩列表来做哈希键的底层实现。
举个例子, 执行以下命令将创建一个压缩列表实现的哈希键:
每个压缩列表节点可以保存一个字节数组或者一个整数值, 其中, 字节数组可以是以下三种长度的其中一种:
而整数值则可以是以下六种长度的其中一种:
每个压缩列表节点都由 previous_entry_length 、 encoding 、 content 三个部分组成
节点的 previous_entry_length 属性以字节为单位, 记录了压缩列表中前一个节点的长度。 previous_entry_length 属性的长度可以是 1 字节或者 5 字节:
因为节点的 previous_entry_length 属性记录了前一个节点的长度, 所以程序可以通过指针运算, 根据当前节点的起始地址来计算出前一个节点的起始地址。
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的: 只要我们拥有了一个指向某个节点起始地址的指针, 那么通过这个指针以及这个节点的 previous_entry_length 属性, 程序就可以一直向前一个节点回溯, 最终到达压缩列表的表头节点。 图 7-8 展示了一个从表尾节点向表头节点进行遍历的完整过程:
节点的 encoding 属性记录了节点的 content 属性所保存数据的类型以及长度:
编码的最高两位 00 表示节点保存的是一个字节数组; 编码的后六位 001011 记录了字节数组的长度 11 ; content 属性保存着节点的值 "hello world" 。
编码 11000000 表示节点保存的是一个 int16_t 类型的整数值; content 属性保存着节点的值 10086 。
对象
https://www.kancloud.cn/kancloud/redisbook/63862
Redis 只对包含整数值的字符串对象进行共享。
除了前面介绍过的 type 、 encoding 、 ptr 和 refcount 四个属性之外, redisObject 结构包含的最后一个属性为 lru 属性, 该属性记录了对象最后一次被命令程序访问的时间:
单机数据库的实现
数据库
删除键 删除数据库中的一个键, 实际上就是在键空间里面删除键所对应的键值对对象。
更新键 对一个数据库键进行更新, 实际上就是对键空间里面键所对应的值对象进行更新, 根据值对象的类型不同, 更新的具体方法也会有所不同。
对键取值
读写键空间时的维护操作 当使用 Redis 命令对数据库进行读写时, 服务器不仅会对键空间执行指定的读写操作, 还会执行一些额外的维护操作, 其中包括:
在读取一个键之后(读操作和写操作都要对键进行读取), 服务器会根据键是否存在, 以此来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数, 这两个值可以在 INFO stats 命令的 keyspace_hits 属性和 keyspace_misses 属性中查看。
在读取一个键之后, 服务器会更新键的 LRU (最后一次使用)时间, 这个值可以用于计算键的闲置时间, 使用命令 OBJECT idletime 命令可以查看键 key 的闲置时间。
如果服务器在读取一个键时, 发现该键已经过期, 那么服务器会先删除这个过期键, 然后才执行余下的其他操作, 本章稍后对过期键的讨论会详细说明这一点。
如果有客户端使用 WATCH 命令监视了某个键, 那么服务器在对被监视的键进行修改之后, 会将这个键标记为脏(dirty), 从而让事务程序注意到这个键已经被修改过, 《事务》一章会详细说明这一点。
服务器每次修改一个键之后, 都会对脏(dirty)键计数器的值增一, 这个计数器会触发服务器的持久化以及复制操作执行, 《RDB 持久化》、《AOF 持久化》和《复制》这三章都会说到这一点。
如果服务器开启了数据库通知功能, 那么在对键进行修改之后, 服务器将按配置发送相应的数据库通知, 本章稍后讨论数据库通知功能的实现时会详细说明这一点。
Redis 服务器的所有数据库都保存在 redisServer.db 数组中, 而数据库的数量则由 redisServer.dbnum 属性保存。
客户端通过修改目标数据库指针, 让它指向 redisServer.db 数组中的不同元素来切换不同的数据库。
数据库主要由 dict 和 expires 两个字典构成, 其中 dict 字典负责保存键值对, 而 expires 字典则负责保存键的过期时间。
因为数据库由字典构成, 所以对数据库的操作都是建立在字典操作之上的。
数据库的键总是一个字符串对象, 而值则可以是任意一种 Redis 对象类型, 包括字符串对象、哈希表对象、集合对象、列表对象和有序集合对象, 分别对应字符串键、哈希表键、集合键、列表键和有序集合键。
expires 字典的键指向数据库中的某个键, 而值则记录了数据库键的过期时间, 过期时间是一个以毫秒为单位的 UNIX 时间戳。
Redis 使用惰性删除和定期删除两种策略来删除过期的键: 惰性删除策略只在碰到过期键时才进行删除操作, 定期删除策略则每隔一段时间, 主动查找并删除过期键。
执行 SAVE 命令或者 BGSAVE 命令所产生的新 RDB 文件不会包含已经过期的键。
执行 BGREWRITEAOF 命令所产生的重写 AOF 文件不会包含已经过期的键。
当一个过期键被删除之后, 服务器会追加一条 DEL 命令到现有 AOF 文件的末尾, 显式地删除过期键。
当主服务器删除一个过期键之后, 它会向所有从服务器发送一条 DEL 命令, 显式地删除过期键。
从服务器即使发现过期键, 也不会自作主张地删除它, 而是等待主节点发来 DEL 命令, 这种统一、中心化的过期键删除策略可以保证主从服务器数据的一致性。
当 Redis 命令对数据库进行修改之后, 服务器会根据配置, 向客户端发送数据库通知。
RDB 持久化
RDB 文件的最开头是 REDIS 部分, 这个部分的长度为 5 字节, 保存着 "REDIS" 五个字符。 通过这五个字符, 程序可以在载入文件时, 快速检查所载入的文件是否 RDB 文件。
db_version 长度为 4 字节, 它的值是一个字符串表示的整数, 这个整数记录了 RDB 文件的版本号, 比如 "0006" 就代表 RDB 文件的版本为第六版。
databases 部分包含着零个或任意多个数据库, 以及各个数据库中的键值对数据:
EOF 常量的长度为 1 字节, 这个常量标志着 RDB 文件正文内容的结束, 当读入程序遇到这个值的时候, 它知道所有数据库的所有键值对都已经载入完毕了
check_sum 是一个 8 字节长的无符号整数, 保存着一个校验和, 这个校验和是程序通过对 REDIS 、 db_version 、 databases 、 EOF 四个部分的内容进行计算得出的。 服务器在载入 RDB 文件时, 会将载入数据所计算出的校验和与 check_sum 所记录的校验和进行对比, 以此来检查 RDB 文件是否有出错或者损坏的情况出现。
每个非空数据库在 RDB 文件中都可以保存为 SELECTDB 、 db_number 、 key_value_pairs 三个部分
SELECTDB 常量的长度为 1 字节, 当读入程序遇到这个值的时候, 它知道接下来要读入的将是一个数据库号码。
db_number 保存着一个数据库号码, 根据号码的大小不同, 这个部分的长度可以是 1 字节、 2 字节或者 5 字节。 当程序读入 db_number 部分之后, 服务器会调用 SELECT 命令, 根据读入的数据库号码进行数据库切换, 使得之后读入的键值对可以载入到正确的数据库中。
key_value_pairs 部分保存了数据库中的所有键值对数据, 如果键值对带有过期时间, 那么过期时间也会和键值对保存在一起。 根据键值对的数量、类型、内容、以及是否有过期时间等条件的不同, key_value_pairs 部分的长度也会有所不同。
不带过期时间的键值对在 RDB 文件中对由 TYPE 、 key 、 value 三部分组成, 如图 IMAGE_KEY_WITHOUT_EXPIRE_TIME 所示
以上列出的每个 TYPE 常量都代表了一种对象类型或者底层编码, 当服务器读入 RDB 文件中的键值对数据时, 程序会根据 TYPE 的值来决定如何读入和解释 value 的数据。 key 和 value 分别保存了键值对的键对象和值对象:
https://www.kancloud.cn/kancloud/redisbook/63879
AOF 持久化
AOF 持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。
当 AOF 持久化功能处于打开状态时, 服务器在执行完一个写命令之后, 会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾:
因为服务器在处理文件事件时可能会执行写命令, 使得一些内容被追加到 aof_buf 缓冲区里面, 所以在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile 函数, 考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面, 这个过程可以用以下伪代码表示:
如果用户没有主动为 appendfsync 选项设置值, 那么 appendfsync 选项的默认值为 everysec , 关于 appendfsync 选项的更多信息, 请参考 Redis 项目附带的示例配置文件 redis.conf 。
为了提高文件的写入效率, 在现代操作系统中, 当用户调用 write 函数, 将一些数据写入到文件的时候, 操作系统通常会将写入数据暂时保存在一个内存缓冲区里面, 等到缓冲区的空间被填满、或者超过了指定的时限之后, 才真正地将缓冲区中的数据写入到磁盘里面。
这种做法虽然提高了效率, 但也为写入数据带来了安全问题, 因为如果计算机发生停机, 那么保存在内存缓冲区里面的写入数据将会丢失。为此, 系统提供了 fsync 和 fdatasync 两个同步函数, 它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面, 从而确保写入数据的安全性。
如果这时 flushAppendOnlyFile 函数被调用, 假设服务器当前 appendfsync 选项的值为 everysec , 并且根据 server.aof_last_fsync 属性显示, 距离上次同步 AOF 文件已经超过一秒钟, 那么服务器会先将 aof_buf 中的内容写入到 AOF 文件中, 然后再对 AOF 文件进行同步。
当 appendfsync 的值为 always 时, 服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 并且同步 AOF 文件, 所以 always 的效率是 appendfsync 选项三个值当中最慢的一个, 但从安全性来说, always 也是最安全的, 因为即使出现故障停机, AOF 持久化也只会丢失一个事件循环中所产生的命令数据。
当 appendfsync 的值为 everysec 时, 服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 并且每隔超过一秒就要在子线程中对 AOF 文件进行一次同步: 从效率上来讲, everysec 模式足够快, 并且就算出现故障停机, 数据库也只丢失一秒钟的命令数据。
事件
Redis 基于 Reactor 模式开发了自己的网络事件处理器: 这个处理器被称为文件事件处理器(file event handler):
虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
文件事件是对套接字操作的抽象, 每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时, 就会产生一个文件事件。 因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。
I/O 多路复用程序负责监听多个套接字, 并向文件事件分派器传送那些产生了事件的套接字。
尽管多个文件事件可能会并发地出现, 但 I/O 多路复用程序总是会将所有产生事件的套接字都入队到一个队列里面, 然后通过这个队列, 以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字: 当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字, 如图 IMAGE_DISPATCH_EVENT_VIA_QUEUE 。
文件事件分派器接收 I/O 多路复用程序传来的套接字, 并根据套接字产生的事件的类型, 调用相应的事件处理器。
服务器会为执行不同任务的套接字关联不同的事件处理器, 这些处理器是一个个函数, 它们定义了某个事件发生时, 服务器应该执行的动作。
Redis 在 I/O 多路复用程序的实现源码中用 #include 宏定义了相应的规则, 程序会在编译时自动选择系统中性能最高的 I/O 多路复用函数库来作为 Redis 的 I/O 多路复用程序的底层实现:
https://www.kancloud.cn/kancloud/redisbook/63885
假设一个 Redis 服务器正在运作, 那么这个服务器的监听套接字的 AE_READABLE 事件应该正处于监听状态之下, 而该事件所对应的处理器为连接应答处理器。
如果这时有一个 Redis 客户端向服务器发起连接, 那么监听套接字将产生 AE_READABLE 事件, 触发连接应答处理器执行: 处理器会对客户端的连接请求进行应答, 然后创建客户端套接字, 以及客户端状态, 并将客户端套接字的 AE_READABLE 事件与命令请求处理器进行关联, 使得客户端可以向主服务器发送命令请求。
之后, 假设客户端向主服务器发送一个命令请求, 那么客户端套接字将产生 AE_READABLE 事件, 引发命令请求处理器执行, 处理器读取客户端的命令内容, 然后传给相关程序去执行。
执行命令将产生相应的命令回复, 为了将这些命令回复传送回客户端, 服务器会将客户端套接字的 AE_WRITABLE 事件与命令回复处理器进行关联: 当客户端尝试读取命令回复的时候, 客户端套接字将产生 AE_WRITABLE 事件, 触发命令回复处理器执行, 当命令回复处理器将命令回复全部写入到套接字之后, 服务器就会解除客户端套接字的 AE_WRITABLE 事件与命令回复处理器之间的关联。
图 IMAGE_COMMAND_PROGRESS 总结了上面描述的整个通讯过程, 以及通讯时用到的事件处理器。
根据客户端类型的不同, fd 属性的值可以是 -1 或者是大于 -1 的整数: 伪客户端(fake client)的 fd 属性的值为 -1 : 伪客户端处理的命令请求来源于 AOF 文件或者 Lua 脚本, 而不是网络, 所以这种客户端不需要套接字连接, 自然也不需要记录套接字描述符。 目前 Redis 服务器会在两个地方用到伪客户端, 一个用于载入 AOF 文件并还原数据库状态, 而另一个则用于执行 Lua 脚本中包含的 Redis 命令。
普通客户端的 fd 属性的值为大于 -1 的整数: 普通客户端使用套接字来与服务器进行通讯, 所以服务器会用 fd 属性来记录客户端套接字的描述符。 因为合法的套接字描述符不能是 -1 , 所以普通客户端的套接字描述符的值必然是大于 -1 的整数。
执行 CLIENT_LIST 命令可以列出目前所有连接到服务器的普通客户端, 命令输出中的 fd 域显示了服务器连接客户端所使用的套接字描述符:
使用 CLIENT_SETNAME 命令可以为客户端设置一个名字, 让客户端的身份变得更清晰。
可以是多个标志的二进制或, 比如:
flags = <flag1> | <flag2> | ...
输入缓冲区的大小会根据输入内容动态地缩小或者扩大, 但它的最大大小不能超过 1 GB , 否则服务器将关闭这个客户端。
如果 authenticated 的值为 0 , 那么表示客户端未通过身份验证; 如果 authenticated 的值为 1 , 那么表示客户端已经通过了身份验证。
那么从客户端发送 SET KEY VALUE 命令到获得回复 OK 期间, 客户端和服务器共需要执行以下操作:
发送命令请求 Redis 服务器的命令请求来自 Redis 客户端, 当用户在客户端中键入一个命令请求时, 客户端会将这个命令请求转换成协议格式, 然后通过连接到服务器的套接字, 将协议格式的命令请求发送给服务器
读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时, 服务器将调用命令请求处理器来执行以下操作:
https://www.kancloud.cn/kancloud/redisbook/63892
多机数据库的实现
旧版复制功能的实现
Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作:
同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
而命令传播操作则用于在主服务器的数据库状态被修改, 导致主从服务器的数据库状态出现不一致时, 让主从服务器的数据库重新回到一致状态。
同步 当客户端向从服务器发送 SLAVEOF 命令, 要求从服务器复制主服务器时, 从服务器首先需要执行同步操作, 也即是, 将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
从服务器对主服务器的同步操作需要通过向主服务器发送 SYNC 命令来完成, 以下是 SYNC 命令的执行步骤:
举个例子, 假设一个主服务器和一个从服务器刚刚完成同步操作, 它们的数据库都保存了相同的五个键 k1 至 k5 , 如图 IMAGE_CONSISTENT 所示。
如果这时, 客户端向主服务器发送命令 DEL k3 , 那么主服务器在执行完这个 DEL 命令之后, 主从服务器的数据库将出现不一致: 主服务器的数据库已经不再包含键 k3 , 但这个键却仍然包含在从服务器的数据库里面, 如图 IMAGE_INCONSISTENT 所示。
为了让主从服务器再次回到一致状态, 主服务器需要对从服务器执行命令传播操作: 主服务器会将自己执行的写命令 —— 也即是造成主从服务器不一致的那条写命令 —— 发送给从服务器执行, 当从服务器执行了相同的写命令之后, 主从服务器将再次回到一致状态。
在上面的例子中, 主服务器因为执行了命令 DEL k3 而导致主从服务器不一致, 所以主服务器将向从服务器发送相同的命令 DEL k3 : 当从服务器执行完这个命令之后, 主从服务器将再次回到一致状态 —— 现在主从服务器两者的数据库都不再包含键 k3 了, 如图 IMAGE_PROPAGATE_DEL_k3 所示。
Sentinel
$ redis-sentinel /path/to/your/sentinel.conf
或者命令:$ redis-server /path/to/your/sentinel.conf --sentinel
这两个命令的效果完全相同。当一个 Sentinel 启动时, 它需要执行以下步骤:
比如说, 普通 Redis 服务器使用 redis.h/REDIS_SERVERPORT 常量的值作为服务器端口:
#define REDIS_SERVERPORT 6379
而 Sentinel 则使用 sentinel.c/REDIS_SENTINEL_PORT 常量的值作为服务器端口:#define REDIS_SENTINEL_PORT 26379
除此之外, 普通 Redis 服务器使用 redis.c/redisCommandTable 作为服务器的命令表:
sentinelRedisInstance.addr 属性是一个指向 sentinel.c/sentinelAddr 结构的指针, 这个结构保存着实例的 IP 地址和端口号:
对 Sentinel 状态的初始化将引发对 masters 字典的初始化, 而 masters 字典的初始化是根据被载入的 Sentinel 配置文件来进行的。
对于每个被 Sentinel 监视的主服务器来说, Sentinel 会创建两个连向主服务器的异步网络连接:
一个是命令连接, 这个连接专门用于向主服务器发送命令, 并接收命令回复。 另一个是订阅连接, 这个连接专门用于订阅主服务器的 sentinel:hello 频道。 为什么有两个连接?
在 Redis 目前的发布与订阅功能中, 被发送的信息都不会保存在 Redis 服务器里面, 如果在信息发送时, 想要接收信息的客户端不在线或者断线, 那么这个客户端就会丢失这条信息。
因此, 为了不丢失 sentinel:hello 频道的任何信息, Sentinel 必须专门用一个订阅连接来接收该频道的信息。 而另一方面, 除了订阅频道之外, Sentinel 还又必须向主服务器发送命令, 以此来与主服务器进行通讯, 所以 Sentinel 还必须向主服务器创建命令连接。
并且因为 Sentinel 需要与多个实例创建多个网络连接, 所以 Sentinel 使用的是异步连接。
图 IMAGE_SENTINEL_CONNECT_SERVER 展示了一个 Sentinel 向被它监视的两个主服务器 master1 和 master2 创建命令连接和订阅连接的例子。
Sentinel 系统选举领头 Sentinel 的方法是对 Raft 算法的领头选举方法的实现
一个 Redis 集群通常由多个节点(node)组成, 在刚开始的时候, 每个节点都是相互独立的, 它们都处于一个只包含自己的集群当中, 要组建一个真正可工作的集群, 我们必须将各个独立的节点连接起来, 构成一个包含多个节点的集群。
连接各个节点的工作可以使用 CLUSTER MEET 命令来完成, 该命令的格式如下:
CLUSTER MEET <ip> <port>
向一个节点 node 发送 CLUSTER MEET 命令, 可以让 node 节点与 ip 和 port 所指定的节点进行握手(handshake), 当握手成功时, node节点就会将 ip 和 port 所指定的节点添加到 node 节点当前所在的集群中。 举个例子, 假设现在有三个独立的节点 127.0.0.1:7000 、 127.0.0.1:7001 、 127.0.0.1:7002 (下文省略 IP 地址,直接使用端口号来区分各个节点), 我们首先使用客户端连上节点 7000 , 通过发送 CLUSTER NODE 命令可以看到, 集群目前只包含 7000 自己一个节点:
通过向节点 7000 发送以下命令, 我们可以将节点 7001 添加到节点 7000 所在的集群里面:
继续向节点 7000 发送以下命令, 我们可以将节点 7002 也添加到节点 7000 和节点 7001 所在的集群里面:
现在, 这个集群里面包含了 7000 、 7001 和 7002 三个节点, 图 IMAGE_CONNECT_NODES_1 至 IMAGE_CONNECT_NODES_5 展示了这三个节点进行握手的整个过程。
节点(运行在集群模式下的 Redis 服务器)会继续使用所有在单机模式中使用的服务器组件, 比如说:
节点会继续使用文件事件处理器来处理命令请求和返回命令回复。
节点会继续使用时间事件处理器来执行 serverCron 函数, 而 serverCron 函数又会调用集群模式特有的 clusterCron 函数: clusterCron函数负责执行在集群模式下需要执行的常规操作, 比如向集群中的其他节点发送 Gossip 消息, 检查节点是否断线; 又或者检查是否需要对下线节点进行自动故障转移, 等等。
节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象。
节点会继续使用 RDB 持久化模块和 AOF 持久化模块来执行持久化工作。
节点会继续使用发布与订阅模块来执行 PUBLISH 、 SUBSCRIBE 等命令。
节点会继续使用复制模块来进行节点的复制工作。
节点会继续使用 Lua 脚本环境来执行客户端输入的 Lua 脚本。
集群数据结构 clusterNode 结构保存了一个节点的当前状态, 比如节点的创建时间, 节点的名字, 节点当前的配置纪元, 节点的 IP 和地址, 等等。
每个节点都会使用一个 clusterNode 结构来记录自己的状态, 并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的clusterNode 结构, 以此来记录其他节点的状态:
clusterNode 结构的 link 属性是一个 clusterLink 结构, 该结构保存了连接节点所需的有关信息, 比如套接字描述符, 输入缓冲区和输出缓冲区:
redisClient 结构和 clusterLink 结构的相同和不同之处
redisClient 结构和 clusterLink 结构都有自己的套接字描述符和输入、输出缓冲区, 这两个结构的区别在于, redisClient 结构中的套接字和缓冲区是用于连接客户端的, 而 clusterLink 结构中的套接字和缓冲区则是用于连接节点的。
最后, 每个节点都保存着一个 clusterState 结构, 这个结构记录了在当前节点的视角下, 集群目前所处的状态 —— 比如集群是在线还是下线, 集群包含多少个节点, 集群当前的配置纪元, 诸如此类:
以前面介绍的 7000 、 7001 、 7002 三个节点为例, 图 IMAGE_CLUSTER_STATE_OF_7000 展示了节点 7000 创建的 clusterState 结构, 这个结构从节点 7000 的角度记录了集群、以及集群包含的三个节点的当前状态 (为了空间考虑,图中省略了 clusterNode 结构的一部分属性):
节点 7001 和节点 7002 也会创建类似的 clusterState 结构:
不过在节点 7001 创建的 clusterState 结构中, myself 指针将指向代表节点 7001 的 clusterNode 结构, 而节点 7000 和节点 7002 则是集群中的其他节点。
而在节点 7002 创建的 clusterState 结构中, myself 指针将指向代表节点 7002 的 clusterNode 结构, 而节点 7000 和节点 7001 则是集群中的其他节点。
CLUSTER MEET 命令的实现 通过向节点 A 发送 CLUSTER MEET 命令, 客户端可以让接收命令的节点 A 将另一个节点 B 添加到节点 A 当前所在的集群里面:
CLUSTER MEET <ip> <port>
收到命令的节点 A 将与节点 B 进行握手(handshake), 以此来确认彼此的存在, 并为将来的进一步通信打好基础:
之后, 节点 A 会将节点 B 的信息通过 Gossip 协议传播给集群中的其他节点, 让其他节点也与节点 B 进行握手, 最终, 经过一段时间之后, 节点 B 会被集群中的所有节点认识。
独立功能的实现
https://www.kancloud.cn/kancloud/redisbook/63905