Redis设计与实现学习笔记

学习时间:2023年2月26日

学习来源:

  • Redis设计与实现第二版
  • redis-2.8

1 数据结构与对象

1.1 简单动态字符串

Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串simple dynamic string,SDS)的抽象类型,并将 SDS用作Redis 的默认字符串表示。

在Redis里面,C字符串只会作为字符串字面量(string literal)用在一些无须对字符串值进行修改的地方,比如打印日志:

1
redisLog(REDIS_WARNING,"Redis is now ready to exit,bye bye...");

当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis 就会使用SDS来表示字符串值,比如在Redis的数据库里面,包含字符串值的键值对在底层都是由SDS实现的。

如果客户端执行命令:

1
SET msg "hello world"

Redis将在数据库中创建一个新的键值对,其中:

  • 键是一个字符串对象,对象的底层实现是一个保存着字符串msg的SDS
  • 值也是一个字符串对象,底层实现是一个保存着字符串hello world的SDS

又如:

1
RPUSH fruits "apple" "banana" "cherry"

Redis将在数据库中创建一个新的键值对,其中:

  • 键是一个字符串对象,对象的底层实现是一个保存着字符串fruits的SDS
  • 值是一个列表对象,列表对象包含三个SDS。

1.1.1 SDS的定义

SDS定义在头文件sds.h中,是一个结构体:

1
2
3
4
5
6
7
struct sdshdr {
int len; // 记录buf数组中已经使用的有效字节数(不包含空字符),等于SDS所保存的字符串长度

int free; // 记录buf数组中未使用字节的数量

char buf[]; // 字节数组,保存字符串(有空字符),长度为buf=len+free+1
}

image-20230226153439761

  • free属性的值为0,表示这个SDS没有分配任何未使用空间。
  • len属性的值为5,表示这个SDS保存了一个五字节长的字符串。
  • buf属性是一个char类型的数组,数组的前五个字节分别保存了Redis五个字符,而最后一个字节则保存了空字符'\0'

1.1.2 SDS与C字符串的区别

区别1:常数复杂度获取字符串长度

和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1)

区别2:杜绝缓冲区溢出

C字符串不检查缓冲区,因此可能会出现缓冲区溢出,例如使用strcat函数时:

1
char *strcat(char *dest, const char *src);

如果dest的空间不足,不能容纳下dest+src个字节的长度,则会溢出。

区别3:减少修改字符串时带来的内存重分配次数

SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录。

通过未使用空间,SDS 实现了空间预分配惰性空间释放两种优化策略。

  • 空间预分配

空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。

其中,额外分配的未使用空间数量由以下公式决定:

  1. 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。举个例子,如果进行修改之后,SDS的len将变成13字节,那么程序也会分配13字节的未使用空间,SDS的buf数组的实际长度将变成13+13+1=27字节(额外的一字节用于保存空字符)。
  2. 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度为30MB+1MB+1byte

示例:

执行sdscat之前的SDS:

image-20230226155752371

执行:

1
sdscat(s, " Cluster");

执行之后的SDS:buf的长度为free+len+1=27

image-20230226155831417

  • 惰性空间释放

惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。

举个例子,sdstrim函数接受一个SDS和一个C字符串作为参数,移除SDS中所有在C字符串中出现过的字符。

示例:

执行之前:

image-20230226160022225

执行:

1
sdstrim(s, "XY");

执行之后:

image-20230226160047107

总结

image-20230226160147617

1.1.3 相关API

image-20230226160225336

image-20230226160233775

1.2 链表

1.2.1 链表和链表节点的实现

链表节点在头文件adlist.h中定义,是一个结构体:

1
2
3
4
5
6
7
8
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点值
void *value;
}listNode; // 本质是一个双向链表

image-20230226160656181

链表在头文件adlist.h中定义,也是一个结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;

image-20230226161815509

1.2.2 相关API

image-20230226162003042

image-20230226162014998

1.3 字典

1.3.1 概念

字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。

在字典中,一个键(key)可以和一个值(value)进行关联(或者说将键映射为值),这些关联的键和值就称为键值对。

字典中的每个键都是独一无二的,程序可以在字典中根据键查找与之关联的值,或者通过键来更新值,又或者根据键来删除整个键值对,等等。

字典经常作为一种数据结构内置在很多高级编程语言里面,但Redis所使用的C语言并没有内置这种数据结构,因此Redis构建了自己的字典实现。

字典在Redis中的应用相当广泛,比如Redis的数据库就是使用字典来作为底层实现的,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。


哈希表补充:

概念

哈希表:也叫做散列表。是根据关键字(键)和值直接进行访问的数据结构。也就是说,它通过关键字 key 和一个映射函数 Hash(key) 计算出对应的值 value,然后把键值对映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数(散列函数),用于存放记录的数组叫做哈希表(散列表)。 哈希表的关键思想是使用哈希函数,将键 key 和值 value 映射到对应表的某个区块中。

image-20230226163622134

哈希函数的作用:将关键字(键,key)映射为值(哈希地址,value,数组下标)。

哈希冲突

哈希冲突:不同的关键字通过同一个哈希函数可能得到同一哈希地址,即 key1 ≠ key2,而 Hash(key1) = Hash(key2),这种现象称为哈希冲突。

解决:

  • 开放地址法:指的是将哈希表中的「空地址」向处理冲突开放。当哈希表未满时,处理冲突时需要尝试另外的单元,直到找到空的单元为止。
    • H(i) = (Hash(key) + F(i)) \% m, i = 1, 2, 3, ..., n (n ≤ m - 1)
      • F(i):冲突解决方法
      • Hash(key):哈希值
      • m:哈希表长
    • 线性探测法
    • 二次探测法
    • 伪随机序列
  • 链地址法:将具有相同哈希地址的元素(或记录)存储在同一个线性链表中。

1.3.2 字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。

哈希表dictht

dict.h中定义:

1
2
3
4
5
6
7
8
9
10
11
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表数组大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于size-1,作用:让索引始终落在0~size-1之间
unsigned long sizemask;
// 该哈希表已有节点的数量(已有的键值对数量)
unsigned long used;
} dictht;

image-20230226162453635

哈希节点dictEntry

哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct dictEntry {
// 键
void *key;
// 值,联合,可以用以下三种类型中的一种来表示值的类型
union(
void *val;
uint64_tu64;
int64_ts64;
}V;
// 指向下个哈希表节点,形成链表;作用:采用链地址法来解决哈希冲突
struct dictEntry *next;
} dictEntry;

image-20230226164201809

字典dict

1
2
3
4
5
6
7
8
9
10
11
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当rehash不在进行时,值为-1
int rehashidx; /* rehashing not in progress if rehashidx ==-1 */
} dict;

type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的:

  • type 属性是一个指向 dictType 结构的指针,每个 dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
  • privdata 属性则保存了需要传给那些类型特定函数的可选参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;
  • ht 属性是一个包含两个项的数组,数组中的每个项都是一个 dictht 哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
  • rehashidx记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。

image-20230226165720152

1.3.3 哈希算法

当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

计算方法:

1
2
hash = dict->type->hashFunction(key); // 计算key的哈希值
index = hash & dict->ht[x].sizemark; // 使用sizemask和哈希值计算索引值,x可为0或1,计算出的index即为键值对在ht[x]数组的所在位置下标

示例

size=4,sizemark=3的字典为例:

假设计算后的哈希值为hash(k0)=8,则索引为index = 8 & 3 = (1000) & (0011) = 0000 = 0

image-20230226170750517

1.3.4 键冲突

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突。

Redis的哈希表使用链地址法(separate chaining)来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。

1.3.5 rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。

扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

  • 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及 ht[0]当前包含的键值对数量(也即是ht[0].used属性的值):
    • 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的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 做准备。

当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:

  • 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
  • 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5

其中哈希表的负载因子可以通过公式计算:

1
load_factor = ht[0].used / ht[0].size;

1.3.6 渐进式rehash

rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。

这样做的原因在于,如果ht[0]里只保存着四个键值对,那么服务器可以在瞬间就将这些键值对全部rehash到ht[1];但是,如果哈希表里保存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht[1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。

因此,为了避免 rehash 对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash 到ht[1]。

渐进rehash步骤

以下是哈希表渐进式rehash的详细步骤:

  1. ht[1]分配空间,让字典同时持有ht[0]ht[1]两个哈希表。
  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示 rehash 工作正式开始。
  3. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 而带来的庞大计算量。

渐进rehash执行期间的哈希表操作

因为在进行渐进式rehash 的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类。

另外,在渐进式 rehash 执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

1.3.7 相关API

image-20230226173849178

image-20230226173855723

1.4 跳跃表

1.4.1 概念

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

跳跃表支持平均 O(logM)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。

在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。

Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis就会使用跳跃表来作为有序集合键的底层实现。

1.5 整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

1.5.1 实现

整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_tint32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。

每个intset.h/intset结构表示一个整数集合:

1
2
3
4
5
6
7
8
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)。

image-20230227100619218

image-20230227100627223

1.5.2 升级

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。

步骤分为三步:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组中

升级的好处

  • 提升灵活性

因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。

但是,因为整数集合可以通过自动升级底层数组来适应新元素,所以我们可以随意地将int16_t、int32_t 或者int64_t类型的整数添加到集合中,而不必担心出现类型错误,这种做法非常灵活。

  • 节约内存

1.5.3 降级

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。

1.5.4 相关API

image-20230227102138684

1.6 压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。

例如,执行以下命令将创建一个压缩列表实现的列表键:

1
2
3
4
redis> RPUSH lst 1 3 5 10086 "hello" "world"
(integer)6
redis> OBJECT ENCODING lst
"ziplist"

1.6.1 构成

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

构成部分如下:

image-20230227102547199

示例

image-20230227102641759

  • 列表zlbytes属性的值为0x50(十进制80),表示压缩列表的总长为80字节。
  • 列表zltail属性的值为0x3c(十进制60),这表示如果我们有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量60,就可以计算出表尾节点entry3的地址。
  • 列表zllen属性的值为0x3(十进制3),表示压缩列表包含三个节点。

1.6.2 列表节点

列表节点entry的组成部分如下:

image-20230227102914689

每个压缩列表节点可以保存一个字节数组或者一个整数值。

1.7 对象

Redis 并没有直接使用上面的数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包含字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象,每种对象都用到了至少一种我们前面所介绍的数据结构。

通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令。使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。

1.7.1 对象的类型和编码

Redis使用对象来表示数据库中的键和值,每次当我们在Redis的数据库中新创建一个键值对时,我们至少会创建两个对象,一个对象用作键值对的键(键对象),另一个对象用作键值对的值(值对象)。

Redis中的每个对象都由一个redis.h/redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性、encoding属性和ptr属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
// redis.h/redisObject
typedef struct redisObject {
// 类型,对应redis的五大数据类型
unsigned type:4;
// 编码,底层实现采用哪种数据结构
unsigned encoding:4;
// 记录对象最后一次被程序访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数
int refcount;
// 指向底层实现数据结构的指针
void *ptr;
} robj;
① 类型

type属性为下面常量中的一个:

1
2
3
4
5
6
/* Object types */
#define REDIS_STRING 0 // 字符串对象
#define REDIS_LIST 1 // 列表对象
#define REDIS_SET 2 // 集合对象
#define REDIS_ZSET 3 // 有序集合对象
#define REDIS_HASH 4 // 哈希对象

对于Redis数据库保存的键值对来说,键总是一个字符串对象,而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种,因此:

  • 当我们称呼一个数据库键为字符串键时,我们指的是“这个数据库键所对应的值为字符串对象”;
  • 当我们称呼一个键为列表键时,我们指的是“这个数据库键所对应的值为列表对象”。

对数据库键使用TYPE命令时,返回该键对应值对象的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 键为字符串对象,值为字符串对象
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 Tom age 25 career Programmer
OK
redis> TYPE profile
hash

# 键为字符串对象,值为集合对象
redis> SADD fruits apple banana cherry
(integer)3
redis> TYPE fruits
set
② 编码和底层实现

对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定,也就是说这个对象使用了什么数据结构作为对象的底层实现,这个属性的值为下面常量中的一个:

1
2
3
4
5
6
7
8
#define REDIS_ENCODING_RAW 0     /* Raw representation */
#define REDIS_ENCODING_INT 1 /* Encoded as integer */
#define REDIS_ENCODING_HT 2 /* Encoded as hash table */
#define REDIS_ENCODING_ZIPMAP 3 /* Encoded as zipmap */
#define REDIS_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */
#define REDIS_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define REDIS_ENCODING_INTSET 6 /* Encoded as intset */
#define REDIS_ENCODING_SKIPLIST 7 /* Encoded as skiplist */

image-20230227104500348

每种类型的对象都至少使用了两种不同的编码:

image-20230227104823029

使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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"

1.7.2 字符串对象

① 编码类型

字符串对象的编码可以是intraw或者embstr

  • 如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int

image-20230314144906442

  • 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw

执行以下命令:

1
2
3
4
5
6
redis> SET story "Long,long ago there lived a king ..."
OK
redis> STRLEN story
(integer)37
redis> OBJECT ENCODING story
"raw"

image-20230314145015268

  • 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。

embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构。

image-20230314145229418

执行命令:

1
2
3
4
redis> SET msg "hello"
OK
redis> OBJECT ENCODING msg
"embstr"

image-20230314145339333

  • 可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的。如果我们要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值。

image-20230327120356173

② 编码转换

int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下,会被转换为raw编码的字符串对象。

对于int编码的字符串对象来说,如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw。

另外,因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int编码的字符串对象和raw编码的字符串对象有这些程序),所以embstr编码的字符串对象实际上是只读的。当我们对 embstr 编码的字符串对象执行任何修改命令时,程序会先将对象的编码从 embstr转换成raw,然后再执行修改命令。因为这个原因,embstr 编码的字符串对象在执行修改命令之后,总会变成一个raw编码的字符串对象。

1.7.3 列表对象

① 编码类型

列表对象的编码可以是ziplist或者linkedlist

ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。举个例子,如果我们执行以下RPUSH命令,那么服务器将创建一个列表对象作为numbers键的值:

1
2
redis> RPUSH numbers 1 "three" 5 
(integer) 3

如果numbers键的值对象使用的是ziplist编码,这个这个值对象将会是图8-5所示:

image-20230327162727541

另一方面,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。

举个例子,如果前面所说的numbers键创建的列表对象使用的不是ziplist编码,而是linkedlist编码,那么numbers键的值对象将是图8-6所示的样子。

image-20230327162800815

注意,linkedlist编码的列表对象在底层的双端链表结构中包含了多个字符串对象,这种嵌套字符串对象的行为在稍后介绍的哈希对象、集合对象和有序集合对象中都会出现,字符串对象是Redis五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象。

完整的StringObject表示如下:

image-20230327162922509

image-20230327171429237

② 编码转换

当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码:

  • 列表对象保存的所有字符串元素的长度都小于64字节;
  • 列表对象保存的元素数量小于512个;不能满足这两个条件的列表对象需要使用linkedlist编码。

1.7.4 哈希对象

① 编码类型

哈希对象的编码可以是ziplist或者hashtable

ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾,因此:

  • 保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;
  • 先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。

举个例子,如果我们执行以下HSET命令,那么服务器将创建一个列表对象作为profile 键的值:

1
2
3
4
5
6
redis> HSET profile name "Tom"
(integer) 1
redis> HSET profile age 25
(integer) 1
redis> HSET profile career "Programmer"
(integer) 1

image-20230327174033838

image-20230327174042835

另一方面,hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存:

  • 字典的每个键(void *key)都是一个字符串对象,对象中保存了键值对的键;
  • 字典的每个值(union{...}v)都是一个字符串对象,对象中保存了键值对的值

举个例子,如果前面profile键创建的不是ziplist编码的哈希对象,而是hashtable 编码的哈希对象,那么这个哈希对象应该会是图8-11所示的样子。

image-20230327174152702

② 编码转换

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
  • 哈希对象保存的键值对数量小于512个;不能满足这两个条件的哈希对象需要使用hashtable编码。

1.7.5 集合对象

1.7.6 有序集合对象

1.7.7 类型检查与命令多态

1.7.8 内存回收

因为C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。

每个对象的引用计数信息由redisObject结构的refcount属性记录。

对象的引用计数信息会随着对象的使用状态而不断变化:

  • 在创建一个新对象时,引用计数的值会被初始化为1;
  • 当对象被一个新程序使用时,它的引用计数值会被增一;
  • 当对象不再被一个程序使用时,它的引用计数值会被减一;
  • 当对象的引用计数值变为0时,对象所占用的内存会被释放。

1.7.9 对象共享

除了用于实现引用计数内存回收机制之外,对象的引用计数属性还带有对象共享的作用。举个例子,假设键A创建了一个包含整数值100的字符串对象作为值对象。

如果这时键B也要创建一个同样保存了整数值100的字符串对象作为值对象,那么服务器有以下两种做法:

  1. 为键B新创建一个包含整数值100的字符串对象;
  2. 让键A和键B共享同一个字符串对象;

很显然,第二种方法更节约内存。因此,redis采用第二种方法来保存值对象。

image-20230228074651667

注意,Redis只对包含整数值的字符串对象进行共享。

1.7.10 对象的空转时长

OBJECT IDLETIME 命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的lru时间计算得出的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
redis> SET msg "hello world"
OK

# 等待一小段时间
redis> OBJECT IDLETIME msg
(integer) 20

# 等待一阵子
redis> OBJECT IDLETIME msg
(integer)180

#访问msg 键的值
redis> GET msg
"hello world"
# 键处于活跃状态,空转时长为0
redis> OBJECT IDLETIME msg
(integer) 0

除了可以被 OBJECTIDLETIME 命令打印出来之外,键的空转时长还有另外一项作用:如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru 或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。

2 单机数据库的实现

2.1 数据库

2.1.1 服务器中的数据库

Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的 db 数组中,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库。

在初始化服务器时,程序会根据服务器状态的 dbnum 属性来决定应该创建多少个数据库。

1
2
3
4
5
6
7
8
9
struct redisServer {
// ...
// 一个数组,保存着服务器中的所有数据库
redisDb *db;
// ...
// 服务器的数量
int dbnum;
// ...
};

dbnum属性的值由服务器配置的database选项决定,默认情况下,该选项的值为16,所以Redis服务器默认会创建16个数据库。

image-20230228080103715

2.1.2 切换数据库

每个Redis客户端都有自己的目标数据库,每当客户端执行数据库写命令或者数据库读命令的时候,目标数据库就会成为这些命令的操作对象。

默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库。

以下代码示例演示了客户端在0号数据库设置并读取键msg,之后切换到2号数据库并执行类似操作的过程:

1
2
3
4
5
6
7
8
9
10
11
12
redis> SET msg "hello world"
OK
redis> GET msg
"hello world"
redis> SELECT 2 # 选择2号数据库
OK
redis[2]> GET msg
(nil)
redis[2]> SET msg "another world"
OK
redis[2]> GET msg
"another world"

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

1
2
3
4
5
6
typedef struct redisClient {
// ...
// 记录当前客户端正在使用的数据库
redisDb *db;
// ...
}redisClient;

image-20230228080550805

2.1.3 数据库键空间

Redis是一个键值对(key-value pair)数据库服务器,服务器中的每个数据库都由一个redis.h/redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间(key space),键空间的底层实现就是一个字典的指针。

1
2
3
4
5
6
7
8
9
typedef struct redisDb {
dict *dict; /* The keyspace for this DB,数据库键空间 */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP) */
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id;
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;

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

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

举个例子,如果我们在空白的数据库中执行以下命令:

1
2
3
4
5
6
7
8
9
10
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

数据库的键空间会成为下面的样子:(图9-4

图9-4

① 添加新键

添加一个新键值对到数据库,实际上就是将一个新键值对添加到键空间字典里面,其中键为字符串对象,而值则为任意一种类型的Redis对象。

举个例子,如果键空间当前的状态如图9-4所示,那么在执行以下命令之后:

1
2
redis> SET date "2013.12.1"
OK

image-20230228082200884

② 删除键

③ 更新键

对一个数据库键进行更新,实际上就是对键空间里面键所对应的值对象进行更新,根据值对象的类型不同,更新的具体方法也会有所不同。

举个例子,如果键空间当前的状态如图9-4所示,那么在执行以下命令之后:

1
2
redis> SET message "blah blah"
OK

image-20230228082437792

继续执行命令:

1
2
redis> HSET book page 320
(integer) 1

image-20230228082510173

④ 对键取值

⑤ 其他操作

除了上面列出的添加、删除、更新、取值操作之外,还有很多针对数据库本身的Redis 命令,也是通过对键空间进行处理来完成的。

比如说,用于清空整个数据库的FLUSHDB命令,就是通过删除键空间中的所有键值对来实现的。又比如说,用于随机返回数据库中某个键的RANDOMKEY命令,就是通过在键空间中随机返回一个键来实现的。

另外,用于返回数据库键数量的DBSIZE命令,就是通过返回键空间中包含的键值对的数量来实现的。类似的命令还有EXISTSRENAMEKEYS等,这些命令都是通过对键空间进行操作来实现的。

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

① 设置过期时间

Redis有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间(键什么时候会被删除):

1
2
3
4
5
EXPIRE <key> <ttl> # 将key的生存时间设置为ttl秒
PEXPIRE <key> <ttl> # 将key的生存时间设置为ttl毫秒

EXPIREAT <key> <timestamp> # 将key的过期时间设置为timestamp所指定的秒数时间戳
PEXPIREAT <key> <timestamp> # 将key的过期时间设置为timestamp所指定的毫秒数时间戳

示例1:

1
2
3
4
5
6
7
8
9
10
11
12
redis> SET key value 
OK
redis> EXPIRE key 5
(integer) 1

# 5秒之内
redis> GET key
"value"

# 5秒之后
redis> GET key
(nil)

示例2:过期时间是一个UNIX时间戳,当键的过期时间来临时,服务器就会自动从数据库中删除这个键:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
redis> SET key value
OK
redis> EXPIREAT key 1377257300
(integer) 1
redis> TIME
1)"1377257296"
2)"296543"
# 1377257300之前
redis> GET key
"value"
redis> TIME
1)"1377257303"
2)"230656"
# 1377257300之后
redis> GET key
(nil)

示例3:TTL命令和PTTL命令接受一个带有生存时间或者过期时间的键,返回这个键的剩余生存时间,也就是,返回距离这个键被服务器自动删除还有多长时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
redis> SET key value 
OK
redis> EXPIRE key 1000
(integer) 1
redis> TTL key
(integer) 997
redis> SET another_key another_value
OK
redis> TIME
1)"1377333070"
2)"761687"
redis> EXPIREAT another_key 1377333100
(integer) 1
redis> TTL another_key
(integer)10

虽然有多种不同单位和不同形式的设置命令,但实际上EXPIRE、PEXPIRE、EXPIREAT 三个命令都是使用PEXPIREAT命令来实现的:无论客户端执行的是以上四个命令中的哪一个,经过转换之后,最终的执行效果都和执行PEXPIREAT命令一样。

image-20230228084800160

② 保存过期时间

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

1
2
3
4
5
typedef struct redisDb {
// ...
dict *expires; /* Timeout of keys with a timeout set */
// ...
} redisDb;
  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)。
  • 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间:一个毫秒精度的 UNIX 时间戳。

image-20230228085028348

当执行命令时:

1
2
redis> PEXPIREAT message 1391234400000 
(integer) 1

image-20230228085406532

③ 移除过期时间

使用命令PERSIST

1
2
3
4
5
6
7
8
redls>PEXPIREAT message 1391234400000 
(integer)1
redis> TTL message
(integer) 13893281
redis> PERSIST message
(integer) 1
redis> TTL message
(integer) -1

PERSIST命令就是PEXPIREAT命令的反操作:PERSIST命令在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联。

④ 过期键的判定

通过过期字典,程序可以用以下步骤检查一个给定键是否过期:

  1. 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间。
  2. 检查当前UNIX时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。

2.1.5 过期键删除策略

① 惰性删除策略的实现

过期键的惰性删除策略由db.c/expireIfNeed函数实现,所有读写数据库的Redis 命令在执行之前都会调用 expireIfNeeded函数对输入键进行检查:

  • 如果输入键已经过期,那么 expireIfNeeded 函数将输入键从数据库中删除。
  • 如果输入键未过期,那么 expireIfNeeded函数不做动作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int expireIfNeeded(redisDb *db, robj *key) {
mstime_t when = getExpire(db,key);
mstime_t now;
// 没有过期
if (when < 0) return 0; /* No expire for this key */

/* Don't expire anything while loading. It will be done later. */
if (server.loading) return 0;

/* If we are in the context of a Lua script, we claim that time is
* blocked to when the Lua script started. This way a key can expire
* only the first time it is accessed and not in the middle of the
* script execution, making propagation to slaves / AOF consistent.
* See issue #1525 on Github for more information. */
now = server.lua_caller ? server.lua_time_start : mstime();

/* If we are running in the context of a slave, return ASAP:
* the slave key expiration is controlled by the master that will
* send us synthesized DEL operations for expired keys.
*
* Still we try to return the right information to the caller,
* that is, 0 if we think the key should be still valid, 1 if
* we think the key is expired at this time. */
if (server.masterhost != NULL) return now > when;

/* Return when this key has not expired */
if (now <= when) return 0;

/* Delete the key */
server.stat_expiredkeys++;
propagateExpire(db,key);
notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
"expired",key,db->id);
return dbDelete(db,key);
}
② 定期删除策略的实现

过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires 字典中随机检查一部分键的过期时间,并删除其中的过期键。

2.1.6 AOF

暂略

2.1.7 数据库通知

暂略

2.2 RDB持久化

image-20230305190808308

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

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

RDB持久化既可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中。RDB 持久化功能所生成的 RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。

image-20230305190908247

2.2.1 RDB文件的创建与载入

有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE(BackGround save)

SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求:

1
2
3
#等待直到RDB文件创建完毕
redis> SAVE
OK

和SAVE命令直接阻塞服务器进程的做法不同,BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求:

1
2
3
# 派生子进程,并由子进程创建RDB文件
redis> BGSAVE
Background saving started

创建RDB文件的实际工作由rdb.c/rdbSave函数完成,SAVE命令和BGSAVE命令会以不同的方式调用这个函数,通过以下伪代码可以明显地看出这两个命令之间的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def save():
// 创建RDB文件
rdbSave();

def BGSAVE():
# 创建子进程
pid = fork()
if pid == 0:
# 子进程负责创建RDB文件
rdbSave()
# 完成之后向父进程发送信号
signal_parent()
elif pid > 0:
# 父进程继续处理命令请求,并通过轮询等待子进程的信号
handle_request_and_wait_signal()else:
else:
# 处理出错
handle_fork_error()

部分源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 处理save命令
void saveCommand(redisClient *c) {
if (server.rdb_child_pid != -1) {
addReplyError(c,"Background save already in progress");
return;
}
if (rdbSave(server.rdb_filename) == REDIS_OK) { // 执行rdbSave函数
addReply(c,shared.ok);
} else {
addReply(c,shared.err);
}
}

// 处理bgsave命令
void bgsaveCommand(redisClient *c) {
if (server.rdb_child_pid != -1) {
addReplyError(c,"Background save already in progress");
} else if (server.aof_child_pid != -1) {
addReplyError(c,"Can't BGSAVE while AOF log rewriting is in progress");
} else if (rdbSaveBackground(server.rdb_filename) == REDIS_OK) { // 执行rdbSaveBackground函数
addReplyStatus(c,"Background saving started");
} else {
addReply(c,shared.err);
}
}

int rdbSaveBackground(char *filename) {
pid_t childpid; // 子进程pid
long long start;

if (server.rdb_child_pid != -1) return REDIS_ERR;

server.dirty_before_bgsave = server.dirty;
server.lastbgsave_try = time(NULL);

start = ustime();
// fork
if ((childpid = fork()) == 0) { // 子进程分支
int retval;

/* Child */
closeListeningSockets(0);
redisSetProcTitle("redis-rdb-bgsave");
retval = rdbSave(filename);
if (retval == REDIS_OK) {
size_t private_dirty = zmalloc_get_private_dirty();

if (private_dirty) {
redisLog(REDIS_NOTICE,
"RDB: %zu MB of memory used by copy-on-write",
private_dirty/(1024*1024));
}
}
exitFromChild((retval == REDIS_OK) ? 0 : 1);
} else { // 父进程分支
/* Parent */
server.stat_fork_time = ustime()-start;
server.stat_fork_rate = (double) zmalloc_used_memory() * 1000000 / server.stat_fork_time / (1024*1024*1024); /* GB per second. */
latencyAddSampleIfNeeded("fork",server.stat_fork_time/1000);
if (childpid == -1) {
server.lastbgsave_status = REDIS_ERR;
redisLog(REDIS_WARNING,"Can't save in background: fork: %s",
strerror(errno));
return REDIS_ERR;
}
redisLog(REDIS_NOTICE,"Background saving started by pid %d",childpid);
server.rdb_save_time_start = time(NULL);
server.rdb_child_pid = childpid;
server.rdb_child_type = REDIS_RDB_CHILD_TYPE_DISK;
updateDictResizePolicy();
return REDIS_OK;
}
return REDIS_OK; /* unreached */
}

和使用SAVE命令或者BGSAVE命令创建RDB文件不同,RDB文件的载人工作是在服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就会自动载入RDB文件。

1
2
3
4
$ redis-server
[7379] 30 Aug 21:07:01.270 # Server started,Redis version 2.9.11
[7379] 30 Aug 21:07:01.289 * DB loaded from disk:0.018 seconds
[7379] 30 Aug 21:07:01.289 * The server is now ready to accept connections on port 6379

另外值得一提的是,因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:

  • 如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。
  • 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。

服务器判断该用哪个文件来还原数据库状态的流程如图10-4所示。

载入RDB文件的实际工作由rdb.c/rdbLoad函数完成,这个函数和rdbSave函数之间的关系可以用图10-5表示。

image-20230305192106186

2.2.2 自动间隔性保存

因为BGSAVE命令可以在不阻塞服务器进程的情况下执行,所以Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。

用户可以通过save选项设置多个保存条件,但只要其中任意一个条件被满足,服务器就会执行BGSAVE命令。

举个例子,如果我们向服务器提供以下配置:

1
2
3
save 900 1 
save 300 10
save 60 10000

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

  • 服务器在 900秒之内,对数据库进行了至少1次修改。
  • 服务器在300秒之内,对数据库进行了至少10次修改。
  • 服务器在60秒之内,对数据库进行了至少10000次修改。
① 设置保存条件

当Redis服务器启动时,用户可以通过指定配置文件或者传入启动参数的方式设置save 选项,如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件

1
2
3
save 900 1 
save 300 10
save 60 10000

接着,服务器程序会根据save选项所设置的保存条件,设置服务器状态 redisServer 结构的saveparams属性:

1
2
3
4
5
struct redisServer { 
// ...
struct saveparam *saveparams;
// ...
};

saveparam结构定义为:

1
2
3
4
5
6
struct saveparam {
// 秒数
time_t seconds;
// 修改数
int changes;
};

image-20230305194103280

② dirty计数器和lastsave属性
  • dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)。
  • lastsave 属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间。
1
2
3
4
5
6
struct redisServer (
// 修改计数器
long long dirty;
// 上一次执行保存的时间
time_t lastsave;
);
③ 检查保存条件是否满足

Redis 的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令。

2.2.3 RDB文件结构

image-20230305195801438

  • REDIS:RDB文件的最开头是REDIS部分,这个部分的长度为5字节,保存着”REDIS”五个字符。通过这五个字符,程序可以在载入文件时,快速检查所载入的文件是否RDB文件。
  • db_version:四字节,记录版本号。
  • databases:databases部分包含着零个或任意多个数据库,以及各个数据库中的键值对数据
    • 如果服务器的数据库状态为空(所有数据库都是空的),那么这个部分也为空,长度为0字节。
    • 如果服务器的数据库状态为非空(有至少一个数据库非空),那么这个部分也为非空,根据数据库所保存键值对的数量、类型和内容不同,这个部分的长度也会有所不同。
  • EOF:1字节,表示RDB文件的正文内容结束
  • check_sum:8字节无符号整数,保存校验和
① databases

一个RDB文件的databases部分可以保存任意多个非空数据库。

例如,如果服务器的0号数据库和3号数据库非空,那么服务器将创建一个如图10-12所示的RDB文件,图中的database 0代表0号数据库中的所有键值对数据,而database 3则代表3号数据库中的所有键值对数据。

image-20230305200159642

每个非空数据库在RDB文件中都可以保存为SELECTDBdb_numberkey_value_pairs三个部分,如图10-13所示。

image-20230305200227113

  • SELECTDB:1字节的常量,表示后面将要跟上数据库的号码
  • db_number:数据库的号码,当程序读入db number部分之后,服务器会调用SELECT命令,根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以载入到正确的数据库中。
  • key_value pairs部分保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。根据键值对的数量、类型、内容以及是否有过期时间等条件的不同,key value pairs 部分的长度也会有所不同。

示例

image-20230305200438387

② key_value_paires

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

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

image-20230305200538178

暂略

2.2.4 分析RDB文件

暂略

2.2.5 RDB优缺点

优点

  • RDB文件是某个时间节点的快照,默认使用LZF算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
  • Redis加载RDB文件恢复数据要远远快于AOF方式;

缺点

  • RDB方式实时性不够,无法做到秒级的持久化;
  • 每次调用bgsave都需要fork子进程,fork子进程属于重量级操作,频繁执行成本较高;
  • RDB文件是二进制的,没有可读性,AOF文件在了解其结构的情况下可以手动修改或者补全;
  • 版本兼容RDB文件问题;

2.3 AOF持久化

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

image-20230305200901676

2.3.1 AOF持久化的实现

① 命令追加

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

1
2
3
struct redisServer {
sds aof_buf;
}

例如:执行了下面三个命令:

1
2
3
SADD databases "Redis""MongoDB""MariaDB"
SET date"2013-9-5"
INCR click_counter 10086

那么缓冲区将包含这三个命令的协议内容:

image-20230805163543287

② AOF文件的写入与同步

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

1
2
3
4
5
6
7
8
9
def eventLoop():
while True:
#处理文件事件,接收命令请求以及发送命令回复
#处理命令请求时可能会有新内容被追加到aof_buf缓冲区中
processFileEvents()
#处理时间事件
processTimeEvents()
#考虑是否要将aof_buf中的内容写入和保存到AOF文件里面
flushAppendOnlyFile()

flushAppendOnlyFile函数的行为由服务器配置的appendfsync选项的值来决定,各个不同值产生的行为如表11-1所示。

image-20230805163428008

2.3.2 AOF文件的载入与数据还原

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

image-20230805163734543

2.3.3 AOF文件的重写

2.4 事件

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

  • 文件事件(file event):Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端(或者其他服务器)的通信会产生相应的文件事件,而服务器则通过监听并处理这些事件来完成一系列网络通信操作。
  • 时间事件(time event):Redis服务器中的一些操作(比如 servercron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。

2.4.0 Reactor模式

① 服务端-客户端连接

服务器编程中通常涉及三类socketfd,先简单定义一下:

  • connfd:客户端调用connect与服务端建立连接。
  • listenfd:服务端的监听套接字。
  • clientfd:服务端调用accept获取已连接客户端的套接字。

image-20230307102508099

② 单线程阻塞模型

服务端只有一个线程,阻塞在accept函数上,等待客户端对listenfd成功建立连接。成功连接后处理返回的connfd,直到处理完后才关闭该connfd。

1
2
3
4
5
6
7
8
9
10
11
12
listenfd = socket(); // 初始化监听套接字
bind(); // 绑定监听套接字和服务端地址
listen(listenfd); // 监听
while(1) {
int clientfd;
// 主线程阻塞在 accept 上直到返回已连接套接字
if ((clientfd=accept()) >= 0) {
// 如果返回 大于0,代表有新连接产生
dothing(clientfd); // 处理请求
close(clientfd); // 关闭连接
}
}

image-20230307102658978

特点:服务器端一次只能处理一个客户端连接。

③ 多线程阻塞模型

服务端对每个新连接都单独启动一个线程去处理,主线程继续阻塞在accept上等待新连接。每当accept获取到新的connfd后,把这个connfd交给新的线程去处理。

1
2
3
4
5
6
7
8
9
10
listenfd = socket(); // 初始化监听套接字
bind(); // 绑定监听套接字和服务端地址
listen(listenfd); // 监听
while(1) {
if ((clientfd=accept()) >= 0) {
// 如果返回 大于0,代表有新连接产生
// 启动新的线程去处理这个连接 主线程继续while循环等待新的连接
new_thread(clientfd);
}
}

image-20230307102945916

缺点:

  • 系统最大线程数是有限的。对于突发的大量的客户端连接,不可能创建很多线程去处理连接。
  • 线程的频繁切换极度浪费系统资源。

优化:可以使用线程池(动态和静态)

服务端首先创建一定数目的线程池备用,当新的客户连接来临后,利用负载均衡算法从线程池中取出一个线程去处理这个客户请求。

④ Reactor模式

Reactor模式又叫反应堆模式,是一种常见的高性能的服务器开发模式,著名的NettyRedis等软件都使用到了Reactor模式。

Reacor模式是一种事件驱动机制,它逆转了事件处理的流程,不再是主动地等事件就绪,而是它提前注册好的回调函数,当有对应事件发生时就调用回调函数。 由陈硕所述,Reactor即为非阻塞IO + IO复用,单个Reactor的逻辑大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
while(1) {
// 1.取得下次定时任务的时间,与设定time_out去较大值,即若下次定时任务时间超过1s就取下次定时任务时间为超时时间,否则取1s
int time_out = Max(1000, getNextTimerCallback());
// 2.调用Epoll等待事件发生(阻塞),超时时间为上述的time_out
int rt = epoll_wait(epfd, fds, ...., time_out);
if(rt < 0) {
// epoll调用失败。。
} else {
if (rt > 0) {
// 3. 以此处理发生IO时间的fd,调用其回调函数
}
}
}

它的核心思想就是利用IO复用技术(select,poll,epoll等)来监听套接字上的读写事件,一旦某个fd上发生相应事件,就反过来处理该套接字上的回调函数。

⑤ 单Reactor服务器模型

单Reactor服务器模型就是只有一个主线程运行Reactor。

整个线程有一个epoll句柄(epoll对象实例),用于管理所有的套接字。服务器将listenfd的读事件注册到epoll上,当epoll_wait返回时说明listenfd可读,即有新的连接建立。此时再调用accept函数获取新连接clientfd,然后将clientfd的读写事件也注册到这个epoll上,等待clientfd发生读写事件从epoll_wait返回后,再处理clientfd的事件。

image-20230307104040321

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 单Reactor模型
while(1) {
// 1.取得下次定时任务的时间,与设定time_out去较大值,即若下次定时任务时间超过1s就取下次定时任务时间为超时时间,否则取1s
int time_out = Max(1000, getNextTimerCallback());
// 2.调用Epoll等待事件发生,超时时间为上述的time_out
int rt = epoll_wait(epfd, fds, ...., time_out);
if(rt < 0) {
// epoll调用失败。。
} else {
if (rt > 0 ) {
foreach (fd in fds) {
if (是listenfd的可读事件) {
// 如果是连接事件
1. 获取连接 clientfd = accept();
2. 将clientfd的IO注册到epoll上
} else {
// 不是连接事件,那就是clientfd的读写事件,此时需要处理业务逻辑
dothing(fds[i]);
}
}
}
}
}

对于clienfd发生读写事件后,需要进行业务逻辑处理。业务逻辑处理通常是耗时的,这会影响主线程的执行,也就是说主线程会等到 dothing(fds[i]) 做完之后才进入下一次循环过程。

⑥ 主从Reactor服务器模型

image-20230307104159542

mainReactor由主线程运行,作用如下:通过epoll监听listenfd的可读事件,当可读事件发生后,调用accept函数获取clientfd,然后随机取出一个subReactor,将cliednfd的读写事件注册到这个subReactor的epoll上即可。也就是说,mainReactor只负责建立连接事件,不进行业务处理,也不关心已连接套接字的IO事件。

subReactor通常有多个,每个subReactor由一个线程来运行。subReactor的epoll中注册了clientfd的读写事件,当发生IO事件后,需要进行业务处理。

2.4.1 文件事件

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

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

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

① 文件事件处理器的构成

文件事件处理器由四部分组成:

  • 套接字
  • IO多路复用程序
  • 文件事件分派器(dispatcher
  • 事件处理器

image-20230307094055919

文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。I/O多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。

处理过程:尽管多个文件事件可能会并发地出现,但I/O多路复用程序总是会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序(sequentially)、同步(synchronously)、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),I/O多路复用程序才会继续向文件事件分派器传送下一个套接字。

image-20230307094309642

文件事件分派器接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。

服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,它们定义了某个事件发生时,服务器应该执行的动作。

② IO多路复用程序

Redis的I/O多路复用程序的所有功能都是通过包装常见的select、epoll、evport 和kqueue这些I/O多路复用函数库来实现的,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件,比如 ae_select.cae_epoll.cae_kqueue.c,诸如此类。

因为Redis为每个I/O多路复用函数库都实现了相同的API,所以I/O多路复用程序的底层实现是可以互换的。

Redis在I/O多路复用程序的实现源码中用#include宏定义了相应的规则,程序会在编译时自动选择系统中性能最高的I/O多路复用函数库来作为Redis的I/O多路复用程序的底层实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif
③ 事件类型

I/O多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE 事件,这两类事件和套接字操作之间的对应关系如下:

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

I/O多路复用程序允许服务器同时监听套接字的读写事件,如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理完之后,才处理AE_WRITABLE事件。这也就是说,如果一个套接字又可读又可写的话,那么服务器将先读套接字,后写套接字

④ 文件事件处理器

Redis为文件事件编写了多个处理器,这些事件处理器分别用于实现不同的网络通信需求,比如说:

  • 为了对连接服务器的各个客户端进行应答,服务器要为监听套接字关联连接应答处理器。
  • 为了接收客户端传来的命令请求,服务器要为客户端套接字关联命令请求处理器。
  • 为了向客户端返回命令的执行结果,服务器要为客户端套接字关联命令回复处理器。
  • 当主服务器和从服务器进行复制操作时,主从服务器都需要关联特别为复制功能编写的复制处理器。

在这些事件处理器里面,服务器最常用的要数与客户端进行通信的连接应答处理器、命令请求处理器和命令回复处理器。

连接应答处理器

networking.c/acceptTcpHandler函数是Redis的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。

当Redis 服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字AE_READABLE事件关联起来,当有客户端用sys/socket.h/connect函数连接服务器监听套接字的时候,套接字就会产生AE_READABLE事件,引发连接应答处理器执行,并执行相应的套接字应答操作。

image-20230313144254522

命令请求处理器

networking.c/readQueryFromClient 函数是Redis的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求内容,具体实现为unistd.h/read函数的包装。

当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生AE_READABLE事件,引发命令请求处理器执行,并执行相应的套接字读入操作。

在客户端连接服务器的整个过程中,服务器都会一直为客户端套接字的AE_READABLE事件关联命令请求处理器。

image-20230313170449219

命令回复处理器

networking.c/sendReplyToClient函数是Redis的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为unistd.h/write函数的包装。

当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AE_WRITABLE 事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作。

当命令回复发送完毕之后,服务器就会解除命令回复处理器与客户端套接字的AE_WRITABLE事件之间的关联。

image-20230313170557830

2.4.2 时间事件

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

  1. 定时事件:让一段程序在指定的时间之后执行一次。比如说,让程序X在当前时间的30毫秒之后执行一次。
  2. 周期性事件:让一段程序每隔指定时间就执行一次。比如说,让程序Y每隔30毫秒就执行一次。

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

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

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

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

实现

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

图12-8展示了一个保存时间事件的链表的例子,链表中包含了三个不同的时间事件:因为新的时间事件总是插入到链表的表头,所以三个时间事件分别按ID逆序排序,表头事件的ID为3,中间事件的ID为2,表尾事件的ID为1。

image-20230408143527771

注意,我们说保存时间事件的链表为无序链表,指的不是链表不按ID排序,而是说,该链表不按when属性的大小排序。正因为链表没有按when属性进行排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。

2.4.3 事件的调度与执行

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

事件的调度和执行由ae.c/aeProcessEvents函数负责,源码解析详见2.4.1.⑤小节

image-20230313194908149

2.5 客户端

对于每个与服务器进行连接的客户端,服务器都为这些客户端建立了相应的redis.h/redisClient结构(客户端状态),这个结构保存了客户端当前的状态信息,以及执行相关功能时需要用到的数据结构:

1
2
3
4
5
6
7
8
typedef struct redisClient {
uint64_t id; /* Client incremental unique ID. */
int fd; // 客户端套接字描述符,这里的是服务器accept之后产生的副套接字
redisDb *db; // 指向数据库的指针
robj *name; /* As set by CLIENT SETNAME,客户端的名称 */
struct redisCommand *cmd, *lastcmd; // 客户端当前要和上次执行的命令
// ...
} redisClient;

Redis 服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构,对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成:

1
2
3
4
5
struct redisServer {
// ...
list *clients; /* List of active clients */
// ...
}

image-20230314173033076

2.5.1 客户端属性

① 套接字描述符

客户端状态的fd属性记录了客户端正在使用的套接字描述符。

根据客户端类型的不同,fd属性的值可以是-1或者是大于-1的整数:

  • 伪客户端(fake client)的fd属性的值为-1:伪客户端处理的命令请求来源于AOF文件或者Lua脚本,而不是网络,所以这种客户端不需要套接字连接,自然也不需要记录套接字描述符。目前Redis服务器会在两个地方用到伪客户端,一个用于载入AOF文件并还原数据库状态,而另一个则用于执行Lua脚本中包含的Redis命令。
  • 普通客户端的fd属性的值为大于-1的整数:普通客户端使用套接字来与服务器进行通信,所以服务器会用fd属性来记录客户端套接字的描述符。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// networking.c/createClient
redisClient *createClient(int fd) {
// 创建客户端
redisClient *c = zmalloc(sizeof(redisClient));
if(fd != -1) { // 不是-1,则为普通客户端,执行下列代码
// 非阻塞
anetNonBlock(NULL,fd);
// 非延时
anetEnableTcpNoDelay(NULL,fd);
// keepalive
if(server.tcpkeepalive)
anetKeepAlive(NULL,fd,server.tcpkeepalive);
// 创建文件事件:绑定读事件,且处理函数为readQueryFromClient
if(aeCreateFileEvent(server.el,fd,AE_READABLE,readQueryFromClient,c) == AE_ERR) {
close(fd);
zfree(c);
return NULL;
}
}
// 设置客户端对象的信息
selectDb(c,0);
c->fd = fd; // 设置客户端套接字
// ...
return c;
}

执行 CLIENT list 命令可以列出目前所有连接到服务器的普通客户端,命令输出中的fd 域显示了服务器连接客户端所使用的套接字描述符:

1
2
3
redis> CLIENT list
addr=127.0.0.1:53428 fd=6 name= age=1242 idle=0
addr=127.0.0.1:53469 fd=7 name= age=4 idle=4 ...
② 名字

在默认情况下,一个连接到服务器的客户端是没有名字的。

1
2
3
4
5
redisClient *createClient(int fd) {
// ...
c->name = NULL;
// ...
}
③ 标志

客户端的标志属性flags记录了客户端的角色(role),以及客户端目前所处的状态:

flags属性的值可以是单个标志:flags = <flag>

也可以是多个标志的二进制或,比如:flags =<flag1>|<flag2>|...

取值有:redis.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* Client flags */
#define REDIS_SLAVE (1<<0) /* This client is a slave server */
#define REDIS_MASTER (1<<1) /* This client is a master server */
#define REDIS_MONITOR (1<<2) /* This client is a slave monitor, see MONITOR */
#define REDIS_MULTI (1<<3) /* This client is in a MULTI context */
#define REDIS_BLOCKED (1<<4) /* The client is waiting in a blocking operation */
#define REDIS_DIRTY_CAS (1<<5) /* Watched keys modified. EXEC will fail. */
#define REDIS_CLOSE_AFTER_REPLY (1<<6) /* Close after writing entire reply. */
#define REDIS_UNBLOCKED (1<<7) /* This client was unblocked and is stored in
server.unblocked_clients */
#define REDIS_LUA_CLIENT (1<<8) /* This is a non connected client used by Lua */
#define REDIS_ASKING (1<<9) /* Client issued the ASKING command */
#define REDIS_CLOSE_ASAP (1<<10)/* Close this client ASAP */
#define REDIS_UNIX_SOCKET (1<<11) /* Client connected via Unix domain socket */
#define REDIS_DIRTY_EXEC (1<<12) /* EXEC will fail for errors while queueing */
#define REDIS_MASTER_FORCE_REPLY (1<<13) /* Queue replies even if is master */
#define REDIS_FORCE_AOF (1<<14) /* Force AOF propagation of current cmd. */
#define REDIS_FORCE_REPL (1<<15) /* Force replication of current cmd. */
#define REDIS_PRE_PSYNC (1<<16) /* Instance don't understand PSYNC. */
#define REDIS_READONLY (1<<17) /* Cluster client is in read-only state. */
#define REDIS_PUBSUB (1<<18) /* Client is in Pub/Sub mode. */
④ 输入缓冲区

客户端状态的输入缓冲区用于保存客户端发送的命令请求:

1
2
3
4
5
typedef struct redisClient {
// ...
sds querybuf; // 输入缓冲区
// ...
} redisClient;

如果客户端向服务器发送了以下命令请求:

1
SET key value

那么客户端状态的querybuf属性将是一个包含以下内容的SDS值:

1
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n

image-20230314171637150

补充:redis通信协议之请求数据格式

客户端和服务器通过 TCP 连接来进行数据交互, 服务器默认的端口号为 6379 。

客户端和服务器发送的命令或数据一律以\r\n (CRLF)结尾。

协议的一般形式:

1
2
3
4
5
6
*<参数数量> CR LF
$<参数 1 的字节数量> CR LF
<参数 1 的数据> CR LF
...
$<参数 N 的字节数量> CR LF
<参数 N 的数据> CR LF

例如发送命令:

1
SET mykey myvalue

则打印版本为:

1
2
3
4
5
6
7
*3
$3
SET
$5
mykey
$7
myvalue

实际传输为:

1
*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$7\r\nmyvalue\r\n
⑤ 命令与命令参数

在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务器将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性:

1
2
3
4
typedef struct redisClient {
robj **argv;
int argc;
} redisClient;

以上面的命令为例,服务器分析输入缓冲区的内容后,创建如图所示的argc和argv:

image-20230314174534217

⑥ 命令的实现函数

当服务器从协议内容中分析并得出 argv 属性和 argc属性的值之后,服务器将根据项argv[0]的值,在命令表中查找命令所对应的命令实现函数。

下图展示了一个命令表示例,该表是一个字典,字典的键是一个SDS结构,保存了命令的名字,字典的值是命令所对应的redisCommand结构,这个结构保存了命令的实现函数、命令的标志、命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息。

image-20230314175231294

当程序在命令表中成功找到 argv[0]所对应的redisCommand结构时,它会将客户端状态的cmd指针指向这个结构:

1
struct redisCommand *cmd;

之后,服务器就可以使用cmd属性所指向的redisCommand结构,以及argv、argc 属性中保存的命令参数信息,调用命令实现函数,执行客户端指定的命令。

⑦ 输出缓冲区

执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面,每个客户端都有两个输出缓冲区可用,一个大小是固定的,一个是可变的。

1
2
3
4
5
#define REDIS_REPLY_CHUNK_BYTES (16*1024) /* 16k output buffer */
// ...
// 固定缓冲区
int bufpos; // 记录了buf数组目前已使用的字节数量
char buf[REDIS_REPLY_CHUNK_BYTES]; // 输出缓冲区,缓存向客户端发送的数据

下图展示了一个使用固定大小缓冲区来保存返回值+OK\r\n的例子。

image-20230314175802767

1
list *reply; // 可变缓冲区

例如:

image-20230314175838058

补充:redis通信协议之回复

Redis 命令会返回多种不同类型的回复。

通过检查服务器发回数据的第一个字节, 可以确定这个回复是什么类型:

  • 状态回复(status reply)的第一个字节是 "+"
  • 错误回复(error reply)的第一个字节是 "-"
  • 整数回复(integer reply)的第一个字节是 ":"
  • 批量回复(bulk reply)的第一个字节是 "$"
  • 多条批量回复(multi bulk reply)的第一个字节是 "*"

状态回复

一个状态回复(或者单行回复,single line reply)是一段以 "+" 开始、 "\r\n" 结尾的单行字符串。

1
+OK

错误回复

错误回复和状态回复非常相似, 它们之间的唯一区别是, 错误回复的第一个字节是 "-" , 而状态回复的第一个字节是 "+"

1
2
-ERR unknown command 'foobar'
-WRONGTYPE Operation against a key holding the wrong kind of value

整数回复

整数回复就是一个以 ":" 开头, CRLF 结尾的字符串表示的整数。

比如说, ":0\r\n"":1000\r\n" 都是整数回复。

批量回复

服务器使用批量回复来返回二进制安全的字符串,字符串的最大长度为 512 MB 。

1
2
客户端:GET mykey
服务器:foobar

服务器发送的内容中:

  • 第一字节为 "$" 符号
  • 接下来跟着的是表示实际回复长度的数字值
  • 之后跟着一个 CRLF
  • 再后面跟着的是实际回复数据
  • 最末尾是另一个 CRLF

对于前面的 GET key命令,服务器实际发送的内容为:

1
"$6\r\nfoobar\r\n"

如果被请求的值不存在, 那么批量回复会将特殊值 -1 用作回复的长度值, 就像这样:

1
2
客户端:GET non-existing-key
服务器:$-1

这种回复称为空批量回复(NULL Bulk Reply)。

当请求对象不存在时,客户端应该返回空对象,而不是空字符串: 比如 Ruby 库应该返回 nil , 而 C 库应该返回 NULL (或者在回复对象中设置一个特殊标志), 诸如此类。

多条批量回复

像 LRANGE key start stop 这样的命令需要返回多个值, 这一目标可以通过多条批量回复来完成。

多条批量回复是由多个回复组成的数组, 数组中的每个元素都可以是任意类型的回复, 包括多条批量回复本身。

多条批量回复的第一个字节为 "*" , 后跟一个字符串表示的整数值, 这个值记录了多条批量回复所包含的回复数量, 再后面是一个 CRLF 。

1
2
3
4
5
6
7
8
9
10
客户端: LRANGE mylist 0 3
服务器: *4
服务器: $3
服务器: foo
服务器: $3
服务器: bar
服务器: $5
服务器: Hello
服务器: $5
服务器: World

在上面的示例中,服务器发送的所有字符串都由 CRLF 结尾。

正如你所见到的那样, 多条批量回复所使用的格式, 和客户端发送命令时使用的统一请求协议的格式一模一样。 它们之间的唯一区别是:

  • 统一请求协议只发送批量回复。
  • 而服务器应答命令时所发送的多条批量回复,则可以包含任意类型的回复。

以下例子展示了一个多条批量回复, 回复中包含四个整数值, 以及一个二进制安全字符串:

1
2
3
4
5
6
7
*5\r\n
:1\r\n
:2\r\n
:3\r\n
:4\r\n
$6\r\n
foobar\r\n

在回复的第一行, 服务器发送 *5\r\n , 表示这个多条批量回复包含 5 条回复, 再后面跟着的则是 5 条回复的正文。

⑧ 身份验证

暂略

⑨ 时间

暂略

2.5.2 客户端的创建与关闭

① 创建

如果客户端是通过网络连接与服务器进行连接的普通客户端,那么在客户端使用connect函数连接到服务器时,服务器就会调用连接事件处理器,为客户端创建相应的客户端状态,并将这个新的客户端状态添加到服务器状态结构 clients 链表的末尾。

② 关闭

一个普通客户端可以因为多种原因而被关闭:

  • 如果客户端进程退出或者被杀死,那么客户端与服务器之间的网络连接将被关闭,从而造成客户端被关闭。
  • 如果客户端向服务器发送了带有不符合协议格式的命令请求,那么这个客户端也会被服务器关闭。
  • 如果客户端成为了CLIENT KILL命令的目标,那么它也会被关闭。
  • 如果用户为服务器设置了timeout配置选项,那么当客户端的空转时间超过timeout 选项设置的值时,客户端将被关闭。不过timeout 选项有一些例外情况:如果客户端是主服务器(打开了REDIS_MASTER标志),从服务器(打开了REDIS_SLAVE标志),正在被BLPOP等命令阻塞(打开了REDIS_BLOCKED标志),或者正在执行SUBSCRIBE、PSUBSCRIBE等订阅命令,那么即使客户端的空转时间超过了timeout 选项的值,客户端也不会被服务器关闭。
  • 如果客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为1GB),那么这个客户端会被服务器关闭。
  • 如果要发送给客户端的命令回复的大小超过了输出缓冲区的限制大小,那么这个客户端会被服务器关闭。
③ Lua脚本的伪客户端

服务器会在初始化时创建负责执行Lua 脚本中包含的Redis命令的伪客户端,并将这个伪客户端关联在服务器状态结构的lua_client属性中:

1
2
3
struct redisServer {
redisClient *lua_client;
}

lua伪客户端在服务器运行的整个生命期中会一直存在,只有服务器被关闭时,这个客户端才会被关闭。

④ AOF文件的伪客户端

服务器在载人AOF文件时,会创建用于执行AOF文件包含的Redis命令的伪客户端,并在载入完成之后,关闭这个伪客户端。

2.6 服务器

2.6.1 命令请求的执行过程

以命令为例:

1
SET KEY VALUE
① 发送命令请求

Redis服务器的命令请求来自Redis客户端,当用户在客户端中键入一个命令请求时,客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器。

image-20230316175526428

请求数据格式详见2.5.1.④小节

② 读取命令请求

当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:

  1. 读取套接字中协议格式的命令请求,并将其保存到客户端状态redisClient的输入缓冲区querybuf里面。
  2. 对输入缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv属性和argc属性里面。
  3. 调用命令执行器,执行客户端指定的命令。

image-20230316175740080

然后将得到的结果保存在redisClient中的argc和argv属性中:

image-20230316175828906

之后,服务器将通过调用命令执行器来完成执行命令所需的余下步骤,以下几个小节将分别介绍命令执行器所执行的工作。

③ 查找命令

命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表(command table)中查找参数所指定的命令,并将找到的命令保存到redisClientcmd属性里面。

命令表是一个字典,字典的键是一个个命令名字,比如"set"、"get"、"del"等等;

1
2
3
4
5
6
7
/* redis.c */
struct redisCommand redisCommandTable[] = {
{"get",getCommand,2,"rF",0,NULL,1,1,1,0,0},
{"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0},
{"setnx",setnxCommand,3,"wmF",0,NULL,1,1,1,0,0},
// ...
};

而字典的值则是一个个 redisCommand 结构,每个redisCommand结构记录了一个Redis命令的实现信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* redis.h/redisCommand */
typedef void redisCommandProc(redisClient *c);
typedef int *redisGetKeysProc(struct redisCommand *cmd, robj **argv, int argc, int *numkeys, int flags);
struct redisCommand {
char *name; // 命令的名字,例如"set"
redisCommandProc *proc; // 命令的实现函数
int arity; // 命令参数个数;如果为负数,表示参数个数大于等于其绝对值;注意命令的名字本身也是一个参数
char *sflags; // 命令属性
int flags; // sflags的二进制标识
redisGetKeysProc *getkeys_proc;
int firstkey;
int lastkey;
int keystep;
long long microseconds, calls; // 执行该命令的次数和耗时
};

sflags属性可以使用的标识值如下:

image-20230317161728407

setget命令为例:

1
2
{"get",getCommand,2,"rF",0,NULL,1,1,1,0,0},
{"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0},

image-20230317162413690

继续之前 SET命令的例子,在命令表中进行查找时,命令表将返回”set”键所对应的redisCommand结构,客户端状态的cmd指针会指向这个redisCommand结构,如图所示。image-20230317162542343

④ 执行预备操作

⑤ 调用命令的实现函数
1
client->cmd->proc(client);

image-20230317162729912

被调用的命令实现函数会执行指定的操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区里面(buf属性和reply属性),之后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端。

对于前面SET命令的例子来说,函数调用setCommand(client)将产生个"+OK\r\n"回复,这个回复会被保存到客户端状态的buf属性里面,如图所示。

image-20230317162822344

⑥ 执行后续工作

暂略

⑦ 将命令回复发送给客户端

前面说过,命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并为客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。

当命令回复发送完毕之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。

以图14-7所示的客户端状态为例子,当客户端的套接字变为可写状态时,命令回复处理器会将协议格式的命令回复"+OK\r\n"发送给客户端。

2.6.2 serverCron函数

2.6.3 初始化服务器

① 初始化服务器状态结构

初始化服务器的第一步就是创建一个struct redisServer类型的实例变量server作为服务器的状态,并为结构中的各个属性设置默认值。

初始化server变量的工作由redis.c/initServerConfig函数完成。

主要工作有:

  • 设置服务器的运行 ID
  • 设置服务器的默认运行频率
  • 设置服务器的默认配置文件路径
  • 设置服务器的运行架构
  • 设置服务器的默认端口号。
  • 设置服务器的默认RDB持久化条件和AOF持久化条件
  • 初始化服务器的LRU时钟
  • 创建命令表

initServerConfig函数设置的服务器状态属性基本都是一些整数、浮点数、或者字符串属性,除了命令表之外,initServerConfig函数没有创建服务器状态的其他数据结构,数据库、慢查询日志、Lua环境、共享对象这些数据结构在之后的步骤才会被创建出来。

当initServerConfig函数执行完毕之后,服务器就可以进入初始化的第二个阶段———载人配置选项。

② 载入配置选项
③ 初始化服务器数据结构

在之前执行initServerconfig函数初始化server状态时,程序只创建了命令表一个数据结构,不过除了命令表之外,服务器状态还包含其他数据结构,比如:

  • server.clients链表,这个链表记录了所有与服务器相连的客户端的状态结构,链表的每个节点都包含了一个redisClient结构实例。
  • server.db数组,数组中包含了服务器的所有数据库。
  • 用于保存频道订阅信息的server.pubsub_channels字典,以及用于保存模式订阅信息的server.pubsub_patterns链表。
  • 用于执行Lua脚本的Lua环境server.lua。
  • 用于保存慢查询日志的server.slowlog属性。

当初始化服务器进行到这一步,服务器将调用initServer函数,为以上提到的数据结构分配内存,并在有需要时,为这些数据结构设置或者关联初始化值。

服务器到现在才初始化数据结构的原因在于,服务器必须先载入用户指定的配置选项,然后才能正确地对数据结构进行初始化。如果在执行initServerConfig函数时就对数据结构进行初始化,那么一旦用户通过配置选项修改了和数据结构有关的服务器状态属性,服务器就要重新调整和修改已创建的数据结构。为了避免出现这种麻烦的情况,服务器选择了将server状态的初始化分为两步进行,initServerConfig函数主要负责初始化一般属性,而initServer函数主要负责初始化数据结构。

除了初始化数据结构之外,initServer还进行了一些非常重要的设置操作,其中包括:

  • 为服务器设置进程信号处理器。
  • 创建共享对象:这些对象包含Redis服务器经常用到的一些值,比如包含”OK”回复的字符串对象,包含”ERR”回复的字符串对象,包含整数1到10000的字符串对象等等,服务器通过重用这些共享对象来避免反复创建相同的对象。
  • 打开服务器的监听端口,并为监听套接字关联连接应答事件处理器,等待服务器正式运行时接受客户端的连接。
  • 为servercron函数创建时间事件,等待服务器正式运行时执行serverCron函数。口如果AOF持久化功能已经打开,那么打开现有的AOF文件,如果AOF文件不存在,那么创建并打开一个新的AOF文件,为AOF写入做好准备。口初始化服务器的后台I/O模块(bio),为将来的I/O操作做好准备。

当initServer函数执行完毕之后,服务器将用ASCII字符在日志中打印出Redis的图标,以及Redis的版本号信息。

④ 还原数据库状态

暂略

⑤ 执行事件循环

在初始化的最后一步,服务器将打印出以下日志:

1
[5244] 21 Nov 22:43:49.084*The server isnow ready to accept connections on port 6379 

并开始执行服务器的事件循环(loop)。

至此,服务器的初始化工作圆满完成,服务器现在开始可以接受客户端的连接请求,并处理客户端发来的命令请求了。

3 多机数据库的实现

3.1 复制

image-20230320152351542

在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)另一个服务器,我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被称为从服务器(slave)。

假设现在有两个Redis服务器,地址分别为127.0.0.1:6379127.0.0.1:12345

1
2
127.0.0.1:12345> SLAVEOF 127.0.0.1 6379
OK

那么服务器127.0.0.1:12345将成为127.0.0.1:6379的从服务器。

进行复制中的主从服务器双方的数据库将保存相同的数据,概念上将这种现象称作”数据库状态一致”,或者简称”一致”。

比如说,如果我们在主服务器上执行以下命令:

1
2
127.0.0.1:6379> SET msg "hello world"
OK

那么我们应该既可以在主服务器上获取msg键的值:

1
2
127.0.0.1:6379> GET msg 
"hello world"

又可以在从服务器上获取msg键的值:

1
2
127.0.0.1:12345> GET msg 
"hello world"

另一方面,如果我们在主服务器中删除了键 msg,那么不仅主服务器上的msg键会被删除,从服务器上的msg键也会删除。

3.1.1 旧版复制功能的实现

Redis 的复制功能分为同步(sync)和命令传播(command propagate)两个操作:

  • 同步操作用于将从服务器的数据库状态更新至主服务器当前所处的数据库状态。
  • 命令传播操作则用于在主服务器的数据库状态被修改,导致主从服务器的数据库状态出现不一致时,让主从服务器的数据库重新回到一致状态。
① 同步

当客户端向从服务器发送SLAVEOF命令,要求从服务器复制主服务器时,从服务器首先需要执行同步操作,也即是,将从服务器的数据库状态更新至主服务器当前所处的数据库状态。

从服务器对主服务器的同步操作需要通过向主服务器发送SYNC命令来完成,以下是SYNC命令的执行步骤:

  1. 从服务器向主服务器发送SYNC命令。
  2. 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
  3. 当主服务器的BGSAVE命令执行完毕时,主服务器会将BGSAVE命令生成的RDB 文件发送给从服务器,从服务器接收并载人这个RDB文件,将自己的数据库状态更新至主服务器执行BGSAVE命令时的数据库状态。
  4. 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些写命令,将自己的数据库状态更新至主服务器数据库当前所处的状态。

image-20230326135649623

一个例子

image-20230326135712602

② 命令传播

在同步操作执行完毕之后,主从服务器两者的数据库将达到一致状态,但这种一致并不是一成不变的,每当主服务器执行客户端发送的写命令时,主服务器的数据库就有可能会被修改,并导致主从服务器状态不再一致。

举个例子,假设一个主服务器和一个从服务器刚刚完成同步操作,它们的数据库都保存了相同的五个键k1至k5,如图15-3所示。

如果这时,客户端向主服务器发送命令 DEL k3,那么主服务器在执行完这个DEL命令之后,主从服务器的数据库将出现不一致:主服务器的数据库已经不再包含键k3,但这个键却仍然包含在从服务器的数据库里面,如图15-4所示。

image-20230326135832795

为了让主从服务器再次回到一致状态,主服务器需要对从服务器执行命令传播操作:主服务器会将自己执行的写命令,也即是造成主从服务器不一致的那条写命令,发送给从服务器执行,当从服务器执行了相同的写命令之后,主从服务器将再次回到一致状态。

在上面的例子中,主服务器因为执行了命令DEL k3而导致主从服务器不一致,所以主服务器将向从服务器发送相同的命令 DEL k3。当从服务器执行完这个命令之后,主从服务器将再次回到一致状态,现在主从服务器两者的数据库都不再包含键k3了,如图15-5所示。

image-20230326135938749

3.1.2 旧版复制功能的缺陷

在Redis中,从服务器对主服务器的复制可以分为以下两种情况:

  • 初次复制:从服务器以前没有复制过任何主服务器,或者从服务器当前要复制的主服务器和上一次复制的主服务器不同。
  • 断线后重复制:处于命令传播阶段的主从服务器因为网络原因而中断了复制,但从服务器通过自动重连接重新连上了主服务器,并继续复制主服务器。

对于初次复制来说,旧版复制功能能够很好地完成任务,但对于断线后重复制来说,旧版复制功能虽然也能让主从服务器重新回到一致状态,但效率却非常低

要理解这一情况,请看表15-2展示的断线后重复制例子。

image-20230326140119302

可以看出,SYNC命令是一个非常消耗资源的操作,它需要把主服务器上的所有资源都复制一份发送给从服务器,即使大部分数据在从服务器上与主服务器保持一致。

3.1.3 新版复制功能的实现

为了解决旧版复制功能在处理断线重复制情况时的低效问题,Redis从2.8版本开始,使用PSYNC命令代替SYNC命令来执行复制时的同步操作。

PSYNC命令具有完整重同步(full resynchronization)和部分重同步(partial resynchronization)两种模式:

  • 完整重同步用于处理初次复制情况:完整重同步的执行步骤和SYNC命令的执行步骤基本一样,它们都是通过让主服务器创建并发送RDB文件,以及向从服务器发送保存在缓冲区里面的写命令来进行同步。
  • 部分重同步则用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,如果条件允许,主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以将数据库更新至主服务器当前所处的状态。

PSYNC命令的部分重同步模式解决了旧版复制功能在处理断线后重复制时出现的低效情况,表15-3展示了如何使用PSYNC命令高效地处理上一节展示的断线后复制情况。

image-20230326140613891

通信过程如下:

image-20230326140704629

3.1.4 部分重同步的实现

① 复制偏移量

执行复制的双方:主服务器和从服务器会分别维护一个复制偏移量:

  • 主服务器每次向从服务器传播N个字节的数据时,就将自己的复制偏移量的值加上N。
  • 从服务器每次收到主服务器传播来的N个字节的数据时,就将自己的复制偏移量的值加上N。

在图15-7所示的例子中,主从服务器的复制偏移量的值都为10086

image-20230326141001842

如果这时主服务器向三个从服务器传播长度为33字节的数据,那么主服务器的复制偏移量将更新为10086+33=10119,而三个从服务器在接收到主服务器传播的数据之后,也会将复制偏移量更新为10119,如图15-8所示。

image-20230326141014378

通过对比主从服务器的复制偏移量,程序可以很容易地知道主从服务器是否处于一致状态:

  • 如果主从服务器处于一致状态,那么主从服务器两者的偏移量总是相同的。
  • 相反,如果主从服务器两者的偏移量并不相同,那么说明主从服务器并未处于一致状态。

例如:

image-20230326141536418

② 复制积压缓冲区

复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为1MB。

当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里。

image-20230326141404362

因此,主服务器的复制积压缓冲区里面会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量,就像表15-4展示的那样。

image-20230326141429528

当从服务器重新连上主服务器时,从服务器会通过PSYNC命令将自己的复制偏移量offset 发送给主服务器,主服务器会根据这个复制偏移量来决定对从服务器执行何种同步操作:

  • 如果 offset 偏移量之后的数据(也即是偏移量 offset+1 开始的数据)仍然存在于复制积压缓冲区里面,那么主服务器将对从服务器执行部分重同步操作。
  • 相反,如果offset偏移量之后的数据已经不存在于复制积压缓冲区,那么主服务器将对从服务器执行完整重同步操作。

回到之前图15-9展示的断线后重连接例子:

  • 当从服务器A断线之后,它立即重新连接主服务器,并向主服务器发送PSYNC命令,报告自己的复制偏移量为10086。
  • 主服务器收到从服务器发来的 PSYNC命令以及偏移量10086 之后,主服务器将检查偏移量10086之后的数据是否存在于复制积压缓冲区里面,结果发现这些数据仍然存在,于是主服务器向从服务器发送 +CONTINUE回复,表示数据同步将以部分重同步模式来进行。

  • 接着主服务器会将复制积压缓冲区10086偏移量之后的所有数据(偏移量为10087至10119)都发送给从服务器。

  • 从服务器只要接收这33字节的缺失数据,就可以回到与主服务器一致的状态

复制积压缓冲区的大小

复制积压缓冲区的最小大小可以根据公式 second * write_size_per_second 来估算:

  • second为从服务器断线后重新连接上主服务器所需的平均时间(以秒计算)
  • write_size_per_second则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和)。

例如,如果主服务器平均每秒产生1MB的写数据,而从服务器断线之后平均要5 秒才能重新连接上主服务器,那么复制积压缓冲区的大小就不能低于5MB。

③ 服务器运行ID

每个Redis服务器,不论主服务器还是从服务,都会有自己的运行ID。

运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成,例如53b9b28df8042fdc9ab5e3fcbbbabffld5dce2b3

当从服务器对主服务器进行初次复制时,主服务器会将自己的运行ID传送给从服务器,而从服务器则会将这个运行ID保存起来。

当从服务器断线并重新连上一个主服务器时,从服务器将向当前连接的主服务器发送之前保存的运行ID:

  • 如果从服务器保存的运行ID和当前连接的主服务器的运行ID相同,那么说明从服务器断线之前复制的就是当前连接的这个主服务器,主服务器可以继续尝试执行部分重同步操作。
  • 相反地,如果从服务器保存的运行ID和当前连接的主服务器的运行ID并不相同。那么说明从服务器断线之前复制的主服务器并不是当前连接的这个主服务器,主服务器将对从服务器执行完整重同步操作。

3.1.5 PSYNC命令的实现

PSYNC命令的调用方法有两种:

  1. 如果从服务器以前没有复制过任何主服务器,或者之前执行过SLAVEOF no one命令,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC ? -1命令,主动请求主服务器进行完整重同步(因为这时不可能执行部分重同步)。
  2. 相反地,如果从服务器已经复制过某个主服务器,那么从服务器在开始一次新的复制时将向主服务器发送PSYNC <runid> <offset>命令:其中runid是上一次复制的主服务器的运行ID,而offset则是从服务器当前的复制偏移量,接收到这个命令的主服务器会通过这两个参数来判断应该对从服务器执行哪种同步操作。根据情况,接收到PSYNC命令的主服务器会向从服务器返回以下三种回复的其中一种:
    1. 如果主服务器返回 +FULLRESYNC <runid> <offset>回复,那么表示主服务器将与从服务器执行完整重同步操作:其中runid是这个主服务器的运行ID,从服务器会将这个ID保存起来,在下一次发送PSYNC命令时使用;而offset则是主服务器当前的复制偏移量,从服务器会将这个值作为自己的初始化偏移量。
    2. 如果主服务器返回 +CONTINUE 回复,那么表示主服务器将与从服务器执行部分重同步操作,从服务器只要等着主服务器将自己缺少的那部分数据发送过来就可以了。
    3. 如果主服务器返回-BRR回复,那么表示主服务器的版本低于Redis 2.8,它识别不了PSYNC命令,从服务器将向主服务器发送SYNC命令,并与主服务器执行完整同步操作。

流程图15-12总结了PSYNC命令执行完整重同步和部分重同步时可能遇上的情况。

image-20230326142228694

网络中断——重复制例子

首先,假设有两个Redis服务器,它们的版本都是Redis2.8,其中主服务器的地址为127.0.0.1:6379,从服务器的地址为127.0.0.1:12345

如果客户端向从服务器发送命令 SLAVEOF 127.0.0.1 6379,并且假设从服务器是第一次执行复制操作,那么从服务器将向主服务器发送PSYNC ? -1命令,请求主服务器执行完整重同步操作。

主服务器在收到完整重同步请求之后,将在后台执行BGSAVE命令,并向从服务器返回 +FULLRESYNC 53b9b28df8042fdc9ab5e3fcbbbabff1d5dce2b3 10086 回复,其中53b9b28df8042fdc9ab5e3fcbbbabffld5dce2b3是主服务器的运行ID,而10086 则是主服务器当前的复制偏移量。

假设完整重同步成功执行,并且主从服务器在一段时间之后仍然保持一致,但是在复制偏移量为20000的时候,主从服务器之间的网络连接中断了,这时从服务器将重新连接主服务器,并再次对主服务器进行复制。

因为之前曾经对主服务器进行过复制,所以从服务器将向主服务器发送命令PSYNC 53b9b28df8042fdc9ab5e3fcbbbabffld5dce2b3 20000,请求进行部分重同步。

主服务器在接收到从服务器的PSYNC命令之后,首先对比从服务器传来的运行ID53b9b28df8042fdc9ab5e3fcbbbabffld5dce2b3和主服务器自身的运行ID,结果显示该ID和主服务器的运行ID相同,于是主服务器继续读取从服务器传来的偏移量20000,检查偏移量为20000之后的数据是否存在于复制积压缓冲区里面,结果发现数据仍然存在。

确认运行ID相同并且数据存在之后,主服务器将向从服务器返回+CONTINUE 回复,表示将与从服务器执行部分重同步操作,之后主服务器会将保存在复制积压缓冲区20000 偏移量之后的所有数据发送给从服务器,主从服务器将再次回到一致状态。

3.1.6 复制的实现

通过向从服务器发送SLAVEOF命令,我们可以让一个从服务器去复制一个主服务器:

SLAVEOF <master_ip> <master_port>

本节将以从服务器127.0.0.1:12345接收到命令:SLAVEOF 127.0.0.1 6379

为例,展示Redis2.8或以上版本的复制功能的详细实现步骤。

① 设置主服务器的地址和端口

从服务器首先要做的就是将客户端给定的主服务器IP地址127.0.0.1以及端口6379 保存到服务器状态的masterhost 属性和masterport 属性里面:

1
2
3
4
5
6
struct redisServer {
// 主服务器地址
char *masterhost;
// 主服务器端口
int masterport;
};

SLAVEOF命令是一个异步命令,在完成masterhost 属性和masterport属性的设置工作之后,从服务器将向发送SLAVEOF命令的客户端返回OK,表示复制指令已经被接收,而实际的复制工作将在OK返回之后才真正开始执行。

② 建立套接字连接

此时,从服务器将作为客户端,与主服务器创建套接字连接。

image-20230326143000578

③ 发送PING命令

从服务器成为主服务器的客户端之后,做的第一件事就是向主服务器发送一个PING命令。

作用:

  • 虽然主从服务器成功建立起了套接字连接,但双方并未使用该套接字进行过任何通信,通过发送PING命令可以检查套接字的读写状态是否正常。
  • 因为复制工作接下来的几个步骤都必须在主服务器可以正常处理命令请求的状态下才能进行,通过发送PING命令可以检查主服务器能否正常处理命令请求。

从服务器在发送PING命令之后将遇到以下三种情况的其中一种:

  • 如果主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定的时限(timeout)内读取出命令回复的内容,那么表示主从服务器之间的网络连接状态不佳,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
  • 如果主服务器向从服务器返回一个错误,那么表示主服务器暂时没办法处理从服务器的命令请求,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。比如说,如果主服务器正在处理一个超时运行的脚本,那么当从服务器向主服务器发送PING命令时,从服务器将收到主服务器返回的BUSY Redisis busy running a script.You can only call SCRIPT KILL or SHUTDOWN NOSAVE.错误。
  • 如果从服务器读取到PONG回复,那么表示主从服务器之间的网络连接状态正常,并且主服务器可以正常处理从服务器(客户端)发送的命令请求,在这种情况下,从服务器可以继续执行复制工作的下个步骤。

image-20230326143306678

④ 身份验证

暂略

⑤ 发送端口信息

在身份验证步骤之后,从服务器将执行命令REPLCONF listening-port <port-number>,向主服务器发送从服务器的监听端口号。

主服务器在接收到这个命令之后,会将端口号记录在从服务器所对应的客户端状态的slave_listening_port属性中:

1
2
3
4
struct redisServer {
// 从服务器的监听端口号
int slave_listening_port;
};
⑥ 同步

在这一步,从服务器将向主服务器发送PSYNC命令,执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态。

值得一提的是,在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会成为从服务器的客户端:

  • 如果PSYNC命令执行的是完整重同步操作,那么主服务器需要成为从服务器的客户端,才能将保存在缓冲区里面的写命令发送给从服务器执行。
  • 如果PSYNC命令执行的是部分重同步操作,那么主服务器需要成为从服务器的客户端,才能向从服务器发送保存在复制积压缓冲区里面的写命令。

因此,在同步操作执行之后,主从服务器双方都是对方的客户端,它们可以互相向对方发送命令请求,或者互相向对方返回命令回复,如图15-22所示。

正因为主服务器成为了从服务器的客户端,所以主服务器才可以通过发送写命令来改变从服务器的数据库状态,不仅同步操作需要用到这一点,这也是主服务器对从服务器执行命令传播操作的基础。

image-20230326143826679

⑦ 命令传播

当完成了同步之后,主从服务器就会进入命令传播阶段,这时主服务器只要一直将自己执行的写命令发送给从服务器,而从服务器只要一直接收并执行主服务器发来的写命令,就可以保证主从服务器一直保持一致了。

以上就是Redis 2.8或以上版本的复制功能的实现步骤。

3.1.7 心跳检测

在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:

REPLCONF ACK <replication_offset>

其中replication_offset是从服务器当前的复制偏移量。

发送REPLCONF ACK命令对于主从服务器有三个作用:

  1. 检测主从服务器的网络连接状态。
  2. 辅助实现min-slaves选项。
  3. 检测命令丢失。
① 检测主从服务器的网络连接状态

主从服务器可以通过发送和接收REPLCONF ACK命令来检查两者之间的网络连接是否正常:如果主服务器超过一秒钟没有收到从服务器发来的REPLCONFACK命令,那么主服务器就知道主从服务器之间的连接出现问题了。

通过向主服务器发送INFO replication命令,在列出的从服务器列表的lag一栏中,我们可以看到相应从服务器最后一次向主服务器发送REPLCONF ACK命令距离现在过了多少秒:

1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6379> INFO replication 
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=12345,state=online,offset=211,lag=0 # 刚刚发送过 REPLCONF ACK命令
slavel:ip=127.0.0.1,port=56789,state=online,offset=197,lag=15 # 15秒之前发送过REPLCONF ACK命令
master_repl_offset:211
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:2
repl_backlog_histlen:210

在一般情况下,lag的值应该在0秒或者1秒之间跳动,如果超过1秒的话,那么说明主从服务器之间的连接出现了故障。

② 辅助实现min-slaves选项

Redis 的min-slaves-to-writemin-slaves-max-lag两个选项可以防止主服务器在不安全的情况下执行写命令。

举个例子,如果我们向主服务器提供以下设置:

min-slaves-to-write 3 min-slaves-max-lag 10

那么在从服务器的数量少于3个,或者三个从服务器的延迟值都大于或等于10 秒时,主服务器将拒绝执行写命令。

③ 检测命令丢失

如果因为网络故障,主服务器传播给从服务器的写命令在半路丢失,那么当从服务器向主服务器发送REPLCONF ACK命令时,主服务器将发觉从服务器当前的复制偏移量少于自己的复制偏移量,然后主服务器就会根据从服务器提交的复制偏移量,在复制积压缓冲区里面找到从服务器缺少的数据,并将这些数据重新发送给从服务器。

注意,主服务器向从服务器补发缺失数据这一操作的原理和部分重同步操作的原理非常相似,这两个操作的区别在于,补发缺失数据操作在主从服务器没有断线的情况下执行,而部分重同步操作则在主从服务器断线并重连之后执行。

3.2 Sentinel

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

图16-1展示了一个Sentinel系统监视服务器的例子,其中:

  • 用双环图案表示的是当前的主服务器server1
  • 用单环图案表示的是主服务器的三个从服务器server2、server3以及 server4。
  • server2、server3、server4三个从服务器正在复制主服务器server1,而 Sentinel系统则在监视所有四个服务器。

image-20230326145622092

假设这时,主服务器 server1进入下线状态,那么从服务器 server2、server3、server4对主服务器的复制操作将被中止,并且Sentinel系统会察觉到server1已下线,如图16-2所示(下线的服务器用虚线表示)。

image-20230326145637375

当server1的下线时长超过用户设定的下线时长上限时,Sentinel系统就会对server1执行故障转移操作:

  • 首先,Sentinel系统会挑选server1属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器。
  • 之后,Sentinel系统会向server1属下的所有从服务器发送新的复制指令SLAVEOF,让它们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作执行完毕。
  • 另外,Sentinel还会继续监视已下线的serverl,并在它重新上线时,将它设置为新的主服务器的从服务器(降级)。

3.2.1 启动并初始化哨兵

启动一个Sentinel可以使用命令:

1
$ redis-sentinel /path/to/your/sentinel.conf

或者:

1
$ redis-server /path/to/your/sentinel.conf --sentinel

当一个Sentinel启动时,它需要执行以下步骤:

  1. 初始化服务器。
  2. 将普通Redis服务器使用的代码替换成Sentinel专用代码。
  3. 初始化 Sentinel状态。
  4. 根据给定的配置文件,初始化 Sentinel的监视主服务器列表
  5. 创建连向主服务器的网络连接。
① 初始化服务器

首先,因为Sentinel本质上只是一个运行在特殊模式下的Redis服务器,所以启动Sentinel的第一步,就是初始化一个普通的Redis服务器。

不过,因为Sentinel执行的工作和普通Redis服务器执行的工作不同,所以Sentinel的初始化过程和普通Redis服务器的初始化过程并不完全相同。

例如,普通服务器在初始化时会通过载入RDB文件或者AOF文件来还原数据库状态,但是因为Sentinel并不使用数据库,所以初始化Sentinel时就不会载入RDB文件或者AOF文件。

表16-1展示了Redis服务器在Sentinel模式下运行时,服务器各个主要功能的使用情况。

image-20230326192958146

② 使用哨兵专用代码

启动Sentinel的第二个步骤就是将一部分普通Redis服务器使用的代码替换成Sentinel 专用代码。比如说,普通Redis服务器使用redis.h/REDIS SERVERPORT常量的值作为服务器端口:

1
#define REDIS_SERVERPORT 6379

而Sentinel则使用sentinel.c/REDIS_SENTINEL_PORT常量的值作为服务器端口:

1
#define REDIS_SENTINELPORT 26379

Sentinel使用sentinel.c/sentinelcmds作为服务器的命令表,并且其中的INFO命令会使用Sentinel模式下的专用实现sentinel.c/sentinelInfoCommand函数,而不是普通Redis服务器使用的实现redis.c/infoCommand函数:

1
2
3
4
5
6
7
8
9
struct redisCommand sentinelcmds[] = {
{"ping",pingCommand,1,"",0,NULL,0,0,0,0,0,0},
{"sentinel",sentinelCommand,-2,"",0,NULL,0,0,0,0,0),
{"subscribe",subscribeCommand,-2,"",0,NULL,0,0,0,0,0,0,0),
{"unsubscribe",unsubscribeCommand,-1,"",0,NULL,0,0,0,0,0,0),
{"psubscribe",psubscribeCommand,-2,"",0,NULL,0,0,0,0,0,0,0),
{"punsubscribe",punsubscribeCommand,-1,"",0,NULL,0,0,0,0,0,0,0,0},
{"info",sentinelInfoCommand,-1,"",0,NULL,0,0,0,0,0}
};
③ 初始化哨兵状态

在应用了Sentinel的专用代码之后,接下来,服务器会初始化一个sentinel.c/sentinelState结构(后面简称”Sentinel状态”),这个结构保存了服务器中所有和Sentinel功能有关的状态(服务器的一般状态仍然由redis.h/redisServer结构保存):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
④ 初始化masters属性

Sentinel状态中的masters 字典记录了所有被Sentinel监视的主服务器的相关信息,其中:

  • 字典的键是被监视主服务器的名字。
  • 字典的值则是被监视主服务器对应的sentinel.c/sentinelRedisInstance结构. 每个 sentinelRedisInstance 结构(后面简称“实例结构”)代表一个被Sentinel监视的Redis 服务器实例(instance),这个实例可以是主服务器、从服务器,或者另外一个Sentinel。

实例结构包含的属性非常多,以下代码展示了实例结构在表示主服务器时使用的其中一部分属性,本章接下来将逐步对实例结构中的各个属性进行介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 实例结构
typedef struct sentinelRedisInstance {
// 标识值,记录了实例的类型,以及该实例的当前状态
int flags;
// 实例的名字
// 主服务器的名字由用户在配置文件中设置
// 从服务器以及Sentinel的名字由Sentinel自动设置
// 格式为ip:port,例如"127.0.0.1:26379"
char *name;
// 实例的运行ID
char *runid;
// 配置纪元,用于实现故障转移
uint64_t config_epoch;
// 实例的地址
sentinelAddr *addr;
dict *slaves; /* Slaves for this master instance. */
dict *sentinels; /* Other sentinels monitoring the same master. */
// SENTINEL down-after-milliseconds 选项设定的值
// 实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)
mstime_t down_after_period;
// SENTINEL monitor <master-name> <IP> <port> <quorum>选项中的quorum参数
// 判断这个实例为客观下线(objectively down)所需的支持投票数量
int quorum;
// SENTINEL parallel-syncs <master-name><number>选项的值
// 在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
int parallel_syncs;
// SENTINEL failover-timeout <master-name><ms>选项的值
// 刷新故障迁移状态的最大时限
mstime_t failover_timeout;
} sentinelRedisInstance;

sentinelRedisInstance.addr属性是一个指向sentinel.c/sentinelAddr 结构的指针,这个结构保存着实例的IP地址和端口号:

1
2
3
4
typedef struct sentinelAddr {
char *ip;
int port;
} sentinelAddr;

对Sentinel状态的初始化将引发对masters字典的初始化,而 masters字典的初始化是根据被载入的 Sentinel 配置文件来进行的。

举个例子,如果用户在启动Sentinel时,指定了包含以下内容的配置文件:

image-20230327093903002

那么Sentinel将为主服务器master1创建如图16-5所示的实例结构,并为主服务器master2创建如图16-6所示的实例结构,而这两个实例结构又会被保存到Sentinel状态的masters字典中。

image-20230327093955331

image-20230327094012924

image-20230327094022920

⑤ 创建连向主服务器的网络连接

初始化Sentinel的最后一步是创建连向被监视主服务器的网络连接,Sentinel将成为主服务器的客户端,它可以向主服务器发送命令,并从命令回复中获取相关的信息。

对于每个被Sentinel监视的主服务器来说,Sentinel会创建两个连向主服务器的异步网络连接:

  • 命令连接,这个连接专门用于向主服务器发送命令,并接收命令回复。
  • 订阅连接,这个连接专门用于订阅主服务器的__sentinel__:hello频道。

image-20230327094253184

3.2.2 获取主服务器信息

Sentinel默认会以每十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令,并通过分析INFO命令的回复来获取主服务器的当前信息。

举个例子,主服务器master 有三个从服务器slave0、slave1和slave2,并且一个Sentinel正在连接主服务器,那么Sentinel将持续地向主服务器发送INFO命令,并获得类似于以下内容的回复:

1
2
3
4
5
6
7
8
9
10
# Server
run id:7611c59dc3a29aa6fa0609f841bb6a1019008a9c
...
# Replication
role:master
...
slave0:ip=127.0.0.1,port=1111,state=online,offset=43,lag=0 slave1:ip=127.0.0.1,port=2222,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无须用户提供从服务器的地址信息,就可以自动发现从服务器。

根据run_id域和role域记录的信息,Sentinel将对主服务器的实例结构进行更新,例如,主服务器重启之后,它的运行ID就会和实例结构之前保存的运行ID不同,Sentinel 检测到这一情况之后,就会对实例结构的运行ID进行更新。

至于主服务器返回的从服务器信息,则会被用于更新主服务器实例结构的slaves字典,这个字典记录了主服务器属下从服务器的名单:

  • 字典的键是由Sentinel自动设置的从服务器名字,格式为ip:port:如对于IP地址为127.0.0.1,端口号为1111的从服务器来说,Sentinel为它设置的名字就是127.0.0.1:1111
  • 字典的值则是从服务器对应的实例结构:比如说,如果键是127.0.0.1:1111,那么这个键的值就是IP地址为127.0.0.1,端口号为11111的从服务器的实例结构。

Sentinel在分析INFO命令中包含的从服务器信息时,会检查从服务器对应的实例结构是否已经存在于slaves字典:

  • 如果从服务器对应的实例结构已经存在,那么Sentinel对从服务器的实例结构进行更新。
  • 如果从服务器对应的实例结构不存在,那么说明这个从服务器是新发现的从服务器,Sentinel会在slaves字典中为这个从服务器新创建一个实例结构。

对于我们之前列举的主服务器 master和三个从服务器slave0、slavel和slave2 的例子来说,Sentinel将分别为三个从服务器创建它们各自的实例结构,并将这些结构保存到主服务器实例结构的slaves字典里面,如图16-10所示。

image-20230327095141969

注意对比图中主服务器实例结构和从服务器实例结构之间的区别:

  • 主服务器实例结构的flags属性的值为SRI_MASTER,而从服务器实例结构的flags属性的值为SRI_SLAVE
  • 主服务器实例结构的name属性的值是用户使用Sentinel配置文件设置的,而从服务器实例结构的name属性的值则是Sentinel根据从服务器的IP地址和端口号自动设置的。

3.2.3 获取从服务器信息

当Sentinel发现主服务器有新的从服务器出现时,Sentinel除了会为这个新的从服务器创建相应的实例结构之外,Sentinel还会创建连接到从服务器的命令连接和订阅连接。

image-20230327095801109

在创建命令连接之后,Sentinel在默认情况下,会以每十秒一次的频率通过命令连接向从服务器发送INFO命令,并获得类似于以下内容的回复:

1
2
3
4
5
6
7
8
9
10
11
12
# 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
...

根据这些信息,Sentinel会对从服务器的实例结构进行更新,图16-12展示了Sentinel 根据上面的INFO命令回复对从服务器的实例结构进行更新之后,实例结构的样子。

image-20230327100017772

3.2.4 向主服务器和从服务器发送信息

在默认情况下,Sentinel会以每两秒一次的频率,通过命令连接向所有被监视的主服务器和从服务器发送以下格式的命令:

1
PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"

该条命令向__sentinel__:hello频道发送了一条信息,信息的内容有:

  • s_ 开头的参数记录的是Sentinel本身的信息。
  • m_开头的参数记录的则是主服务器的信息。如果Sentinel正在监视的是主服务器,那么这些参数记录的就是主服务器的信息;如果Sentinel正在监视的是从服务器,那么这些参数记录的就是从服务器正在复制的主服务器的信息。

image-20230327102712022

image-20230327102718405

3.2.5 接收来自主服务器和从服务器的频道信息

当Sentinel与一个主服务器或者从服务器建立起订阅连接之后,Sentinel就会通过订阅连接,向服务器发送以下命令:

1
SUBSCRIBE __sentinel__:hello

__sentinel__:hello频道的订阅会一直持续到Sentinel与服务器的Sentinel对连接断开为止。

image-20230327103006023

举个例子,假设现在有sentinel1、sentine12、sentine13三个Sentinel在监视同一个服务器,那么当sentinell向服务器的hello频道发送一条信息时,所有订阅了hello频道的Sentinel(包括sentinel1自己在内)都会收到这条信息,如图16-14所示。

image-20230327103138354

当哨兵接收到订阅频道发送来的消息时,会对消息中的参数进行检查:

  • 如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID相同,那么说明这条信息是Sentinel自己发送的,Sentinel将丢弃这条信息,不做进一步处理。
  • 相反地,如果信息中记录的Sentinel运行ID和接收信息的Sentinel的运行ID不相同,那么说明这条信息是监视同一个服务器的其他Sentinel发来的,接收信息的Sentinel 将根据信息中的各个参数,对相应主服务器的实例结构进行更新。
① 更新sentinels字典

Sentinel为主服务器创建的实例结构中的sentinels字典保存了除Sentinel本身之外,所有同样监视这个主服务器的其他Sentinel的资料:

  • sentinels字典的键是其中一个Sentinel的名字,格式为ip:port,比如对于IP地址为127.0.0.1,端口号为26379的Sentinel来说,这个Sentinel在sentinels字典中的键就是”127.0.0.1:26379”。
  • sentinels字典的值则是键所对应Sentinel的实例结构,比如对于键”127.0.0.1:26379”来说,这个键在sentinels字典中的值就是IP为127.0.0.1,端口号为26379的Sentinel的实例结构。

当一个Sentinel接收到其他Sentinel发来的信息时(我们称呼发送信息的Sentinel为源Sentinel,接收信息的Sentinel为目标Sentinel),目标Sentinel会从信息中分析并提取出以下两方面参数:

  • 与Sentinel有关的参数:源Sentinel的IP地址、端口号、运行ID和配置纪元。
  • 与主服务器有关的参数:源Sentinel正在监视的主服务器的名字、IP地址、端口号和配置纪元。

根据信息中提取出的主服务器参数,目标Sentinel会在自己的Sentinel状态的masters字典中查找相应的主服务器实例结构,然后根据提取出的Sentinel参数,检查主服务器实例结构的sentinels字典中,源Sentinel的实例结构是否存在:

  • 如果源 Sentinel 的实例结构已经存在,那么对源Sentinel的实例结构进行更新。
  • 如果源 Sentinel 的实例结构不存在,那么说明源 Sentinel是刚刚开始监视主服务器的新 Sentinel,目标 Sentinel会为源 Sentinel创建一个新的实例结构,并将这个结构添加到sentinels字典里面。

举个例子,假设分别有127.0.0.1:26379、127.0.0.1:26380、127.0.0.1:26381三个Sentinel正在监视主服务器127.0.0.0.0.1:6379,那么当127.0.0.1:26379这个Sentinel 接收到以下信息时:

image-20230328084635103

Sentinel将执行以下动作:

  • 第一条信息的发送者为127.0.0.1:26379自己,这条信息会被忽略。
  • 第二条信息的发送者为127.0.0.1:26381,Sentinel会根据这条信息中提取出的内容,对sentinels字典中127.0.0.1:26381对应的实例结构进行更新。
  • 第三条信息的发送者为127.0.0.1:26380,Sentinel会根据这条信息中提取出的内容、对sentinels字典中127.0.0.1:26380所对应的实例结构进行更新

图16-15展示了Sentinel 127.0.0.1:26379为主服务器127.0.0.1:6379创建的实例结构,以及结构中的sentinels字典。

image-20230328084827611

② 创建连向其他sentinel的命令连接

当Sentinel通过频道信息发现一个新的Sentinel 时,它不仅会为新 Sentinel在sentinels字典中创建相应的实例结构,还会创建一个连向新Sentinel 的命令连接,而新 Sentinel也同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器的多个Sentinel将形成相互连接的网络:Sentinel A有连向 Sentinel B的命令连接,而 Sentinel B也有连向Sentinel A 的命令连接。此时,A和B之间可以相互发送和接收命令,例如投票命令。

图16-16展示了三个监视同一主服务器的Sentinel之间是如何互相连接的。

image-20230328085019212

注意:哨兵之间不会创建订阅连接。

3.2.6 检测主观下线状态

在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他Sentinel在内)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。

image-20230328085350330

Sentinel配置文件中的down-after-milliseconds 选项指定了Sentinel判断实例进入主观下线所需的时间长度:如果一个实例在down-after-milliseconds毫秒内,连续向Sentinel返回无效回复,那么 Sentinel 会修改这个实例所对应的实例结构,在结构的flags属性中打开SRI_S_DOWN标识,以此来表示这个实例已经进入主观下线状态。

多个哨兵设置的主观下线时长可能不同,因此,存在对于一个被监控的服务器,一个哨兵认为它在线,一个哨兵认为它已经下线的情况。

3.2.7 检查客观下线状态

当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作。

① 发送投票命令

哨兵使用命令:

1
SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>

来询问其他哨兵是否同意主服务器已经下线。

image-20230328085824188

② 接收投票命令并回复

当一个Sentinel(目标Sentinel)接收到另一个Sentinel(源Sentinel)发来的SENTINEL is-master-down-by命令时,目标Sentinel会分析并取出命令请求中包含的各个参数,并根据其中的主服务器IP和端口号,检查主服务器是否已下线,然后向源 Sentinel返回一条包含三个参数的Multi Bulk回复作为 SENTINEL is-master-down-by 命令的回复:

1) <down_state>
2) <leader_runid>
3) <leader_epoch>

image-20230328090056795

③ 接收投票命令回复

根据其他Sentinel发回的命令回复,Sentinel 将统计其他Sentinel同意主服务器已下线的数量,当这一数量达到配置指定的判断客观下线所需的数量时,Sentinel会将主服务器实例结构flags属性的SRI_O_DOWN标识打开,表示主服务器已经进入客观下线状态,如图16-19所示。

image-20230328090212319

多个哨兵设置的客观下线条件可能不同。

3.2.8 选举领头哨兵

当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个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在接收到SENTINELis-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正在监视同一个主服务器,并且这三个Sentinel之前已经通过SENTINEL is-master-down-by-addr命令确认主服务器进入了客观下线状态,如图16-20所示。

image-20230328091518728

那么为了选出领头 Sentinel,三个Sentinel将再次向其他Sentinel 发 送 SENTINEL is-master-down-by-addr命令,如图16-21所示。

image-20230328091555762

和检测客观下线状态时发送的SENTINEL is-master-down-by-addr 命 令 不 同,Sentinel这次发送的命令会带有Sentinel自己的运行ID,例如:

1
SENTINEL is-master-down-by-addr 127.0.0.1 6379 0 e955b4c85598ef5b5f055bc7ebf d5e828dbed4fa

如果接收到这个命令的Sentinel还没有设置局部领头Sentinel的话,它就会将运行ID为e955b4c85598ef5b5f055bc7ebfd5e828dbed4fa的Sentinel设置为自己的局部领头Sentinel,并返回类似以下的命令回复:

  1. 1:主服务器已下线
  2. e955b4c85598ef5b5f055bc7ebfd5e828dbed4fa:目标哨兵的局部领头的id
  3. 0:配置纪元

然后接收到命令回复的Sentinel就可以根据这一回复,统计出有多少个Sentinel将自己设置成了局部领头Sentinel。

根据命令请求发送的先后顺序不同,可能会有某个Sentinel的SENTINEL is-master-down-by-addr 命令比起其他Sentinel发送的相同命令都更快到达,并最终胜出领头Sentinel的选举,然后这个领头Sentinel就可以开始对主服务器执行故障转移操作了。

3.2.9 故障转移

在选举产生出领头 Sentinel 之后,领头 Sentinel 将对已下线的主服务器执行故障转移操作,该操作包含以下三个步骤:

  1. 在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器。
  2. 让已下线主服务器属下的所有从服务器改为复制新的主服务器。
  3. 将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器。
① 选出新的主服务器

故障转移操作第一步要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送SLAVEOF no one命令,将这个从服务器转换为主服务器。

图16-22展示了在一次故障转移操作中,领头Sentinel向被选中的从服务器server2发送SLAVEOF no one命令的情形。

image-20230328092110252

在发送SLAVEOF no one命令之后,领头Sentinel会以每秒一次的频率(平时是每十秒一次),向被升级的从服务器发送INFO命令,并观察命令回复中的角色(role)信息,当被升级服务器的role 从原来的slave变为master时,领头Sentinel就知道被选中的从服务器已经顺利升级为主服务器了。

image-20230328092144627

新的主服务器的选择方法

领头Sentinel会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项地对列表进行过滤:

  • 删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。
  • 删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器这可以保证列表中剩余的从服务器都是最近成功进行过通信的。
  • 删除所有与已下线主服务器连接断开超过down-after-milliseconds 10 毫秒的从服务器:down-after-milliseconds选项指定了判断主服务器下线所需的时间,而删除断开时长超过down-after-milliseconds 10毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。

之后,领头Sentinel将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。

如果有多个具有相同最高优先级的从服务器,那么领头Sentinel将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,并选出其中偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。

最后,如果有多个优先级最高、复制偏移量最大的从服务器,那么领头Sentinel将按照运行ID对这些从服务器进行排序,并选出其中运行ID最小的从服务器。

② 修改从服务器的复制目标

当新的主服务器出现之后,领头Sentinel下一步要做的就是,让已下线主服务器属下的所有从服务器去复制新的主服务器,这一动作可以通过向从服务器发送SLAVEOF命令来实现。

图16-24展示了在故障转移操作中,领头Sentinel向已下线主服务器serverl的两个从服务器server3和server4发送SLAVEOF命令,让它们复制新的主服务器server2的例子。

image-20230328092355361

该过程结束后如图:

image-20230328092416399

③ 将旧的主服务器变为从服务器

故障转移操作最后要做的是,将已下线的主服务器设置为新的主服务器的从服务器。比如说,图16-26就展示了被领头Sentinel设置为从服务器之后,服务器server1的样子。

因为旧的主服务器已经下线,所以这种设置是保存在server1对应的实例结构里面的,当server1重新上线时,Sentinel就会向它发送SLAVEOF命令,让它成为server2的从服务器。

例如,图16-27就展示了server1重新上线并成为server2的从服务器的例子。

image-20230328092516372

3.3 集群

Redis集群是Redis提供的分布式数据库方案,集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能。

3.3.1 节点

一个Redis集群通常由多个节点(node)组成,在刚开始的时候,每个节点都是相互独立的,它们都处于一个只包含自己的集群当中,要组建一个真正可工作的集群,我们必须将各个独立的节点连接起来,构成一个包含多个节点的集群。

连接各个节点的工作可以使用CLUSTER MEET命令来完成,该命令的格式如下:

1
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 NODES命令可以看到,集群目前只包含7000自己一个节点:

1
2
127.0.0.1:7000> CLUSTER NODES
51549e625cfda318ad27423a31e7476fe3cd2939 :0 myself,master - 0 0 0 connected

通过向节点7000发送以下命令,我们可以将节点7001添加到节点7000所在的集群里面:

1
2
3
4
5
127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7001
OK
127.0.0.1:7000> CLUSTER NODES
68eef66df23420a5862208ef5bla7005b806f2ff 127.0.0.1:7001 master - 0 1388204746210 0 connected
51549e625cfda318ad27423a31e7476fe3cd2939:0 myself,master - 0 0 0 connected

继续添加7002节点。

image-20230327101210543

① 启动节点

4 独立功能的实现

4.1 发布与订阅

4.1.1 概述

举个例子,假设A、B、C三个客户端都执行了命令:

1
SUBSCRIBE news.it

那么这三个客户端就是news.it频道的订阅者,如图18-1所示。

image-20230426105659179

如果这时某个客户端执行命令

1
PUBLISH news.it hello

那么三个订阅者都将收到这个消息。

image-20230426105841606

4.1.2 频道的订阅与退订

Redis将所有频道的订阅关系都保存在服务器状态的pubsub_channels字典里面,这个字典的键是某个被订阅的频道,而键的值则是一个链表,链表里面记录了所有订阅这个频道的客户端:

1
2
3
4
struct redisServer {
// 保存所有频道的订阅关系
dict *pubsub_channels;
};

例如:

image-20230426110121335

① 订阅

执行命令SUBSCRIBE时,分为两种情况:

  • 该频道(键)存在,则将该客户端插入到链表末尾
  • 该频道不存在,则先创建频道(键),然后再插入

伪代码:

1
2
3
4
5
6
7
8
9
def subscribe(*all_input_channels):
# 遍历输入的所有频道
for channel in all_input_channels:
# 如果channel不存在于pubsub_channels字典(没有任何订阅者)
# 那么在字典中添加channel键,并设置它的值为空链表
if channel not in server.pubsub_channels:
server.pubsub_channels[channel] = []
# 将订阅者添加到频道所对应的链表的末尾
server.pubsub_channels[channel].append(client)
② 退订

执行UNSUBSCRIBE时,也分为两种情况:

  • 根据频道的名字,在对应链表中找到该客户端,然后删除
  • 如果删除后,链表为空了,则将该频道(键)删除

4.1.3 模式的订阅与退订

服务器将所有模式的订阅关系都保存在服务器状态的pubsub_patterns属性里面:

1
2
3
4
struct redisServer {
// 保存所有模式的订阅关系
list *pubsub_patterns;
};

pubsub_patterns属性是一个链表,链表中的每个节点都包含着一个pubsubPattern结构,这个结构的pattern属性记录了被订阅的模式,而client属性则记录了订阅模式的客户端:

1
2
3
4
5
6
typedef struct pubsubPattern {
// 订阅模式的客户端
redisClient *client;
// 被订阅的模式
robj *pattern;
} pubsubPattern;

例如:

image-20230426111240120

① 订阅

执行命令PSUBSCRIBE时,执行下面两个操作:

  • 新建一个pubsubPattern结构,将结构的pattern属性设置为被订阅的模式,client 属性设置为订阅模式的客户端。
  • pubsubPattern结构添加到 pubsub_patterns 链表的表尾。
② 退订

模式的退订命令PUNSUBSCRIBEPSUBSCRIBE命令的反操作:当一个客户端退订某个或某些模式的时候,服务器将在 pubsub_patterns 链表中查找并删除那些 pattern 属性为被退订模式,并且client属性为执行退订命令的客户端的 pubsubPattern结构。

4.1.4 发送消息

当一个Redis客户端执行PUBLISH <channel> <message>命令将消息message 发送给频道channel的时候,服务器需要同时执行以下两个动作:

  • 将消息message发送给channel频道的所有订阅者(直接匹配)
  • 如果有一个或多个模式pattern与频道channel相匹配,那么将消息message发送给pattern模式的订阅者(模式匹配)
① 将消息发送给频道订阅者

image-20230426111907566

如果这时某个客户端执行命令:

1
PUBLISH news.it hello

此时服务器会在字典中根据键找到链表,然后将hello发送给该链表上的所有用户(遍历)。

② 将消息发送给模式订阅者

image-20230426112431230

同样,某个客户端执行命令:

1
PUBLISH news.it hello

服务器遍历链表,查找哪个pubsubPattern结构的pattern属性与news.it匹配,匹配则发送。

4.1.5 查看订阅信息

PUBSUB命令是Redis2.8新增加的命令之一,客户端可以通过这个命令来查看频道或者模式的相关信息,比如某个频道目前有多少订阅者,又或者某个模式目前有多少订阅者,诸如此类。

① PUBSUB CHANNELS

PUBSUB CHANNELS [pattern]子命令用于返回服务器当前被订阅的频道,其中pattern 参数是可选的:

  • 如果不给定 pattern参数,那么命令返回服务器当前被订阅的所有频道
  • 如果给定 pattern参数,那么命令返回服务器当前被订阅的频道中那些与pattern模式相匹配的频道。

这个子命令是通过遍历服务器 pubsub_channels字典的所有键(每个键都是一个被订阅的频道),然后记录并返回所有符合条件的频道来实现的。

例如:

image-20230426113146517

1
2
3
4
5
6
7
8
9
redis> PUBSUB CHANNELS 
1) "news.it"
2) "news.sport"
3) "news.business"
4) "news.movie"

redis> PUBSUB CHANNELS "news.[is]*"
1) "news.it"
2) "news.sport"
② PUBSUB NUMSUB

PUBSUB NUMSUB [channel-l channel-2 ….channel-n]子命令接受任意多个频道作为输入参数,并返回这些频道的订阅者数量。

原理:在pubsub_channels字典中找到对应的频道(键),然后遍历频道对应的链表得到长度。

例如:

image-20230426113517540

1
2
3
4
5
6
7
8
9
redis> PUBSUB NUMSUB news.it news.sport news.business news.movie 
1) "news.it"
2) "3"
3) "news.sport"
4) "2"
5) "news.business"
6) "2"
7) "news,movie"
8) "1"

4.2 事务

4.2.1 概述

Redis通过MULTIEXECWATCH等命令来实现事务(transaction)功能。事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。

以下是一个事务执行的过程,该事务首先以一个MULTI命令为开始,接着将多个命令放入事务当中,最后由EXEC命令将这个事务提交(commit)给服务器执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
redis> MULTI 
OK

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

redis> GET "name"
QUEUED

redis> SET "author" "Peter Seibel"
QUEUED

redis> GET "author"
QUEUED

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

4.2.2 事务实现

一个事务从开始到结束通常会经历以下三个阶段:

  1. 事务开始
  2. 命令入队
  3. 事务执行
① 事务开始

MULTI命令的执行标志着事务的开始。

MULTI命令可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的flags属性中打开REDIS_MULTI标识来完成的。

② 命令入队

当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作:

  • 如果客户端发送的命令为 EXEC、DISCARD、WATCH、MULTI四个命令的其中一个,那么服务器立即执行这个命令。
  • 与此相反,如果客户端发送的命令是EXEC、DISCARD、WATCH、MULTI四个命令以外的其他命令,那么服务器并不立即执行这个命令,而是将这个命令放入一个事务队列里面,然后向客户端返回 QUEUED 回复。

服务器判断命令是该入队还是该立即执行的过程可以用流程图19-1来描述。

image-20230426114835921

③ 事务队列

每个Redis客户端都有自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面:

1
2
3
4
typedef struct redisClient {
// 事务状态
multiState mstate; // MULTI/EXEC state
}redisClient;

事务状态包含一个事务队列,以及一个已入队命令的计数器(也可以说是事务队列的长度):

1
2
3
4
5
6
typedef struct multiState {
// 事务队列,FIFO
multiCmd *commands;
// 已入队命令计数
int count;
} multiState;

事务队列是一个multicmd类型的数组,数组中的每个multiCmd结构都保存了一个已入队命令的相关信息,包括指向命令实现函数的指针、命令的参数,以及参数的数量:

1
2
3
4
5
6
7
8
typedef struct multiCmd (
// 参数
robj **argv;
// 参数数量
int argcr
// 命令指针
struct redisCommand *cmd;
} multiCmd;

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
redis> MULTI 
OK
redis> SET "name" "Practical Common Lisp"
QUEUED

redis> GET "name"
QUEUED

redis> SET "author" "Peter Seibel"
QUEUED

redis> GET "author"
QUEUED

image-20230426115508670

④ 执行事务

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

4.2.3 WATCH命令的实现

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

例如:

1
2
3
4
5
6
7
8
9
10
11
redis> WATCH "name"
OK

redis> MULTI
OK

redis> SET "name" "peter"
QUEUED

redis> EXEC
(nil)

image-20230426120156241

① 监视数据库键

每个Redis数据库都保存着一个watched_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端:

1
2
3
4
typedef struct redisDb {
// 正在被WATCH监视的键
dict *watched_keys;
} redisDb;

例如:

image-20230426120537090

当客户端10086执行:

1
WATCH "name" "age"

image-20230426120610682

② 监视机制的触发

所有对数据库进行修改的命令在执行之后都会调用multi.c/touchWatchKey函数对watched_keys字典进行检查,查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有的话,那么touchWatchKey函数会将监视被修改键的客户端的flags的REDIS_DIRTY_CAS标识打开,表示该客户端的事务安全性已经被破坏。

例如:

image-20230426141640392

如果键name被修改,那么c1、c2、c10086三个客户端的REDIS_DIRTY_CAS标识将被打开。

③ 判断事务是否安全

image-20230426141739045

4.3 Lua脚本