Redis 设计与实现
Redis 3.0
# 数据结构与对象
# SDS 简单动态字符串
Redis 没有直接使用 C语言 传统的字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 用作 Redis 的默认字符串表示。在 Redis 的数据库里面,包含字符串值的键值对在底层都是由 SDS 实现的。 如果客户端执行命令:
redis> SET msg "hello world"
OK
那么 Redis 将在数据库中创建一个新的键值对,其中:
- 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串“msg”的 SDS。
- 键值对的值也是一个字符串对象,对象的底层实现是一个保存着字符串“hello world”的 SDS。
又比如,如果客户端执行命令:
redis> RPUSH fruits "apple" "banana" "cherry"
(integer) 3
那么 Redis 将在数据库中创建一个新的键值对,其中:
- 键值对的键是一个字符串对象,对象的底层实现是一个保存了字符串“fruits”的 SDS。
- 键值对的值是一个列表对象,列表对象包含了三个字符串对象,这三个字符串对象分别由三个 SDS 实现:第一个 SDS 保存着字符串“apple”,第二个 SDS 保存着字符串“banana”,第三个 SDS 保存着字符串“cherry”。
除了用来保存数据库中的字符串值之外,SDS 还被用作缓冲区(buffer):AOF 模块中的 AOF 缓冲区,以及客户端状态中的输入缓冲区,都是由 SDS 实现的。
# SDS 的定义
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
- free 属性的值为 5,表示这个 SDS 分配了 5 个字节未使用空间。
- len 属性的值为 5,表示这 个SDS 保存了 5 字节长的字符串。
- buf 属性是一个 char 类型的数组,数组的前五个字节分别保存了 'R'、'e'、'd'、'i'、's' 五个字符,而最后一个字节则保存了空字符 '\0'。
SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的 1 字节空间不计算在 SDS 的 len 属性里面,并且为空字符分配额外的 1 字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS 函数自动完成的,所以这个空字符对于 SDS 的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS 可以直接重用一部分 C 字符串函数库里面的函数。
# SDS 的好处
- 获取字符长度时间复杂度为 O(1),C语言获取字符串长度需要进行遍历,O(n)。
- 二进制安全,使用 len 属性来获取字符串的具体数据,C 语言是找到第一个为
\0
的字符,进行截取。如果数据中包含\0
呢。 - 防止数据溢出,C语言进行字符串的追加时,要先分配内存空间,如果忘记分配内存空间,会污染后续的数据,即内存溢出。如果截断数据,需要进行空间释放,否则会造成数内存泄漏。
- 使用 SDS 可以调用C语言的 string 函数。
- 空间预分配 + 惰性空间释放
- 创建 SDS 对象时,分配一个 free 空间和 len 长度一样,但需要拼接数据时,如果 free 空间能存放,直接追加,不用再次申请内存空间,即 空间预分配。大于 1M 的字符串最多只分配 1M 的 free 空间。
- 如果截断字符,只需要改变 free 和 len 的数据,不用释放内存空间。即 空间惰性释放。
举例: len = 5,free = 5,buf[] = Redis\0 扩容后 len = 8,free = 2,buf[] = Redis123\0 缩容后 len = 3,free = 7,buf[] = Red\0
# Linked List 链表
链表被广泛用于实现 Redis 的各种功能,比如 列表键、发布与订阅、慢查询、监视器等。 每个链表节点使用一个 adlist.h/listNode 结构来表示:
typedef struct listNode {
// 前置节点
struct listNode * prev;
// 后置节点
struct listNode * next;
// 节点的值
void * value;
}listNode;
多个 listNode 可以通过 prev 和 next 指针组成双端链表 虽然仅仅使用多个 listNode 结构就可以组成链表,但使用 adlist.h/list 来持有链表的话,操作起来会更方便:
typedef struct list {
// 表头节点
listNode * head;
// 表尾节点
listNode * tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr,void *key);
} list;
list 结构为链表提供了表头指针 head、表尾指针 tail,以及链表长度计数器 len,而 dup、free 和 match 成员则是用于实现多态链表所需的类型特定函数:
- dup 函数用于复制链表节点所保存的值;
- free 函数用于释放链表节点所保存的值;
- match 函数则用于对比链表节点所保存的值和另一个输入值是否相等。
特性:
- 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(1)。
- 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点。
- 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
- 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为 O(1)。
- 多态:链表节点使用 void* 指针来保存节点值,并且可以通过 list 结构的 dup、free、match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
# Dict 字典
字典在 Redis 中的应用相当广泛,比如 Redis 的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。除了用来表示数据库之外,字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis 就会使用字典作为哈希键的底层实现。
# 字典的实现
Redis 的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。 哈希表由 dict.h/dictht 结构定义:、
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size-1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
table 属性是一个数组,数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针,每个 dictEntry 结构保存着一个键值对。size 属性记录了哈希表的大小,也即是 table 数组的大小,而 used 属性则记录了哈希表目前已有节点(键值对)的数量。sizemask 属性的值总是等于 size-1,这个属性和哈希值一起决定一个键应该被放到 table 数组的哪个索引上面。 哈希表节点使用 dictEntry 结构表示,每个 dictEntry 结构都保存着一个键值对:
typedef struct dictEntry {
// 键
void *key;
// 值
union{
void *val;
uint64_tu64;
int64_ts64;
} v;
// 指向下个哈希表节点,形成链表(哈希冲突)
struct dictEntry *next;
} dictEntry;
字典由 dict.h/dict 结构表示:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;
type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的:
- type 属性是一个指向 dictType 结构的指针,每个 dictType 结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数。
- privdata 属性保存了需要传给那些类型特定函数的可选参数。
ht 属性是一个包含两个项的数组,数组中的每个项都是一个 dictht 哈希表,一般情况下,字典只使用 ht[0] 哈希表,ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用。除了ht [1] 之外,另一个和 rehash 有关的属性就是 rehashidx,它记录了 rehash 目前的进度,如果目前没有在进行 rehash,那么它的值为 -1。
# 哈希算法
Redis计算哈希值和索引值的方法如下:
- 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
hashFunction 即 dictType 的
unsigned int (*hashFunction)(const void *key);
- 使用哈希表的 sizemask 属性和哈希值,计算出索引值,根据情况不同(是否正在进行 rehash),ht[x] 可以是 ht[0] 或者 ht[1] index = hash & dict->ht[x].sizemask;
对于一个正整数 n,如果它是 2 的幂次方(即 n = 2^k,其中 k 为非负整数),那么 (n-1)&m 的结果就等于 n%m。
# rehash
随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。 扩展和收缩哈希表的工作可以通过执行 rehash(重新散列)操作来完成,Redis 对字典的哈希表执行 rehash 的步骤如下:
- 为字典的 ht[1] 哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及 ht[0] 当前包含的键值对数量(也即是 ht[0].used 属性的值):
- 如果执行的是扩展操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used*2 的 2n(2 的 n 次方幂);
- 如果执行的是收缩操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2n。
- 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1] 哈希表的指定位置上。
- 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后(ht[0] 变为空表),释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空白哈希表,为下一次 rehash 做准备。
哈希表的扩展与收缩 当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1。
- 服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 5。
负载因子 = 哈希表已保存节点数量 / 哈希表大小 load_factor = ht[0].used / ht[0].size
当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作。
# 渐进式 rehash
扩展或收缩哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面,但是,这个 rehash 动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。 哈希表渐进式rehash的详细步骤:
- 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为 0,表示 rehash 工作正式开始。
- 在 rehash 进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1],当 rehash 工作完成之后,程序将 rehashidx 属性的值增一。
- 随着字典操作的不断执行,最终在某个时间点上,ht[0] 的所有键值对都会被 rehash 至 ht[1],这时程序将 rehashidx 属性的值设为 -1,表示 rehash 操作已完成。包括 ht[1] 成为 ht[0],新建一个空的 ht[1]。
渐进式 rehash 的好处在于它采取分而治之的方式,将 rehash 键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 而带来的庞大计算量。
# Skip List 跳表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。 跳跃表支持平均 O(logN)、最坏 O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。 在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。 Redis 只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。
Redis 的跳跃表由 redis.h/zskiplistNode 和 redis.h/zskiplist 两个结构定义,其中 zskiplistNode 结构用于表示跳跃表节点,而 zskiplist 结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。 图片最左边的是 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 是节点所保存的成员对象。
跳跃表节点的实现由 redis.h/zskiplistNode 结构定义:
typedef struct zskiplistNode {
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
} zskiplistNode;
跳跃表节点的 level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。每次创建一个新跳跃表节点的时候,随机生成一个介于 1 和 32 之间的值作为 level 数组的大小,这个大小就是层的“高度”; 每个层都有一个指向表尾方向的前进指针(level[i].forward 属性),用于从表头向表尾方向访问节点。 节点的后退指针(backward 属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
zskiplist 结构的定义如下:
typedef struct zskiplist {
// 表头节点和表尾节点
structz skiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
header 和 tail 指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为 O(1)。 通过使用 length 属性来记录节点的数量,程序可以在 O(1)复杂度内返回跳跃表的长度。 level 属性则用于在 O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量。
# IntSet 整数集合
整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。 整数集合(intset)是 Redis 用于保存整数值的集合抽象数据结构,它可以保存类型为 int16_t、int32_t 或者 int64_t 的整数值,并且保证集合中不会出现重复元素。 每个 intset.h/intset 结构表示一个整数集合:
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
contents 数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。 length 属性记录了整数集合包含的元素数量,也即是 contents 数组的长度。 虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值,contents 数组的真正类型取决于 encoding 属性的值:
- 如果 encoding 属性的值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组里的每个项都是一个 int16_t 类型的整数值(最小值为 -32768,最大值为 32767)。
- 如果 encoding 属性的值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组里的每个项都是一个 int32_t 类型的整数值(最小值为 -2147483648,最大值为 2147483647)。
- 如果 encoding 属性的值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组里的每个项都是一个 int64_t 类型的整数值(最小值为 -9223372036854775808,最大值为 9223372036854775807)。
# 升级
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。 升级整数集合并添加新元素共分为三步进行:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
# ZipList 压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表键的底层实现。 压缩列表是 Redis 为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。 每个压缩列表节点都由 previous_entry_length、encoding、content 三个部分组成。 节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。 previous_entry_length 属性的长度可以是 1 字节或者 5 字节:
- 如果前一节点的长度小于 254 字节,那么 previous_entry_length 属性的长度为 1 字节:前一节点的长度就保存在这一个字节里面。
- 如果前一节点的长度大于等于 254 字节,那么 previous_entry_length 属性的长度为5字节:其中属性的第一字节会被设置为 0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度。
现在我需要从列表的表尾向表头进行遍历,我们需要使用到以下属性:zlbytes 和 zltail 定位最后一个 entry; 处理最后一个 entry;利用 previous_entry_length 定位倒数第二个节点;以此类推。
值的最高位为 00、01 或者 10 的是字节数组编码;值的最高位以 11 开头的是整数编码。 节点的 content 属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的 encoding 属性决定。
# 连锁更新
有多个连续的、长度介于 250 字节到 253 字节之间的节点 e1 至 eN。 添加一个新节点到头结点(长度 >254),由于头结点长度大于 265 后续节点由之前 1 个字节记录变为 5 个字节来记录,250 + 5 > 254,后续的所有节点记录都要变为 5 字节,都要进行重新分配空间,即连锁更新。
# 对象
Redis 使用对象来表示数据库中的键和值,每次当我们在 Redis 的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。 Redis 中的每个对象都由一个 redisObject 结构表示,该结构中和保存数据有关的三个属性分别是 type 属性、 encoding 属性和 ptr 属性:
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// ...
} robj;
# 类型
对象的 type 属性记录了对象的类型。
# 编码和底层实现
对象的 ptr 指针指向对象的底层实现数据结构,而这些数据结构由对象的 encoding 属性决定。encoding 属性记录了对象所使用的编码。 每种类型的对象都至少使用了两种不同的编码。
# 字符串对象
字符串对象的编码可以是 int、raw 或者 embstr。 如果一个字符串对象保存的是整数值,并且这个整数值可以用 long 类型来表示,那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换成 long),并将字符串对象的编码设置为 int。 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于 32 字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为 raw。如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于 32 字节,那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。
字符串对象保存各类型值的编码方式
# 列表对象
列表对象的编码可以是 ziplist 或者 linkedlist。 当列表对象可以同时满足以下两个条件时,列表对象使用 ziplist 编码:
- 列表对象保存的所有字符串元素的长度都小于 64 字节;
- 列表对象保存的元素数量小于 512 个;
不能满足这两个条件的列表对象需要使用 linkedlist 编码。
以上两个条件的上限值是可以修改的,配置文件中关于 list-max-ziplist-value
选项和 list-max-ziplist-entries
选项。
# 哈希对象
哈希对象的编码可以是 ziplist 或者 hashtable。 ziplist 编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:
- 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
- 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
当哈希对象可以同时满足以下两个条件时,哈希对象使用 ziplist 编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
- 哈希对象保存的键值对数量小于 512 个;
不能满足这两个条件的哈希对象需要使用 hashtable 编码。
这两个条件的上限值是可以修改的,具体配置文件中关于 hash-max-ziplist-value
选项和 hash-max-ziplist-entries
选项。
# 集合对象
集合对象的编码可以是 intset 或者 hashtable。 当集合对象可以同时满足以下两个条件时,对象使用 intset 编码:
- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过 512 个。
第二个条件的上限值是可以修改的,配置文件中关于 set-max-intset-entries
选项。
# 有序集合对象
有序集合的编码可以是 ziplist 或者 skiplist。 当有序集合对象可以同时满足以下两个条件时,对象使用 ziplist 编码:
- 有序集合保存的元素数量小于 128 个;
- 有序集合保存的所有元素成员的长度都小于 64 字节;
不能满足以上两个条件的有序集合对象将使用 skiplist 编码。
以上两个条件的上限值是可以修改的,配置文件中关于 zset-max-ziplist-entries
选项和 zset-max-ziplist-value
选项。
# 类型检查与命令多态
服务器在执行某些命令之前,会先检查给定键的类型能否执行指定的命令,而检查一个键的类型就是检查键的值对象的类型。 我们知道,列表底层可使用 ziplist 和 linkedlist 两种,那么这两种数据类型的长度的获取函数肯定是不一样的。我们在对列表进行 LLEN 操作的时候,我们的 Redis 会通过多态的性质,进行 ziplist 和 linedlist 的类型判断。
# 对象内存回收
因为 C语言 并不具备自动内存回收功能,所以 Redis 在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。
typedef struct redisObject {
// ...
// 引用计数
int refcount;
// ...
} robj;
- 在创建一个新对象时,引用计数的值会被初始化为 1;
- 当对象被一个新程序使用时,它的引用计数值会被增一;
- 当对象不再被一个程序使用时,它的引用计数值会被减一;
- 当对象的引用计数值变为 0 时,对象所占用的内存会被释放。
# 对象共享
Redis 会在初始化服务器时,创建一万个字符串对象,这些对象包含了从 0 到 9999 的所有整数值,当服务器需要用到值为 0 到 9999 的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象。 OK 和 ERR 字符串对象也是。
创建共享字符串对象的数量可以通过修改 redis.h/REDIS_SHARED_INTEGERS 常量来修改。
# 对象的空转时长
除了前面介绍过的 type、encoding、ptr 和 refcount 四个属性之外,redisObject 结构包含的最后一个属性为 lru 属性,该属性记录了对象最后一次被命令程序访问的时间:
typedef struct redisObject {
// ...
unsigned lru:22;
// ...
} robj;
OBJECT IDLETIME key
命令可以打印出给定键的空转时长。
除了可以被 OBJECT IDLETIME 命令打印出来之外,键的空转时长还有另外一项作用:如果服务器打开了 maxmemory 选项,并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru,那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。
# 单机数据库的实现
# 数据库
- 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 持久化功能所生成的 RDB 文件是一个经过压缩的二进制文件,通过该文件可以还原生成 RDB 文件时的数据库状态。
# RDB 文件的创建与载入
有两个 Redis 命令可以用于生成 RDB 文件,一个是 SAVE
,另一个是 BGSAVE
。
- SAVE 命令会阻塞 Redis 服务器进程,直到 RDB 文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求。
- BGSAVE 命令会派生出一个子进程,然后由子进程负责创建 RDB 文件,服务器进程(父进程)继续处理命令请求。
RDB 文件的载入工作是在服务器启动时自动执行的,所以 Redis 并没有专门用于载入 RDB 文件的命令,只要 Redis 服务器在启动时检测到 RDB 文件存在,它就会自动载入 RDB 文件。
- 如果服务器开启了 AOF 持久化功能,那么服务器会优先使用 AOF 文件来还原数据库状态。
- 只有在 AOF 持久化功能处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态。
BGSAVE 命令执行期间,服务器处理 SAVE、BGSAVE、BGREWRITEAOF 三个命令的方式会和平时有所不同。
- 在 BGSAVE 命令执行期间,客户端发送的 SAVE 命令会被服务器拒绝,服务器禁止 SAVE 命令和 BGSAVE 命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个 rdbSave 调用,防止产生竞争条件。
- 在 BGSAVE 命令执行期间,客户端发送的 BGSAVE 命令会被服务器拒绝,因为同时执行两个 BGSAVE 命令也会产生竞争条件。
- BGREWRITEAOF 和 BGSAVE 两个命令不能同时执行:
- 如果 BGSAVE 命令正在执行,那么客户端发送的 BGREWRITEAOF 命令会被延迟到 BGSAVE 命令执行完毕之后执行。
- 如果 BGREWRITEAOF 命令正在执行,那么客户端发送的 BGSAVE 命令会被服务器拒绝。
因为 BGREWRITEAOF 和 BGSAVE 两个命令的实际工作都由子进程执行,所以这两个命令在操作方面并没有什么冲突的地方,不能同时执行它们只是一个性能方面的考虑——并发出两个子进程,并且这两个子进程都同时执行大量的磁盘写入操作,这怎么想都不会是一个好主意。
# 自动间隔性保存
默认 save 配置
save 900 1
save 300 10
save 60 10000
那么只要满足以下三个条件中的任意一个,BGSAVE 命令就会被执行:
- 服务器在900秒之内,对数据库进行了至少1次修改。
- 服务器在300秒之内,对数据库进行了至少10次修改。
- 服务器在60秒之内,对数据库进行了至少10000次修改。
服务器程序会根据所设置的保存条件,设置服务器状态 redisServer 结构的 saveparams 属性:
struct redisServer {
// ...
// 记录了保存条件的数组
struct saveparam *saveparams;
// ...
};
saveparams 属性是一个数组,数组中的每个元素都是一个 saveparam 结构,每个 saveparam 结构都保存了一个 save 选项设置的保存条件:
struct saveparam {
// 秒数
time_t seconds;
// 修改数
int changes;
};
除了 saveparams 数组之外,服务器状态还维持着一个 dirty 计数器,以及一个 lastsave 属性:
- dirty 计数器记录距离上一次成功执行 SAVE 命令或者 BGSAVE 命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)。
- lastsave 属性是一个 UNIX 时间戳,记录了服务器上一次成功执行 SAVE 命令或者 BGSAVE 命令的时间。
struct redisServer {
// ...
// 修改计数器
long long dirty;
// 上一次执行保存的时间
time_t lastsave;
// ...
};
Redis 的服务器周期性操作函数 serverCron 默认每隔 100 毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查 save 选项所设置的保存条件是否已经满足,如果满足的话,就执行 BGSAVE 命令。
# RDB 文件结构
- RDB 文件的最开头是 REDIS 部分,这个部分的长度为 5 字节,保存着“REDIS”五个字符。通过这五个字符,程序可以在载入文件时,快速检查所载入的文件是否 RDB 文件。
- db_version 长度为 4 字节,它的值是一个字符串表示的整数,这个整数记录了 RDB 文件的版本号,比如"0006"就代表 RDB 文件的版本为第六版。本章只介绍第六版 RDB 文件的结构。
- databases 部分包含着零个或任意多个数据库,以及各个数据库中的键值对数据:
- 如果服务器的数据库状态为空(所有数据库都是空的),那么这个部分也为空,长度为 0 字节。
- 如果服务器的数据库状态为非空(有至少一个数据库非空),那么这个部分也为非空,
- EOF 常量的长度为 1 字节,这个常量标志着 RDB 文件正文内容的结束,当读入程序遇到这个值的时候,它知道所有数据库的所有键值对都已经载入完毕了。
- check_sum 是一个 8 字节长的无符号整数,保存着一个校验和,这个校验和是程序通过对 REDIS、db_version、databases、EOF 四个部分的内容进行计算得出的。服务器在载入 RDB 文件时,会将载入数据所计算出的校验和与 check_sum 所记录的校验和进行对比,以此来检查 RDB 文件是否有出错或者损坏的情况出现。
# database 部分
假设 db0 和 db3 是非空。 每个非空数据库在 RDB 文件中都可以保存为 SELECTDB、db_number、key_value_pairs 三个部分。
- SELECTDB 常量的长度为 1 字节,当读入程序遇到这个值的时候,它知道接下来要读入的将是一个数据库号码。
- db_number 保存着一个数据库号码,根据号码的大小不同,这个部分的长度可以是 1 字节、2 字节或者 5 字节。当程序读入 db_number 部分之后,服务器会调用 SELECT 命令,根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以载入到正确的数据库中。
- key_value_pairs 部分保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。根据键值对的数量、类型、内容以及是否有过期时间等条件的不同,key_value_pairs 部分的长度也会有所不同。
# key_value_pairs 部分
不带过期时间的键值对在 RDB 文件中由 TYPE、key、value 三部分组成 带有过期时间的键值对在 RDB 文件中的结构
- EXPIRETIME_MS 常量的长度为 1 字节,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间。
- ms 是一个 8 字节长的带符号整数,记录着一个以毫秒为单位的 UNIX 时间戳,这个时间戳就是键值对的过期时间。
- key 总是一个字符串对象,它的编码方式和 REDIS_RDB_TYPE_STRING 类型的 value 一样。
- 根据 TYPE 类型的不同,以及保存内容长度的不同,保存 value 的结构和长度也会有所不同。
- 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
# AOF 持久化
与 RDB 持久化通过保存数据库中的键值对来记录数据库状态不同,AOF 持久化是通过保存 Redis 服务器所执行的写命令来记录数据库状态的。
# 实现
当 AOF 持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾:
struct redisServer {
// ...
// AOF 缓冲区
sds aof_buf;
// ...
};
服务器每次结束一个事件循环之前,它都会调用 flushAppendOnlyFile 函数进行文件写入。 flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定。默认 everysec。
# AOF 文件的载入与数据还原
Redis 读取 AOF 文件并还原数据库状态的详细步骤如下:
- 创建一个不带网络连接的伪客户端(fake client):因为 Redis 的命令只能在客户端上下文中执行,而载入 AOF 文件时所使用的命令直接来源于 AOF 文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行 AOF 文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。
- 从 AOF 文件中分析并读取出一条写命令。
- 使用伪客户端执行被读出的写命令。
- 一直执行步骤 2 和步骤 3,直到 AOF 文件中的所有写命令都被处理完毕为止。
# AOF 重写
为了解决 AOF 文件体积膨胀的问题,Redis 提供了 AOF 文件重写(rewrite)功能。通过该功能,Redis 服务器可以创建一个新的 AOF 文件来替代现有的 AOF 文件,新旧两个 AOF 文件所保存的数据库状态相同,但新 AOF 文件不会包含任何浪费空间的冗余命令,所以新 AOF 文件的体积通常会比旧 AOF 文件的体积要小得多。 虽然 Redis 将生成新 AOF 文件替换旧 AOF 文件的功能命名为“AOF文件重写”,但实际上,AOF 文件重写并不需要对现有的 AOF 文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。 Redis 不希望 AOF 重写造成服务器无法处理请求,所以 Redis 决定将 AOF 重写程序放到子进程里执行,这样做可以同时达到两个目的:
- 子进程进行 AOF 重写期间,服务器进程(父进程)可以继续处理命令请求。
- 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下,保证数据的安全性。
不过,使用子进程也有一个问题需要解决,因为子进程在进行 AOF 重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的 AOF 文件所保存的数据库状态不一致。
为了解决这种数据不一致问题,Redis 服务器设置了一个 AOF 重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当 Redis 服务器执行完一个写命令之后,它会同时将这个写命令发送给 AOF 缓冲区和 AOF 重写缓冲区。 这样一来可以保证:
- AOF 缓冲区的内容会定期被写入和同步到 AOF 文件,对现有 AOF 文件的处理工作会如常进行。
- 从创建子进程开始,服务器执行的所有写命令都会被记录到 AOF 重写缓冲区里面。
当子进程完成 AOF 重写工作之后,它会向父进程发送一个信号,父进程在接到该信号之后,会调用一个信号处理函数,并执行以下工作:
- 将 AOF 重写缓冲区中的所有内容写入到新 AOF 文件中,这时新 AOF 文件所保存的数据库状态将和服务器当前的数据库状态一致。
- 对新的 AOF 文件进行改名,原子地(atomic)覆盖现有的 AOF 文件,完成新旧两个 AOF 文件的替换。
# 事件
Redis 服务器是一个事件驱动程序,服务器需要处理以下两类事件:
- 文件事件(file event):Redis 服务器通过套接字与客户端(或者其他 Redis 服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。
- 时间事件(time event):Redis 服务器中的一些操作(比如 serverCron 函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。
# 文件事件
Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler):
- 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
- 当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。
# 文件事件处理器的构成
文件事件处理器的四个组成部分,它们分别是套接字、I/O多路复用程序、文件事件分派器(dispatcher),以及事件处理器。 尽管多个文件事件可能会并发地出现,但 I/O 多路复用程序总是会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字。
# I/O 多路复用程序的实现
Redis 程序会在编译时自动选择系统中性能最高的 I/O 多路复用函数库来作为 Redis 的 I/O 多路复用程序的底层实现。
# 事件的类型
I/O 多路复用程序可以监听多个套接字的 ae.h/AE_READABLE 事件和 ae.h/AE_WRITABLE 事件,这两类事件和套接字操作之间的对应关系如下:
- 当套接字变得可读时(客户端对套接字执行 write 操作,或者执行 close 操作),或者有新的可应答(acceptable)套接字出现时(客户端对服务器的监听套接字执行 connect 操作),套接字产生 AE_READABLE 事件。
- 当套接字变得可写时(客户端对套接字执行 read 操作),套接字产生 AE_WRITABLE 事件。
如果一个套接字又可读又可写的话,那么服务器将先读套接字,后写套接字。
# 时间事件
周期性事件:让一段程序每隔指定时间就执行一次。比如说,让程序 Y 每隔 30 毫秒就执行一次。 一个时间事件主要由以下三个属性组成:
- id:服务器为时间事件创建的全局唯一 ID(标识号)。ID 号按从小到大的顺序递增,新事件的 ID 号比旧事件的 ID 号要大。
- when:毫秒精度的 UNIX 时间戳,记录了时间事件的到达(arrive)时间。
- timeProc:时间事件处理器,一个函数。当时间事件到达时,服务器就会调用相应的处理器来处理事件。
如果事件处理器返回一个非 AE_NOMORE 的整数值,那么这个事件为周期性时间:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的 when 属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。比如说,如果一个时间事件的处理器返回整数值 30,那么服务器应该对这个时间事件进行更新,让这个事件在 30 毫秒之后再次到达。
服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。
服务器在一般情况下只执行 serverCron 函数一个时间事件,并且这个事件是周期性事件。
# 客户端
Redis 服务器是典型的一对多服务器程序:一个服务器可以与多个客户端建立网络连接,每个客户端可以向服务器发送命令请求,而服务器则接收并处理客户端发送的命令请求,并向客户端返回命令回复。
# 客户端属性
typedef struct redisClient {
// 在默认情况下,一个连接到服务器的客户端是没有名字的,可以使用 client setname 设置
robj *name;
// 根据客户端类型的不同,fd 属性的值可以是 -1 或者是大于 -1 的整数
// 伪客户端(fake client)的 fd 属性的值为 -1
// 普通客户端的fd属性的值为大于-1的整数
int fd;
// 记录了客户端的角色(role)举例:REDIS_MASTER, REDIS_SLAVE, REDIS_PRE_PSYNC(客户端是一个从服务器,并且版本低于 Redis2.8)
int flags;
// 客户端状态的输入缓冲区用于保存客户端发送的命令请求
sds querybuf;
// argv属性是一个数组。如 set msg "hello" 会被保存为三个数组元素
robj **argv;
// 表示命令长度,如 set msg "hello", argc 为3
int argc;
// 客户端命令执行函数指针。当服务器从协议内容中分析并得出 argv 属性和 argc 属性的值之后,服务器将根据项 argv[0] 的值,在命令表中查找命令所对应的命令实现函数
struct redisCommand *cmd;
// 执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面
char buf[REDIS_REPLY_CHUNK_BYTES];
// 记录了 buf 数组目前已使用的字节数量
int bufpos;
// 如果 authenticated 的值为 0,那么表示客户端未通过身份验证
// 如果 authenticated 的值为 1,那么表示客户端已经通过了身份验证。
int authenticated;
// 记录了创建客户端的时间
time_t ctime;
// 记录了客户端与服务器最后一次进行互动(interaction)的时间
time_t lastinteraction;
// 记录了输出缓冲区第一次到达软性限制(soft limit)的时间
time_t obuf_soft_limit_reached_time;
} redisClient;
# Lua脚本的伪客户端
服务器会在初始化时创建负责执行 Lua 脚本中包含的 Redis 命令的伪客户端,并将这个伪客户端关联在服务器状态结构的 lua_client 属性中:
struct redisServer {
// ...
redisClient *lua_client;
// ...
};
lua_client 伪客户端在服务器运行的整个生命期中会一直存在,只有服务器被关闭时,这个客户端才会被关闭。
# Redis Client 的关闭
一个普通客户端可以因为多种原因而被关闭:
- 如果客户端进程退出或者被杀死,那么客户端与服务器之间的网络连接将被关闭,从而造成客户端被关闭。
- 如果客户端向服务器发送了带有不符合协议格式的命令请求,那么这个客户端也会被服务器关闭。
- 如果客户端成为了 CLIENT KILL 命令的目标,那么它也会被关闭。
- 如果用户为服务器设置了 timeout 配置选项,那么当客户端的空转时间超过 timeout 选项设置的值时,客户端将被关闭。
- 如果客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为1 GB),那么这个客户端会被服务器关闭。
- 如果要发送给客户端的命令回复的大小超过了输出缓冲区的限制大小,那么这个客户端会被服务器关闭。
服务器使用两种模式来限制客户端输出缓冲区的大小:
- 硬性限制(hard limit):如果输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器立即关闭客户端。
- 软性限制(soft limit):如果输出缓冲区的大小超过了软性限制所设置的大小,但还没超过硬性限制,那么服务器将使用客户端状态结构的 obuf_soft_limit_reached_time 属性记录下客户端到达软性限制的起始时间;之后服务器会继续监视客户端,如果输出缓冲区的大小一直超出软性限制,并且持续时间超过服务器设定的时长,那么服务器将关闭客户端;相反地,如果输出缓冲区的大小在指定时间之内,不再超出软性限制,那么客户端就不会被关闭,并且 obuf_soft_limit_reached_time 属性的值也会被清零。
#
# 服务器
# 命令处理过程
# 读取命令请求
当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:
- 读取套接字中协议格式的命令请求,并将其保存到客户端状态的输入缓冲区里面。
- 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的 argv 属性和 argc 属性里面。
- 调用命令执行器,执行客户端指定的命令。
# 命令执行器
- 查找命令 命令执行器要做的第一件事就是根据客户端状态的 argv[0] 参数,在命令表(command table)中查找参数所指定的命令,并将找到的命令保存到客户端状态的 cmd 属性里面。 命令表是一个字典,字典的键是一个个命令名字,比如 "set"、"get"、"del" 等等;而字典的值则是一个个 redisCommand 结构,每个 redisCommand 结构记录了一个 Redis 命令的实现信息 。
- 执行预备操作
到目前为止,服务器已经将执行命令所需的命令实现函数(保存在客户端状态的 cmd 属性)、参数(保存在客户端状态的 argv 属性)、参数个数(保存在客户端状态的 argc 属性)都收集齐了,但是在真正执行命令之前,程序还需要进行一些预备操作,从而确保命令可以正确、顺利地被执行,这些操作包括:
- 检查客户端状态的 cmd 指针是否指向 NULL,如果是的话,那么说明用户输入的命令名字找不到相应的命令实现,服务器不再执行后续步骤,并向客户端返回一个错误。
- 根据客户端 cmd 属性指向的 redisCommand 结构的 arity 属性,检查命令请求所给定的参数个数是否正确。
- 检查客户端是否已经通过了身份验证,未通过身份验证的客户端只能执行AUTH命令。
- 如果服务器打开了 maxmemory 功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收,从而使得接下来的命令可以顺利执行。如果内存回收失败,那么不再执行后续步骤,向客户端返回一个错误。
- 如果服务器上一次执行 BGSAVE 命令时出错,并且服务器打开了 stop-writes-on-bgsave-error 功能,而且服务器即将要执行的命令是一个写命令,那么服务器将拒绝执行这个命令,并向客户端返回一个错误。
- 如果客户端当前正在用 SUBSCRIBE 命令订阅频道,或者正在用 PSUBSCRIBE 命令订阅模式,那么服务器只会执行客户端发来的 SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE 四个命令,其他命令都会被服务器拒绝。
- 如果服务器正在进行数据载入,那么客户端发送的命令必须带有l标识(比如 INFO、SHUTDOWN、PUBLISH 等等)才会被服务器执行,其他命令都会被服务器拒绝。
- 如果服务器因为执行 Lua 脚本而超时并进入阻塞状态,那么服务器只会执行客户端发来的 SHUTDOWN nosave 命令和 SCRIPT KILL 命令,其他命令都会被服务器拒绝。
- 如果客户端正在执行事务,那么服务器只会执行客户端发来的 EXEC、DISCARD、MULTI、WATCH 四个命令,其他命令都会被放进事务队列中。
- 如果服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器。
当完成了以上预备操作之后,服务器就可以开始真正执行命令了
- 调用命令的实现函数
- **执行后续工作
**在执行完实现函数之后,服务器还需要执行一些后续工作:
- 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志。
- 根据刚刚执行命令所耗费的时长,更新被执行命令的 redisCommand 结构的 milliseconds 属性,并将命令的 redisCommand 结构的 calls 计数器的值增一。
- 如果服务器开启了 AOF 持久化功能,那么 AOF 持久化模块会将刚刚执行的命令请求写入到 AOF 缓冲区里面。
- 如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器。
当以上操作都执行完了之后,服务器对于当前命令的执行到此就告一段落了,之后服务器就可以继续从文件事件处理器中取出并处理下一个命令请求了。
命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并为客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。
# serverCron 函数
Redis 服务器中的 serverCron 函数默认每隔 100 毫秒执行一次,这个函数负责管理服务器的资源,并保持服务器自身的良好运转。
# 更新服务器时间缓存
Redis 服务器中有不少功能需要获取系统的当前时间,而每次获取系统的当前时间都需要执行一次系统调用,为了减少系统调用的执行次数,服务器状态中的 unixtime 属性和 mstime 属性被用作当前时间的缓存:
struct redisServer {
// ...
// 保存了秒级精度的系统当前 UNIX 时间戳
time_t unixtime;
// 保存了毫秒级精度的系统当前 UNIX 时间戳
long long mstime;
// ...
};
因为 serverCron 函数默认会以每 100 毫秒一次的频率更新 unixtime 属性和 mstime 属性,所以这两个属性记录的时间的精确度并不高:
- 服务器只会在打印日志、更新服务器的 LRU 时钟、决定是否执行持久化任务、计算服务器上线时间(uptime)这类对时间精确度要求不高的功能上。
- 对于为键设置过期时间、添加慢查询日志这种需要高精确度时间的功能来说,服务器还是会再次执行系统调用,从而获得最准确的系统当前时间。
# 更新 LRU 时钟
struct redisServer {
// ...
// 默认每 10 秒更新一次的时钟缓存,用于计算键的空转(idle)时长。
unsigned lruclock:22;
// ...
};
当服务器要计算一个数据库键的空转时间(也即是数据库键对应的值对象的空转时间),程序会用服务器的 lruclock 属性记录的时间减去对象的 lru 属性记录的时间,得出的计算结果就是这个对象的空转时间。
OBJECT IDLETIME key
# 更新服务器每秒执行命令次数
serverCron 函数中的 trackOperationsPerSecond 函数会以每 100 毫秒一次的频率执行,这个函数的功能是以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量。
# 更新服务器内存峰值记录
服务器状态中的 stat_peak_memory 属性记录了服务器的内存峰值大小:
struct redisServer {
// ...
// 已使用内存峰值
size_t stat_peak_memory;
// ...
};
每次 serverCron 函数执行时,程序都会查看服务器当前使用的内存数量,并与 stat_peak_memory 保存的数值进行比较,如果当前使用的内存数量比 stat_peak_memory 属性记录的值要大,那么程序就将当前使用的内存数量记录到 stat_peak_memory 属性里面。
# 处理SIGTERM信号
在启动服务器时,Redis 会为服务器进程的 SIGTERM 信号关联处理器 sigtermHandler 函数,这个信号处理器负责在服务器接到 SIGTERM 信号时,打开服务器状态的 shutdown_asap 标识。
struct redisServer {
// ...
// 关闭服务器的标识:
// 值为 1 时,关闭服务器,
// 值为 0 时,不做动作。
int shutdown_asap;
// ...
};
服务器在关闭自身之前会进行 RDB 持久化操作,这也是服务器拦截 SIGTERM 信号的原因,如果服务器一接到 SIGTERM 信号就立即关闭,那么它就没办法执行持久化操作了。
# 管理客户端资源
serverCron 函数每次执行都会调用 clientsCron 函数,clientsCron 函数会对一定数量的客户端进行以下两个检查:
- 如果客户端与服务器之间的连接已经超时(很长一段时间里客户端和服务器都没有互动),那么程序释放这个客户端。
- 如果客户端在上一次执行命令请求之后,输入缓冲区的大小超过了一定的长度,那么程序会释放客户端当前的输入缓冲区,并重新创建一个默认大小的输入缓冲区,从而防止客户端的输入缓冲区耗费了过多的内存。
# 管理数据库资源
serverCron 函数每次执行都会调用 databasesCron 函数,这个函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典进行收缩操作。
# 执行被延迟的 BGREWRITEAOF
在服务器执行 BGSAVE 命令的期间,如果客户端向服务器发来 BGREWRITEAOF 命令,那么服务器会将BGREWRITEAOF 命令的执行时间延迟到 BGSAVE 命令执行完毕之后。
# 检查持久化操作的运行状态
服务器状态中的 rdb_child_pid 属性和 aof_child_pid 属性来记录执行 BGSAVE 命令和 BGREWRITEAOF 命令的子进程的 ID。这两个属性也可以用于检查 BGSAVE 命令或者 BGREWRITEAOF 命令是否正在执行。
struct redisServer {
// ...
// 记录执行 BGSAVE命令的子进程的 ID
// 如果服务器没有在执行BGSAVE,那么这个属性的值为 -1
pid_t rdb_child_pid; /* PID of RDB saving child */
// 记录执行 BGREWRITEAOF 命令的子进程的 ID
// 如果服务器没有在执行 BGREWRITEAOF,那么这个属性的值为 -1
pid_t aof_child_pid; /* PID if rewriting process */
// ...
};
# 将AOF缓冲区中的内容写入AOF文件
如果服务器开启了 AOF 持久化功能,并且 AOF 缓冲区里面还有待写入的数据,那么 serverCron 函数会调用相应的程序,将 AOF 缓冲区中的内容写入到 AOF 文件里面。
# 关闭异步客户端
服务器会关闭那些输出缓冲区大小超出限制的客户端。
# 增加 cronloops 计数器的值
服务器状态的cronloops属性记录了serverCron函数执行的次数:
struct redisServer {
// ...
// serverCron 函数的运行次数计数器
// serverCron 函数每执行一次,这个属性的值就增一。
int cronloops;
// ...
};
# 初始化服务器
一个 Redis 服务器从启动到能够接受客户端的命令请求,需要经过一系列的初始化和设置过程,比如初始化服务器状态,接受用户指定的服务器配置,创建相应的数据结构和网络连接等等。
# 初始化服务器状态结构
初始化服务器的第一步就是创建一个 struct redisServer 类型的实例变量 server 作为服务器的状态,并为结构中的各个属性设置默认值。 初始化 server 变量的工作由redis.c/initServerConfig函数完成:
- 设置服务器的运行 ID。
- 设置服务器的默认运行频率。
- 设置服务器的默认配置文件路径。
- 设置服务器的运行架构。
- 设置服务器的默认端口号。
- 设置服务器的默认 RDB 持久化条件和 AOF 持久化条件。
- 初始化服务器的 LRU 时钟。
- 创建命令表。
# 载入配置选项
服务器在用 initServerConfig 函数初始化完 server 变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对 server 变量相关属性的值进行修改。
# 初始化服务器数据结构
在之前执行 initServerConfig 函数初始化 server 状态时,程序只创建了命令表一个数据结构,还要初始化以下数据结构:
- server.clients 链表,这个链表记录了所有与服务器相连的客户端的状态结构,链表的每个节点都包含了一个redisClient 结构实例。
- server.db 数组,数组中包含了服务器的所有数据库。
- 用于保存频道订阅信息的 server.pubsub_channels 字典,以及用于保存模式订阅信息的 server.pubsub_patterns 链表。
- 用于执行 Lua 脚本的 Lua 环境 server.lua。
- 用于保存慢查询日志的 server.slowlog 属性。
除此之外,还会:
- 为服务器设置进程信号处理器。
- 创建共享对象:这些对象包含 Redis 服务器经常用到的一些值,比如包含"OK"回复的字符串对象,包含"ERR"回复的字符串对象,包含整数 0 到 9999 的字符串对象等等,服务器通过重用这些共享对象来避免反复创建相同的对象。
- 打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的连接。
- 为 serverCron 函数创建时间事件,等待服务器正式运行时执行 serverCron 函数。
- 如果 AOF 持久化功能已经打开,那么打开现有的 AOF 文件,如果 AOF 文件不存在,那么创建并打开一个新的 AOF 文件,为 AOF 写入做好准备。
- 初始化服务器的后台 I/O 模块(bio),为将来的 I/O 操作做好准备。
# 还原数据库状态
在完成了对服务器状态 server 变量的初始化之后,服务器需要载入 RDB 文件或者 AOF 文件,并根据文件记录的内容来还原服务器的数据库状态。
# 执行事件循环
开始执行服务器的事件循环(loop)。 至此,服务器的初始化工作圆满完成,服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了。
# 多机数据库的实现
# 复制
在 Redis 中,用户可以通过执行 SLAVEOF 命令或者设置 slaveof 选项,让一个服务器去复制(replicate)另一个服务器,我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被称为从服务器(slave)。
# 旧版本复制功能的实现
Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作:
- 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
- 命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态。
# 同步
从服务器对主服务器的同步操作需要通过向主服务器发送 SYNC 命令来完成:
- 从服务器向主服务器发送 SYNC 命令。
- 收到 SYNC 命令的主服务器执行 BGSAVE 命令,在后台生成一个 RDB 文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
- 当主服务器的 BGSAVE 命令执行完毕时,主服务器会将 BGSAVE 命令生成的 RDB 文件发送给从服务器,从服务器接收并载入这个 RDB 文件,将自己的数据库状态更新至主服务器执行 BGSAVE 命令时的数据库状态。
- 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。
# 命令传播
为了让主从服务器再次回到一致状态,主服务器需要对从服务器执行命令传播操作:主服务器会将自己执行的写命令,也即是造成主从服务器不一致的那条写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态。
# 旧版复制功能的缺陷
在 Redis 中,从服务器对主服务器的复制可以分为以下两种情况:
- 初次复制:从服务器以前没有复制过任何主服务器,或者从服务器当前要复制的主服务器和上一次复制的主服务器不同。
- 断线后重复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器通过自动重连接重新连上了主服务器,并继续复制主服务器。
对于初次复制来说,旧版复制功能能够很好地完成任务,但对于断线后重复制来说,旧版复制功能虽然也能让主从服务器重新回到一致状态,但效率却非常低。(只需要同步短线后的数据)
# SYNC 的消耗
- 出服务器需要执行 RDB 来生成 RDB 文件,消耗大量 CPU、内存和磁盘IO资源。
- 主服务器将生成的 RDB 文件通过网络发送给从服务器,耗费大量流量和带宽,影响主服务器的命令响应。
- 接收 RDB 文件的从服务器,载入期间,一直阻塞无法处理请求。
# 新版复制功能的实现
为了解决旧版复制功能在处理断线重复制情况时的低效问题,Redis 从 2.8 版本开始,使用 PSYNC 命令代替 SYNC 命令来执行复制时的同步操作。 PSYNC 命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:
- 完整重同步用于处理初次复制情况。其执行步骤和 SYNC 命令的执行步骤基本一样,都是通过让主服务器创建并发送 RDB 文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步。
- 部分重同步则用于处理断线后重复制情况。当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器。从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。
# 部分重同步的实现
部分重同步功能由以下三个部分构成:
- 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量。
- 主服务器的复制积压缓冲区(replication backlog)。
- 服务器的运行ID(run ID)。
# 复制偏移量
执行复制的双方——主服务器和从服务器会分别维护一个复制偏移量:
- 主服务器每次向从服务器传播 N 个字节的数据时,就将自己的复制偏移量的值加上 N。
- 从服务器每次收到主服务器传播来的 N 个字节的数据时,就将自己的复制偏移量的值加上 N。
通过对比主从服务器的复制偏移量,程序可以很容易地知道主从服务器是否处于一致状态:
- 如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的。
- 相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态。
# 复制积压缓冲区
复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为 1MB。 当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里面。 因此,主服务器的复制积压缓冲区里面会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量。 当从服务器重新连上主服务器时,从服务器会通过 PSYNC 命令将自己的复制偏移量 offset发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作:
- 如果 offset 偏移量之后的数据(也即是偏移量 offset+1 开始的数据)仍然存在于复制积压缓冲区里面,那么主服务器将对从服务器执行部分重同步操作。
- 相反,如果 offset 偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作。
复制积压缓冲区的大小 Redis 为复制积压缓冲区设置的默认大小为 1MB,如果主服务器需要执行大量写命令,又或者主从服务器断线后重连接所需的时间比较长,那么这个大小也许并不合适。如果复制积压缓冲区的大小设置得不恰当,那么 PSYNC 命令的复制重同步模式就不能正常发挥作用,因此,正确估算和设置复制积压缓冲区的大小非常重要。 复制积压缓冲区的最小大小可以根据公式 second*write_size_per_second 来估算:
- 其中 second 为从服务器断线后重新连接上主服务器所需的平均时间(以秒计算)。
- 而 write_size_per_second 则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)。
例如,如果主服务器平均每秒产生 1MB 的写数据,而从服务器断线之后平均要 5 秒才能重新连接上主服务器,那么复制积压缓冲区的大小就不能低于5MB。为了安全起见,可以将复制积压缓冲区的大小设为 2secondwrite_size_per_second,这样可以保证绝大部分断线情况都能用部分重同步来处理。 至于复制积压缓冲区大小的修改方法,可以参考配置文件中关于repl-backlog-size选项的说明。
# 服务器运行 ID
除了复制偏移量和复制积压缓冲区之外,实现部分重同步还需要用到服务器运行 ID(runID):
- 每个Redis服务器,不论主服务器还是从服务,都会有自己的运行 ID。
- 运行 ID 在服务器启动时自动生成,由 40 个随机的十六进制字符组成,例如 53b9b28df8042fdc9ab5e3fcbbbabff1d5dce2b3。
当从服务器对主服务器进行初次复制时,主服务器会将自己的运行 ID 传送给从服务器,而从服务器则会将这个运行 ID 保存起来。 当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行 ID:
- 如果从服务器保存的运行 ID 和当前连接的主服务器的运行 ID 相同,那么说明从服务器断线之前复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作。
- 相反地,如果从服务器保存的运行 ID 和当前连接的主服务器的运行 ID 并不相同,那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整重同步操作。
# PSYNC 命令的实现
PSYNC 命令的调用方法有两种:
- 如果从服务器以前没有复制过任何主服务器,或者之前执行过
SLAVEOF no one
命令,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC ? -1
命令,主动请求主服务器进行完整重同步(因为这时不可能执行部分重同步)。 - 相反地,如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时将向主服务器发送
PSYNC <runid> <offset>
命令:其中 runid 是上一次复制的主服务器的运行 ID,而 offset 则是从服务器当前的复制偏移量,接收到这个命令的主服务器会通过这两个参数来判断应该对从服务器执行哪种同步操作。
根据情况,接收到 PSYNC 命令的主服务器会向从服务器返回以下三种回复的其中一种:
- 如果主服务器返回
+FULLRESYNC <runid> <offset>
回复,那么表示主服务器将与从服务器执行完整重同步操作:其中 runid 是这个主服务器的运行 ID,从服务器会将这个 ID 保存起来,在下一次发送 PSYNC 命令时使用;而 offset 则是主服务器当前的复制偏移量,从服务器会将这个值作为自己的初始化偏移量。 - 如果主服务器返回
+CONTINUE
回复,那么表示主服务器将与从服务器执行部分重同步操作,从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了。 - 如果主服务器返回
-ERR
回复,那么表示主服务器的版本低于 Redis 2.8,它识别不了 PSYNC 命令,从服务器将向主服务器发送 SYNC 命令,并与主服务器执行完整同步操作。
# 复制的实现
# 设置主服务器的地址和端口
当客户端向从服务器发送以下命令时:
127.0.0.1:12345> SLAVEOF 127.0.0.1 6379
OK
从服务器首先要做的就是将客户端给定的主服务器 IP 地址 127.0.0.1 以及端口 6379 保存到服务器状态的 masterhost 属性和 masterport 属性里面:
struct redisServer {
// ...
// 主服务器的地址
char *masterhost;
// 主服务器的端口
int masterport;
// ...
};
SLAVEOF 命令是一个异步命令,在完成 masterhost 属性和 masterport 属性的设置工作之后,从服务器将向发送 SLAVEOF 命令的客户端返回 OK,表示复制指令已经被接收,而实际的复制工作将在 OK 返回之后才真正开始执行。
# 建立套接字连接
在 SLAVEOF 命令执行之后,从服务器将根据命令所设置的 IP 地址和端口,创建连向主服务器的套接字连接。
- 从服务器创建套接字成功,为这个套接字关联一个专门用于处理复制工作的文件事件处理器,比如接收 RDB 文件,以及接收主服务器传播来的写命令,诸如此类。
- 从服务器接受(accept)从服务器的套接字连接后,为该套接字创建客户端状态,看成是一个客户端来对待。
因为复制工作接下来的几个步骤都会以从服务器向主服务器发送命令请求的形式来进行,所以理解“从服务器是主服务器的客户端”这一点非常重要。
# 发送 PING 命令
从服务器成为主服务器的客户端之后,做的第一件事就是向主服务器发送一个 PING 命令。 这个 PING 命令有两个作用:
- 虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信,通过发送 PING 命令可以检查套接字的读写状态是否正常。
- 因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送 PING 命令可以检查主服务器能否正常处理命令请求。
发送 PING 命令可能会出现的情况:
- 网络超时,客户端主动断开,进行重新连接。
- 返回错误,服务器暂时无法处理这个请求,再进行其他阻塞操作,客户端主动断开连接,进行重新连接。
- 返回 PONG,网络连接正常,并且主服务器可以正常处理请求。
# 身份验证
从服务器在收到主服务器返回的"PONG"回复之后,下一步要做的就是决定是否进行身份验证:
- 如果从服务器设置了 masterauth 选项,那么进行身份验证。
- 如果从服务器没有设置 masterauth 选项,那么不进行身份验证。
在需要进行身份验证的情况下,从服务器将向主服务器发送一条 AUTH 命令,命令的参数为从服务器 masterauth 选项的值。
# 发送端口信息
在身份验证步骤之后,从服务器将执行命令 REPLCONF listening-port <port-number>
,向主服务器发送从服务器的监听端口号。
主服务器在接收到这个命令之后,会将端口号记录在从服务器所对应的客户端状态的 slave_listening_port 属性中:
typedef struct redisClient {
// ...
// 从服务器的监听端口号
int slave_listening_port;
// ...
} redisClient;
slave_listening_port 属性目前唯一的作用就是在主服务器执行 INFO replication 命令时打印出从服务器的端口号。
# 同步
在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会成为从服务器的客户端:
- 如果 PSYNC 命令执行的是完整重同步操作,那么主服务器需要成为从服务器的客户端,才能将保存在缓冲区里面的写命令发送给从服务器执行。
- 如果 PSYNC 命令执行的是部分重同步操作,那么主服务器需要成为从服务器的客户端,才能向从服务器发送保存在复制积压缓冲区里面的写命令。
因此,在同步操作执行之后,主从服务器双方都是对方的客户端,它们可以互相向对方发送命令请求,或者互相向对方返回命令回复。 正因为主服务器成为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,不仅同步操作需要用到这一点,这也是主服务器对从服务器执行命令传播操作的基础。
# 命令传播
当完成了同步之后,主从服务器就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从服务器一直保持一致了。
# 心跳检测
在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:REPLCONF ACK <replication_offset>
其中 replication_offset 是从服务器当前的复制偏移量。
发送 REPLCONF ACK 命令对于主从服务器有三个作用:
- 检测主从服务器的网络连接状态。
- 辅助实现 min-slaves 选项。
- 检测命令丢失。
# 检测主从服务器的网络连接状态
通过向主服务器发送 INFO replication 命令,在列出的从服务器列表的 lag 一栏中,我们可以看到相应从服务器最后一次向主服务器发送 REPLCONF ACK 命令距离现在过了多少秒。在一般情况下,lag 的值应该在 0 秒或者 1 秒之间跳动,如果超过 1 秒的话,那么说明主从服务器之间的连接出现了故障。
# 辅助实现 min-slaves 配置选项
Redis的 min-slaves-to-write
和 min-slaves-max-lag
两个选项可以防止主服务器在不安全的情况下执行写命令。
举个例子,如果我们向主服务器提供以下设置:
min-slaves-to-write 3
min-slaves-max-lag 10
那么在从服务器的数量少于 3 个,或者三个从服务器的延迟(lag)值都大于或等于 10 秒时,主服务器将拒绝执行写命令,这里的延迟值就是上面提到的 INFO replication 命令的 lag 值。
# 检测命令丢失
如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送 REPLCONF ACK 命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。
# Sentinel
Sentinel(哨岗、哨兵)是 Redis 的高可用性(high availability)解决方案:由一个或多个 Sentinel 实例(instance)组成的 Sentinel 系统(system)可以监视任意多个主服务器,以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。
# 启动并初始化 Sentinel
启动一个 Sentinel 可以使用命令:
$ redis-sentinel /path/to/your/sentinel.conf
或者命令:
$ redis-server /path/to/your/sentinel.conf --sentinel
这两个命令的效果完全相同。 当一个 Sentinel 启动时,它需要执行以下步骤:
- 初始化服务器。
- 将普通 Redis 服务器使用的代码替换成 Sentinel 专用代码。
- 初始化 Sentinel 状态。
- 根据给定的配置文件,初始化 Sentinel 的监视主服务器列表。
- 创建连向主服务器的网络连接。
# 初始化服务器
Sentinel 本质上只是一个运行在特殊模式下的 Redis 服务器,所以启动 Sentinel 的第一步,就是初始化一个普通的 Redis 服务器。因为 Sentinel 执行的工作和普通 Redis 服务器执行的工作不同,所以 Sentinel 的初始化过程和普通 Redis 服务器的初始化过程并不完全相同。例如,普通服务器在初始化时会通过载入 RDB 文件或者 AOF 文件来还原数据库状态,但是因为 Sentinel 并不使用数据库,所以初始化 Sentinel 时就不会载入 RDB 文件或者AOF文件。
# 使用 Sentinel 专用代码
启动 Sentinel 的第二个步骤就是将一部分普通 Redis 服务器使用的代码替换成 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 作为服务器的命令表。 而 Sentinel 则使用 sentinel.c/sentinelcmds 作为服务器的命令表。
# 初始化 Sentinel 状态
在应用了 Sentinel 的专用代码之后,接下来,服务器会初始化一个 sentinel.c/sentinelState 结构(后面简称“Sentinel状态”),这个结构保存了服务器中所有和 Sentinel 功能有关的状态。
struct sentinelState {
// 当前纪元,用于实现故障转移
uint64_t current_epoch;
// 保存了所有被这个 sentinel 监视的主服务器
// 字典的键是主服务器的名字
// 字典的值则是一个指向 sentinelRedisInstance 结构的指针
dict *masters;
// 是否进入了 TILT 模式?
int tilt;
// 目前正在执行的脚本的数量
int running_scripts;
// 进入 TILT 模式的时间
mstime_t tilt_start_time;
// 最后一次执行时间处理器的时间
mstime_t previous_time;
// 一个 FIFO 队列,包含了所有需要执行的用户脚本
list *scripts_queue;
} sentinel;
# 初始化 Sentinel 状态的 masters 属性
Sentinel 状态中的 masters 字典记录了所有被 Sentinel 监视的主服务器的相关信息,其中:
- 字典的键是被监视主服务器的名字。
- 而字典的值则是被监视主服务器对应的 sentinel.c/sentinelRedisInstance 结构。
每个 sentinelRedisInstance 结构(后面简称“实例结构”)代表一个被 Sentinel 监视的 Redis 服 务器实例(instance),这个实例可以是主服务器、从服务器,或者另外一个 Sentinel。
# 创建连向主服务器的网络连接
初始化 Sentinel 的最后一步是创建连向被监视主服务器的网络连接,Sentinel 将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息。 对于每个被 Sentinel 监视的主服务器来说,Sentinel 会创建两个连向主服务器的异步网络连接:
- 个是命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。
- 另一个是订阅连接,这个连接专门用于订阅主服务器的
__sentinel__:hello
频道。
# 获取主服务器信息
Sentinel 默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送 INFO 命令,并通过分析 INFO 命令的回复来获取主服务器的当前信息。 主服务器 master 有三个从服务器 slave0、slave1 和 slave2,并且一个 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
...
通过分析主服务器返回的 INFO 命令回复,Sentinel 可以获取以下两方面的信息:
- 一方面是关于主服务器本身的信息,包括 run_id 域记录的服务器运行 ID,以及 role 域记录的服务器角色;
- 另一方面是关于主服务器属下所有从服务器的信息,每个从服务器都由一个"slave"字符串开头的行记录,每行的 ip= 域记录了从服务器的 IP 地址,而 port= 域则记录了从服务器的端口号。根据这些 IP 地址和端口号,Sentinel 无须用户提供从服务器的地址信息,就可以自动发现从服务器。
# 获取从服务器信息
当 Sentinel 发现主服务器有新的从服务器出现时,Sentinel 除了会为这个新的从服务器创建相应的实例结构之外,Sentinel 还会创建连接到从服务器的命令连接和订阅连接。 在创建命令连接之后,Sentinel 在默认情况下,会以每十秒一次的频率通过命令连接向从服务器发送 INFO 命令,并获得类似于以下内容的回复:
# Server
...
run_id:32be0699dd27b410f7c90dada3a6fab17f97899f
...
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
slave_repl_offset:11887
slave_priority:100
# Other sections
...
根据 INFO 命令的回复,Sentinel 会提取出以下信息:
- 从服务器的运行 ID run_id。
- 从服务器的角色 role。
- 主服务器的 IP 地址 master_host,以及主服务器的端口号 master_port。
- 主从服务器的连接状态 master_link_status。
- 从服务器的优先级 slave_priority。
- 从服务器的复制偏移量 slave_repl_offset。
# 向主服务器和从服务器发送信息
在默认情况下,Sentinel 会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:
PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"
信息的内容由多个参数组成:
- 其中以 s_ 开头的参数记录的是 Sentinel 本身的信息。
- 而 m_ 开头的参数记录的则是主服务器的信息。
# 接收来自主服务器和从服务器的频道信息
当 Sentinel 与一个主服务器或者从服务器建立起订阅连接之后,Sentinel 就会通过订阅连接,向服务器发送以下命令:
SUBSCRIBE __sentinel__:hello
Sentinel 对 sentinel:hello 频道的订阅会一直持续到 Sentinel 与服务器的连接断开为止。 这也就是说,对于每个与 Sentinel 连接的服务器,Sentinel 既通过命令连接向服务器的 sentinel:hello 频道发送信息,又通过订阅连接从服务器的 sentinel:hello 频道接收信息。
对于监视同一个服务器的多个 Sentinel 来说,一个 Sentinel 发送的信息会被其他 Sentinel 接收到,这些信息会被用于更新其他 Sentinel 对发送信息 Sentinel 的认知,也会被用于更新其他 Sentinel 对被监视服务器的认知。 当一个 Sentinel 从 sentinel:hello 频道收到一条信息时,Sentinel 会对这条信息进行分析,提取出信息中的 Sentinel IP地址、Sentinel 端口号、Sentinel 运行 ID 等八个参数,并进行以下检查:
- 如果信息中记录的 Sentinel 运行 ID 和接收信息的 Sentinel 的运行 ID 相同,那么说明这条信息是 Sentinel 自己发送的,Sentinel 将丢弃这条信息,不做进一步处理。
- 相反地,如果信息中记录的 Sentinel 运行 ID 和接收信息的 Sentinel 的运行 ID 不相同,那么说明这条信息是监视同一个服务器的其他 Sentinel 发来的,接收信息的 Sentinel 将根据信息中的各个参数,对相应主服务器的实例结构进行更新。
# 检测主观下线状态
在默认情况下,Sentinel 会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他 Sentinel 在内)发送 PING 命令,并通过实例返回的 PING 命令回复来判断实例是否在线。 Sentinel 配置文件中的 down-after-milliseconds 选项指定了 Sentinel 判断实例进入主观下线所需的时间长度:如果一个实例在 down-after-milliseconds 毫秒内,连续向 Sentinel 返回无效回复,那么 Sentinel 会修改这个实例所对应的实例结构,在结构的 flags 属性中打开 SRI_S_DOWN 标识,以此来表示这个实例已经进入主观下线状态。
# 检查客观下线状态
当 Sentinel 将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他 Sentinel 进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当 Sentinel 从其他 Sentinel 那里接收到足够数量的已下线判断之后,Sentinel 就会将从服务器判定为客观下线,并对主服务器执行故障转移操作。 当认为主服务器已经进入下线状态的 Sentinel 的数量,超过 Sentinel 配置中设置的 quorum 参数的值,那么该 Sentinel 就会认为主服务器已经进入客观下线状态。比如说,如果 Sentinel 在启动时载入了以下配置:
sentinel monitor master 127.0.0.1 6379 2
包括当前 Sentinel 在内,只要总共有两个 Sentinel 认为主服务器已经进入下线状态,那么当前 Sentinel 就将主服务器判断为客观下线。
# 选举领头 Sentinel
当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个 Sentinel 会进行协商,选举出一个领头 Sentinel ,并由领头 Sentinel 对下线主服务器执行故障转移操作。
以下是 Redis 选举领头 Sentinel 的规则和方法:
- 所有在线的 Sentinel 都有被选为领头 Sentinel 的资格,换句话说,监视同一个主服务器的多个在线 Sentinel 中的任意一个都有可能成为领头 Sentinel。
- 每次进行领头 Sentinel 选举之后,不论选举是否成功,所有 Sentinel 的配置纪元(configuration epoch)的值都会自增一次。配置纪元实际上就是一个计数器,并没有什么特别的。
- 在一个配置纪元里面,所有 Sentinel 都有一次将某个 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 is-master-down-by-addr
命令之后,将向源 Sentinel 返回一条命令回复,回复中的leader_runid
参数和leader_epoch
参数分别记录了目标 Sentinel 的局部领头 Sentinel 的运行 ID 和配置纪元。 - 源 Sentinel 在接收到目标 Sentinel 返回的命令回复之后,会检查回复中 leader_epoch 参数的值和自己的配置纪元是否相同,如果相同的话,那么源 Sentinel 继续取出回复中的
leader_runid
参数,如果leader_runid
参数的值和源 Sentinel 的运行 ID 一致,那么表示目标 Sentinel 将源 Sentinel 设置成了局部领头 Sentinel。 - 如果有某个 Sentinel 被半数以上的 Sentinel 设置成了局部领头 Sentinel,那么这个 Sentinel 成为领头 Sentinel。举个例子,在一个由 10 个 Sentinel 组成的 Sentinel 系统里面,只要有大于等于 10/2+1=6 个 Sentinel 将某个 Sentinel 设置为局部领头 Sentinel,那么被设置的那个 Sentinel 就会成为领头 Sentinel。
- 因为领头 Sentinel 的产生需要半数以上 Sentinel 的支持,并且每个 Sentinel 在每个配置纪元里面只能设置一次局部领头 Sentinel,所以在一个配置纪元里面,只会出现一个领头 Sentinel。
- 如果在给定时限内,没有一个 Sentinel 被选举为领头 Sentinel,那么各个 Sentinel 将在一段时间之后再次进行选举,直到选出领头 Sentinel 为止。
# 故障转移
在选举产生出领头 Sentinel 之后,领头 Sentinel 将对已下线的主服务器执行故障转移操作,该操作包含以下三个步骤:
- 在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器。
- 让已下线主服务器属下的所有从服务器改为复制新的主服务器。
- 将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器。
# 选出新的主服务器
故障转移操作第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送 SLAVEOF no one
命令,将这个从服务器转换为主服务器。
领头 Sentinel 会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项地对列表进行过滤:
- 删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。
- 删除列表中所有最近五秒内没有回复过领头 Sentinel 的 INFO 命令的从服务器,这可以保证列表中剩余的从服务器都是最近成功进行过通信的。
- 删除所有与已下线主服务器连接断开超过 down-after-milliseconds10 毫秒的从服务器:down-after-milliseconds 选项指定了判断主服务器下线所需的时间,而删除断开时长超过 down-after-milliseconds10 毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。
如果有多个具有相同最高优先级的从服务器,那么领头 Sentinel 将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,并选出其中偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。 最后,如果有多个优先级最高、复制偏移量最大的从服务器,那么领头 Sentinel 将按照运行 ID 对这些从服务器进行排序,并选出其中运行 ID 最小的从服务器。
# 集群
# 节点
一个 Redis 集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。 连接各个节点的工作可以使用 CLUSTER MEET 命令来完成,该命令的格式如下:
CLUSTER MEET <ip> <port>
向一个节点 node 发送 CLUSTER MEET 命令,可以让 node 节点与 ip 和 port 所指定的节点进行握手(handshake),当握手成功时,node 节点就会将 ip 和 port 所指定的节点添加到 node 节点当前所在的集群中。
# 集群数据结构
clusterNode 结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的 IP 地址和端口号等等。每个节点都会使用一个 clusterNode 结构来记录自己的状态,并为集群中的所有其他节点(包括主节点和从节点)都创建一个相应的 clusterNode 结构,以此来记录其他节点的状态:
struct clusterNode {
// 创建节点的时间
mstime_t ctime;
// 节点的名字,由40个十六进制字符组成,例如 68eef66df23420a5862208ef5b1a7005b806f2ff
char name[REDIS_CLUSTER_NAMELEN];
// 节点标识,使用各种不同的标识值记录节点的角色(比如主节点或者从节点),以及节点目前所处的状态(比如在线或者下线)。
int flags;
// 节点当前的配置纪元,用于实现故障转移
uint64_t configEpoch;
// 节点的IP地址
char ip[REDIS_IP_STR_LEN];
// 节点的端口号
int port;
// 保存连接节点所需的有关信息
clusterLink *link;
// ...
};
每个节点都保存着一个 clusterState 结构,这个结构记录了在当前节点的视角下,集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类:
typedef struct clusterState {
// 指向当前节点的指针
clusterNode *myself;
// 集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;
// 集群当前的状态:是在线还是下线
int state;
// 集群中至少处理着一个槽的节点的数量
int size;
// 集群节点名单(包括 myself 节点)
// 字典的键为节点的名字,字典的值为节点对应的 clusterNode 结构
dict *nodes;
// ...
} clusterState;
# CLUSTER MEET 命令的实现
通过向节点 A 发送 CLUSTER MEET 命令,客户端可以让接收命令的节点 A 将另一个节点 B 添加到节点 A 当前所在的集群里面:
CLUSTER MEET <ip> <port>
收到命令的节点 A 将与节点 B 进行握手(handshake),以此来确认彼此的存在,并为将来的进一步通信打好基础:
- 节点 A 会为节点 B 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典里面。
- 之后,节点 A 将根据 CLUSTER MEET 命令给定的 IP 地址和端口号,向节点 B 发送一条 MEET 消息(message)。
- 如果一切顺利,节点 B 将接收到节点 A 发送的 MEET 消息,节点 B 会为节点 A 创建一个 clusterNode 结构,并将该结构添加到自己的 clusterState.nodes 字典里面。
- 之后,节点 B 将向节点 A 返回一条 PONG 消息。
- 如果一切顺利,节点 A 将接收到节点 B 返回的 PONG 消息,通过这条 PONG 消息节点 A 可以知道节点 B 已经成功地接收到了自己发送的 MEET 消息。
- 之后,节点 A 将向节点 B 返回一条 PING 消息。
- 如果一切顺利,节点 B 将接收到节点 A 返回的 PING 消息,通过这条 PING 消息节点 B 可以知道节点 A 已经成功地接收到了自己返回的 PONG 消息,握手完成。
之后,节点 A 会将节点 B 的信息通过 Gossip 协议传播给集群中的其他节点,让其他节点也与节点 B 进行握手,最终,经过一段时间之后,节点 B 会被集群中的所有节点认识。
# 槽指派
Redis 集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为 16384 个槽(slot),数据库中的每个键都属于这 16384 个槽的其中一个,集群中的每个节点可以处理 0 个或最多 16384 个槽。 当数据库中的 16384 个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。 通过向节点发送 CLUSTER ADDSLOTS 命令,我们可以将一个或多个槽指派(assign)给节点负责:
CLUSTER ADDSLOTS <slot> [slot ...]
# 将槽0至槽5000指派给节点7000负责
127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000
OK
# 将槽5001至槽10000指派给节点7001负责
127.0.0.1:7001> CLUSTER ADDSLOTS 5001 5002 5003 5004 ... 10000
OK
# 将槽10001至槽16383指派给7002负责:
127.0.0.1:7002> CLUSTER ADDSLOTS 10001 10002 10003 10004 ... 16383
OK
# 当以上三个 CLUSTER ADDSLOTS 命令都执行完毕之后,数据库中的 16384 个槽都已经被指派给了相应的节点,集群进入上线状态
# 记录节点的槽指派信息
clusterNode 结构的 slots 属性和 numslot 属性记录了节点负责处理哪些槽:
struct clusterNode {
// ...
unsigned char slots[16384/8];
int numslots;
// ...
};
slots 属性是一个二进制位数组(bit array),这个数组的长度为 16384/8=2048 个字节,共包含 16384 个二进制位。 Redis 以 0 为起始索引,16383 为终止索引,对 slots 数组中的 16384 个二进制位进行编号,并根据索引 i 上的二进制位的值来判断节点是否负责处理槽 i :
- 如果 slots 数组在索引i上的二进制位的值为 1,那么表示节点负责处理槽 i。
- 如果 slots 数组在索引i上的二进制位的值为 0,那么表示节点不负责处理槽 i。
numslots 属性则记录节点负责处理的槽的数量,也即是 slots 数组中值为 1 的二进制位的数量。
# 传播节点的槽指派信息
一个节点除了会将自己负责处理的槽记录在 clusterNode 结构的 slots 属性和 numslots 属性之外,它还会将自己的 slots 数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。
- 节点 7000 会通过消息向节点 7001 和节点 7002 发送自己的 slots 数组,以此来告知这两个节点,自己负责处理槽 0 至槽 5000。
- 节点 7001 会通过消息向节点 7000 和节点 7002 发送自己的slots数组,以此来告知这两个节点,自己负责处理槽 5001 至槽 10000。
- 节点 7002 会通过消息向节点 7000 和节点 7001 发送自己的 slots 数组,以此来告知这两个节点,自己负责处理槽 10001 至槽 16383 。
# 记录集群所有槽的指派信息
clusterState 结构中的 slots 数组记录了集群中所有 16384 个槽的指派信息:
typedef struct clusterState {
// ...
clusterNode *slots[16384];
// ...
} clusterState;
slots 数组包含 16384 个项,每个数组项都是一个指向 clusterNode 结构的指针:
- 如果 slots[i] 指针指向 NULL,那么表示槽 i 尚未指派给任何节点。
- 如果 slots[i] 指针指向一个 clusterNode 结构,那么表示槽 i 已经指派给了 clusterNode 结构所代表的节点。
# 在集群中执行命令
在对数据库中的 16384 个槽都进行了指派之后,集群就会进入上线状态,这时客户端就可以向集群中的节点发送数据命令了。 当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算(CRC16(key) & 16383)出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:
- 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令。
- 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个 MOVED 错误,指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令。
MOVED 错误的格式为:
MOVED <slot> <ip>:<port>
其中 slot 为键所在的槽,而 ip 和 port 则是负责处理槽 slot 的节点的 IP 地址和端口号。例如错误:
MOVED 10086 127.0.0.1:7002
表示槽 10086 正由 IP 地址为 127.0.0.1,端口号为 7002 的节点负责。 当客户端接收到节点返回的 MOVED 错误时,客户端会根据 MOVED 错误中提供的 IP 地址和端口号,转向至负责处理槽 slot 的节点,并向该节点重新发送之前想要执行的命令。
# 重新分片
Redis 集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。 Redis 集群的重新分片操作是由 Redis 的集群管理软件 redis-trib 负责执行的,Redis 提供了进行重新分片所需的所有命令,而 redis-trib 则通过向源节点和目标节点发送命令来进行重新分片操作。
# 复制与故障转移
Redis 集群中的节点分为主节点(master)和从节点(slave),其中主节点用于处理槽,而从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求。
集群中的每个节点都会定期地向集群中的其他节点发送 PING 消息,以此来检测对方是否在线,如果接收 PING 消息的节点没有在规定的时间内,向发送 PING 消息的节点返回 PONG 消息,那么发送 PING 消息的节点就会将接收 PING 消息的节点标记为疑似下线(probable fail,PFAIL)。 如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点 x 报告为疑似下线,那么这个主节点 x 将被标记为已下线(FAIL),将主节点 x 标记为已下线的节点会向集群广播一条关于主节点 x 的 FAIL 消息,所有收到这条 FAIL 消息的节点都会立即将主节点 x 标记为已下线。
当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:
- 复制下线主节点的所有从节点里面,会有一个从节点被选中。
- 被选中的从节点会执行
SLAVEOF no one
命令,成为新的主节点。 - 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己。
- 新的主节点向集群广播一条 PONG 消息,这条 PONG 消息可以让集群中的其他节点立即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点负责处理的槽。
- 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成。