redis设计与实现笔记

Redis数据库中的每个键值对都是由对象组成的:

  • 数据库键总是一个字符串对象;
  • 数据库键的值可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象这五种对象中的一种。

数据结构与对象

简单动态字符串

Redis自己构建了一种名叫“简单动态字符串”(SDS)的类型,当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,使用SDS用作默认字符串表示。如果执行了:

1
2
redis> SET msg "hello world" 
OK

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

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

sds的定义

每个 sds.h/sdshdr 结构表示一个 SDS 值:

1
2
3
4
5
6
7
8
9
10
11
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;

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

// 字节数组,用于保存字符串
char buf[];
};

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

SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作都是由SDS函数自动完成的,所以这个空字符对于SDS的使用者来说是完全透明的。

SDS与C字符串的区别

C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符’\0’。C语言使用的这种简单的字符串表示方式,并不能满足Redis对字符串在安全性、效率、以及功能方面的要求。

Redis中使用SDS的优势:

  • 常数复杂度获取字符串长度:因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,这个操作的复杂度为O(N)。因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1)。设置和更新SDS长度的工作是由SDS的API在执行时自动完成的,使用SDS无须进行任何手动修改长度的工作。
  • 杜绝缓冲区溢出strcat函数执行字符串拼接时假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。与C字符串不同,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDSAPI需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题。
  • 减少内存分配次数,因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作:
    • 如果程序执行的是增长字符串的操作,那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出。
    • 如果程序执行的是缩短字符串的操作,那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏。
    • 为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。
    • 空间预分配:空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。其中,额外分配的未使用空间数量由以下公式决定:
      • 如果对SDS进行修改之后,SDS的长度(也即是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDSlen属性的值将和free属性的值相同。
      • 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度将为30MB+1MB+1byte。
    • 惰性空间释放:惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。
  • 二进制安全:虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见,因此,为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的(binary-safe):所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设。
  • 兼容部分C字符串函数:虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:这些API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。

SDS API

函数 作用 时间复杂度
sdsnew 创建一个包含给定 C 字符串的 SDS 。 O(N) , N 为给定 C 字符串的长度。
sdsempty 创建一个不包含任何内容的空 SDS 。 O(1)
sdsfree 释放给定的 SDS 。 O(1)
sdslen 返回 SDS 的已使用空间字节数。 这个值可以通过读取 SDS 的 len 属性来直接获得, 复杂度为 O(1) 。
sdsavail 返回 SDS 的未使用空间字节数。 这个值可以通过读取 SDS 的 free 属性来直接获得, 复杂度为 O(1) 。
sdsdup 创建一个给定 SDS 的副本(copy)。 O(N) , N 为给定 SDS 的长度。
sdsclear 清空 SDS 保存的字符串内容。 因为惰性空间释放策略,复杂度为 O(1) 。
sdscat 将给定 C 字符串拼接到 SDS 字符串的末尾。 O(N) , N 为被拼接 C 字符串的长度。
sdscatsds 将给定 SDS 字符串拼接到另一个 SDS 字符串的末尾。 O(N) , N 为被拼接 SDS 字符串的长度。
sdscpy 将给定的 C 字符串复制到 SDS 里面, 覆盖 SDS 原有的字符串。 O(N) , N 为被复制 C 字符串的长度。
sdsgrowzero 用空字符将 SDS 扩展至给定长度。 O(N) , N 为扩展新增的字节数。
sdsrange 保留 SDS 给定区间内的数据, 不在区间内的数据会被覆盖或清除。 O(N) , N 为被保留数据的字节数。
sdstrim 接受一个 SDS 和一个 C 字符串作为参数, 从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符。 O(M*N) , M 为 SDS 的长度, N 为给定C字符串的长度。
sdscmp 对比两个 SDS 字符串是否相同。 O(N) , N 为两个 SDS 中较短的那个 SDS 的长度。

链表

链表提供了高效的节点重排能力, 以及顺序性的节点访问方式, 并且可以通过增删节点来灵活地调整链表的长度。
每个链表节点使用一个 adlist.h/listNode 结构来表示:

1
2
3
4
5
6
7
8
9
10
typedef struct listNode {
// 前置节点
struct listNode *prev;

// 后置节点
struct listNode *next;

// 节点的值
void *value;
} listNode;


多个 listNode 可以通过 prev 和 next 指针组成双端链表。虽然仅仅使用多个 listNode 结构就可以组成链表, 但使用 adlist.h/list 来持有链表的话, 操作起来会更方便:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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函数则用于对比链表节点所保存的值和另一个输入值是否相等。

Redis的链表实现的特性可以总结如下:

  • 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
  • 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
  • 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
  • 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
  • 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定-函数,所以链表可以用于保存各种不同类型的值。

Redis 链表和链表节点的 API

函数 作用 时间复杂度
listSetDupMethod 将给定的函数设置为链表的节点值复制函数。 O(1) 。
listGetDupMethod 返回链表当前正在使用的节点值复制函数。 复制函数可以通过链表的 dup 属性直接获得, O(1)
listSetFreeMethod 将给定的函数设置为链表的节点值释放函数。 O(1) 。
listGetFree 返回链表当前正在使用的节点值释放函数。 释放函数可以通过链表的 free 属性直接获得, O(1)
listSetMatchMethod 将给定的函数设置为链表的节点值对比函数。 O(1)
listGetMatchMethod 返回链表当前正在使用的节点值对比函数。 对比函数可以通过链表的 match 属性直接获得,O(1)
listLength 返回链表的长度(包含了多少个节点)。 链表长度可以通过链表的 len 属性直接获得, O(1) 。
listFirst 返回链表的表头节点。 表头节点可以通过链表的 head 属性直接获得, O(1) 。
listLast 返回链表的表尾节点。 表尾节点可以通过链表的 tail 属性直接获得, O(1) 。
listPrevNode 返回给定节点的前置节点。 前置节点可以通过节点的 prev 属性直接获得, O(1) 。
listNextNode 返回给定节点的后置节点。 后置节点可以通过节点的 next 属性直接获得, O(1) 。
listNodeValue 返回给定节点目前正在保存的值。 节点值可以通过节点的 value 属性直接获得, O(1) 。
listCreate 创建一个不包含任何节点的新链表。 O(1)
listAddNodeHead 将一个包含给定值的新节点添加到给定链表的表头。 O(1)
listAddNodeTail 将一个包含给定值的新节点添加到给定链表的表尾。 O(1)
listInsertNode 将一个包含给定值的新节点添加到给定节点的之前或者之后。 O(1)
listSearchKey 查找并返回链表中包含给定值的节点。 O(N) , N 为链表长度。
listIndex 返回链表在给定索引上的节点。 O(N) , N 为链表长度。
listDelNode 从链表中删除给定节点。 O(1) 。
listRotate 将链表的表尾节点弹出,然后将被弹出的节点插入到链表的表头, 成为新的表头节点。 O(1)
listDup 复制一个给定链表的副本。 O(N) , N 为链表长度。
listRelease 释放给定链表,以及链表中的所有节点。 O(N) , N 为链表长度。

字典

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

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

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

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

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

Redis 字典的实现

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

哈希表

Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 结构都保存着一个键值对:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct dictEntry {
// 键
void *key;

// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;

// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;

解释:

  • key 属性保存着键值对中的键, 而 v 属性则保存着键值对中的值, 其中键值对的值可以是一个指针, 或者是一个 uint64_t 整数, 又或者是一个 int64_t 整数。
  • next 属性是指向另一个哈希表节点的指针, 这个指针可以将多个哈希值相同的键值对连接在一次, 以此来解决键冲突(collision)的问题。

字典

Redis 中的字典由 dict.h/dict 结构表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
15
16
17
18
19
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 时使用。
  • 另一个和 rehash 有关的属性就是 rehashidx : 它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1 。

Redis 哈希算法

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

Redis 计算哈希值和索引值的方法如下:

1
2
3
4
5
6
# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);

# 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;

如果我们要将一个键值对 k0 和 v0 添加到字典里面, 那么程序会先使用语句:

1
hash = dict->type->hashFunction(k0);

计算键 k0 的哈希值。假设计算得出的哈希值为 8 , 那么程序会继续使用语句:
1
index = hash & dict->ht[0].sizemask = 8 & 3 = 0;

计算出键 k0 的索引值 0 , 这表示包含键值对 k0 和 v0 的节点应该被放置到哈希表数组的索引 0 位置上

Redis 解决键冲突

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

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

举个例子, 假设程序要将键值对 k2 和 v2 添加到图 4-6 所示的哈希表里面, 并且计算得出 k2 的索引值为 2 , 那么键 k1 和 k2 将产生冲突, 而解决冲突的办法就是使用 next 指针将键 k2 和 k1 所在的节点连接起来。

Redis rehash

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

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

  • 为字典的 ht[1] 哈希表分配空间, 这个哈希表的空间大小取决于要执行的操作, 以及 ht[0] 当前包含的键值对数量 (也即是ht[0].used 属性的值):
    • 如果执行的是扩展操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的 2^n (2 的 n 次方幂);
    • 如果执行的是收缩操作, 那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2^n 。
  • 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1] 哈希表的指定位置上。
  • 当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后 (ht[0] 变为空表), 释放 ht[0] , 将 ht[1] 设置为 ht[0] , 并在 ht[1] 新创建一个空白哈希表, 为下一次 rehash 做准备。

举个例子, 假设程序要对图 4-8 所示字典的 ht[0] 进行扩展操作, 那么程序将执行以下步骤:

  • ht[0].used 当前的值为 4 , 4 * 2 = 8 , 而 8 (2^3)恰好是第一个大于等于 4 的 2 的 n 次方, 所以程序会将 ht[1] 哈希表的大小设置为 8 。 图 4-9 展示了 ht[1] 在分配空间之后, 字典的样子。
  • 将 ht[0] 包含的四个键值对都 rehash 到 ht[1] , 如图 4-10 所示。
  • 释放 ht[0] ,并将 ht[1] 设置为 ht[0] ,然后为 ht[1] 分配一个空白哈希表,如图 4-11 所示。
  • 至此, 对哈希表的扩展操作执行完毕, 程序成功将哈希表的大小从原来的 4 改为了现在的 8 。




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

服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 1 ;
服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 5 ;
其中哈希表的负载因子可以通过公式:

1
2
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

根据 BGSAVE 命令或 BGREWRITEAOF 命令是否正在执行, 服务器执行扩展操作所需的负载因子并不相同, 这是因为在执行 BGSAVE 命令或BGREWRITEAOF 命令的过程中, Redis 需要创建当前服务器进程的子进程, 而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率, 所以在子进程存在期间, 服务器会提高执行扩展操作所需的负载因子, 从而尽可能地避免在子进程存在期间进行哈希表扩展操作, 这可以避免不必要的内存写入操作, 最大限度地节约内存。

Redis 渐进式 rehash

扩展或收缩哈希表需要将 ht[0] 里面的所有键值对 rehash 到 ht[1] 里面, 但是, 这个 rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的。这样避免了 rehash 对服务器性能造成影响。服务器不是一次性将 ht[0] 里面的所有键值对全部 rehash 到 ht[1] , 而是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1] 。

以下是哈希表渐进式 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 操作已完成。

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

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

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

Redis 字典 API

函数 作用 时间复杂度
dictCreate 创建一个新的字典。 O(1)
dictAdd 将给定的键值对添加到字典里面。 O(1)
dictReplace 将给定的键值对添加到字典里面, 如果键已经存在于字典,那么用新值取代原有的值。 O(1)
dictFetchValue 返回给定键的值。 O(1)
dictGetRandomKey 从字典中随机返回一个键值对。 O(1)
dictDelete 从字典中删除给定键所对应的键值对。 O(1)
dictRelease 释放给定字典,以及字典中包含的所有键值对。 O(N) , N 为字典包含的键值对数量。

跳跃表

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

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

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

Redis 使用跳跃表作为有序集合键的底层实现之一: 如果一个有序集合包含的元素数量比较多, 又或者有序集合中元素的成员(member)是比较长的字符串时, 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 结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;

// 分值
double score;

// 成员对象
robj *obj;

// 层
struct zskiplistLevel {

// 前进指针
struct zskiplistNode *forward;

// 跨度
unsigned int span;

} level[];

} zskiplistNode;

跳跃表节点的 level 数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。

每次创建一个新跳跃表节点的时候, 程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小, 这个大小就是层的“高度”。

图 5-2 分别展示了三个高度为 1 层、 3 层和 5 层的节点, 因为 C 语言的数组索引总是从 0 开始的, 所以节点的第一层是 level[0] , 而第二层是 level[1] , 以此类推。

每个层都有一个指向表尾方向的前进指针(level[i].forward 属性), 用于从表头向表尾方向访问节点。

图 5-3 用虚线表示出了程序从表头向表尾方向, 遍历跳跃表中所有节点的路径:

迭代程序首先访问跳跃表的第一个节点(表头), 然后从第四层的前进指针移动到表中的第二个节点。
在第二个节点时, 程序沿着第二层的前进指针移动到表中的第三个节点。
在第三个节点时, 程序同样沿着第二层的前进指针移动到表中的第四个节点。
当程序再次沿着第四个节点的前进指针移动时, 它碰到一个 NULL , 程序知道这时已经到达了跳跃表的表尾, 于是结束这次遍历。

层的跨度(level[i].span 属性)用于记录两个节点之间的距离:

  • 两个节点之间的跨度越大, 它们相距得就越远。
  • 指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点。
    初看上去, 很容易以为跨度和遍历操作有关, 但实际上并不是这样 —— 遍历操作只使用前进指针就可以完成了, 跨度实际上是用来计算排位(rank)的: 在查找某个节点的过程中, 将沿途访问过的所有层的跨度累计起来, 得到的结果就是目标节点在跳跃表中的排位。

举个例子, 图 5-4 用虚线标记了在跳跃表中查找分值为 3.0 、 成员对象为 o3 的节点时, 沿途经历的层: 查找的过程只经过了一个层, 并且层的跨度为 3 , 所以目标节点在跳跃表中的排位为 3 。

通过一个zskiplist结构来持有节点,可以更方便的进行处理。

1
2
3
4
5
6
7
8
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tailer;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
ine level;
} zskiplist;

Redis 跳跃表 API

函数 作用 时间复杂度
zslCreate 创建一个新的跳跃表。 O(1)
zslFree 释放给定跳跃表,以及表中包含的所有节点。 O(N) , N 为跳跃表的长度。
zslInsert 将包含给定成员和分值的新节点添加到跳跃表中。 平均 O(N) , N 为跳跃表长度。
zslDelete 删除跳跃表中包含给定成员和分值的节点。 平均 O(N) , N 为跳跃表长度。
zslGetRank 返回包含给定成员和分值的节点在跳跃表中的排位。 平均 O(N) , N 为跳跃表长度。
zslGetElementByRank 返回跳跃表在给定排位上的节点。 平均 O(N) , N 为跳跃表长度。
zslIsInRange 给定一个分值范围(range), 比如 0 到 15 , 20 到 28,诸如此类, 如果给定的分值范围包含在跳跃表的分值范围之内, 那么返回 1 ,否则返回 0 。 通过跳跃表的表头节点和表尾节点, 这个检测可以用 O(1) 复杂度完成。
zslFirstInRange 给定一个分值范围, 返回跳跃表中第一个符合这个范围的节点。 平均 O(N) 。 N 为跳跃表长度。
zslLastInRange 给定一个分值范围, 返回跳跃表中最后一个符合这个范围的节点。 平均 O(N) 。 N 为跳跃表长度。
zslDeleteRangeByScore 给定一个分值范围, 删除跳跃表中所有在这个范围之内的节点。 O(N) , N 为被删除节点数量。
zslDeleteRangeByRank 给定一个排位范围, 删除跳跃表中所有在这个范围之内的节点。 O(N) , N 为被删除节点数量。

整数集合

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

举个例子, 如果我们创建一个只包含五个元素的集合键, 并且集合中的所有元素都是整数值, 那么这个集合键的底层实现就会是整数集合:

1
2
3
4
5
redis> SADD numbers 1 3 5 7 9
(integer) 5

redis> OBJECT ENCODING numbers
"intset"

Redis 整数集合的实现

整数集合(intset)是 Redis 用于保存整数值的集合抽象数据结构, 它可以保存类型为 int16_t 、 int32_t 或者 int64_t 的整数值, 并且保证集合中不会出现重复元素。每个 intset.h/intset 结构表示一个整数集合:

1
2
3
4
5
6
7
8
9
10
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 类型的数组(最小值为 -32,768 ,最大值为 32,767 )。
  • encoding = INTSET_ENC_INT32 , contents 是 int32_t 类型的数组(最小值为 -2,147,483,648 ,最大值为 2,147,483,647 )。
  • encoding = INTSET_ENC_INT64 , contents 是 int64_t 类型的数组(最小值为 -9,223,372,036,854,775,808 ,最大值为 9,223,372,036,854,775,807 )。

Redis升级

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

升级整数集合并添加新元素共分为三步进行:

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

    假设现在有一个 INTSET_ENC_INT16 编码的整数集合, 集合中包含三个 int16_t 类型的元素, 如图 6-3 所示。

因为每个元素都占用 16 位空间, 所以整数集合底层数组的大小为 3 * 16 = 48 位, 图 6-4 展示了整数集合的三个元素在这 48 位里的位置。

现在, 假设我们要将类型为 int32_t 的整数值 65535 添加到整数集合里面, 因为 65535 的类型 int32_t 比整数集合当前所有元素的类型都要长, 所以在将 65535 添加到整数集合之前, 程序需要先对整数集合进行升级。升级首先要做的是, 根据新类型的长度, 以及集合元素的数量(包括要添加的新元素在内), 对底层数组进行空间重分配。整数集合目前有三个元素, 再加上新元素 65535 , 整数集合需要分配四个元素的空间, 因为每个 int32_t 整数值需要占用 32 位空间, 所以在空间重分配之后, 底层数组的大小将是 32 * 4 = 128 位, 如图 6-5 所示。

虽然程序对底层数组进行了空间重分配, 但数组原有的三个元素 1 、 2 、 3 仍然是 int16_t 类型, 这些元素还保存在数组的前 48 位里面, 所以程序接下来要做的就是将这三个元素转换成 int32_t 类型, 并将转换后的元素放置到正确的位上面, 而且在放置元素的过程中, 需要维持底层数组的有序性质不变。

首先, 因为元素 3 在 1 、 2 、 3 、 65535 四个元素中排名第三, 所以它将被移动到 contents 数组的索引 2 位置上, 也即是数组 64 位至 95 位的空间内, 如图 6-6 所示。

接着, 因为元素 2 在 1 、 2 、 3 、 65535 四个元素中排名第二, 所以它将被移动到 contents 数组的索引 1 位置上, 也即是数组的 32位至 63 位的空间内, 如图 6-7 所示。

之后, 因为元素 1 在 1 、 2 、 3 、 65535 四个元素中排名第一, 所以它将被移动到 contents 数组的索引 0 位置上, 也即是数组的 0 位至 31 位的空间内, 如图 6-8 所示。

然后, 因为元素 65535 在 1 、 2 、 3 、 65535 四个元素中排名第四, 所以它将被添加到 contents 数组的索引 3 位置上, 也即是数组的96 位至 127 位的空间内, 如图 6-9 所示。

最后, 程序将整数集合 encoding 属性的值从 INTSET_ENC_INT16 改为 INTSET_ENC_INT32 , 并将 length 属性的值从 3 改为 4 ,因为每次向整数集合添加新元素都可能会引起升级, 而每次升级都需要对底层数组中已有的所有元素进行类型转换, 所以向整数集合添加新元素的时间复杂度为 O(N) 。

其他类型的升级操作, 比如从 INTSET_ENC_INT16 编码升级为 INTSET_ENC_INT64 编码, 或者从 INTSET_ENC_INT32 编码升级为 INTSET_ENC_INT64 编码, 升级的过程都和上面展示的升级过程类似。

因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大, 所以这个新元素的值要么就大于所有现有元素, 要么就小于所有现有元素:

  • 在新元素小于所有现有元素的情况下, 新元素会被放置在底层数组的最开头(索引 0 );
  • 在新元素大于所有现有元素的情况下, 新元素会被放置在底层数组的最末尾(索引 length-1 )。

Redis 升级的好处

整数集合的升级策略有两个好处, 一个是提升整数集合的灵活性, 另一个是尽可能地节约内存。

  • 提升灵活性:通常不会将两种不同类型的值放在同一个数据结构里面。但是, 因为整数集合可以通过自动升级底层数组来适应新元素, 所以我们可以随意地将 int16_t 、 int32_t 或者 int64_t 类型的整数添加到集合中, 而不必担心出现类型错误, 这种做法非常灵活。
  • 节约内存:当然, 要让一个数组可以同时保存 int16_t 、 int32_t 、 int64_t 三种类型的值, 最简单的做法就是直接使用 int64_t 类型的数组作为整数集合的底层实现。但是会出现浪费内存的情况。而整数集合现在的做法既可以让集合能同时保存三种不同类型的值, 又可以确保升级操作只会在有需要的时候进行, 这可以尽量节省内存。

Redis 降级

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

举个例子, 对于一个整数集合来说, 即使我们将集合里唯一一个真正需要使用 int64_t 类型来保存的元素 4294967295 删除了, 整数集合的编码仍然会维持 INTSET_ENC_INT64 , 底层数组也仍然会是 int64_t 类型的。

Redis 整数集合 API

函数 作用 时间复杂度
intsetNew 创建一个新的整数集合。 O(1)
intsetAdd 将给定元素添加到整数集合里面。 O(N)
intsetRemove 从整数集合中移除给定元素。 O(N)
intsetFind 检查给定值是否存在于集合。 因为底层数组有序,查找可以通过二分查找法来进行, 所以复杂度为 O(\log N) 。
intsetRandom 从整数集合中随机返回一个元素。 O(1)
intsetGet 取出底层数组在给定索引上的元素。 O(1)
intsetLen 返回整数集合包含的元素个数。 O(1)
intsetBlobLen 返回整数集合占用的内存字节数。 O(1)

压缩列表

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

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

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

redis> OBJECT ENCODING lst
"ziplist"

因为列表键里面包含的都是 1 、 3 、 5 、 10086 这样的小整数值, 以及 “hello” 、 “world” 这样的短字符串。

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

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

1
2
3
4
5
redis> HMSET profile "name" "Jack" "age" 28 "job" "Programmer"
OK

redis> OBJECT ENCODING profile
"ziplist"

因为哈希键里面包含的所有键和值都是小整数值或者短字符串。

Redis 压缩列表的构成

压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。图 7-1 展示了压缩列表的各个组成部分, 表 7-1 则记录了各个组成部分的类型、长度、以及用途。

压缩列表各个组成部分的详细说明

属性 类型 长度 用途
zlbytes uint32_t 4 字节 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
zltail uint32_t 4 字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。
zllen uint16_t 2 字节 记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX (65535)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。
entryX 列表节点 不定 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlend uint8_t 1 字节 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

图 7-2 展示了一个压缩列表示例:

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

Redis 压缩列表节点的构成

每个压缩列表节点可以保存一个字节数组或者一个整数值, 其中, 字节数组可以是以下三种长度的其中一种:

  • 长度小于等于 63 (2^{6}-1)字节的字节数组;
  • 长度小于等于 16383 (2^{14}-1) 字节的字节数组;
  • 长度小于等于 4294967295 (2^{32}-1)字节的字节数组;

而整数值则可以是以下六种长度的其中一种:

  • 4 位长,介于 0 至 12 之间的无符号整数;
  • 1 字节长的有符号整数;
  • 3 字节长的有符号整数;
  • int16_t 类型整数;
  • int32_t 类型整数;
  • int64_t 类型整数。

每个压缩列表节点都由 previous_entry_length 、 encoding 、 content 三个部分组成。

previous_entry_length:节点的 previous_entry_length 属性以字节为单位, 记录了压缩列表中前一个节点的长度。
previous_entry_length 属性的长度可以是 1 字节或者 5 字节;如果前一节点的长度小于 254 字节, 那么 previous_entry_length 属性的长度为 1 字节;前一节点的长度就保存在这一个字节里面。
如果前一节点的长度大于等于 254 字节, 那么 previous_entry_length 属性的长度为 5 字节: 其中属性的第一字节会被设置为 0xFE(十进制值 254), 而之后的四个字节则用于保存前一节点的长度。

压缩列表的从表尾向表头遍历操作就是使用这一原理实现的: 只要我们拥有了一个指向某个节点起始地址的指针, 那么通过这个指针以及这个节点的 previous_entry_length 属性, 程序就可以一直向前一个节点回溯, 最终到达压缩列表的表头节点。

一个从表尾节点向表头节点进行遍历的完整过程:

  • 首先,我们拥有指向压缩列表表尾节点 entry4 起始地址的指针 p1 (指向表尾节点的指针可以通过指向压缩列表起始地址的指针加上zltail 属性的值得出);
  • 通过用 p1 减去 entry4 节点 previous_entry_length 属性的值, 我们得到一个指向 entry4 前一节点 entry3 起始地址的指针 p2 ;
  • 通过用 p2 减去 entry3 节点 previous_entry_length 属性的值, 我们得到一个指向 entry3 前一节点 entry2 起始地址的指针 p3 ;
  • 通过用 p3 减去 entry2 节点 previous_entry_length 属性的值, 我们得到一个指向 entry2 前一节点 entry1 起始地址的指针 p4 , entry1为压缩列表的表头节点;
  • 最终, 我们从表尾节点向表头节点遍历了整个列表。

encoding:节点的 encoding 属性记录了节点的 content 属性所保存数据的类型以及长度:

  • 一字节、两字节或者五字节长, 值的最高位为 00 、 01 或者 10 的是字节数组编码: 这种编码表示节点的 content 属性保存着字节数组, 数组的长度由编码除去最高两位之后的其他位记录;
  • 一字节长, 值的最高位以 11 开头的是整数编码: 这种编码表示节点的 content 属性保存着整数值, 整数值的类型和长度由编码除去最高两位之后的其他位记录;

content:节点的 content 属性负责保存节点的值, 节点值可以是一个字节数组或者整数, 值的类型和长度由节点的 encoding 属性决定。

Redis 连锁更新

Redis 将在特殊情况下产生的连续多次空间扩展操作称之为“连锁更新”(cascade update)

除了添加新节点可能会引发连锁更新之外, 删除节点也可能会引发连锁更新。

如果 e1 至 eN 都是大小介于 250 字节至 253 字节的节点, big 节点的长度大于等于 254 字节(需要 5 字节的 previous_entry_length 来保存), 而 small 节点的长度小于 254 字节(只需要 1 字节的 previous_entry_length 来保存), 那么当我们将 small 节点从压缩列表中删除之后, 为了让 e1 的 previous_entry_length 属性可以记录 big 节点的长度, 程序将扩展 e1 的空间, 并由此引发之后的连锁更新。

因为连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配操作, 而每次空间重分配的最坏复杂度为 O(N^2) 。

要注意的是, 尽管连锁更新的复杂度较高, 但它真正造成性能问题的几率是很低的:

  • 首先, 压缩列表里要恰好有多个连续的、长度介于 250 字节至 253 字节之间的节点, 连锁更新才有可能被引发, 在实际中, 这种情况并不多见;
  • 其次, 即使出现连锁更新, 但只要被更新的节点数量不多, 就不会对性能造成任何影响: 比如说, 对三五个节点进行连锁更新是绝对不会影响性能的;

因为以上原因, ziplistPush 等命令的平均复杂度仅为 O(N) , 在实际中, 我们可以放心地使用这些函数, 而不必担心连锁更新会影响压缩列表的性能。

Redis 压缩列表 API

函数 作用 算法复杂度
ziplistNew 创建一个新的压缩列表。 O(1)
ziplistPush 创建一个包含给定值的新节点, 并将这个新节点添加到压缩列表的表头或者表尾。 平均 O(N^2) 。
ziplistInsert 将包含给定值的新节点插入到给定节点之后。 平均 O(N^2) 。
ziplistIndex 返回压缩列表给定索引上的节点。 O(N)
ziplistFind 在压缩列表中查找并返回包含了给定值的节点。 因为节点的值可能是一个字节数组, 所以检查节点值和给定值是否相同的复杂度为 O(N^2) 。
ziplistNext 返回给定节点的下一个节点。 O(1)
ziplistPrev 返回给定节点的前一个节点。 O(1)
ziplistGet 获取给定节点所保存的值。 O(1)
ziplistDelete 从压缩列表中删除给定的节点。 平均 O(N^2) 。
ziplistDeleteRange 删除压缩列表在给定索引上的连续多个节点。 平均 O(N^2) 。
ziplistBlobLen 返回压缩列表目前占用的内存字节数。 O(1)
ziplistLen 返回压缩列表目前包含的节点数量。 节点数量小于 65535 时 O(N) 。

因为 ziplistPush 、 ziplistInsert 、 ziplistDelete 和 ziplistDeleteRange 四个函数都有可能会引发连锁更新, 所以它们的最坏复杂度都是 O(N^2) 。

对象

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

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

除此之外, Redis 的对象系统还实现了基于引用计数技术的内存回收机制: 当程序不再使用某个对象的时候, 这个对象所占用的内存就会被自动释放; 另外, Redis 还通过引用计数技术实现了对象共享机制, 这一机制可以在适当的条件下, 通过让多个数据库键共享同一个对象来节约内存。

最后, Redis 的对象带有访问时间记录信息, 该信息可以用于计算数据库键的空转时长, 在服务器启用了 maxmemory 功能的情况下, 空转时长较大的那些键可能会优先被服务器删除。

Redis 对象的类型与编码

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

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

1
2
3
4
5
6
7
8
9
10
typedef struct redisObject {
// 类型
unsigned type:4;

// 编码
unsigned encoding:4;

// 指向底层实现数据结构的指针
void *ptr;
} robj;

对象的 type 属性记录了对象的类型, 这个属性的值可以是表中列出的常量的其中一个。

类型常量 对象的名称
REDIS_STRING 字符串对象
REDIS_LIST 列表对象
REDIS_HASH 哈希对象
REDIS_SET 集合对象
REDIS_ZSET 有序集合对象

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

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

TYPE 命令的实现方式也与此类似, 当我们对一个数据库键执行 TYPE 命令时, 命令返回的结果为数据库键对应的值对象的类型, 而不是键对象的类型:

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
# 键为字符串对象,值为字符串对象
redis> SET msg "hello world"
OK

redis> TYPE msg
string

# 键为字符串对象,值为列表对象
redis> RPUSH numbers 1 3 5
(integer) 6

redis> TYPE numbers
list

# 键为字符串对象,值为哈希对象
redis> HMSET profile name Tome age 25 career Programmer
OK

redis> TYPE profile
hash

# 键为字符串对象,值为集合对象
redis> SADD fruits apple banana cherry
(integer) 3

redis> TYPE fruits
set

# 键为字符串对象,值为有序集合对象
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3

redis> TYPE price
zset

表中列出了 TYPE 命令在面对不同类型的值对象时所产生的输出。

对象 对象 type 属性的值 TYPE 命令的输出
字符串对象 REDIS_STRING “string”
列表对象 REDIS_LIST “list”
哈希对象 REDIS_HASH “hash”
集合对象 REDIS_SET “set”
有序集合对象 REDIS_ZSET “zset”

编码和底层实现

对象的 ptr 指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding 属性决定。

encoding 属性记录了对象所使用的编码, 也即是说这个对象使用了什么数据结构作为对象的底层实现, 这个属性的值可以是表 8-3 列出的常量的其中一个。

编码常量 编码所对应的底层数据结构
REDIS_ENCODING_INT long 类型的整数
REDIS_ENCODING_EMBSTR embstr 编码的简单动态字符串
REDIS_ENCODING_RAW 简单动态字符串
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 双端链表
REDIS_ENCODING_ZIPLIST 压缩列表
REDIS_ENCODING_INTSET 整数集合
REDIS_ENCODING_SKIPLIST 跳跃表和字典

每种类型的对象都至少使用了两种不同的编码, 表中列出了每种类型的对象可以使用的编码。

类型 编码 对象
REDIS_STRING REDIS_ENCODING_INT 使用整数值实现的字符串对象。
REDIS_STRING REDIS_ENCODING_EMBSTR 使用 embstr 编码的简单动态字符串实现的字符串对象。
REDIS_STRING REDIS_ENCODING_RAW 使用简单动态字符串实现的字符串对象。
REDIS_LIST REDIS_ENCODING_ZIPLIST 使用压缩列表实现的列表对象。
REDIS_LIST REDIS_ENCODING_LINKEDLIST 使用双端链表实现的列表对象。
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象。
REDIS_HASH REDIS_ENCODING_HT 使用字典实现的哈希对象。
REDIS_SET REDIS_ENCODING_INTSET 使用整数集合实现的集合对象。
REDIS_SET REDIS_ENCODING_HT 使用字典实现的集合对象。
REDIS_ZSET REDIS_ENCODING_ZIPLIST 使用压缩列表实现的有序集合对象。
REDIS_ZSET REDIS_ENCODING_SKIPLIST 使用跳跃表和字典实现的有序集合对象。

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

1
2
3
4
5
redis> SET msg "hello wrold"
OK

redis> OBJECT ENCODING msg
"embstr"

表中列出了不同编码的对象所对应的 OBJECT ENCODING 命令输出。

对象所使用的底层数据结构 编码常量 OBJECT ENCODING 命令输出
整数 REDIS_ENCODING_INT “int”
embstr 编码的简单动态字符串(SDS) REDIS_ENCODING_EMBSTR “embstr”
简单动态字符串 REDIS_ENCODING_RAW “raw”
字典 REDIS_ENCODING_HT “hashtable”
双端链表 REDIS_ENCODING_LINKEDLIST “linkedlist”
压缩列表 REDIS_ENCODING_ZIPLIST “ziplist”
整数集合 REDIS_ENCODING_INTSET “intset”
跳跃表和字典 REDIS_ENCODING_SKIPLIST “skiplist”

通过 encoding 属性来设定对象所使用的编码, 而不是为特定类型的对象关联一种固定的编码, 极大地提升了 Redis 的灵活性和效率, 因为 Redis 可以根据不同的使用场景来为一个对象设置不同的编码, 从而优化对象在某一场景下的效率。

Redis 字符串对象

字符串对象的编码可以是 int 、 raw 或者 embstr

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

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

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

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

embstr 编码的字符串对象在执行命令时, 产生的效果和 raw 编码的字符串对象执行命令时产生的效果是相同的, 但使用 embstr 编码的字符串对象来保存短字符串值有以下好处:

  • embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次
  • 释放 embstr 编码的字符串对象只需要调用一次内存释放函数, 而释放 raw 编码的字符串对象需要调用两次内存释放函数。
  • 因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起 raw 编码的字符串对象能够更好地利用缓存带来的优势。

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

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

字符串命令的实现

因为字符串键的值为字符串对象, 所以用于字符串键的所有命令都是针对字符串对象来构建的, 表中列举了其中一部分字符串命令, 以及这些命令在不同编码的字符串对象下的实现方法。

命令 int 编码的实现方法 embstr 编码的实现方法 raw 编码的实现方法
SET 使用 int 编码保存值。 使用 embstr 编码保存值。 使用 raw 编码保存值。
GET 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后向客户端返回这个字符串值。 直接向客户端返回字符串值。 直接向客户端返回字符串值。
APPEND 将对象转换成 raw 编码, 然后按raw 编码的方式执行此操作。 将对象转换成 raw 编码, 然后按raw 编码的方式执行此操作。 调用 sdscatlen 函数, 将给定字符串追加到现有字符串的末尾。
INCRBYFLOAT 取出整数值并将其转换成 longdouble 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 取出字符串值并尝试将其转换成long double 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。 取出字符串值并尝试将其转换成 longdouble 类型的浮点数, 对这个浮点数进行加法计算, 然后将得出的浮点数结果保存起来。 如果字符串值不能被转换成浮点数, 那么向客户端返回一个错误。
INCRBY 对整数值进行加法计算, 得出的计算结果会作为整数被保存起来。 embstr 编码不能执行此命令, 向客户端返回一个错误。 raw 编码不能执行此命令, 向客户端返回一个错误。
DECRBY 对整数值进行减法计算, 得出的计算结果会作为整数被保存起来。 embstr 编码不能执行此命令, 向客户端返回一个错误。 raw 编码不能执行此命令, 向客户端返回一个错误。
STRLEN 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 计算并返回这个字符串值的长度。 调用 sdslen 函数, 返回字符串的长度。 调用 sdslen 函数, 返回字符串的长度。
SETRANGE 将对象转换成 raw 编码, 然后按raw 编码的方式执行此命令。 将对象转换成 raw 编码, 然后按raw 编码的方式执行此命令。 将字符串特定索引上的值设置为给定的字符。
GETRANGE 拷贝对象所保存的整数值, 将这个拷贝转换成字符串值, 然后取出并返回字符串指定索引上的字符。 直接取出并返回字符串指定索引上的字符。 直接取出并返回字符串指定索引上的字符。

Redis 列表对象

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

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

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

另一方面, linkedlist 编码的列表对象使用双端链表作为底层实现, 每个双端链表节点(node)都保存了一个字符串对象, 而每个字符串对象都保存了一个列表元素。举个例子, 如果前面所说的 numbers 键创建的列表对象使用的不是 ziplist 编码, 而是 linkedlist 编码, 那么 numbers 键的值对象将是图 8-6 所示的样子。

为了简化字符串对象的表示, 我们在图 8-6 使用了一个带有 StringObject 字样的格子来表示一个字符串对象, 而 StringObject 字样下面的是字符串对象所保存的值。 图 8-7 代表的就是一个包含了字符串值 “three” 的字符串对象, 它是 8-8 的简化表示。

编码转换

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

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

以上两个条件的上限值是可以修改的, 具体请看配置文件中关于 list-max-ziplist-value 选项和 list-max-ziplist-entries 选项的说明。

对于使用 ziplist 编码的列表对象来说, 当使用 ziplist 编码所需的两个条件的任意一个不能被满足时, 对象的编码转换操作就会被执行: 原本保存在压缩列表里的所有列表元素都会被转移并保存到双端链表里面, 对象的编码也会从 ziplist 变为 linkedlist 。

以下代码展示了列表对象因为保存了长度太大的元素而进行编码转换的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 所有元素的长度都小于 64 字节
redis> RPUSH blah "hello" "world" "again"
(integer) 3

redis> OBJECT ENCODING blah
"ziplist"

# 将一个 65 字节长的元素推入列表对象中
redis> RPUSH blah "wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww"
(integer) 4

# 编码已改变
redis> OBJECT ENCODING blah
"linkedlist"

列表命令的实现

因为列表键的值为列表对象, 所以用于列表键的所有命令都是针对列表对象来构建的, 表 8-8 列出了其中一部分列表键命令, 以及这些命令在不同编码的列表对象下的实现方法。

命令 ziplist 编码的实现方法 linkedlist 编码的实现方法
LPUSH 调用 ziplistPush 函数, 将新元素推入到压缩列表的表头。 调用 listAddNodeHead 函数, 将新元素推入到双端链表的表头。
RPUSH 调用 ziplistPush 函数, 将新元素推入到压缩列表的表尾。 调用 listAddNodeTail 函数, 将新元素推入到双端链表的表尾。
LPOP 调用 ziplistIndex 函数定位压缩列表的表头节点, 在向用户返回节点所保存的元素之后, 调用ziplistDelete 函数删除表头节点。 调用 listFirst 函数定位双端链表的表头节点, 在向用户返回节点所保存的元素之后, 调用 listDelNode 函数删除表头节点。
RPOP 调用 ziplistIndex 函数定位压缩列表的表尾节点, 在向用户返回节点所保存的元素之后, 调用ziplistDelete 函数删除表尾节点。 调用 listLast 函数定位双端链表的表尾节点, 在向用户返回节点所保存的元素之后, 调用 listDelNode 函数删除表尾节点。
LINDEX 调用 ziplistIndex 函数定位压缩列表中的指定节点, 然后返回节点所保存的元素。 调用 listIndex 函数定位双端链表中的指定节点, 然后返回节点所保存的元素。
LLEN 调用 ziplistLen 函数返回压缩列表的长度。 调用 listLength 函数返回双端链表的长度。
LINSERT 插入新节点到压缩列表的表头或者表尾时, 使用ziplistPush 函数; 插入新节点到压缩列表的其他位置时, 使用 ziplistInsert 函数。 调用 listInsertNode 函数, 将新节点插入到双端链表的指定位置。
LREM 遍历压缩列表节点, 并调用 ziplistDelete 函数删除包含了给定元素的节点。 遍历双端链表节点, 并调用 listDelNode 函数删除包含了给定元素的节点。
LTRIM 调用 ziplistDeleteRange 函数, 删除压缩列表中所有不在指定索引范围内的节点。 遍历双端链表节点, 并调用 listDelNode 函数删除链表中所有不在指定索引范围内的节点。
LSET 调用 ziplistDelete 函数, 先删除压缩列表指定索引上的现有节点, 然后调用 ziplistInsert 函数, 将一个包含给定元素的新节点插入到相同索引上面。 调用 listIndex 函数, 定位到双端链表指定索引上的节点, 然后通过赋值操作更新节点的值。

Redis 哈希对象

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

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

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

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

1
2
3
4
5
6
7
8
redis> HSET profile name "Tom"
(integer) 1

redis> HSET profile age 25
(integer) 1

redis> HSET profile career "Programmer"
(integer) 1

如果 profile 键的值对象使用的是 ziplist 编码, 那么这个值对象将会是图 8-9 所示的样子, 其中对象所使用的压缩列表如图 8-10 所示。

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

  • 字典的每个键都是一个字符串对象, 对象中保存了键值对的键;
  • 字典的每个值都是一个字符串对象, 对象中保存了键值对的值。

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

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

对于使用 ziplist 编码的列表对象来说, 当使用 ziplist 编码所需的两个条件的任意一个不能被满足时, 对象的编码转换操作就会被执行: 原本保存在压缩列表里的所有键值对都会被转移并保存到字典里面, 对象的编码也会从 ziplist 变为 hashtable 。

Redis 集合对象

集合对象的编码可以是 intset 或者 hashtable 。intset 编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。举个例子, 以下代码将创建一个如图 8-12 所示的 intset 编码集合对象:

1
2
redis> SADD numbers 1 3 5
(integer) 3

另一方面, hashtable 编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL 。举个例子, 以下代码将创建一个如图 8-13 所示的 hashtable 编码集合对象:

1
2
redis> SADD fruits "apple" "banana" "cherry"
(integer) 3

编码的转换:当集合对象可以同时满足以下两个条件时, 对象使用 intset 编码:

  • 集合对象保存的所有元素都是整数值;
  • 集合对象保存的元素数量不超过 512 个;
  • 不能满足这两个条件的集合对象需要使用 hashtable 编码。

对于使用 intset 编码的集合对象来说, 当使用 intset 编码所需的两个条件的任意一个不能被满足时, 对象的编码转换操作就会被执行: 原本保存在整数集合中的所有元素都会被转移并保存到字典里面, 并且对象的编码也会从 intset 变为 hashtable 。

Redis 有序集合对象

有序集合的编码可以是 ziplist 或者 skiplist 。ziplist 编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。

压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。举个例子, 如果我们执行以下 ZADD 命令, 那么服务器将创建一个有序集合对象作为 price 键的值:

1
2
redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3

如果 price 键的值对象使用的是 ziplist 编码, 那么这个值对象将会是图 8-14 所示的样子, 而对象所使用的压缩列表则会是 8-15 所示的样子。

skiplist 编码的有序集合对象使用zset结构作为底层实现, 一个 zset 结构同时包含一个字典和一个跳跃表:

1
2
3
4
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;

zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素: 跳跃表节点的 object 属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。 通过这个跳跃表, 程序可以对有序集合进行范围型操作, 比如 ZRANK 、ZRANGE 等命令就是基于跳跃表 API 来实现的。

除此之外, zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过这个字典, 程序可以用 O(1) 复杂度查找给定成员的分值, ZSCORE 命令就是根据这一特性实现的, 而很多其他有序集合命令都在实现的内部用到了这一特性。

有序集合每个元素的成员都是一个字符串对象, 而每个元素的分值都是一个 double 类型的浮点数。 值得一提的是, 虽然 zset 结构同时使用跳跃表和字典来保存有序集合元素, 但这两种数据结构都会通过指针来共享相同元素的成员和分值, 所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值, 也不会因此而浪费额外的内存。

为什么有序集合需要同时使用跳跃表和字典来实现?在理论上来说, 有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现, 但无论单独使用字典还是跳跃表, 在性能上对比起同时使用字典和跳跃表都会有所降低。另一方面, 如果我们只使用跳跃表来实现有序集合, 那么跳跃表执行范围型操作的所有优点都会被保留, 但因为没有了字典, 所以根据成员查找分值这一操作的复杂度将从 O(log N) 。

有序集合命令的实现

因为有序集合键的值为有序集合对象, 所以用于有序集合键的所有命令都是针对有序集合对象来构建的, 列出了其中一部分有序集合键命令, 以及这些命令在不同编码的有序集合对象下的实现方法。

命令 ziplist 编码的实现方法 zset 编码的实现方法
ZADD 调用 ziplistInsert 函数, 将成员和分值作为两个节点分别插入到压缩列表。 先调用 zslInsert 函数, 将新元素添加到跳跃表, 然后调用 dictAdd 函数, 将新元素关联到字典。
ZCARD 调用 ziplistLen 函数, 获得压缩列表包含节点的数量, 将这个数量除以 2 得出集合元素的数量。 访问跳跃表数据结构的 length 属性, 直接返回集合元素的数量。
ZCOUNT 遍历压缩列表, 统计分值在给定范围内的节点的数量。 遍历跳跃表, 统计分值在给定范围内的节点的数量。
ZRANGE 从表头向表尾遍历压缩列表, 返回给定索引范围内的所有元素。 从表头向表尾遍历跳跃表, 返回给定索引范围内的所有元素。
ZREVRANGE 从表尾向表头遍历压缩列表, 返回给定索引范围内的所有元素。 从表尾向表头遍历跳跃表, 返回给定索引范围内的所有元素。
ZRANK 从表头向表尾遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。 从表头向表尾遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。
ZREVRANK 从表尾向表头遍历压缩列表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。 从表尾向表头遍历跳跃表, 查找给定的成员, 沿途记录经过节点的数量, 当找到给定成员之后, 途经节点的数量就是该成员所对应元素的排名。
ZREM 遍历压缩列表, 删除所有包含给定成员的节点, 以及被删除成员节点旁边的分值节点。 遍历跳跃表, 删除所有包含了给定成员的跳跃表节点。 并在字典中解除被删除元素的成员和分值的关联。
ZSCORE 遍历压缩列表, 查找包含了给定成员的节点, 然后取出成员节点旁边的分值节点保存的元素分值。 直接从字典中取出给定成员的分值。

Redis 类型检查与命令多态

Redis 中用于操作键的命令基本上可以分为两种类型。其中一种命令可以对任何类型的键执行, 比如说 DEL 命令、 EXPIRE 命令、 RENAME 命令、 TYPE 命令、 OBJECT 命令, 等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 字符串键
redis> SET msg "hello"
OK

# 列表键
redis> RPUSH numbers 1 2 3
(integer) 3

# 集合键
redis> SADD fruits apple banana cherry
(integer) 3

redis> DEL msg
(integer) 1

redis> DEL numbers
(integer) 1

redis> DEL fruits
(integer) 1

而另一种命令只能对特定类型的键执行, 比如说:

  • SET 、 GET 、 APPEND 、 STRLEN 等命令只能对字符串键执行;
  • HDEL 、 HSET 、 HGET 、 HLEN 等命令只能对哈希键执行;
  • RPUSH 、 LPOP 、 LINSERT 、 LLEN 等命令只能对列表键执行;
  • SADD 、 SPOP 、 SINTER 、 SCARD 等命令只能对集合键执行;
  • ZADD 、 ZCARD 、 ZRANK 、 ZSCORE 等命令只能对有序集合键执行;

类型检查的实现

从上面发生类型错误的代码示例可以看出, 为了确保只有指定类型的键可以执行某些特定的命令, 在执行一个类型特定的命令之前, Redis 会先检查输入键的类型是否正确, 然后再决定是否执行给定的命令。

类型特定命令所进行的类型检查是通过 redisObject 结构的 type 属性来实现的:在执行一个类型特定命令之前, 服务器会先检查输入数据库键的值对象是否为执行命令所需的类型, 如果是的话, 服务器就对键执行指定的命令;否则, 服务器将拒绝执行命令, 并向客户端返回一个类型错误。

多态命令的实现

Redis 除了会根据值对象的类型来判断键是否能够执行指定命令之外, 还会根据值对象的编码方式, 选择正确的命令实现代码来执行命令。举个例子, 在前面介绍列表对象的编码时我们说过, 列表对象有 ziplist 和 linkedlist 两种编码可用, 其中前者使用压缩列表 API 来实现列表命令, 而后者则使用双端链表 API 来实现列表命令。

现在, 考虑这样一个情况, 如果我们对一个键执行 LLEN 命令, 那么服务器除了要确保执行命令的是列表键之外, 还需要根据键的值对象所使用的编码来选择正确的 LLEN 命令实现:

  • 如果列表对象的编码为 ziplist , 那么说明列表对象的实现为压缩列表, 程序将使用 ziplistLen 函数来返回列表的长度;
  • 如果列表对象的编码为 linkedlist , 那么说明列表对象的实现为双端链表, 程序将使用 listLength 函数来返回双端链表的长度;

Redis 内存回收

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

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

1
2
3
4
5
6
typedef struct redisObject {
// 引用计数
int refcount;

// ...
} robj;

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

  • 在创建一个新对象时, 引用计数的值会被初始化为 1 ;
  • 当对象被一个新程序使用时, 它的引用计数值会被增一;
  • 当对象不再被一个程序使用时, 它的引用计数值会被减一;
  • 当对象的引用计数值变为 0 时, 对象所占用的内存会被释放。
函数 作用
incrRefCount 将对象的引用计数值增一。
decrRefCount 将对象的引用计数值减一, 当对象的引用计数值等于 0 时, 释放对象。
resetRefCount 将对象的引用计数值设置为 0 , 但并不释放对象, 这个函数通常在需要重新设置对象的引用计数值时使用。

对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段。

作为例子, 以下代码展示了一个字符串对象从创建到释放的整个过程:

1
2
3
4
5
6
7
8
// 创建一个字符串对象 s ,对象的引用计数为 1
robj *s = createStringObject(...)

// 对象 s 执行各种操作 ...

// 将对象 s 的引用计数减一,使得对象的引用计数变为 0
// 导致对象 s 被释放
decrRefCount(s)

其他不同类型的对象也会经历类似的过程。

Redis 对象共享

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

图 8-21 就展示了包含整数值 100 的字符串对象同时被键 A 和键 B 共享之后的样子, 可以看到, 除了对象的引用计数从之前的 1 变成了 2 之外, 其他属性都没有变化。

共享对象机制对于节约内存非常有帮助, 数据库中保存的相同值对象越多, 对象共享机制就能节约越多的内存。

为什么 Redis 不共享包含字符串的对象?验证操作消耗的 CPU 时间会越来越多:

  • 如果共享对象是保存整数值的字符串对象, 那么验证操作的复杂度为 O(1) ;
  • 如果共享对象是保存字符串值的字符串对象, 那么验证操作的复杂度为 O(N) ;

如果共享对象是包含了多个值(或者对象的)对象, 比如列表对象或者哈希对象, 那么验证操作的复杂度将会是 O(N^2) 。
因此, 尽管共享更复杂的对象可以节约更多的内存, 但受到 CPU 时间的限制, Redis 只对包含整数值的字符串对象进行共享。

Redis 对象的空转时长

除了前面介绍过的 type 、 encoding 、 ptr 和 refcount 四个属性之外, redisObject 结构包含的最后一个属性为 lru 属性, 该属性记录了对象最后一次被命令程序访问的时间:

1
2
3
4
5
typedef struct redisObject {
unsigned lru:22;

// ...
} robj;

OBJECT IDLETIME 命令可以打印出给定键的空转时长, 这一空转时长就是通过将当前时间减去键的值对象的 lru 时间计算得出的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

OBJECT IDLETIME 命令的实现是特殊的, 这个命令在访问键的值对象时, 不会修改值对象的 lru 属性。

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

单机数据库的实现

数据库

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

1
2
3
struct redisServer {
redisDb* db;
}

每个Redis客户端都有自己的目标数据库,每当客户端执行数据库读写命令时,目标数据库就会成为这些命令的操作对象。服务器内部客户端状态redisClient结构的db属性记录了客户端当前的目标数据库,这个属性是一个指向redisServer.db数组中的其中一个元素的指针:

1
2
3
typedef struct redisClient {
redisDb* db;
}

如果客户端执行SELECT命令,则会指向不同的数据库,这一操作是通过修改redisClient.db指针实现的。

Redis 数据库键空间

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

1
2
3
4
5
6
typedef struct redisDb {
// ...

// 数据库键空间,保存着数据库中的所有键值对
dict *dict;
} redisDb;

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

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

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

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

那么在这些命令执行之后, 数据库的键空间将会是图 IMAGE_DB_EXAMPLE 所展示的样子:

  • alphabet 是一个列表键, 键的名字是一个包含字符串 “alphabet” 的字符串对象, 键的值则是一个包含三个元素的列表对象。
  • book 是一个哈希表键, 键的名字是一个包含字符串 “book” 的字符串对象, 键的值则是一个包含三个键值对的哈希表对象。
  • message 是一个字符串键, 键的名字是一个包含字符串 “message” 的字符串对象, 键的值则是一个包含字符串 “hello world” 的字符串对象。

添加新键

添加一个新键值对到数据库, 实际上就是将一个新键值对添加到键空间字典里面, 其中键为字符串对象, 而值则为任意一种类型的 Redis 对象。举个例子, 如果键空间当前的状态如图 IMAGE_DB_EXAMPLE 所示, 那么在执行以下命令之后:

1
2
redis> SET date "2013.12.1"
OK

键空间将添加一个新的键值对, 这个新键值对的键是一个包含字符串 “date” 的字符串对象, 而键值对的值则是一个包含字符串 “2013.12.1”的字符串对象, 如图 IMAGE_DB_AFTER_ADD_NEW_KEY 所示。

删除键

删除数据库中的一个键, 实际上就是在键空间里面删除键所对应的键值对对象。举个例子, 如果键空间当前的状态如图 IMAGE_DB_EXAMPLE 所示, 那么在执行以下命令之后:

1
2
redis> DEL book
(integer) 1

键 book 以及它的值将从键空间中被删除, 如图 IMAGE_DB_AFTER_DEL 所示。

更新键

对一个数据库键进行更新, 实际上就是对键空间里面键所对应的值对象进行更新, 根据值对象的类型不同, 更新的具体方法也会有所不同。举个例子, 如果键空间当前的状态如图 IMAGE_DB_EXAMPLE 所示, 那么在执行以下命令之后:

1
2
redis> SET message "blah blah"
OK

键 message 的值对象将从之前包含 “hello world” 字符串更新为包含 “blah blah” 字符串, 如图 IMAGE_DB_UPDATE_CAUSE_SET 所示。

对键取值

对一个数据库键进行取值, 实际上就是在键空间中取出键所对应的值对象, 根据值对象的类型不同, 具体的取值方法也会有所不同。举个例子, 如果键空间当前的状态如图 IMAGE_DB_EXAMPLE 所示, 那么当执行以下命令时:

1
2
redis> GET message
"hello world"

GET 命令将首先在键空间中查找键 message , 找到键之后接着取得该键所对应的字符串对象值, 之后再返回值对象所包含的字符串 “helloworld” , 取值过程如图 IMAGE_FETCH_VALUE_VIA_GET 所示。

其他键空间操作

很多针对数据库本身的 Redis 命令, 也是通过对键空间进行处理来完成的。比如说, 用于清空整个数据库的 FLUSHDB 命令, 就是通过删除键空间中的所有键值对来实现的。用于随机返回数据库中某个键的 RANDOMKEY 命令, 就是通过在键空间中随机返回一个键来实现的。

读写键空间时的维护操作

当使用 Redis 命令对数据库进行读写时, 服务器不仅会对键空间执行指定的读写操作, 还会执行一些额外的维护操作, 其中包括:

  • 在读取一个键之后(读操作和写操作都要对键进行读取), 服务器会根据键是否存在, 以此来更新服务器的键空间命中(hit)次数或键空间不命中(miss)次数,
  • 在读取一个键之后, 服务器会更新键的 LRU (最后一次使用)时间, 这个值可以用于计算键的闲置时间,
  • 如果服务器在读取一个键时, 发现该键已经过期, 那么服务器会先删除这个过期键, 然后才执行余下的其他操作,
  • 如果有客户端使用 WATCH 命令监视了某个键, 那么服务器在对被监视的键进行修改之后, 会将这个键标记为脏(dirty), 从而让事务程序注意到这个键已经被修改过,
  • 服务器每次修改一个键之后, 都会对脏(dirty)键计数器的值增一, 这个计数器会触发服务器的持久化以及复制操作执行,
  • 如果服务器开启了数据库通知功能, 那么在对键进行修改之后, 服务器将按配置发送相应的数据库通知。

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

通过EXPIRE命令或者PEXPIRE命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间TTL,在经过指定时间后,服务器自动删除生存时间为0的键。

1
EXPIRE key 5

客户通过EXPIREAT或者PEXPIREAT以秒或毫秒精度给数据库中某个键设置过期时间(Linux时间戳),当键的过期时间到了,服务器自动从数据库中删除这个键。

Redis有四个不同命令用于设置键的生存时间或过期时间:

  • EXPIRE 将键key的生存时间设置为ttl秒。
  • PEXPIRE 将键key的生存时间设置为ttl毫秒。
  • EXPIREAT 将键key的过期时间设置为timestamp秒时间戳。
  • PEXPIREAT 将键key的过期时间设置为timestamp毫秒时间戳。

在redisDb结构体的expires字典用来保存数据库中所有键的过期时间,叫做过期字典:

  • 过期字典的键是一个指针,指向键空间中的某个键对象,
  • 值是一个long long类型的整数,保存间所指向的数据库键的过期时间(精确的UNIX毫秒时间戳)

图中是一个带有过期字典的数据库例子,键空间保存了数据库中的所有键值对,而过期字典保存了数据库键的过期时间。

PERSIST命令可以移除一个键的过期时间,在过期字典中找到一个给定的键,并解除键和值在过期字典中的关联。

TTL命令以秒为单位饭会键的剩余生存时间,而PTTL命令则以毫秒为单位饭会键的剩余生存时间。

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

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

过期键删除策略

如果一个键被删除了,什么时候会被删除:

  • 定时删除:设置过期时间同时,创建一个定时器,让定时器在键的过期时间到来时,立即执行对键的删除操作。
    • 优点:对内存友好,保证过期键尽可能快的删除,并释放过期键所占用的内存。
    • 缺点:对cpu时间是最不友好的,若过期键过多,会占用相当多cpu时间。
  • 惰性删除(被动删除):放任过期键不管,当获取键时expireIfNeeded函数检查是否过期,过期就删除该键。
    • 优点:对cpu时间友好,只会操作键做过期检查,而不会删除其他无关过期键
    • 缺点:对内存不友好,过期键占用内存不释放,如果一个键再也不被访问到,那就造成内存一直占用,类似内存泄漏
  • 定期删除:每过一段时间,程序对数据库检查,然后删除过期键。
    • 如果删除操作执行得太频繁,或者执行时间太长,定期删除策略就会退化成定时删除策略,CPU消耗在删除过期键上
    • 如果删除操作执行太少,则退化为惰性删除

Redis的过期键删除策略

过期键的惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令执行前都会调用expireIfNeeded函数对输入键进行检查,expireIfNeeded像是一个过滤器,它可以在命令真正执行前过滤掉过期的输入键。每个被访问的键都可能因为过期而被expireIfNeeded函数删除,所以每个命令的实现函数都必须同时能处理键存在或不存在的情况。

过期键的定期删除由redis.c/activeExpireCycle实现,它在规定的时间内分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键。

AOF、RDB和复制功能对过期键的处理

生成RDB文件

在执行SAVE或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新建的RDB文件中。

载入RDB文件

在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入:

  • 如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检查,未过期的键会被载入到数据库中,而过期键则会被忽略,所以过期键对载入RDB文件的主服务器不会造成影响。
  • 如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否过期,都会被载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响。

AOF文件写入

当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加一条DEL命令,来显式地记录该键已被删除。

举个例子,如果客户端使用GETmessage命令,试图访问过期的message键,那么服务器将执行以下三个动作:

  1. 从数据库中删除message键
  2. 追加一条DELmessage命令到AOF文件
  3. 向执行GE丆命令的客户端返回空回复。

AOF重写

和生成RDB文件时类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。

复制

服务器运行在复制模式下,从服务器的过期键删除动作由主服务器控制:

  • 主服务器在删除一个过期键之后,会显式地向所有从服务器发送一个DEL命令,告知从服务器删除这个过期键。
  • 从服务器在执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键。
  • 从服务器只有在接到主服务器发来的DEL命令之后,才会删除过期键

通过由主服务器来控制从服务器统一地删除过期键,可以保证主从服务器数据的一致性,也正是因为这个原因,当一个过期键仍然存在于主服务器的数据库时,这个过期键在从服务器里的复制品也会继续存在。

数据库通知

让客户通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况,有两类:

  • 一类关注某个键执行了什么命令的通知称为键空间通知
  • 一类关注某个命令被什么健执行了,称为键事件通知

发送通知

发送数据库通知的功能由notify.c/notifyKeyspaceEvent函数实现:

1
void notifyKeyspaceEvent(int type, char* event, robj* key, int dbid);

type参数是当前想要发送的通知类型,其余三个是事件的名称、产生事件的键,以及产生事件的数据库号码。

流程:

  1. server.notify_keyspace_events属性就是服务器配置notify—keyspace—events选项所设置的值,如果给定的通知类型type不是服务器允许发送的通知类型,那么函数会直接返回,不做任何动作。
  2. 如果给定的通知是服务器允许发送的通知,那么下一步函数会检测服务器是否允许发送键空间通知,如果允许的话,程序就会构建并发送事件通知。
  3. 最后,函数检测服务器是否允许发送键事件通知,如果允许的话,程序就会构建并发送事件通知。

重点回顾

内容较多,回顾一下:

  • 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持久化

Redis是一个键值对数据库服务器,服务器中通常包含任意个非空数据库,而每个非空数据库中又可以包含任意个键值对,服务器中的非空数据库以及它们的键值对统称为数据库状态

Redis提供了RDB持久化功能,这个功能可以将Redis在内存中的数据库状态保存到磁盘中,避免数据意外丢失,产生的RDB文件是一个经过压缩的二进制文件,该文件可以还原生成RDB文件时的数据库状态。

RDB文件的创建与载入

两个Redis命令可以用于生成RDB文件,一个是SAVE,一个是BGSAVE。SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止才重新处理命令请求。BGSAVE则会派生一个子进程,然后由子进程负责创建RDB文件,服务器进程可以继续处理命令请求,但是不能再处理一个SAVE和BGSAVE命令。

RDB文件的载入是在服务器启动时自动执行的,且会阻塞进程,所以RDB没有专门用于载入RDB文件的命令,如果Redis在启动时检测到了RDB文件存在,则会自动载入。

AOF文件的更新频率比RDB文件高,所以:

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

自动间隔性保存

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

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

表示,在900秒内进行了至少一次修改,在300秒内至少10次修改,或在60秒内至少10000次修改

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

1
2
3
struct redisServer {
struct saveparam *saveparams;
}

saveparams属性是一个数组,每个saveparam结构都保存了一个save选项设置的保存条件。

服务器状态还维持着一个dirty计数器,以及一个lastsave属性:

  • dirty计数器记录距离上一次成功执行SAVE命令后,服务器对服务器状态进行了多少次修改。
  • lastsave是一个UNIX时间戳,记录上一次成功执行SAVE命令时的时间。

Redis的服务器周期性操作函数serverCron默认每隔100毫秒会执行一次,用于对正在运行的服务器进行维护,其中一项工作就是遍历saveparams数组中的所有保存条件,检查保存条件是否满足,如果满足则执行BGSAVE命令。

RDB文件结构

RDB 文件的最开头是 REDIS 部分, 这个部分的长度为 5 字节, 保存着 “REDIS” 五个字符。 通过这五个字符, 程序可以在载入文件时, 快速检查所载入的文件是否 RDB 文件。

db_version 长度为 4 字节, 它的值是一个字符串表示的整数, 这个整数记录了 RDB 文件的版本号, 比如 “0006” 就代表 RDB 文件的版本为第六版。

databases 部分包含着零个或任意多个数据库, 以及各个数据库中的键值对数据:

  • 如果服务器的数据库状态为空(所有数据库都是空的), 那么这个部分也为空, 长度为 0 字节。
  • 如果服务器的数据库状态为非空(有至少一个数据库非空), 那么这个部分也为非空, 根据数据库所保存键值对的数量、类型和内容不同, 这个部分的长度也会有所不同。

EOF 常量的长度为 1 字节, 这个常量标志着 RDB 文件正文内容的结束, 当读入程序遇到这个值的时候, 它知道所有数据库的所有键值对都已经载入完毕了。

check_sum 是一个 8 字节长的无符号整数, 保存着一个校验和, 这个校验和是程序通过对 REDIS 、 db_version 、 databases 、 EOF 四个部分的内容进行计算得出的。 服务器在载入 RDB 文件时, 会将载入数据所计算出的校验和与 check_sum 所记录的校验和进行对比, 以此来检查 RDB 文件是否有出错或者损坏的情况出现。

databases 部分

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

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

每个非空数据库在 RDB 文件中都可以保存为 SELECTDB 、 db_number 、 key_value_pairs 三个部分, 如图 IMAGE_DATABASE_STRUCT_OF_RDB 所示。

SELECTDB 常量的长度为 1 字节, 当读入程序遇到这个值的时候, 它知道接下来要读入的将是一个数据库号码。

db_number 保存着一个数据库号码, 根据号码的大小不同, 这个部分的长度可以是 1 字节、 2 字节或者 5 字节。 当程序读入 db_number 部分之后, 服务器会调用 SELECT 命令, 根据读入的数据库号码进行数据库切换, 使得之后读入的键值对可以载入到正确的数据库中。

key_value_pairs 部分保存了数据库中的所有键值对数据, 如果键值对带有过期时间, 那么过期时间也会和键值对保存在一起。 根据键值对的数量、类型、内容、以及是否有过期时间等条件的不同, key_value_pairs 部分的长度也会有所不同。

key_value_pairs 部分

RDB 文件中的每个 key_value_pairs 部分都保存了一个或以上数量的键值对, 如果键值对带有过期时间的话, 那么键值对的过期时间也会被保存在内。不带过期时间的键值对在 RDB 文件中对由 TYPE 、 key 、 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
    以上列出的每个 TYPE 常量都代表了一种对象类型或者底层编码, 当服务器读入 RDB 文件中的键值 对数据时, 程序会根据 TYPE 的值来决定如何读入和解释 value 的数据。

key 和 value 分别保存了键值对的键对象和值对象:

  • key 总是一个字符串对象, 它的编码方式和 REDIS_RDB_TYPE_STRING 类型的 value 一样。 根据内容长度的不同, key 的长度也会有所不同。
  • 根据 TYPE 类型的不同, 以及保存内容长度的不同, 保存 value 的结构和长度也会有所不同, 本节稍后会详细说明每种 TYPE 类型的value 结构保存方式。

带有过期时间的键值对在 RDB 文件中对由EXPIRETIME_MS、ms、 TYPE 、 key 、 value 五部分组成。

  • TYPE 、 key 、 value 三个部分的意义完全相同。
  • EXPIRETIME_MS 常量的长度为 1 字节, 它告知读入程序, 接下来要读入的将是一个以毫秒为单位的过期时间。
  • ms 是一个 8 字节长的带符号整数, 记录着一个以毫秒为单位的 UNIX 时间戳, 这个时间戳就是键值对的过期时间。

value的编码

RDB 文件中的每个 value 部分都保存了一个值对象, 每个值对象的类型都由与之对应的 TYPE 记录, 根据类型的不同, value 部分的结构、长度也会有所不同。

字符串对象

如果 TYPE 的值为 REDIS_RDB_TYPE_STRING , 那么 value 保存的就是一个字符串对象, 字符串对象的编码可以是 REDIS_ENCODING_INT 或者REDIS_ENCODING_RAW 。如果字符串对象的编码为 REDIS_ENCODING_INT , 那么说明对象中保存的是长度不超过 32 位的整数, 这种编码的对象将以ENCODING integer所示的结构保存,其中, ENCODING 的值可以是 REDIS_RDB_ENC_INT8 、 REDIS_RDB_ENC_INT16 或者 REDIS_RDB_ENC_INT32 三个常量的其中一个, 它们分别代表 RDB 文件使用 8 位(bit)、 16 位或者 32 位来保存整数值 integer。

如果字符串对象的编码为 REDIS_ENCODING_RAW , 那么说明对象所保存的是一个字符串值, 根据字符串长度的不同, 有压缩和不压缩两种方法来保存这个字符串:

  • 如果字符串的长度小于等于 20 字节, 那么这个字符串会直接被原样保存len string
  • 如果字符串的长度大于 20 字节, 那么这个字符串会被压缩之后再保存REDIS_RDB_ENC_LZF compressed_len origin_len compressed_string。REDIS_RDB_ENC_LZF 常量标志着字符串已经被 LZF 算法(http://liblzf.plan9.de)压缩过了, 读入程序在碰到这个常量时, 会对字符串进行解压缩: 其中 compressed_len 记录的是字符串被压缩之后的长度, 而 origin_len 记录的是字符串原来的长度, compressed_string 记录的则是被压缩之后的字符串。
列表对象

如果 TYPE 的值为 REDIS_RDB_TYPE_LIST , 那么 value 保存的就是一个 REDIS_ENCODING_LINKEDLIST 编码的列表对象, RDB 文件保存这种对象的结构如list_length item1 item2 ... itemN所示。list_length 记录了列表的长度, 它记录列表保存了多少个项(item)。以 item 开头的部分代表列表的项,程序会以处理字符串对象的方式来保存和读入列表项。

集合对象

如果 TYPE 的值为 REDIS_RDB_TYPE_SET , 那么 value 保存的就是一个 REDIS_ENCODING_HT 编码的集合对象, RDB 文件保存这种对象的结构如set_size elem1 elem2 ... elemN所示。set_size 是集合的大小, 它记录集合保存了多少个元素。图中以 elem 开头的部分代表集合的元素,程序会以处理字符串对象的方式来保存和读入集合元素。

哈希表对象

如果 TYPE 的值为 REDIS_RDB_TYPE_HASH , 那么 value 保存的就是一个 REDIS_ENCODING_HT 编码的集合对象, RDB 文件保存这种对象的结构如 hash_size key_value_pair1 key_value_pair2 ... key_value_pairN 所示。hash_size 记录了哈希表保存了多少键值对,以 key_value_pair 开头的部分代表哈希表中的键值对, 结构中的每个键值对都以键紧挨着值的方式排列在一起,键值对的键和值都是字符串对象, 所以程序会以处理字符串对象的方式来保存和读入键值对。

有序集合对象

如果 TYPE 的值为 REDIS_RDB_TYPE_ZSET , 那么 value 保存的就是一个 REDIS_ENCODING_SKIPLIST 编码的有序集合对象, RDB 文件保存这种对象的结构如sorted_set_size elem1 elem2 ... elemN所示。

sorted_set_size 记录了有序集合的大小, 也即是这个有序集合保存了多少元素。以 element 开头的部分代表有序集合中的元素, 每个元素又分为成员(member)和分值(score)两部分, 成员是一个字符串对象, 分值则是一个 double 类型的浮点数, 程序在保存 RDB 文件时会先将分值转换成字符串对象, 然后再用保存字符串对象的方法将分值保存起来。

INTSET 编码的集合

如果 TYPE 的值为 REDIS_RDB_TYPE_SET_INTSET , 那么 value 保存的就是一个整数集合对象, RDB 文件保存这种对象的方法是, 先将整数集合转换为字符串对象, 然后将这个字符串对象保存到 RDB 文件里面。

ZIPLIST 编码的列表、哈希表或者有序集合

如果 TYPE 的值为 REDIS_RDB_TYPE_LIST_ZIPLIST 、 REDIS_RDB_TYPE_HASH_ZIPLIST 或者 REDIS_RDB_TYPE_ZSET_ZIPLIST , 那么 value 保存的就是一个压缩列表对象, RDB 文件保存这种对象的方法是:

  • 将压缩列表转换成一个字符串对象。
  • 将转换所得的字符串对象保存到 RDB 文件。

如果程序在读入 RDB 文件的过程中, 碰到由压缩列表对象转换成的字符串对象, 那么程序会根据 TYPE 值的指示, 执行以下操作:

  • 读入字符串对象,并将它转换成原来的压缩列表对象。
  • 根据 TYPE 的值,设置压缩列表对象的类型: REDIS_RDB_TYPE_LIST_ZIPLIST 对应列表;REDIS_RDB_TYPE_HASH_ZIPLIST 对应哈希表; REDIS_RDB_TYPE_ZSET_ZIPLIST 对应有序集合。

分析RDB文件

使用od命令分析RDB文件。

不包含任何键值对的RDB文件

1
2
3
4
5
6
7
8
9
10
redis> FLUSHALL
OK

redis> SAVE
OK

$od -c dump.rdb
0000000 R E D I S 0 0 0 6 377 334 263 C 360 Z 334
0000020 362 V
0000022

这个RDB文件由一下四个部分组成:

  • 五个字节的”REDIS”字符串
  • 四个字节的版本号(db_version)0006
  • 一个字节的EOF常量(377)
  • 八个字节的校验和(377 334 263 C 360 Z 334 362 V)

包含字符串键的RDB文件

1
2
3
4
5
6
7
8
9
10
11
12
redis> FLUSHALL
OK

redis> SET MSG "HELLO"

redis> SAVE
OK

$od -c dump.rdb
0000000 R E D I S 0 0 0 6 376 \0 \0 003 M S G
0000020 005 H E L L O 377 207 z = 304 f T L 343
0000037

当一个数据库被保存到RDB文件时,这个数据块由一下三部分组成:

  • 一个一字节长度的特殊值SELECTDB(376)
  • 一个数据库号码(\0,0号数据库)
  • 一个或以上数量的键值对,在这里是(\0 003 M S G 005 H E L L O),\0是字符串类型的TYPE值REDIS_RDB_TYPE_STRING,003是键长度,005是值长度。

AOF持久化

AOF(Append Only File)通过保存Redis服务器所执行的写命令来记录数据库状态,被写入AOF文件的所有命令都是以Redis的命令请求协议格式保存的,因为Redis的命令请求协议是纯文本格式,所以可以直接打开一个AOF文件。

Redis AOF持久化的实现

AOF 持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。

命令追加

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

1
2
3
4
5
6
struct redisServer {
// AOF 缓冲区
sds aof_buf;

// ...
};

举个例子, 如果客户端向服务器发送以下命令:
1
2
redis> SET KEY VALUE
OK

那么服务器在执行这个 SET 命令之后, 会将以下协议内容追加到 aof_buf 缓冲区的末尾:
1
*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n

AOF 文件的写入与同步

Redis 的服务器进程就是一个事件循环(loop), 这个循环中的文件事件负责接收客户端的命令请求, 以及向客户端发送命令回复, 而时间事件则负责执行像 serverCron 函数这样需要定时运行的函数。

在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile 函数, 考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面, 这个过程可以用以下伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
def eventLoop():

while True:

# 处理文件事件,接收命令请求以及发送命令回复
# 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
processFileEvents()

# 处理时间事件
processTimeEvents()

# 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
flushAppendOnlyFile()

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

appendfsync 选项的值 flushAppendOnlyFile 函数的行为
always 将 aof_buf 缓冲区中的所有内容写入并同步到 AOF 文件。
everysec 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 如果上次同步 AOF 文件的时间距离现在超过一秒钟, 那么再次对 AOF 文件进行同步, 并且这个同步操作是由一个线程专门负责执行的。
no 将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 但并不对 AOF 文件进行同步, 何时同步由操作系统来决定。

如果用户没有主动为 appendfsync 选项设置值, 那么 appendfsync 选项的默认值为 everysec

举个例子, 假设服务器在处理文件事件期间, 执行了以下三个写入命令:

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

那么 aof_buf 缓冲区将包含这三个命令的协议内容:
1
2
3
*5\r\n$4\r\nSADD\r\n$9\r\ndatabases\r\n$5\r\nRedis\r\n$7\r\nMongoDB\r\n$7\r\nMariaDB\r\n
*3\r\n$3\r\nSET\r\n$4\r\ndate\r\n$8\r\n2013-9-5\r\n
*3\r\n$4\r\nINCR\r\n$13\r\nclick_counter\r\n$5\r\n10086\r\n

如果这时 flushAppendOnlyFile 函数被调用, 假设服务器当前 appendfsync 选项的值为 everysec , 并且根据 server.aof_last_fsync 属性显示, 距离上次同步 AOF 文件已经超过一秒钟, 那么服务器会先将 aof_buf 中的内容写入到 AOF 文件中, 然后再对 AOF 文件进行同步。

AOF 持久化的效率和安全性

服务器配置 appendfsync 选项的值直接决定 AOF 持久化功能的效率和安全性。

  • always 的效率是最慢的一个, 但lways 也是最安全的, 因为即使出现故障停机, AOF 持久化也只会丢失一个事件循环中所产生的命令数据。
  • 当 appendfsync 的值为 everysec 时,每隔超过一秒就要在子线程中对 AOF 文件进行一次同步: 从效率上来讲, everysec 模式足够快, 并且就算出现故障停机, 数据库也只丢失一秒钟的命令数据。
  • 当 appendfsync 的值为 no 时, 何时对 AOF 文件进行同步, 由操作系统控制。

AOF文件的载入与数据还原

服务器只要读取并重新执行一遍AOF文件里边保存的写命令就可以还原服务器关闭之前的数据库状态。步骤如下:

  1. 创建一个不带网络连接的伪客户端,因为Redis的命令只能在客户端上下文中执行,而载入AOF文件时没有客户端上下文;
  2. 从AOF文件中读取写命令;
  3. 使用伪客户端执行写命令;
  4. 直到AOF文件中的所有写命令被读取完。

AOF重写

AOF文件的体积不能无限增大,Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,但新AOF文件不会包含任何浪费空间的冗余命令,如对一条记录的重复修改。

AOF文件重写的实现

这个功能通过读取服务器当前数据库状态实现的。

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
def AOF_REWRITE(tmp_tile_name):

f = create(tmp_tile_name)

# 遍历所有数据库
for db in redisServer.db:

# 如果数据库为空,那么跳过这个数据库
if db.is_empty(): continue

# 写入 SELECT 命令,用于切换数据库
f.write_command("SELECT " + db.number)

# 遍历所有键
for key in db:
# 如果键带有过期时间,并且已经过期,那么跳过这个键
if key.have_expire_time() and key.is_expired(): continue
if key.type == String:
# 用 SET key value 命令来保存字符串键
value = get_value_from_string(key)
f.write_command("SET " + key + value)
elif key.type == List:
# 用 RPUSH key item1 item2 ... itemN 命令来保存列表键
item1, item2, ..., itemN = get_item_from_list(key)
f.write_command("RPUSH " + key + item1 + item2 + ... + itemN)
elif key.type == Set:
# 用 SADD key member1 member2 ... memberN 命令来保存集合键
member1, member2, ..., memberN = get_member_from_set(key)
f.write_command("SADD " + key + member1 + member2 + ... + memberN)
elif key.type == Hash:
# 用 HMSET key field1 value1 field2 value2 ... fieldN valueN 命令来保存哈希键
field1, value1, field2, value2, ..., fieldN, valueN =\
get_field_and_value_from_hash(key)
f.write_command("HMSET " + key + field1 + value1 + field2 + value2 +\
... + fieldN + valueN)
elif key.type == SortedSet:
# 用 ZADD key score1 member1 score2 member2 ... scoreN memberN
# 命令来保存有序集键
score1, member1, score2, member2, ..., scoreN, memberN = \
get_score_and_member_from_sorted_set(key)
f.write_command("ZADD " + key + score1 + member1 + score2 + member2 +\
... + scoreN + memberN)
else:
raise_type_error()
# 如果键带有过期时间,那么用 EXPIREAT key time 命令来保存键的过期时间
if key.have_expire_time():
f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp())
# 关闭文件
f.close()

因为新AOF文件只包含还原当前数据库状态所必须的命令,因此新AOF文件不会浪费硬盘空间。

AOF后台重写

调用aof_rewrite函数可以很好地完成创建一个新AOF文件的任务,但是因为这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,所以需要将AOF重写程序放到子进程里实现。

使用子进程也要注意新的命令可能会对现有的数据库状态进行修改从而使得服务器当前的数据库状态和重写后的AOF文件所保存的状态不一致。为了解决不一致,Redis设置了一个AOF重写缓冲区,当Redis服务器执行完一个写命令后,会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,这样可以保证:

  • AOF缓冲区的内容会定期被写入和同步到AOF文件中,对现有的AOF文件的处理工作会正常进行;
  • 从创建子进程开始,服务器执行的所有写操作都会被记录到AOF重写缓冲区中;

当子进程完成对AOF文件重写之后,它会向父进程发送一个完成信号,父进程接到该完成信号之后,会调用一个信号处理函数,该函数完成以下工作:

  • 将AOF重写缓存中的内容全部写入到新的AOF文件中;这个时候新的AOF文件所保存的数据库状态和服务器当前的数据库状态一致;
  • 对新的AOF文件进行改名,原子地(atomic)覆盖原有的AOF文件;完成新旧两个AOF文件的替换。

当这个信号处理函数执行完毕之后,主进程就可以继续像往常一样接收命令请求了。在整个AOF后台重写过程中,只有最后的“主进程写入命令到AOF缓存”和“对新的AOF文件进行改名,覆盖原有的AOF文件”这两个步骤(信号处理函数执行期间)会造成主进程阻塞,在其他时候,AOF后台重写都不会对主进程造成阻塞,这将AOF重写对性能造成的影响降到最低。

触发AOF后台重写的条件

AOF重写可以由用户通过调用BGREWRITEAOF手动触发。
服务器在AOF功能开启的情况下,会维持以下三个变量:

  • 记录当前AOF文件大小的变量aof_current_size。
  • 记录最后一次AOF重写之后,AOF文件大小的变量aof_rewrite_base_size。
  • 增长百分比变量aof_rewrite_perc。

每次当serverCron(服务器周期性操作函数)函数执行时,它会检查以下条件是否全部满足,如果全部满足的话,就触发自动的AOF重写操作:

  • 没有BGSAVE命令(RDB持久化)/AOF持久化在执行;
  • 没有BGREWRITEAOF在进行;
  • 当前AOF文件大小要大于server.aof_rewrite_min_size(默认为1MB),或者在redis.conf配置了auto-aof-rewrite-min-size大小;
  • 当前AOF文件大小和最后一次重写后的大小之间的比率等于或者等于指定的增长百分比(在配置文件设置了auto-aof-rewrite-percentage参数,不设置默认为100%)

如果前面三个条件都满足,并且当前AOF文件大小比最后一次AOF重写时的大小要大于指定的百分比,那么触发自动AOF重写。

重点回顾

  • AOF重写的目的是为了解决AOF文件体积膨胀的问题,使用更小的体积来保存数据库状态,整个重写过程基本上不影响Redis主进程处理命令请求;
  • AOF重写其实是一个有歧义的名字,实际上重写工作是针对数据库的当前状态来进行的,重写过程中不会读写、也不适用原来的AOF文件;
  • AOF可以由用户手动触发,也可以由服务器自动触发。

事件

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

  • 文件事件:Redis服务器通过套接字与客户端进行连接,而文件事件就是服务器对套接字操作的抽象。服务器与客户端的通信产生相应文件事件,而服务器通过监听并处理这些事件来完成一系列网络通信。
  • 时间事件:Redis服务器中的一些操作需要在给定时间点执行。

文件事件

Redis 基于 Reactor 模式开发了自己的网络事件处理器: 这个处理器被称为文件事件处理器(file event handler)。文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字, 并根据套接字目前执行的任务来为套接字关联不同的事件处理器。当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

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

文件事件处理器的构成

下图展示了文件事件处理器的四个组成部分, 它们分别是套接字、 I/O 多路复用程序、 文件事件分派器(dispatcher)、 以及事件处理器。

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

尽管多个文件事件可能会并发地出现, 但 I/O 多路复用程序通过一个队列有序同步、每次一个套接字的方式向文件事件分派器传送套接字: 当上一个套接字产生的事件被处理完毕之后, I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字。文件事件分派器接收 I/O 多路复用程序传来的套接字, 并根据套接字产生的事件的类型, 调用相应的事件处理器。

I/O 多路复用程序的实现

Redis 的 I/O 多路复用程序的所有功能都是通过包装常见的 selectepollevportkqueue 这些 I/O 多路复用函数库来实现的。因为 Redis 为每个 I/O 多路复用函数库都实现了相同的 API , 所以 I/O 多路复用程序的底层实现是可以互换的

事件的类型

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

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

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

API

如下:

  • ae.c/aeCreateFileEvent 函数将给定套接字的给定事件加入到 I/O 多路复用程序的监听范围之内, 并对事件和事件处理器进行关联。
  • ae.c/aeDeleteFileEvent 函数让 I/O 多路复用程序取消对给定套接字的给定事件的监听, 并取消事件和事件处理器之间的关联。
  • ae.c/aeGetFileEvents 函数返回该套接字正在被监听的事件类型:
    • 如果套接字没有任何事件被监听, 那么函数返回 AE_NONE 。
    • 如果套接字的读事件正在被监听, 那么函数返回 AE_READABLE 。
    • 如果套接字的写事件正在被监听, 那么函数返回 AE_WRITABLE 。
    • 如果套接字的读事件和写事件正在被监听, 那么函数返回 AE_READABLE | AE_WRITABLE 。
  • ae.c/aeWait 函数在给定的时间内阻塞并等待套接字的给定类型事件产生, 当事件成功产生, 或者等待超时之后, 函数返回。
  • ae.c/aeApiPoll 函数在指定的时间內, 阻塞并等待所有被 aeCreateFileEvent 函数设置为监听状态的套接字产生文件事件, 当有至少一个事件产生, 或者等待超时后, 函数返回。
  • ae.c/aeProcessEvents 函数是文件事件分派器, 它先调用 aeApiPoll 函数来等待事件产生, 然后遍历所有已产生的事件, 并调用相应的事件处理器来处理这些事件。
  • ae.c/aeGetApiName 函数返回 I/O 多路复用程序底层所使用的 I/O 多路复用函数库的名称: 返回 “epoll” 表示底层为 epoll 函数库, 返回”select” 表示底层为 select 函数库, 诸如此类。

文件事件的处理器

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

  • 为了对连接服务器的各个客户端进行应答, 服务器要为监听套接字关联连接应答处理器。程序会将这个连接应答处理器和服务器监听套接字的 AE_READABLE 事件关联起来, 当有客户端用sys/socket.h/connect 函数连接服务器监听套接字的时候, 套接字就会产生 AE_READABLE 事件, 引发连接应答处理器执行。
  • 为了接收客户端传来的命令请求, 服务器要为客户端套接字关联命令请求处理器。当一个客户端通过连接应答处理器成功连接到服务器之后, 服务器会将客户端套接字的 AE_READABLE 事件和命令请求处理器关联起来, 当客户端向服务器发送命令请求的时候, 套接字就会产生 AE_READABLE 事件, 引发命令请求处理器执行。
  • 为了向客户端返回命令的执行结果, 服务器要为客户端套接字关联命令回复处理器。当服务器有命令回复需要传送给客户端的时候, 服务器会将客户端套接字的 AE_WRITABLE 事件和命令回复处理器关联起来, 当客户端准备好接收服务器传回的命令回复时, 就会产生 AE_WRITABLE 事件, 引发命令回复处理器执行。
  • 当主服务器和从服务器进行复制操作时, 主从服务器都需要关联特别为复制功能编写的复制处理器。

一次完整的客户端与服务器连接事件示例

一次 Redis 客户端与服务器进行连接并发送命令的整个过程:

  • 假设一个 Redis 服务器正在运作, 那么这个服务器的监听套接字的 AE_READABLE 事件应该正处于监听状态之下, 而该事件所对应的处理器为连接应答处理器。
  • 如果这时有一个 Redis 客户端向服务器发起连接, 那么监听套接字将产生 AE_READABLE事件, 触发连接应答处理器执行: 处理器会对客户端的连接请求进行应答, 然后创建客户端套接字, 以及客户端状态, 并将客户端套接字的 AE_READABLE 事件与命令请求处理器进行关联, 使得客户端可以向主服务器发送命令请求。
  • 之后, 假设客户端向主服务器发送一个命令请求, 那么客户端套接字将产生 AE_READABLE 事件, 引发命令请求处理器执行, 处理器读取客户端的命令内容, 然后传给相关程序去执行。
  • 执行命令将产生相应的命令回复, 为了将这些命令回复传送回客户端, 服务器会将客户端套接字的 AE_WRITABLE 事件与命令回复处理器进行关联: 当客户端尝试读取命令回复的时候, 客户端套接字将产生 AE_WRITABLE 事件, 触发命令回复处理器执行, 当命令回复处理器将命令回复全部写入到套接字之后, 服务器就会解除客户端套接字的 AE_WRITABLE 事件与命令回复处理器之间的关联。

时间事件

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

  1. 定时事件:程序在指定的时间之后执行一次。
  2. 周期性事件:程序每隔指定时间执行一次。

时间事件的属性:

  1. id:服务器为时间事件创建的全局唯一ID,ID号从小到大递增。
  2. when:毫秒精度的unix时间戳,记录时间事件的到达时间。
  3. timeProc:时间事件处理器,一个函数。当时间事件到达事,执行此函数。

时间事件的返回值决定了时间事件类型:如返回ae.h/AE_NOMORE,表示事件为定时事件,到达一次后则删除;如返回一个非AE_NOMORE的整数,表示事件为周期性事件,当事件到达之后,服务器会根据返回值更新时间事件的when属性,并以这种方式一直更新下去。当前Redis版本中只有周期性事件,没有使用定时事件。

实现

服务器将所有时间事件都存放在一个无序链表中,每当时间事件执行器执行时,它就遍历整个链表,找到所有已到达的时间事件并调用相应事件处理器。这里的的无序链表,指的是不按when属性大小排序,其实是按ID排序了,新的时间事件总是插入链表的表头。当前Redis版本中,服务器只使用serverCron一个时间事件,而在benchmark模式下,服务器也只使用2个时间事件,所以使用无序链表来保存时间事情,并不影响性能。

API

如下:

  • ae.c/aeCreateTimeEvent函数:接受一个毫秒数milliseconds和一个时间事件处理器proc作为参数,将一个新的时间事件添加到服务器,这个新的时间事件将在当前时间的milliseconds毫 秒之后到达,而事件的处理器为proc。
  • ae.c/aeDeleteFileEvent函数:数接受一个时间事件ID作为参数,然后从服务器中删除该ID所对应的时间事件。
  • ae.c/aeSearchNearestTimer函数:返回到达时间距离当前时间最接近的那个时间事件。
  • ae.c/processTimeEvents函数是时间事件的执行器,这个函数会遍历所有已到达的时间事件,并调用这些事件的处理器。已到达指的是,时间事件的when属性记录的UNIX时间戳等于或小于当前时间的UNIX时间戳。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def processTimeEvents():
# 遍历服务器中的所有时间事件
for time_event in all_time_event():
# 检查事件是否已经到达
if time_event.when <= unix_ts_now():
# 事件已到达
# 执行事件处理器,并获取返回值
retval = time_event.timeProc()

# 如果这是一个定时事件
if retval == AE_NOMORE:
# 那么将该事件从服务器中删除
delete_time_event_from_server(time_event)
# 如果这是一个周期性事件
else:
# 那么按照事件处理器的返回值更新时间事件的 when 属性
# 让这个事件在指定的时间之后再次到达
update_when(time_event, retval)

时间事件应用实例:serverCron函数

持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:

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

事件调度与执行

因为服务器中同时存在文件事件和时间事件两种事件类型,所以服务器必须对这两种事件进行调度。事件的调度和执行由ae.c/aeProcessEvents函数负责,以下是该函数的伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def aeProcessEvents():
# 获取到达时间离当前时间最接近的时间事件
time_event = aeSearchNearestTimer()

# 计算最接近的时间事件距离到达还有多少毫秒
remaind_ms = time_event.when - unix_ts_now()

# 如果事件已到达,那么remaind_ms 的值可能为负数,将它设定为0
if remaind_ms < 0:
remaind_ms = 0

# 根据remaind_ms 的值,创建timeval 结构
timeval = create_timeval_with_ms(remaind_ms)

# 阻塞并等待文件事件产生,最大阻塞时间由传入的timeval 结构决定
# 如果remaind_ms 的值为0 ,那么aeApiPoll 调用之后马上返回,不阻塞
aeApiPoll(timeval)

# 处理所有已产生的文件事件
processFileEvents()

# 处理所有已到达的时间事件
processTimeEvents()

将aeProcessEvents函数置于一个循环里面,加上初始化和清理函数,这就构成了Redis服务器的主函数,以下是该函数的伪代码表示:

1
2
3
4
5
6
7
8
9
10
def main():
# 初始化服务器
init_server()

# 一直处理事件,直到服务器关闭为止
while server_is_not_shutdown():
aeProcessEvents()

# 服务器关闭,执行清理操作
clean_server()

事件的调度和执行规则:

  • aeApiPoll函数的最大阻塞时间由到达时间最接近当前时间的时间事件决定,这个方法既可以避免服务器对时间事件进行频繁的轮询(忙等待),也可以确保aeApiPoll函数不会阻塞过长时间。
  • 因为文件事件是随机出现的,如果等待并处理完一次文件事件之后,仍未有任何时间事件到达,那么服务器将再次等待并处理文件事件。随着文件事件的不断执行,时间会逐 渐向时间事件所设置的到达时间逼近,并最终来到到达时间,这时服务器就可以开始处理到 达的时间事件了。
  • 对文件事件和时间事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占,因此,不管是文件事件的处理器,还是时间事件的处理器,它们都会尽可地减少程序的阻塞时间,并在有需要时主动让出执行权,从而降低造成事件饥饿的可能性。
  • 因为时间事件在文件事件之后执行,并且事件之间不会出现抢占,所以时间事件的实际处理时间,通常会比时间事件设定的到达时间稍晚一些。

客户端

Redis服务器是典型的一对多服务器程序:一个服务器可以与多个客户端建立网络连接, 每个客户端可以向服务器发送命令请求,而服务器则接收并处理客户端发送的命令请求,并向客户端返回命令回复。
Redis服务器通过使用由I/O多路复用技术实现的文件事件处理器,Redis服务器使用单线程单进程的方式来处理命令请求,并与多个客户端进行网络通信。对每个客户端都会建立redisClient结构。Redis服务器状态结构的clients属性是一个链表,这个链表保存了所有与服务器连接的客户端的状态结构,对客户端执行批量操作,或者查找某个指定的客户端,都可以通过遍历clients链表来完成。

1
2
3
4
5
struct redisServer {
// ...
list *clients;// 一个链表,保存了所有客户端状态
// ...
};

客户端属性

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

  • 客户端的套接字描述符fd,记录了客户端正在使用的套接字描述符。如果是伪客户端,则为-1,否则为大于-1的整数。
  • 客户端的名字,默认时没有名字,使用CLIENT setname命令可以为客户端设置一个名字。
  • 客户端的标志值(flag)记录了客户端的角色,以及客户端目前的状态。flags属性的值可以是单个标志,也可以是多个标志的二进制或。
    • 一部分标志记录了客户端的角色:
      • REDIS_MASTER标志表示客户端代表的是一个主服务器, REDIS_SLAVE标志表示客户端代表的是一个从服务器
      • REDIS_PRE_PSYNC标志表示客户端代表的是一个版本低于Redis2.8的从服务器,主服务器不能使用PSYNC命令与这个从服务器进行同步。
      • REDIS_LUA_CLIENT标识表示客户端是专门用于处理Lua脚本里面包含的Redis命令的伪客户端
    • 而另外一部分标志则记录了客户端目前所处的状态:
      • REDIS_MONITOR标志表示客户端正在执行MONITOR命令
      • REDIS_UNIX_SOCKET标志表示服务器使用UNIX套接字来连接客户端
      • REDIS_BLOCKED标志表示客户端正在被BRPOP、BLPOP等命令阻塞
      • REDIS_UNBLOCKED标志表示客户端已经从REDIS_BLOCKED标志所表示的阻塞状态中脱离出来,只能在REDIS_BLOCKED标志打开的情况下使用
      • REDIS_MULTI标志表示客户端正在执行事务
      • REDIS_DIRTY_CAS标志表示事务使用WATCH命令监视的数据库键已经被修改。
      • REDIS_DIRTY_EXEC标志表示事务在命令入队时出现了错误,以上两个标志都表示事务的安全性已经被破坏
      • REDIS_CLOSE_ASAP标志表示客户端的输出缓冲区大小超出了服务器允许的范围,服务器会在下一次执行serverCron函数时关闭这个客户端,以免服务器的稳定性受到这个客户端影响。
      • REDIS_CLOSE_AFTER_REPLY标志表示有用户对这个客户端执行了CLIENT KILL命令,或者客户端发送给服务器的命令请求中包含了错误的协议内容。服务器会将客户端积存在输出缓冲区中的所有内容发送给客户端,然后关闭客户端。
      • REDIS_ASKING标志表示客户端向集群节点发送了 ASKING命令
      • REDIS_FORCE_AOF标志强制服务器将当前执行的命令写入到AOF文件里面, REDIS_FORCE_REPL标志强制主服务器将当前执行的命令复制给所有从服务器。
      • 在主从服务器进行命令传播期间,从服务器需要向主服务器发送REPLICATION ACK命令,在发送这个命令之前,从服务器必须打开主服务器对应的客户端的REDIS_MASTER_FORCE_REPLY标志,否则发送操作会被拒绝执行。
  • 指向客户端正在使用的数据库的指针,以及该数据库的号码
  • 客户端当前要执行的命令、命令的参数、命令参数的个数,以及指向命令实现函数的指针。
    • 在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务器将得到的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性:
      • argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数
      • argc属性则负责记录argv数组的长度
    • 分析出argv和argc之后,根据argv[0]的值找到命令所对应的实现函数,之后就可以调用并执行函数。
  • 客户端的输入缓冲区和输出缓冲区,用于保存客户端发送的命令请求,最大大小超过1GB则关闭这个客户端
    • 两个输出缓冲区;
    • 一个缓冲区固定大小,保存长度比较小的回复,如OK等
    • 另一个缓冲区可变大小,用于保存长度比较大的回复,由链表实现
  • 客户端的复制状态信息,以及进行复制所需的数据结构
  • 客户端执行BRPOP、BLPOP等列表阻塞命令时使用的数据结构
  • 客户端的事务状态,以及执行WATCH命令时用到的数据结构
  • 客户端执行发布与订阅功能时用到的数据结构
  • 客户端的身份验证标志,用于记录客户端是否通过了身份验证,当客户端未通过身份验证时,所有的命令(除了AUTH)都会被拒绝执行
  • 客户端的创建时间,客户端和服务器最后一次互动的时间,以及客户端的输出缓冲区大小超出软性限制(soft limit)的时间

客户端状态包含的属性可以分为两类:

  • 一类是比较通用的属性,这些属性很少与特定功能相关,无论客户端执行的是什么工 作,它们都要用到这些属性
  • 另外一类是和特定功能相关的属性,比如操作数据库时需要用到的db属性和dictid属性,执行事务时需要用到的mstate属性,以及执行WATCH命令时需要用到的watched_key s属性等等

客户端的创建与关闭

普通客户端的创建

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

普通客户端的关闭

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

  • 客户端进程退出或者被杀死
  • 客户端向服务器发送了带有不符合协议格式的命令请求
  • 客户端成为了CLIENT KILL命令的目标
  • 用户为服务器设置了timeout配置选项,那么当客户端的空转时间超过timeout选项设置的值时,客户端将被关闭
  • 客户端发送的命令请求的大小超过了输入缓冲区的限制大小(默认为1 GB)
  • 要发送给客户端的命令回复的大小超过了输出缓冲区的限制大小

服务器使用两种模式来限制客户端输出缓冲区的大小:

  • 硬性限制(hard limit):如果输出缓冲区的大小超过了硬性限制所设置的大小,那么服务器立即关闭客户端
  • 软性限制(soft limit):如果输出缓冲区的大小超过了软性限制所设置的大小,但还没超过硬性限制,那么服务器将使用客户端状态结构的obuf_soft_limit_reached_time属性记录下客户端到达软性限制的起始时间;之后服务器会继续监视客户端,
    • 如果输出缓冲区的大小 一直超出软性限制,并且持续时间超过服务器设定的时长,那么服务器将关闭客户端
    • 相反地,如果输出缓冲区的大小在指定时间之内,不再超出软性限制,那么客户端就不会被关闭

使用client-output-buffer-limit选项可以为普通客户端、从服务器客户端、执行发布与订阅功能的客户端分别设置不同的软性限制和硬性限制,该选项的格式为:

1
2
3
4
client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit slave 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60

以下是三个设置示例:
第一行设置将普通客户端的硬性限制和软性限制都设置为0,表示不限制客户端的输出缓冲区大小
第二行设置将从服务器客户端的硬性限制设置为256MB,而软性限制设置为64MB,软 性限制的时长为60秒
第三行设置将执行发布与订阅功能的客户端的硬性限制设置为32MB,软性限制设置为 8MB,软性限制的时长为60秒

Lua脚本的伪客户端

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

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

AOF文件的伪客户端

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

服务器

Redis服务器负责与多个客户端建立网络连接,处理客户端发送的命令请求,在数据库中保存客户端执行命令产生的数据,并通过资源管理来维持服务器自身的运转。

命令请求的执行过程

从客户端发送SET KEY VALUE命令到获得回复OK期间,客户端和服务器共需要执行以下操作:

  • 客户端向服务器发送命令请求SET KEY VALUE
  • 服务器接收并处理客户端发来的命令请求SET KEY VALUE,在数据库中进行设置操作,并产生命令回复OK
  • 服务器将命令回复OK发送给客户端
  • 客户端接收服务器返回的命令回复OK,并将这个回复打印给用户观看

发送命令请求

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

读取命令请求

服务器将调用命令请求处理器来执行以下操作:

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

下图展示了程序将命令请求保存到客户端状态的输入缓冲区之后,客户端状态的样子:

之后,分析程序将对输入缓冲区中的协议进行分析,并将得出的分析结果保存到客户端状态的argv属性和argc属性里面,如下图所示:

命令执行器(1):查找命令实现

命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表(command table)中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性里面,命令表将返回”set”键所对应的redisCommand结构,客户端状态的cmd指针会指向这个redisCommand结构。

命令表是一个字典,字典的键是一个个命令名字,比如”set”、”get”、”del”等等;而字典的值则是一个个redisCommand结构,每个redisCommand结构记录了一个Redis命令的实现信息:

下表列出了sflags属性可以使用的标识值,以及这些标识的意义:

命令执行器(2):执行预备操作

到目前为止,服务器已经将执行命令所需的命令实现函数(保存在客户端状态的cmd属性)、参数(保存在客户端状态的argv属性)、参数个数(保存在客户端状态的argc属性)都收集齐了。程序还需要进行一些预备操作:

  1. 检查客户端状态的cmd指针是否指向NULL,如果是的话,那么说明用户输入的命令名字找不到相应的命令实现,服务器返回一个错误;
  2. 根据客户端cmd属性指向的redisCommand结构的arity属性,检查参数个数是否正确,当参数个数不正确时直接返回一个错误;
  3. 检查客户端是否已经通过了身份验证,未通过身份验证的客户端只能执行AUTH命令;
  4. 如果服务器打开了maxmemory功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收;
  5. 如果服务器上一次执行BGSAVE命令时出错,并且服务器打开了stop-writes-on-bgsaveerror功能,而且服务器即将要执行的命令是一个写命令,那么服务器将向客户端返回一个错误;
  6. 如果客户端当前正在用SUBSCRIBE命令订阅频道,或者正在用PSUBSCRIBE命令订阅模式,那么服务器只会执行SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、 PUNSUBSCRIBE四个命令;
  7. 如果服务器正在进行数据载入,那么客户端发送的命令必须带有l标识才会被服务器执行;
  8. 如果服务器因为执行Lua脚本而超时并进入阻塞状态,那么服务器只会执行客户端发来的SHUTDOWN nosave命令和SCRIPT KILL命令;
  9. 如果客户端正在执行事务,那么服务器只会执行客户端发来的EXEC、DISCARD、 MULTI、WATCH四个命令,其他命令都会被放进事务队列中;
  10. 如果服务器打开了监视器功能,那么服务器会将要执行的命令和参数等信息发送给监视器。当完成了以上预备操作之后,服务器就可以开始真正执行命令了;

命令执行器(3):调用命令的实现函数

服务器决定要执行命令时,它只要执行以下语句就可以了:

1
2
//client 是指向客户端状态的指针
client->cmd->proc(client);

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

命令执行器(4):执行后续工作

在执行完实现函数之后,服务器还需要执行一些后续工作:

  1. 如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志;
  2. 根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand结构的milliseconds属性,并将命令的redisCommand结构的calls计数器的值增一;
  3. 如果服务器开启了AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区里面
  4. 如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器

将命令回复发送给客户端

当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。当命令回复发送完毕之后,回复处理器会清空客户端状态的输出缓冲区,为处理下一个命令请求做好准备。

serverCron函数

Redis服务器中的serverCron函数默认每隔100毫秒执行一次,这个函数负责管理服务器的资源,并保持服务器自身的良好运转。

更新服务器时间缓存

为了减少系统调用的执行次数,服务器状态中的unixtime属性和mstime属性被用作当前时间的缓存:

1
2
3
4
5
6
7
8
struct redisServer {
// ...
//保存了秒级精度的系统当前UNIX 时间戳
time_t unixtime;
//保存了毫秒级精度的系统当前UNIX 时间戳
long long mstime;
// ...
};

这两个属性记录的时间的精确度并不高。

更新LRU时钟(lruclock属性、lru属性)

服务器状态中的lruclock属性保存了服务器的LRU时钟,这个属性和上面介绍的unixtime 属性、mstime属性一样,都是服务器时间缓存的一种:

1
2
3
4
5
6
7
struct redisServer {
// ...
//默认每10 秒更新一次的时钟缓存,
//用于计算键的空转(idle )时长。
unsigned lruclock:22;
// ...
};

每个Redis对象都会有一个lru属性,这个lru属性保存了对象最后一次被命令访问的时间,当服务器要计算一个数据库键的空转时间(也即是数据库键对应的值对象的空转时间),程序会用服务器的lruclock属性记录的时间减去对象的lru属性记录的时间,得出的计算结果就是这个对象的空转时间。

更新服务器每秒执行命令次数(opssec开头的属性)

serverCron函数中的trackOperationsPerSecond函数会以每100毫秒一次的频率执行,这个函数的功能是以抽样计算的方式,估算并记录服务器在最近一秒钟处理的命令请求数量,这个值可以通过INFO status命令的instantaneous_ops_per_sec域查看。

trackOperationsPerSecond函数和服务器状态中四个opssec开头的属性有关:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct redisServer {
//上一次进行抽样的时间
long long ops_sec_last_sample_time;

//上一次抽样时,服务器已执行命令的数量
long long ops_sec_last_sample_ops;

// REDIS_OPS_SEC_SAMPLES 大小(默认值为16 )的环形数组,
//数组中的每个项都记录了一次抽样结果。
long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES];

// ops_sec_samples 数组的索引值,
//每次抽样后将值自增一,
//在值等于16 时重置为0 ,
//让ops_sec_samples 数组构成一个环形数组。
int ops_sec_idx;
// ...
};

trackOperationsPerSecond函数每次运行,都会:- 根据ops_sec_last_sample_time记录的上一次抽样时间服务器的当前时间,以及ops_sec_last_sample_ops记录的上一次抽样的已执行命令数量服务器当前的已执行命令数量,计算出服务器在一秒钟内能处理多少个命令请求的估计值,这个估计值会被作为一个新的数组项被放进 ops_sec_samples环形数组里面。

更新服务器内存峰值记录(stat_peak_memory属性)

服务器状态中的stat_peak_memory属性记录了服务器的内存峰值大小:每次serverCron函数执行时,程序都会查看服务器当前使用的内存数量,并与 stat_peak_memory保存的数值进行比较,如果当前使用的内存数量比stat_peak_memory属性记录的值要大,那么程序就将当前使用的内存数量记录到stat_peak_memory属性里面。

处理SIGTERM信号(sigtermHandler函数)

Redis会为服务器进程的SIGTERM信号关联sigtermHandler函数,这个信号处理器负责在服务器接到SIGTERM信号时,打开服务器状态的shutdown_asap标识:

1
2
3
4
5
6
7
// SIGTERM 信号的处理器
static void sigtermHandler(int sig) {
//打印日志
redisLogFromHandler(REDIS_WARNING,"Received SIGTERM, scheduling shutdown...");
//打开关闭标识
server.shutdown_asap = 1;
}

每次serverCron函数运行时,程序都会对服务器状态的shutdown_asap属性进行检查,并根据属性的值决定是否关闭服务器,服务器在关闭自身之前会进行RDB持久化操作,这也是服务器拦 截SIGTERM信号的原因,如果服务器一接到SIGTERM信号就立即关闭,那么它就没办法执 行持久化操作了。

管理客户端资源(clientsCron函数)

serverCron函数调用clientsCron函数,clientsCron函数会对客户端进行检查:

  • 如果客户端与服务器之间的连接已经超时,那么程序释放这个客户端
  • 如果客户端输入缓冲区的大小过长,那么程序会释放输入缓冲区,并重新创建一个,从而防止客户端的输入缓冲区耗费了过多的内存

管理数据库资源(databasesCron函数)

serverCron函数每次执行都会调用databasesCron函数,这个函数会对服务器中的一部分数据库进行检查,删除其中的过期键,并在有需要时,对字典进行收缩操作。

执行被延迟的BGREWRITEAOF(aof_rewrite_scheduled标记)

在服务器执行BGSAVE命令的期间,如果客户端向服务器发来BGREWRITEAOF命令, 那么服务器会将BGREWRITEAOF命令的执行时间延迟到BGSAVE命令执行完毕之后。
服务器的aof_rewrite_scheduled标识记录了服务器是否延迟了BGREWRITEAOF命令:

1
2
3
4
5
6
struct redisServer {
// ...
//如果值为1 ,那么表示有 BGREWRITEAOF 命令被延迟了。
int aof_rewrite_scheduled;
// ...
};

每次serverCron函数执行时,函数都会检查BGSAVE命令或者BGREWRITEAOF命令是否正在执行,如果这两个命令都没在执行,并且aof_rewrite_scheduled属性的值为1,那么服务器就会执行之前被推延的BGREWRITEAOF命令。

检查持久化操作的运行状态(rdb_child_pid属性、aof_child_pid属性)

服务器状态使用rdb_child_pid属性和aof_child_pid属性记录执行BGSAVE命令和BGREWRITEAOF命令的子进程的ID,这两个属性也可以用于检查BGSAVE命令或者BGREWRITEAOF命令是否正在执行。

每次serverCron函数执行时,程序都会检查rdb_child_pid和aof_child_pid两个属性的值, 只要其中一个属性的值不为-1,程序就会执行一次wait3函数,检查子进程是否有信号发来服务器进程

  • 如果有信号到达,那么表示新的RDB文件已经生成完毕(对于BGSAVE命令来说),或者AOF文件已经重写完毕(对于BGREWRITEAOF命令来说),服务器需要进行相应命令的后续操作,比如用新的RDB文件替换现有的RDB文件,或者用重写后的AOF文件替换现有的 AOF文件。
  • 如果没有信号到达,那么表示持久化操作未完成,程序不做动作

如果rdb_child_pid和aof_child_pid两个属性的值都为-1,那么表示服务器没有在进行持久化操作,执行以下三个检查:

  • 查看是否有BGREWRITEAOF被延迟了,如果有的话,那么开始一次新的 BGREWRITEAOF操作;
  • 检查服务器的自动保存条件是否已经被满足,如果条件满足,并且服务器没有在执行其他持久化操作,那么服务器开始一次新的BGSAVE操作;
  • 检查服务器设置的AOF重写条件是否满足,如果条件满足,并且服务器没有在执行 其他持久化操作,那么服务器将开始一次新的BGREWRITEAOF操作;

将AOF缓冲区中的内容写入AOF文件

如果服务器开启了AOF持久化功能,并且AOF缓冲区里面还有待写入的数据,那么 serverCron函数将AOF缓冲区中的内容写入到AOF文件里面

关闭异步客户端

服务器会关闭那些输出缓冲区大小超出限制的客户端。

增加cronloops计数器的值(cronloops属性)

服务器状态的cronloops属性记录了serverCron函数执行的次数。cronloops属性目前在服务器中的唯一作用,就是在复制模块中实现“每执行serverCron函数N次就执行一次指定代码”的功能。

初始化服务器

初始化服务器状态结构(initServerConfig函数)

初始化服务器的第一步就是创建一个struct redisServer类型的实例变量server作为服务器的状态,并为结构中的各个属性设置默认值
初始化server变量的工作由redis.c/initServerConfig函数完成,以下是initServerConfig函数完成的主要工作:

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

载入配置选项(initServerConfig函数)

在启动服务器时,用户可以通过给定配置参数或者指定配置文件来修改服务器的默认配置。

服务器在用initServerConfig函数初始化完server变量之后,就会开始载入用户给定的配置参数和配置文件,并根据用户设定的配置,对server变量相关属性的值进行修改。

初始化服务器数据结构(initServer函数)

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

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

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

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

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

还原数据库状态

在完成了对服务器状态server变量的初始化之后,服务器需要载入RDB文件或者AOF文件,并根据文件记录的内容来还原服务器的数据库状态
根据服务器是否启用了AOF持久化功能,服务器载入数据时所使用的目标文件会有所不同:

  • 如果服务器启用了AOF持久化功能,那么服务器使用AOF文件来还原数据库状态
  • 相反地,如果服务器没有启用AOF持久化功能,那么服务器使用RDB文件来还原数据库状态

当服务器完成数据库状态还原工作之后,服务器将在日志中打印出载入文件并还原数据库状态所耗费的时长:

执行事件循环

在初始化的最后一步,开始执行服务器的事件循环(loop)

多机数据库的实现

复制

在Redis中,用户可以通过执行SLAVEOF命令或者设置slaveof选项,让一个服务器去复制(replicate)另一个服务器。我们称呼被复制的服务器为主服务器(master),而对主服务器进行复制的服务器则被称为从服务器(slave)。进行复制中的主从服务器双方的数据库将保存相同的数据,概念上将这种现象称作“数据库状态一致”,或者简称“一致

有两个Redis服务器,地址分别为127.0.0.1:6379和127.0.0.1:12345,如果向127.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的从服务器,而服务器127.0.0.1:6379则会成为127.0.0.1:12345的主服务器。

旧版复制功能的实现

Redis的复制功能分为下面两个操作:

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

同步(SYNC命令)

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

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

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

下表展示了一个主从服务器进行同步的例子

命令传播

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

旧版复制功能的缺陷

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

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

对于初次复制来说,旧版复制功能能够很好地完成任务,但对于断线后重复制来说,旧版复制功能虽然也能让主从服务器重新回到一致状态,但效率却非常低,因为需要重新执行SYNC命令,从服务器重新接收RDB文件进行同步。

新版复制功能

Redis从2.8版本开始,使用PSYNC命令代替SYNC命令来执行复制时的同步操作。
PSYNC命令具有完整重同步(full resy nchronization)和部分重同步(partial resynchronization)两种模式:

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

部分重同步的实现

部分同步功能由以下三个部分构成:

  • 主服务器的复制偏移量(replication offset)和从服务器的复制偏移量
  • 主服务器的复制积压缓冲区(replication backlog)
  • 服务器的运行ID(run ID)

复制偏移量

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

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

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

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

复制积压缓冲区

复制积压缓冲区是由主服务器维护的一个固定长度(fixed-size)先进先出(FIFO)队列,默认大小为1MB。当主服务器进行命令传播时,它不仅会将写命令发送给所有从服务器,还会将写命令入队到复制积压缓冲区里面,如下图所示:

主服务器的复制积压缓冲区里面会保存着一部分最近传播的写命令,并且复制积压缓冲区会为队列中的每个字节记录相应的复制偏移量。

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

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

服务器运行ID

每个Redis服务器,不论主服务器还是从服务,都会有自己的运行ID;运行ID在服务器启动时自动生成,由40个随机的十六进制字符组成。当从服务器对主服务器进行初次复制时,主服务器会将自己的运行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命令,并与主服务器执行完整同步操作

复制的实现

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

步骤1:设置主服务器的地址和端口(masterhost、masterport属性)

当客户端向从服务器发送以下命令时:

1
127.0.0.1:12345> SLAVEOF 127.0.0.1 6479

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

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

步骤2:建立套接字连接(connect、accept)

在SLAVEOF命令执行之后,从服务器将根据命令所设置的IP地址和端口,创建连向主服务器的套接字连接。

如果从服务器创建的套接字能成功连接(connect)到主服务器,那么从服务器将为这个套接字关联一个专门用于处理复制工作的文件事件处理器,这个处理器将负责执行后续的复制工作。

而主服务器在接受(accept)从服务器的套接字连接之后,将为该套接字创建相应的客户端状态,并将从服务器看作是一个连接到主服务器的客户端来对待,这时从服务器将同时具有服务器(server)和客户端(client)两个身份:从服务器可以向主服务器发送命令请求,而主服务器则会向从服务器返回命令回复。

从服务器是主服务器的客户端

步骤3:发送PING命令

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

  • 发送PING命令可以检查套接字的读写状态是否正常
  • 发送PING命令可以检查主服务器能否正常处理命令请求

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

  • 如果主服务器向从服务器返回了一个命令回复,但从服务器却不能在规定的时限 (timeout)内读取出命令回复的内容,那么表示主从服务器之间的网络连接状态不佳,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
  • 如果主服务器向从服务器返回一个错误,那么表示主服务器暂时没办法处理从服务器的命令请求,不能继续执行复制工作的后续步骤。当出现这种情况时,从服务器断开并重新创建连向主服务器的套接字。
  • 如果从服务器读取到”PONG”回复,那么表示主从服务器之间的网络连接状态正常,并且主服务器可以正常处理从服务器(客户端)发送的命令请求,在这种情况下,从服务器可以继续执行复制工作的下个步骤。

步骤4:身份验证(AUTH命令、masterauth选项)

从服务器在收到主服务器返回的”PONG”回复之后,下一步要做的就是决定是否进行身份验证:

  • 如果从服务器设置了masterauth选项,那么进行身份验证
  • 如果从服务器没有设置masterauth选项,那么不进行身份验证

在需要进行身份验证的情况下,从服务器将向主服务器发送一条AUTH命令,命令的参数为从服务器masterauth选项的值。

从服务器在身份验证阶段可能遇到的情况有以下几种:

  1. 如果主服务器没有设置requirepass选项,并且从服务器也没有设置masterauth选项,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行
  2. 如果从服务器通过AUTH命令发送的密码和主服务器requirepass选项所设置的密码相同,那么主服务器将继续执行从服务器发送的命令,复制工作可以继续进行。与此相反,如果主从服务器设置的密码不相同,那么主服务器将返回一个invalid password错误
  3. 如果主服务器设置了requirepass选项,但从服务器却没有设置masterauth选项,那么主服务器将返回一个NOAUTH错误。另一方面,如果主服务器没有设置requirepass选项,但从服务器却设置了masterauth选项,那么主服务器将返回一个no password is set错误

步骤5:发送端口信息(REPLCONF命令、slave_listening_port属性)

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

步骤6:同步(PSYNC命令)

在这一步,从服务器将向主服务器发送PSYNC命令执行同步操作,并将自己的数据库更新至主服务器数据库当前所处的状态。值得一提的是,在同步操作执行之前,只有从服务器是主服务器的客户端,但是在执行同步操作之后,主服务器也会成为从服务器的客户端

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

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

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

步骤7:命令传播

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

心跳检测

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

1
2
REPLCONF ACK <replication_offset>
//其中replication_offset是从服务器当前的复制偏移量

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

  • 检测主从服务器的网络连接状态
  • 辅助实现min-slaves选项
  • 检测命令丢失

检测主从服务器的网络连接状态(lag标志)

主从服务器可以通过发送和接收REPLCONF ACK命令来检查两者之间的网络连接是否正常:如果主服务器超过一秒钟没有收到从服务器发来的REPLCONF ACK命令,那么主服务器就知道主从服务器之间的连接出现问题了。
通过向主服务器发送INFO replication命令,在列出的从服务器列表的lag一栏中,我们可以看到相应从服务器最后一次向主服务器发送REPLCONF ACK命令距离现在过了多少秒:

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

辅助实现min-slaves配置选项

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

1
2
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)可以监视任意多个主服务器以及这些主服务器属下的所有从服务器,并在被监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

下图中:

  • 用双环图案表示的是当前的主服务器server1
  • 用单环图案表示的是主服务器的三个从服务器server2、server3以及server4
  • server2、server3、server4三个从服务器正在复制主服务器server1,而Sentinel系统则在监视所有四个服务器
  • 主服务器server1进入下线状态,那么从服务器server2、server3、server4对主服务器的复制操作将被中止,并且Sentinel系统会察觉到server1已下线
  • 当server1的下线时长超过用户设定的下线时长上限时,Sentinel系统就会对server1执行故障转移操作:
    • 首先,Sentinel系统会挑选server1属下的其中一个从服务器,并将这个被选中的从服务器升级为新的主服务器
    • 之后,Sentinel系统会向server1属下的所有从服务器发送新的复制指令,让它们成为新的主服务器的从服务器,当所有从服务器都开始复制新的主服务器时,故障转移操作执行完毕
    • 系统将server2升级为新的主服务器,并让服务器server3和server4成为server2的从服务器的过程
  • 另外,Sentinel还会继续监视已下线的server1,并在它重新上线时,将它设置为新的主服务器的从服务器
  • 如果server1重新上线的话,它将被Sentinel系统降级为server2的从服务器


Sentinel服务器的启动与初始化

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

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

或者命令:
1
redis-server /path/to/your/sentinel.conf --sentinel

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

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

初始化Sentinel服务器

首先,因为Sentinel本质上只是一个运行在特殊模式下的Redis服务器,所以启动Sentinel的第一步,就是初始化一个普通的Redis服务器
初始化Sentinel服务器与普通服务器的区别:
不过,因为Sentinel执行的工作和普通Redis服务器执行的工作不同,所以Sentinel的初始化过程和普通Redis服务器的初始化过程并不完全相同,下表展示了Redis服务器在Sentinel模式下运行时,服务器各个主要功能的使用情况:

使用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作为服务器的命令表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct redisCommand redisCommandTable[] = {
{"get",getCommand,2,"r",0,NULL,1,1,1,0,0},
{"set",setCommand,-3,"wm",0,noPreloadGetKeys,1,1,1,0,0},
{"setnx",setnxCommand,3,"wm",0,noPreloadGetKeys,1,1,1,0,0},
// ...
{"script",scriptCommand,-2,"ras",0,NULL,0,0,0,0,0},
{"time",timeCommand,1,"rR",0,NULL,0,0,0,0,0},
{"bitop",bitopCommand,-4,"wm",0,NULL,2,-1,1,0,0},
{"bitcount",bitcountCommand,-2,"r",0,NULL,1,1,1,0,0}
}

struct redisCommand sentinelcmds[] = {
{"ping",pingCommand,1,"",0,NULL,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},
{"unsubscribe",unsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
{"psubscribe",psubscribeCommand,-2,"",0,NULL,0,0,0,0,0},
{"punsubscribe",punsubscribeCommand,-1,"",0,NULL,0,0,0,0,0},
{"info",sentinelInfoCommand,-1,"",0,NULL,0,0,0,0,0}
};

sentinelcmds命令表也解释了为什么在Sentinel模式下,Redis服务器不能执行诸如SET、 DBSIZE、EVAL等等这些命令,因为服务器根本没有在命令表中载入这些命令。PINGSENTINELINFOSUBSCRIBEUNSUBSCRIBEPSUBSCRIBEPUNSUBSCRIBE这七个命令就是客户端可以对Sentinel执行的全部命令了。

初始化Sentinel状态(struct sentinelState)

服务器会初始化一个sentinel.c/sentinelState结构(后面简称“Sentinel状态”),这个结构保存了服务器中所有和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
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属性(struct sentinelRedisInstance)

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
31
32
33
34
35
36
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;

// 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为主服务器master1创建如下第1张图所示的实例结构,并为主服务器master2创建如下第2张图所示的实例结构,而这两个实例结构又会被保存到Sentinel状态的masters字典中,

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

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

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

为什么有两个连接?

在Redis目前的发布与订阅功能中,被发送的信息都不会保存在Redis服务器里面, 如果在信息发送时,想要接收信息的客户端不在线或者断线,那么这个客户端就会丢失这条信息。因此,为了不丢失sentinel:hello频道的任何信息,Sentinel必须专门用一 个订阅连接来接收该频道的信息
另一方面,除了订阅频道之外,Sentinel还必须向主服务器发送命令,以此来与主服务器进行通信,所以Sentinel还必须向主服务器创建命令连接
因为Sentinel需要与多个实例创建多个网络连接,所以Sentinel使用的是异步连接

获取主服务器信息

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

Sentinel可以获取以下两方面的信息:

  • 一方面是关于主服务器本身的信息,包括run_id域记录的服务器运行ID,以及role域记录的服务器角色。根据run_id域和role域记录的信息,Sentinel将对主服务器的实例结构进行更新;
  • 另一方面是关于主服务器属下所有从服务器的信息:
    • 每个从服务器都由一个”slave”字符串开头的行记录;
    • 每行的ip=域记录了从服务器的IP地址;
    • port=域则记录了从服务器的端口号。
    • 从服务器信息则会被用于更新主服务器实例结构的slaves字典, 这个字典记录了主服务器属下从服务器的名单。

获取从服务器信息

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

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

根据INFO命令的回复,Sentinel会提取出以下信息:

  • 从服务器的运行ID run_id
  • 从服务器的角色role
  • 主服务器的IP地址master_host,以及主服务器的端口号master_port
  • 主从服务器的连接状态master_link_status
  • 从服务器的优先级slave_priority
  • 从服务器的复制偏移量slave_repl_offset

向主服务器和从服务器发送消息

在默认情况下,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正在监视的是从服务器,那么这些参数记录的就是从服务器正在复制的主服务器的信息

接收服务器和从服务器的频道消息

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

1
SUBSCRIBE __sentinel__:hello

Sentinel对__sentinel__:hello频道的订阅会一直持续到Sentinel与服务器的连接断开为止。对于每个与Sentinel连接的服务器,Sentinel既通过命令连接向服务器的__sentinel__:hello频道发送信息,又通过订阅连接从服务器的__sentinel__:hello频道接收信息

对于监视同一个服务器的多个Sentinel来说,一个Sentinel发送的信息会被其他Sentinel接收到,这些信息会被用于更新其他Sentinel对发送信息Sentinel的认知,也会被用于更新其他Sentinel对被监视服务器的认知。

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

当一个Sentinel从__sentinel__:hello频道收到一条信息时,Sentinel会对这条信息进行分析,提取出信息中的Sentinel IP地址、Sentinel端口号、Sentinel运行ID等八个参数,并进行以下检查:

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

更新sentinels字典

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

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

当一个Sentinel接收到其他Sentinel发来的信息时,目标Sentinel会从信息中分析并提取出以下两方面参数:

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

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

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

创建连向其他Sentinel的命令连接

当Sentinel通过频道信息发现一个新的Sentinel时,它不仅会为新Sentinel在sentinels字典中创建相应的实例结构,还会创建一个连向新Sentinel的命令连接,而新Sentinel也同样会创建连向这个Sentinel的命令连接,最终监视同一主服务器的多个Sentinel将形成相互连接的网络

Sentinel之间不会创建订阅连接:Sentinel在连接主服务器或者从服务器时,会同时创建命令连接和订阅连接,但是在连接其他Sentinel时,却只会创建命令连接,而不创建订阅连接。这是因为Sentinel需要通过接收主服务器或者从服务器发来的频道信息来发现未知的新Sentinel,所以才需要建立订阅连接,而相互已知的Sentinel只要使用命令连接来进行通信就足够。

检测主观下线状态

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

实例对PING命令的回复可以分为以下两种情况:

  • 有效回复:实例返回+PONG、-LOADING、-MASTERDOWN三种回复的其中一种
  • 无效回复:实例返回除+PONG、-LOADING、-MASTERDOWN三种回复之外的其他 回复,或者在指定时限内没有返回任何回复

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

检测客观下线状态

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

发送SENTINEL is-master-down-byaddr <ip> <port> <current_epoch> <runid>命令询问其他Sentinel是否同意主服务器已下线。

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

  1. down_state
  2. leader_runid
  3. leader_epoch

回复含义如下:

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

选取领头Sentinel

当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头Sentinel对下线主服务器执行故障转移操作。

以下是选举领头Sentinel的规则和方法:

  1. 所有在线的Sentinel都有被选为领头Sentinel的资格,换句话说,监视同一个主服务器的多个在线Sentinel中的任意一个都有可能成为领头Sentinel
  2. 每次进行领头Sentinel选举之后,不论选举是否成功,所有Sentinel的配置纪元 (configuration epoch)的值都会自增一次。配置纪元实际上就是一个计数器,并没有什么特别的
  3. 在一个配置纪元里面,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,并且局部领头一旦设置,在这个配置每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel
  4. 当一个Sentinel(源Sentinel)向另一个Sentinel(目标Sentinel)发送SENTINEL ismaster-down-by-addr命令,并且命令中的runid参数不是*符号而是源Sentinel的运行ID时,这表示源Sentinel要求目标Sentinel将前者设置为后者的局部领头Sentinel
  5. Sentinel设置局部领头Sentinel的规则是先到先得最先向目标Sentinel发送设置要求的源Sentinel将成为目标Sentinel的局部领头Sentinel,而之后接收到的所有设置要求都会被目标Sentinel拒绝
  6. 目标Sentinel在接收到SENTINEL is-master-down-by-addr命令之后,将向源Sentinel返回一条命令回复,回复中的leader_runid参数和leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元
  7. 源Sentinel在接收到目标Sentinel返回的命令回复之后,会检查回复中leader_epoch参数的值和自己的配置纪元是否相同,如果相同的话,那么源Sentinel继续取出回复中的leader_runid参数,如果leader_runid参数的值和源Sentinel的运行ID一致,那么表示目标Sentinel将源Sentinel设置成了局部领头Sentinel
  8. 如果有某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel成为领头Sentinel。举个例子,在一个由10个Sentinel组成的Sentinel系统里面,只要有大于等于10/2+1=6个Sentinel将某个Sentinel设置为局部领头Sentinel,那么被设置的那个Sentinel就会成 为领头Sentine
  9. 因为领头Sentinel的产生需要半数以上Sentinel的支持,并且每个Sentinel在每个配置纪元里面只能设置一次局部领头Sentinel,所以在一个配置纪元里面,只会出现一个领头 Sentinel
  10. 如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头Sentinel为止

假设现在有三个Sentinel正在监视同一个主服务器,并且这三个Sentinel之前已经通过 SENTINEL is-master-down-by -addr命令确认主服务器进入了客观下线状态,如下图所示

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

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

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

1
2
3
1) 1
2) e955b4c85598ef5b5f055bc7ebfd5e828dbed4fa
3) 0

然后接收到命令回复的Sentinel就可以根据这一回复,统计出有多少个Sentinel将自己设置成了局部领头Sentinel
根据命令请求发送的先后顺序不同,可能会有某个Sentinel的SENTINEL is-master-downby -addr命令比起其他Sentinel发送的相同命令都更快到达,并最终胜出领头Sentinel的选举, 然后这个领头Sentinel就可以开始对主服务器执行故障转移操作了

故障转移

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

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

选出新的主服务器

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

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

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

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

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

领头Sentinel向被选中的从服务器server2发送SLAVEOF no one命令的情形:

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

1
2
3
4
5
# Replication
role:slave
...
# Other sections
...

变为:
1
2
3
4
5
# Replication
role:master
...
# Other sections
...

的时候,领头Sentinel就知道server2已经成功升级为主服务器了。

修改从服务器的复制目标

当新的主服务器出现之后,领头Sentinel下一步要做的就是,让已下线主服务器属下的所有从服务器去复制新的主服务器,这一动作可以通过向从服务器发送SLAVEOF命令来实现。下图展示了在故障转移操作中,领头Sentinel向已下线主服务器server1的两个从服务器server3和server4发送SLAVEOF命令,让它们复制新的主服务器server2的例子。

下图展示了server3和server4成为server2的从服务器之后,各个服务器以及领头Sentinel的样子:

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

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

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

集群

集群通过分片(sharding)来进行数据共享,并提供复制和故障转移功能

集群节点

一个Redis集群通常由多个节点(node)组成。可以将各个独立的节点连接起来,构成一个包含多个节点的集群。连接各个节点的工作可以使用CLUSTER MEET命令来完成,该命令的格式如下:

1
CLUSTER MEET <ip> <port>

向一个节点node发送CLUSTER MEET命令,可以让node节点与ip和port所指定的节点进行握手,当握手成功时,node节点就会将ip和port所指定的节点添加到node节点当前所在的集群中。

假设现在有三个独立的节点127.0.0.1:7000、127.0.0.1:7001、 127.0.0.1:7002,我们首先使用客户端连上节点7000,通过发送CLUSTER NODE命令可以看到,集群目前只包含7000自己一个节点:

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

继续向节点7000发送以下命令,我们可以将节点7002也添加到节点7000和节点7001所在 的集群里面:

启动节点

一个节点就是一个运行在集群模式下的Redis服务器,Redis服务器在启动时会根据cluster-enabled配置选项是否为yes来决定是否开启服务器的集群模式。

  • 节点(运行在集群模式下的Redis服务器)会继续使用所有在单机模式中使用的服务器组件,比如说:
  • 节点会继续使用文件事件处理器来处理命令请求和返回命令回复
  • 节点会继续使用时间事件处理器来执行serverCron函数,而serverCron函数又会调用集 群模式特有的clusterCron函数clusterCron函数负责执行在集群模式下需要执行的常规操 作,例如向集群中的其他节点发送Gossip消息,检查节点是否断线,或者检查是否需要对下 线节点进行自动故障转移等
  • 节点会继续使用数据库来保存键值对数据,键值对依然会是各种不同类型的对象
  • 节点会继续使用RDB持久化模块和AOF持久化模块来执行持久化工作
  • 节点会继续使用发布与订阅模块来执行PUBLISH、SUBSCRIBE等命令
  • 节点会继续使用复制模块来进行节点的复制工作
  • 节点会继续使用Lua脚本环境来执行客户端输入的Lua脚本

除此之外,节点会继续使用redisServer结构来保存服务器的状态,使用redisClient结构来 保存客户端的状态,至于那些只有在集群模式下才会用到的数据,节点将它们保存到了 cluster.h/clusterNode结构、cluster.h/clusterLink结构,以及cluster.h/clusterState结构里面。

集群数据结构

clusterNode结构保存了一个节点的当前状态,比如节点的创建时间、节点的名字、节点当前的配置纪元、节点的IP地址和端口号等等。每个节点都会使用一个clusterNode结构来记录自己的状态,并为集群中的所有其他节点 (包括主节点和从节点)都创建一个相应的clusterNode结构,以此来记录其他节点的状态:

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
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;
// ...
};

clusterNode结构的link属性是一个clusterLink结构,该结构保存了连接节点所需的有关信息,比如套接字描述符,输入缓冲区和输出缓冲区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct clusterLink {
//连接的创建时间
mstime_t ctime;

// TCP 套接字描述符
int fd;

//输出缓冲区,保存着等待发送给其他节点的消息(message )。
sds sndbuf;

//输入缓冲区,保存着从其他节点接收到的消息。
sds rcvbuf;

//与这个连接相关联的节点,如果没有的话就为NULL
struct clusterNode *node;
} clusterLink;

redisClient结构和clusterLink结构都有自己的套接字描述符和输入、输出缓冲区,这两个结构的区别在于,redisClient结构中的套接字和缓冲区是用于连接客户端的,而clusterLink结构中的套接字和缓冲区则是用于连接节点的

最后,每个节点都保存着一个clusterState结构,这个结构记录了在当前节点的视角下, 集群目前所处的状态,例如集群是在线还是下线,集群包含多少个节点,集群当前的配置纪元,诸如此类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct clusterState {
//指向当前节点的指针
clusterNode *myself;

//集群当前的配置纪元,用于实现故障转移
uint64_t currentEpoch;

//集群当前的状态:是在线还是下线
int state;

//集群中至少处理着一个槽的节点的数量
int size;

//集群节点名单(包括myself 节点)
//字典的键为节点的名字,字典的值为节点对应的clusterNode 结构
dict *nodes;
// ...
} clusterState;

下图展示了节点7000创建的clusterState结构,这个结构从节点7000的角度记录了集群以及集群包含的三个节点的当前状态

  • 结构的currentEpoch属性的值为0,表示集群当前的配置纪元为0
  • 结构的size属性的值为0,表示集群目前没有任何节点在处理槽,因此结构的state属性的 值为REDIS_CLUSTER_FAIL,这表示集群目前处于下线状态
  • 结构的nodes字典记录了集群目前包含的三个节点,
  • 三个节点的clusterNode结构的flags属性都是REDIS_NODE_MASTER,说明三个节点都是主节点
  • 在节点7001创建的clusterState结构中,my self指针将指向代表节点7001的 clusterNode结构,而节点7000和节点7002则是集群中的其他节点

CLUSTER MEET命令的实现

通过向节点A发送CLUSTER MEET命令,客户端可以让接收命令的节点A将另一个节点B添加到节点A当前所在的集群里面:

1
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进行握手

槽指派

Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384个槽(slot),数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0个或最多16384个槽。

当数据库中的16384个槽都有节点在处理时,集群处于上线状态(ok);相反地,如果数据库中有任何一个槽没有得到处理,那么集群处于下线状态(fail)。通过向节点发送CLUSTER ADDSLOTS命令,我们可以将一个或多个槽指派(assign)给节点负责:

1
CLUSTER ADDSLOTS <slot> [slot ...]

执行以下命令可以将槽0至槽5000指派给节点7000负责:
1
127.0.0.1:7000> CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000

记录节点的槽指派信息

clusterNode结构的slots属性和numslot属性记录了节点负责处理哪些槽。slots属性是一个二进制位数组(bit array),这个数组的长度为16384/8=2048个字节,共包含16384个二进制位。
Redis根据索引i上的二进制位的值来判断节点是否负责处理槽i:i上的二进制位的值为1则处理槽i,为0则不处理槽i。

numslots属性则记录节点负责处理的槽的数量,也即是slots数组中值为1的二进制位的数量。

传播节点的槽指派信息

一个节点除了会将自己负责处理的槽记录在clusterNode结构的slots属性和numslots属性之 外,它还会将自己的slots数组通过消息发送给集群中的其他节点,以此来告知其他节点自己目前负责处理哪些槽。集群中的每个节点都会知道数据库中的16384个槽分别被指派给了集群中的哪些节点。

记录集群所有槽的指派信息

clusterState结构中的slots数组记录了集群中所有16384个槽的指派信息:

1
2
3
4
5
typedef struct clusterState {
// ...
clusterNode *slots[16384];
// ...
} clusterState;

slots数组包含16384个项,每个数组项都是一个指向clusterNode结构的指针:

  • 如果slots[i]指针指向NULL,那么表示槽i尚未指派给任何节点
  • 如果slots[i]指针指向一个clusterNode结构,那么表示槽i已经指派给了clusterNode结构所 代表的节点。

如果只将槽指派信息保存在各个节点的clusterNode.slots数组里,会出现一些无法高效地解决的问题,而clusterState.slots数组的存在解决了这些问题:

  • 如果节点只使用clusterNode.slots数组来记录槽的指派信息,那么为了知道槽i是否已经被指派,或者槽i被指派给了哪个节点,程序需要遍历clusterState.nodes字典中的所有clusterNode结构,检查这些结构的slots数组,直到找到负责处理槽i的节点为止,这个过程的复杂度为O(N),其中N为clusterState.nodes字典保存的clusterNode结构的数量
  • 所有槽的指派信息保存在clusterState.slots数组里面,程序要检查槽i是否已经被指派,又或者取得负责处理槽i的节点,只需要访问clusterState.slots[i]的值即可,这个操作的复杂度仅为O(1)

虽然clusterState.slots数组记录了集群中所有槽的指派信息,但使用clusterNode结构的slots数组来记录单个节点的槽指派信息仍然是有必要的:因为当程序需要将某个节点的槽指派信息通过消息发送给其他节点时,程序只需要将相应节点的clusterNode.slots数组整个发送出去就可以了。clusterState.slots数组记录了集群中所有槽的指派信息,而clusterNode.slots数组只记录了clusterNode结构所代表的节点的槽指派信息,这是两个slots数组的关键区别所在。

CLUSTER ADDSLOTS命令的实现

CLUSTER ADDSLOTS命令接受一个或多个槽作为参数,并将所有输入的槽指派给接收该命令的节点负责:

1
CLUSTER ADDSLOTS <slot> [slot ...]

CLUSTER ADDSLOTS命令的实现可以用以下伪代码来表示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def CLUSTER_ADDSLOTS(*all_input_slots):
# 遍历所有输入槽,检查它们是否都是未指派槽
for i in all_input_slots:
# 如果有哪怕一个槽已经被指派给了某个节点
# 那么向客户端返回错误,并终止命令执行
if clusterState.slots[i] != NULL:
reply_error()
return

# 如果所有输入槽都是未指派槽
# 那么再次遍历所有输入槽,将这些槽指派给当前节点
for i in all_input_slots:
# 设置clusterState 结构的slots 数组
# 将slots[i]的指针指向代表当前节点的clusterNode 结构
clusterState.slots[i] = clusterState.myself

# 访问代表当前节点的clusterNode 结构的slots 数组
# 将数组在索引i 上的二进制位设置为1
setSlotBit(clusterState.myself.slots, i)

下图展示了一个节点的clusterState结构,clusterState.slots数组中的所有指针都指向NULL,并且clusterNode.slots数组中的所有二进制位的值都是0,这说明当前节点没有被指派任何槽,并且集群中的所有槽都是未指派的:

当客户端对上图所示的节点执行命令:CLUSTER ADDSLOTS 1 2将槽1和槽2指派给节点之后,节点的clusterState结构将被更新成下图所示的样子:

在集群中执行命令

当客户端向节点发送与数据库键有关的命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己:

  • 如果键所在的槽正好就指派给了当前节点,那么节点直接执行这个命令
  • 如果键所在的槽并没有指派给当前节点,那么节点会向客户端返回一个MOVED错误, 指引客户端转向(redirect)至正确的节点,并再次发送之前想要执行的命令。

计算键所属槽

节点使用以下伪代码算法来计算给定键key属于哪个槽:

1
2
def slot_number(key):
return CRC16(key) & 16383

其中CRC16(key)语句用于计算键key的CRC-16校验和,而& 16383语句则用于计算出一个介于0至16383之间的整数作为键key的槽号

使用CLUSTER KEYSLOT <key>可以查看一个给定键属于哪个槽

判断槽是否由当前节点负责处理

当节点计算出键所属的槽i之后,节点就会检查自己在clusterState.slots数组中的项i,判断键所在的槽是否由自己负责:

  • 如果clusterState.slots[i]等于clusterState.myself,那么说明槽i由当前节点负责,节点可以执行客户端发送的命令
  • 如果clusterState.slots[i]不等于clusterState.myself,那么说明槽i并非由当前节点负责,节点会根据clusterState.slots[i]指向的clusterNode结构所记录的节点IP和端口号,向客户端返回MOVED错误,指引客户端转向至正在处理槽i的节点

MOVED错误

当节点发现键所在的槽并非由自己负责处理的时候,节点就会向客户端返回一个 MOVED错误,指引客户端转向至正在负责槽的节点。

MOVED错误的格式为:MOVED <slot> <ip>:<port>,其中slot为键所在的槽,而ip和port则是负责处理槽slot的节点的IP地址和端口号。当客户端接收到节点返回的MOVED错误时,客户端会根据MOVED错误中提供的IP地址和端口号,转向至负责处理槽slot的节点,并向该节点重新发送之前想要执行的命令。

一个集群客户端通常会与集群中的多个节点创建套接字连接,而所谓的节点转向实际上就是换一个套接字来发送命令
如果客户端尚未与想要转向的节点创建套接字连接,那么客户端会先根据MOVED错误提供的IP地址和端口号来连接节点,然后再进行转向。

集群模式的redis-cli客户端在接收到MOVED错误时,并不会打印出MOVED错误, 而是根据MOVED错误自动进行节点转向,并打印出转向信息,所以我们是看不见节点返回的MOVED错误的。

节点数据库的实现

节点只能使用0号数据库,而单机Redis服务器则没有这一限制。

除了将键值对保存在数据库里面之外,节点还会用clusterState结构中的 slots_to_keys跳跃表来保存槽和键之间的关系:

1
2
3
4
5
typedef struct clusterState {
// ...
zskiplist *slots_to_keys;
// ...
} clusterState;

slots_to_keys跳跃表每个节点的分值(score)都是一个槽号,而每个节点的成员 (member)都是一个数据库键

  • 每当节点往数据库中添加一个新的键值对时,节点就会将这个键以及键的槽号关联到slots_to_keys跳跃表
  • 当节点删除数据库中的某个键值对时,节点就会在slots_to_keys跳跃表解除被删除键与槽号的关联

举例:

重新分片

Redis集群的重新分片操作可以将任意数量已经指派给某个节点(源节点)的槽改为指派给另一个节点(目标节点),并且相关槽所属的键值对也会从源节点被移动到目标节点。重新分片操作可以在线(online)进行,在重新分片的过程中,集群不需要下线,并且源节点和目标节点都可以继续处理命令请求。

Redis集群的重新分片操作是由Redis的集群管理软件redis-trib负责执行的,Redis提供了进行重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作,redis-trib对集群的单个槽slot进行重新分片的步骤如下:

  1. redis-trib对目标节点发送CLUSTER SETSLOT <slot> IMPORTING <source_id>命令, 让目标节点准备好从源节点导入(import)属于槽slot的键值对
  2. redis-trib对源节点发送CLUSTER SETSLOT <slot> MIGRATING <target_id>命令,让源节点准备好将属于槽slot的键值对迁移(migrate)至目标节点
  3. redis-trib向源节点发送CLUSTER GETKEYSINSLOT <slot> <count>命令,获得最多count个属于槽slot的键值对的键名(key name)
  4. 对于步骤3获得的每个键名,redis-trib都向源节点发送一个MIGRATE <target_ip> <target_port> <key_name> 0 <time out>命令,将被选中的键原子地从源节点迁移至目标节点
  5. 重复执行步骤3和步骤4,直到源节点保存的所有属于槽slot的键值对都被迁移至目标节点为止。每次迁移键的过程如下图所示
  6. redis-trib向集群中的任意一个节点发送CLUSTER SETSLOTNODE命令,将槽slot指派给目标节点,这一指派信息会通过消息发送至整个集群,最终集群中的所 有节点都会知道槽slot已经指派给了目标节点

ASK错误

在进行重新分片期间,源节点向目标节点迁移一个槽的过程中,可能会出现这样一种情况:属于被迁移槽的一部分键值对保存在源节点里面,而另一部分键值对则保存在目标节点里面。这时当客户端向源节点发送一个与数据库键有关的命令,并且命令要处理的数据库键恰好就属于正在被迁移的槽时:

  • 源节点会先在自己的数据库里面查找指定的键,如果找到的话,就直接执行客户端发送的命令(底层实现:如果节点收到一个关于键key的命令请求,并且键key所属的槽i正好就指派给了这个节点,那么节点会尝试在自己的数据库里查找键key,如果找到了的话,节点就直接执行客户端发送的命令)
  • 相反地,如果源节点没能在自己的数据库里面找到指定的键,那么这个键有可能已经被迁移到了目标节点,源节点将向客户端返回一个ASK错误,指引客户端转向正在导入槽的目标节点,并再次发送之前想要执行的命令(底层实现:如果节点没有在自己的数据库里找到键key,那么节点会检查自己的 clusterState.migrating_slots_to[i],看键key所属的槽i是否正在进行迁移,如果槽i的确在进行 迁移的话,那么节点会向客户端发送一个ASK错误,引导客户端到正在导入槽i的节点去查找键key)

CLUSTER SETSLOT IMPORTING命令的实现

clusterState结构的importing_slots_from数组记录了当前节点正在从其他节点导入的槽:
如果importing_slots_from[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前节点正在从clusterNode所代表的节点导入槽i

1
2
3
4
5
typedef struct clusterState {
// ...
clusterNode *importing_slots_from[16384];
// ...
} clusterState;

在对集群进行重新分片的时候,向目标节点发送命令,可以将目标节点clusterState.importing_slots_from[i]的值设置为source_id所代表节点的clusterNode结构:CLUSTER SETSLOT <i> IMPORTING <source_id>

CLUSTER SETSLOT MIGRATING命令的实现

clusterState结构的migrating_slots_to数组记录了当前节点正在迁移至其他节点的槽:
如果migrating_slots_to[i]的值不为NULL,而是指向一个clusterNode结构,那么表示当前 节点正在将槽i迁移至clusterNode所代表的节点

1
2
3
4
5
typedef struct clusterState {
// ...
clusterNode *migrating_slots_to[16384];
// ...
} clusterState;

在对集群进行重新分片的时候,向源节点发送命令,可以将源节点clusterState.migrating_slots_to[i]的值设置为target_id所代表节点的 clusterNode结构:CLUSTER SETSLOT <i> MIGRATING <target_id>

ASKING命令

ASKING命令功能:唯一要做的就是打开发送该命令的客户端的REDIS_ASKING标识。在一般情况下,如果客户端向节点发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,那么节点将向客户端返回一个MOVED错误;但是,如果节点的clusterState.importing_slots_from[i]显示节点正在导入槽i,并且发送命令的客户端带有REDIS_ASKING标识,那么节点将破例执行这个关于槽i的命令一次。

当客户端接收到ASK错误并转向至正在导入槽的节点时,客户端会先向节点发送一个 ASKING命令,然后才重新发送想要执行的命令,这是因为如果客户端不发送ASKING命令,而直接发送想要执行的命令的话,那么客户端发送的命令将被节点拒绝执行,并返回 MOVED错误;
另外要注意的是,客户端的REDIS_ASKING标识是一个一次性标识,当节点执行了一个带有REDIS_ASKING标识的客户端发送的命令之后,客户端的REDIS_ASKING标识就会被移除。

ASK错误和MOVED错误的区别

ASK错误和MOVED错误都会导致客户端转向,它们的区别在于:

  • MOVED错误代表槽的负责权已经从一个节点转移到了另一个节点:在客户端收到关于槽i的MOVED错误之后,客户端每次遇到关于槽i的命令请求时,都可以直接将命令请求发送 至MOVED错误所指向的节点,因为该节点就是目前负责槽i的节点
  • ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施:在客户端收到关于槽i的ASK错误之后,客户端只会在接下来的一次命令请求中将关于槽i的命令请求发送至ASK错误所指示的节点,但这种转向不会对客户端今后发送关于槽i的命令请求产生任何影响,客户端仍然会将关于槽i的命令请求发送至目前负责处理槽i的节点,除非ASK错误再次出现

复制与故障转移

Redis集群中的节点分为主节点(master)和从节点(slave):

  • 主节点用于处理槽
  • 从节点则用于复制某个主节点,并在被复制的主节点下线时,代替下线主节点继续处理命令请求

对于包含7000、7001、7002、7003四个主节点的集群来说,我们可以将7004、7005两个节点添加到集群里面,并将这两个节点设定为节点7000的从节点,如下图所示(图中以双圆形表示主节点,单圆形表示从节点)

下表记录了集群各个节点的当前状态,以及它们正在做的工作

如果这时,节点7000进入下线状态,那么集群中仍在正常运作的几个主节点将在节点7000的两个从节点中选出一个节点作为新的主节点,这个新的主节点将接管原来节点7000负责处理的槽,并继续处理客户端发送的命令请求。

设置从节点

向一个节点发送命令,可以让接收命令的节点成为node_id所指定节点的从节点,并开始对主节点进行复制:CLUSTER REPLICATE <node_id>

  • 接收到该命令的节点首先会在自己的clusterState.nodes字典中找到node_id所对应节点的clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构,以此来记录这个节点正在复制的主节点
    1
    2
    3
    4
    5
    6
    struct clusterNode {
    // ...
    //如果这是一个从节点,那么指向主节点
    struct clusterNode *slaveof;
    // ...
    };
  • 然后节点会修改自己在clusterState.myself.flags中的属性,关闭原本的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识,表示这个节点已经由原来 的主节点变成了从节点
  • 最后,节点会调用复制代码,并根据clusterState.myself.slaveof指向的clusterNode结构所保存的IP地址和端口号,对主节点进行复制。因为节点的复制功能和单机Redis服务器的复制功能使用了相同的代码,所以让从节点复制主节点相当于向从节点发送命令SLAVEOF。
  • 一个节点成为从节点,并开始复制某个主节点这一信息会通过消息发送给集群中的其他节点,最终集群中的所有节点都会知道某个从节点正在复制某个主节点
  • 集群中的所有节点都会在代表主节点的clusterNode结构的slaves属性和numslaves属性中记录正在复制这个主节点的从节点名单:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct clusterNode {
    // ...
    //正在复制这个主节点的从节点数量
    int numslaves;

    // 一个数组
    //每个数组项指向一个正在复制这个主节点的从节点的clusterNode 结构
    struct clusterNode **slaves;
    // ...
    };

故障检测

集群中的每个节点都会定期地向集群中的其他节点发送PING消息,以此来检测对方是否在线,如果接收PING消息的节点没有在规定的时间内,向发送PING消息的节点返回 PONG消息,那么发送PING消息的节点就会将接收PING消息的节点标记为疑似下线,在结构的flags属性中打开REDIS_NODE_PFAIL标识,以此表示节点进入了疑似下线状态。

如果在一个集群里面,半数以上负责处理槽的主节点都将某个主节点x报告为疑似下线,那么这个主节点x将被标记为已下线(FAIL),将主节点x标记为已下线的节点会向集群广播一条关于主节点x的FAIL消息,所有收到这条FAIL消息的节点都会立即将主节点x标记为已下线。

故障转移

当一个从节点发现自己正在复制的主节点进入了已下线状态时,从节点将开始对下线主节点进行故障转移,以下是故障转移的执行步骤:

  • 复制下线主节点的所有从节点里面,会有一个从节点被选中
  • 被选中的从节点会执行SLAVEOF no one命令,成为新的主节点
  • 新的主节点会撤销所有对已下线主节点的槽指派,并将这些槽全部指派给自己
  • 新的主节点向集群广播一条PONG消息,这条PONG消息可以让集群中的其他节点立 即知道这个节点已经由从节点变成了主节点,并且这个主节点已经接管了原本由已下线节点 负责处理的槽
  • 新的主节点开始接收和自己负责处理的槽有关的命令请求,故障转移完成

选举新的主节点

以下是集群选举新的主节点的方法:

  1. 集群的配置纪元是一个自增计数器,它的初始值为0
  2. 当集群里的某个节点开始一次故障转移操作时,集群配置纪元的值会被增一
  3. 对于每个配置纪元,集群里每个负责处理槽的主节点都有一次投票的机会,而第一个向主节点要求投票的从节点将获得主节点的投票
  4. 当从节点发现自己正在复制的主节点进入已下线状态时,从节点会向集群广播一条CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST消息,要求所有收到这条消息、并且具有投票权的主节点向这个从节点投票
  5. 如果一个主节点具有投票权(它正在负责处理槽),并且这个主节点尚未投票给其他从节点,那么主节点将向要求投票的从节点返回一条 CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消息,表示这个主节点支持从节点成为新的 主节点
  6. 每个参与选举的从节点都会接收CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK消 息,并根据自己收到了多少条这种消息来统计自己获得了多少主节点的支持
  7. 如果集群里有N个具有投票权的主节点,那么当一个从节点收集到大于等于N/2+1张支持票时,这个从节点就会当选为新的主节点
  8. 因为在每一个配置纪元里面,每个具有投票权的主节点只能投一次票,所以如果有N 个主节点进行投票,那么具有大于等于N/2+1张支持票的从节点只会有一个,这确保了新的 主节点只会有一个
  9. 如果在一个配置纪元里面没有从节点能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的主节点为止

消息

集群中的各个节点通过发送和接收消息(message)来进行通信,我们称发送消息的节点为发送者(sender),接收消息的节点为接收者(receiver)。

节点发送的消息主要有以下五种:

  • MEET消息:当发送者接到客户端发送的CLUSTER MEET命令时,发送者会向接收者 发送MEET消息,请求接收者加入到发送者当前所处的集群里面
  • PING消息:集群里的每个节点默认每隔一秒钟就会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送过PING消息的节点发送PING消息,以此来检测被选中的节点是否在线
  • PONG消息:当接收者收到发送者发来的MEET消息或者PING消息时,为了向发送者确认这条MEET消息或者PING消息已到达,接收者会向发送者返回一条PONG消息。另外, 一个节点也可以通过向集群广播自己的PONG消息来让集群中的其他节点立即刷新关于这个节点的认识
  • FAIL消息:当一个主节点A判断另一个主节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息,所有收到这条消息的节点都会立即将节点B标记为已下线
  • PUBLISH消息:当节点接收到一个PUBLISH命令时,节点会执行这个命令,并向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会执行相同的PUBLISH命令

消息的结构

一条消息由消息头(header)和消息正文(data)组成

节点发送的所有消息都由一个消息头包裹,每个消息头都由一个cluster.h/clusterMsg结构表示:

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 {
//消息的长度(包括这个消息头的长度和消息正文的长度)
uint32_t totlen;
//消息的类型
uint16_t type;
//消息正文包含的节点信息数量
//只在发送MEET 、PING 、PONG 这三种Gossip 协议消息时使用
uint16_t count;
//发送者所处的配置纪元
uint64_t currentEpoch;
//如果发送者是一个主节点,那么这里记录的是发送者的配置纪元
//如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的配置纪元
uint64_t configEpoch;
//发送者的名字(ID )
char sender[REDIS_CLUSTER_NAMELEN];
//发送者目前的槽指派信息
unsigned char myslots[REDIS_CLUSTER_SLOTS/8];
//如果发送者是一个从节点,那么这里记录的是发送者正在复制的主节点的名字
//如果发送者是一个主节点,那么这里记录的是REDIS_NODE_NULL_NAME
//(一个40 字节长,值全为0 的字节数组)
char slaveof[REDIS_CLUSTER_NAMELEN];
//发送者的端口号
uint16_t port;
//发送者的标识值
uint16_t flags;
//发送者所处集群的状态
unsigned char state;
//消息的正文(或者说,内容)
union clusterMsgData data;
} clusterMsg;

clusterMsg.data属性指向联合cluster.h/clusterMsgData,这个联合就是消息的正文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
union clusterMsgData {
// MEET 、PING 、PONG 消息的正文
struct {
//每条MEET 、PING 、PONG 消息都包含两个
// clusterMsgDataGossip 结构
clusterMsgDataGossip gossip[1];
} ping;
// FAIL 消息的正文
struct {
clusterMsgDataFail about;
} fail;
// PUBLISH 消息的正文
struct {
clusterMsgDataPublish msg;
} publish;
//其他消息的正文...
};

clusterMsg结构的currentEpoch、sender、myslots等属性记录了发送者自身的节点信息, 接收者会根据这些信息,在自己的clusterState.nodes字典里找到发送者对应的clusterNode结 构,并对结构进行更新。

MEET、PING、PONG消息的实现(Gossip协议)

Redis集群中的各个节点通过Gossip协议来交换各自关于不同节点的状态信息,其中Gossip协议由MEET、PING、PONG三种消息实现,这三种消息的正文都由两个cluster.h/clusterMsgDataGossip结构组成:

1
2
3
4
5
6
7
8
9
10
union clusterMsgData {
// ...
// MEET 、PING 和PONG 消息的正文
struct {
//每条MEET 、PING 、PONG 消息都包含两个
// clusterMsgDataGossip 结构
clusterMsgDataGossip gossip[1];
} ping;
//其他消息的正文...
};

因为MEET、PING、PONG三种消息都使用相同的消息正文,所以节点通过消息头clusterMsg结构的type属性来判断一条消息是MEET消息、PING消息还是PONG消息。

每次发送MEET、PING、PONG消息时,发送者都从自己的已知节点列表中随机选出两个节点(可以是主节点或者从节点),并将这两个被选中节点的信息分别保存到两个clusterMsgDataGossip结构里面
clusterMsgDataGossip结构记录了被选中节点的名字,发送者与被选中节点最后一次发送和接收PING消息和PONG消息的时间戳,被选中节点的IP地址和端口号,以及被选中节点的标识值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct {
//节点的名字
char nodename[REDIS_CLUSTER_NAMELEN];
//最后一次向该节点发送PING 消息的时间戳
uint32_t ping_sent;
//最后一次从该节点接收到PONG 消息的时间戳
uint32_t pong_received;
//节点的IP 地址
char ip[16];
//节点的端口号
uint16_t port;
//节点的标识值
uint16_t flags;
} clusterMsgDataGossip;

当接收者收到MEET、PING、PONG消息时,接收者会访问消息正文中的两个clusterMsgDataGossip结构,并根据自己是否认识clusterMsgDataGossip结构中记录的被选中节点来选择进行哪种操作:

  • 如果被选中节点不存在于接收者的已知节点列表,那么说明接收者是第一次接触到被选中节点,接收者将根据结构中记录的IP地址和端口号等信息,与被选中节点进行握手
  • 如果被选中节点已经存在于接收者的已知节点列表,那么说明接收者之前已经与被选中节点进行过接触,接收者将根据clusterMsgDataGossip结构记录的信息,对被选中节点所对应的clusterNode结构进行更新

举个发送PING消息和返回PONG消息的例子,假设在一个包含A、B、C、D、E、F六个节点的集群里:

  • 节点A向节点D发送PING消息,并且消息里面包含了节点B和节点C的信息,当节点D收到这条PING消息时,它将更新自己对节点B和节点C的认识
  • 之后,节点D将向节点A返回一条PONG消息,并且消息里面包含了节点E和节点F的消息,当节点A收到这条PONG消息时,它将更新自己对节点E和节点F的认识

FAIL消息的实现

当集群里的主节点A将主节点B标记为已下线(FAIL)时,主节点A将向集群广播一条关于主节点B的FAIL消息,所有接收到这条FAIL消息的节点都会将主节点B标记为已下线
在集群的节点数量比较大的情况下,单纯使用Gossip协议来传播节点的已下线信息会给节点的信息更新带来一定延迟,而发送FAIL消息可以让集群里的所有节点立即知道某个主节点已下线,从而尽快判断是否需要将集群标记为下线,又或者对下线主节点进行故障转移。

FAIL消息的正文由cluster.h/clusterMsgDataFail结构表示,这个结构只包含一个nodename属性,该属性记录了已下线节点的名字,因为集群里的所有节点都有一个独一无二的名字,所以FAIL消息里面只需要保存下线节点的名字,接收到消息的节点就可以根据这个名字来判断是哪个节点下线了

1
2
3
typedef struct {
char nodename[REDIS_CLUSTER_NAMELEN];
} clusterMsgDataFail;

PUBLISH消息的实现

当客户端向集群中的某个节点发送命令:PUBLISH <channel> <message>,接收到PUBLISH命令的节点不仅会向channel频道发送消息message,它还会向集群广播一条PUBLISH消息,所有接收到这条PUBLISH消息的节点都会向channel频道发送 message消息。换句话说,向集群中的某个节点发送PUBLISH命令,将导致集群中的所有节点都向channel频道发送message消息
为什么不直接向节点广播PUBLISH命令:实际上,要让集群的所有节点都执行相同的PUBLISH命令,最简单的方法就是向所有节点广播相同的PUBLISH命令,这也是Redis在复制PUBLISH命令时所使用的方法, 不过因为这种做法并不符合Redis集群的“各个节点通过发送和接收消息来进行通信”这一 规则,所以节点没有采取广播PUBLISH命令的做法

PUBLISH消息的正文由cluster.h/clusterMsgDataPublish结构表示:

1
2
3
4
5
6
7
typedef struct {
uint32_t channel_len;
uint32_t message_len;
//定义为8 字节只是为了对齐其他消息结构
//实际的长度由保存的内容决定
unsigned char bulk_data[8];
} clusterMsgDataPublish;

解释:

  • bulk_data属性是一个字节数组,这个字节数组保存了客户端通过PUBLISH命令发送给节点的channel参数和message参数
  • channel_len和 message_len则分别保存了channel参数的长度和message参数的长度
    • 其中bulk_data的0字节至channel_len-1字节保存的是channel参数
    • 而bulk_data的channel_len字节至channel_len+message_len-1字节保存的则是message参数

例如:如果节点收到的PUBLISH命令为:
PUBLISH “news.it” “hello”
那么节点发送的PUBLISH消息的clusterMsgDataPublish结构将如下图所示:其中 bulk_data数组的前七个字节保存了channel参数的值”news.it”,而bulk_data数组的后五个字节 则保存了message参数的值”hello”

独立功能的实现

发布与订阅

Redis的发布与订阅功能由PUBLISH、SUBSCRIBE、PSUBSCRIBE等命令组成。通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者(subscriber):每当有其他客户端使用PUBLISH命令向被订阅的频道发送消息(message)时,频道的所有订阅者都会收到这条消息

客户端还可以通过执行PSUBSCRIBE命令订阅一个或多个模式,从 而成为这些模式的订阅者:每当有其他客户端向某个频道发送消息时,消息不仅会被发送给 这个频道的所有订阅者,它还会被发送给所有与这个频道相匹配的模式的订阅者。

举个例子,假设如下图所示:
客户端A正在订阅频道news.it
客户端B正在订阅频道news.et
客户端C和客户端D正在订阅与”news.it”频道和”news.et”频道相匹配的模式news.[ie]t

如果这时某个客户端执行PUBLISH命令,向”news.it”频道发送消息”hello”,那么不仅正在订阅”news.it”频道的客户端A会收到消息,客户端C和客户端D也同样会收到消息,因为这两个客户端正在订阅匹配”news.it”频道 的news.[ie]t模式,如下图所示:
PUBLISH "news.it" "hello"

与此类似,如果某个客户端执行下面的命令,向”news.et”频道发送消息”world”,那么不仅正在订阅”news.et”频道的客户端B会收到消 息,客户端C和客户端D也同样会收到消息,因为这两个客户端正在订阅匹配”news.et”频道 的”news.[ie]t”模式,如下图所示:
PUBLISH "news.et" "world"

频道的订阅与退订

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

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

通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道,从而成为这些频道的订阅者(subscriber):每当有其他客户端使用PUBLISH命令向被订阅的频道发送消息(message)时,频道的所有订阅者都会收到这条消息。根据频道是否已经有其他订阅者,关联操作分为两种情况执行:

  • 如果频道已经有其他订阅者,那么它在pubsub_channels字典中必然有相应的订阅者链 表,程序唯一要做的就是将客户端添加到订阅者链表的末尾
  • 如果频道还未有任何订阅者,那么它必然不存在于pubsub_channels字典,程序首先要在 pubsub_channels字典中为频道创建一个键,并将这个键的值设置为空链表,然后再将客户端 添加到链表,成为链表的第一个元素
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命令)

UNSUBSCRIBE命令的行为和SUBSCRIBE命令的行为正好相反,当一个客户端退订某个或某些频道的时候,服务器将从pubsub_channels中解除客户端与被退订频道之间的关联:

  • 程序会根据被退订频道的名字,在pubsub_channels字典中找到频道对应的订阅者链表, 然后从订阅者链表中删除退订客户端的信息
  • 如果删除退订客户端之后,频道的订阅者链表变成了空链表,那么说明这个频道已经没有任何订阅者了,程序将从pubsub_channels字典中删除频道对应的键
1
2
3
4
5
6
7
8
9
10
11

def unsubscribe(*all_input_channels):
#遍历要退订的所有频道
for channel in all_input_channels:
# 在订阅者链表中删除退订的客户端
server.pubsub_channels[channel].remove(client)

# 如果频道已经没有任何订阅者了(订阅者链表为空)
# 那么将频道从字典中删除
if(len(server.pubsub_channels[channel])==0:
server.pubsub_channels.remove(channel)

模式的订阅与退订

服务器将所有频道的订阅关系都保存在服务器状态的pubsub_channels属性里面,与此类似,服务器也将所有模式的订阅关系都保存在服务器状态的pubsub_patterns属性里面:

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

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

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

//被订阅的模式
robj *pattern;
} pubsubPattern;

下图展示了一个pubsub_patterns链表示例,这个链表记录了以下信息:

  • 客户端client-7正在订阅模式”music.*”
  • 客户端client-8正在订阅模式”book.*”
  • 客户端client-9正在订阅模式”news.*”

订阅模式

每当客户端执行PSUBSCRIBE命令订阅某个或某些模式的时候,服务器会对每个被订阅的模式执行以下两个操作:

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

PSUBSCRIBE命令的实现原理可以用以下伪代码来描述:

1
2
3
4
5
6
7
8
9
10
11
def psubscribe(*all_input_patterns):
# 遍历输入的所有模式
for pattern in all_input_patterns:
# 创建新的pubsubPattern 结构
# 记录被订阅的模式,以及订阅模式的客户端
pubsubPattern = create_new_pubsubPattern()
pubsubPattern.client = client
pubsubPattern.pattern = pattern

# 将新的pubsubPattern追加到pubsub_patterns 链表末尾
server.pubsub_patterns.append(pubsubPattern)

模式的退订

模式的退订命令PUNSUBSCRIBE是PSUBSCRIBE命令的反操作:当一个客户端退订某个或某些模式的时候,服务器将在pubsub_patterns链表中查找并删除那些pattern属性为被退订模式,并且client属性为执行退订命令的客户端的pubsubPattern结构。PUNSUBSCRIBE命令的实现原理可以用以下伪代码来描述:

1
2
3
4
5
6
7
8
9
10
11
def punsubscribe(*all_input_patterns):
# 遍历所有要退订的模式
for pattern in all_input_patterns:
# 遍历pubsub_patterns 链表中的所有pubsubPattern 结构
for pubsubPattern in server.pubsub_patterns:
#如果当前客户端和pubsubPattern 记录的客户端相同
# 并且要退订的模式也和pubsubPattern 记录的模式相同
if client == pubsubPattern.client and \
pattern == pubsubPattern.pattern:
# 那么将这个pubsubPattern 从链表中删除
server.pubsub_patterns.remove(pubsubPattern)

消息的发送

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

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

将消息发送给频道订阅者

PUBLISH命令要做的就是在pubsub_channels字典里找到频道channel的订阅者名单(一个链表),然后将消息发送给名单上的所有客户端。PUBLISH命令将消息发送给频道订阅者的方法可以用以下伪代码来描述:

1
2
3
4
5
6
7
8
9
10
11
def channel_publish(channel, message):
# 如果channel键不存在于pubsub_channels 字典中
# 那么说明channel 频道没有任何订阅者
# 程序不做发送动作,直接返回
if channel not in server.pubsub_channels:
return
# 运行到这里,说明channel 频道至少有一个订阅者
# 程序遍历channel 频道的订阅者链表
# 将消息发送给所有订阅者
for subscriber in server.pubsub_channels[channel]:
send_message(subscriber, message)

将消息发送给模式订阅者

为了将消息发送给所有与channel频道相匹配的模式的订阅者,PUBLISH命令要做的就是遍历整个pubsub_patterns链表,查找那些与channel频道相匹配的模式,并将消息发送给订阅了这些模式的客户端。PUBLISH命令将消息发送给模式订阅者的方法可以用以下伪代码来描述:

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

查看订阅信息

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

PUBSUB CHANNELS命令

功能:用于返回服务器当前被订阅的频道
命令格式如下:PUBSUB CHANNELS [pattern]

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def pubsub_channels(pattern=None):
# 一个列表,用于记录所有符合条件的频道
channel_list = []

# 遍历服务器中的所有频道
# (也即是pubsub_channels 字典的所有键)
for channel in server.pubsub_channels:
# 当以下两个条件的任意一个满足时,将频道添加到链表里面:
#1 )用户没有指定pattern 参数
#2 )用户指定了pattern 参数,并且channel 和pattern 匹配
if (pattern is None) or match(channel, pattern):
channel_list.append(channel)
#向客户端返回频道列表
return channel_list

PUBSUB NUMSUB命令

功能:接受任意多个频道作为输入参数,并返回这些频道的订阅者数量,命令格式如下:PUBSUB NUMSUB [channel-1 channel-2...channel-n]

这个子命令是通过在pubsub_channels字典中找到频道对应的订阅者链表,然后返回订阅 者链表的长度来实现的(订阅者链表的长度就是频道订阅者的数量)
这个过程可以用以下 伪代码来描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def pubsub_numsub(*all_input_channels):
# 遍历输入的所有频道
for channel in all_input_channels:
# 如果pubsub_channels 字典中没有channel 这个键
# 那么说明channel 频道没有任何订阅者
if channel not in server.pubsub_channels:
# 返回频道名
reply_channel_name(channel)
# 订阅者数量为0
reply_subscribe_count(0)
# 如果pubsub_channels 字典中存在channel 键
# 那么说明channel 频道至少有一个订阅者
else:
# 返回频道名
reply_channel_name(channel)
# 订阅者链表的长度就是订阅者数量
reply_subscribe_count(len(server.pubsub_channels[channel])

PUBSUB NUMPAT命令

功能:用于返回服务器当前被订阅模式的数量。命令格式如下:PUBSUB NUMPAT

这个子命令是通过返回pubsub_patterns链表的长度来实现的,因为这个链表的长度就是 服务器被订阅模式的数量。这个过程可以用以下伪代码来描述:

1
2
3
def pubsub_numpat():
# pubsub_patterns 链表的长度就是被订阅模式的数量
reply_pattern_count(len(server.pubsub_patterns))

事务

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

Redis事务不支持回滚机制,如果事务中的命令是在执行期间出现了错误,事务的后续命令也会继续执行下去,并且之前执行的命令也不会有任何影响
事务因为命令入队出错而被服务器拒绝执行,事务中的所有命令都不会被执行。

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

事务的实现

三个阶段:

  • 事务开始
  • 命令入队
  • 事务执行

事务开始(MULTI命令)

MULTI命令的执行标志着事务的开始。MULTI命令可以将执行该命令的客户端从非事务状态切换至事务状态,这一切换是通过在客户端状态的flags属性中打开REDIS_MULTI标识来完成的,MULTI命令的实现可以用以 下伪代码来表示:

1
2
3
4
5
def MULTI():
# 打开事务标识
client.flags |= REDIS_MULTI
# 返回OK 回复
replyOK()

命令入队

当一个客户端处于非事务状态时,这个客户端发送的命令会立即被服务器执行。当一个客户端切换到事务状态之后,服务器会根据这个客户端发来的不同命令执行不同的操作

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

事务队列(mstate属性)

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

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

事务状态包含一个事务队列,以及一个已入队命令的计数器(也可以说是事务队列的长度):
1
2
3
4
5
6
7
typedef struct multiState {
//事务队列,FIFO 顺序
multiCmd *commands;

//已入队命令计数
int count;
} multiState;

事务队列是一个multiCmd类型的数组,数组中的每个multiCmd结构都保存了一个已入队命令的相关信息,包括指向命令实现函数的指针、命令的参数,以及参数的数量:
1
2
3
4
5
6
7
8
9
10
typedef struct multiCmd {
//参数
robj **argv;

//参数数量
int argc;

//命令指针
struct redisCommand *cmd;
} multiCmd;

事务队列以先进先出(FIFO)的方式保存入队的命令,较先入队的命令会被放到数组的 前面,而较后入队的命令则会被放到数组的后面

执行事务

当一个处于事务状态的客户端向服务器发送EXEC命令时,这个EXEC命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端。EXEC命令的实现原理可以用以下伪代码来描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def EXEC():
# 创建空白的回复队列
reply_queue = []

# 遍历事务队列中的每个项
# 读取命令的参数,参数的个数,以及要执行的命令
for argv, argc, cmd in client.mstate.commands:
# 执行命令,并取得命令的返回值
reply = execute_command(cmd, argv, argc)

# 将返回值追加到回复队列末尾
reply_queue.append(reply)

# 移除REDIS_MULTI 标识,让客户端回到非事务状态
client.flags & = ~REDIS_MULTI

# 清空客户端的事务状态,包括:
#1 )清零入队命令计数器
#2 )释放事务队列
client.mstate.count = 0
release_transaction_queue(client.mstate.commands)
# 将事务的执行结果返回给客户端
send_reply_to_client(client, reply_queue)

WATCH命令

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

使用WATCH命令监视数据库键(watched_keys字典)

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

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

通过watched_keys字典,服务器可以清楚地知道哪些数据库键正在被监视,以及哪些客户端正在监视这些数据库键。

监视机制的触发(touchWatchKey函数、REDIS_DIRTY_CAS标识)

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

touchWatchKey函数的定义可以用以下伪代码来描述:

1
2
3
4
5
6
7
8
def touchWatchKey(db, key):
# 如果键key 存在于数据库的watched_keys 字典中
# 那么说明至少有一个客户端在监视这个key
if key in db.watched_keys:
# 遍历所有监视键key 的客户端
for client in db.watched_keys[key]:
# 打开标识
client.flags |= REDIS_DIRTY_CAS

举个例子,对于下图所示的watched_keys字典来说:

  • 如果键”name”被修改,那么c1、c2、c10086三个客户端的REDIS_DIRTY_CAS标识将被打开
  • 如果键”age”被修改,那么c3和c10086两个客户端的REDIS_DIRTY_CAS标识将被打开
  • 如果键”address”被修改,那么c2和c4两个客户端的REDIS_DIRTY_CAS标识将被打开

判断事务是否安全

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

  • 如果REDIS_DIRTY_CAS打开,那么说明客户端所监视的键当中,至少有一个键已经被修改过了,服务器会拒绝执行客户端提交的事务
  • 如果REDIS_DIRTY_CAS没有打开,那么说明客户端监视的所有键都没有被修改过,事务仍然是安全的,服务器将执行客户端提交的这个事务

事务的ACID性质

在传统的关系式数据库中,常常用ACID性质来检验事务功能的可靠性和安全性。在Redis中,事务总是具有以下的特性:

  • 原子性(Atomicity)
  • 一致性(Consistency)
  • 隔离性 (Isolation)
  • 当Redis运行在某种特定的持久化模式下时,事务也具有耐久性 (Durability)

原子性

事务具有原子性指的是,数据库将事务中的多个操作当作一个整体来执行,服务器要么就执行事务中的所有操作,要么就一个操作也不执行。对于Redis的事务功能来说,事务队列中的命令要么就全部都执行,要么就一个都不执行,因此,Redis的事务是具有原子性的

Redis不支持事务回滚机制 (rollback),即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将事务队列中的所有命令都执行完毕为止。

一致性

事务具有一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后, 无论事务是否执行成功,数据库也应该仍然是一致的。“一致”指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。

Redis通过谨慎的错误检测和简单的设计来保证事务的一致性,下面将分别介绍三个Redis事务可能出错的地方,并说明Redis是如何妥善地处理这些错误,从而确保事务的一致性的。

  • 入队错误:如果一个事务在入队命令的过程中,出现了命令不存在,或者命令的格式不正确等情况,那么Redis将拒绝执行这个事务
  • 执行错误:事务还可能在执行的过程中发生错误,关于这种错误有两个需要说明的地方:
    • 执行过程中发生的错误都是一些不能在入队时被服务器发现的错误,这些错误只会在命令实际执行时被触发
    • 即使在事务的执行过程中发生了错误,服务器也不会中断事务的执行,它会继续执行事务中余下的其他命令,并且已执行的命令(包括执行命令所产生的结果)不会被出错的命令影响
  • 服务器停机:如果Redis服务器在执行事务的过程中停机,那么根据服务器所使用的持久化模式,可能有以下情况出现:
    • 如果服务器运行在无持久化的内存模式下,那么重启之后的数据库将是空白的,因此数据总是一致的
    • 如果服务器运行在RDB模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的RDB文件来恢复数据,从而将数据库还原到一个一致的状态。如果找不到可供使用的RDB文件,那么重启之后的数据库将是空白的,而空白数据库总是一致的
    • 如果服务器运行在AOF模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的AOF文件来恢复数据,从而将数据库还原到一个一致的状态。如果找不到可供使用的AOF文件,那么重启之后的数据库将是空白的,而空白数据库总是一致的

隔离性

事务的隔离性指的是,即使数据库中有多个事务并发地执行,各个事务之间也不会互相影响,并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同。因为Redis使用单线程的方式来执行事务(以及事务队列中的命令),并且服务器保证, 在执行事务期间不会对事务进行中断,因此,Redis的事务总是以串行的方式运行的,并且事务也总是具有隔离性的

耐久性(持久性)

事务的耐久性指的是,当一个事务执行完毕时,执行这个事务所得的结果已经被保存到永久性存储介质(比如硬盘)里面了,即使服务器在事务执行完毕之后停机,执行事务所得的结果也不会丢失。因为Redis的事务不过是简单地用队列包裹起了一组Redis命令,Redis并没有为事务提供任何额外的持久化功能,所以Redis事务的耐久性由Redis所使用的持久化模式决定。

Lua脚本

创建并修改Lua环境

为了在Redis服务器中执行Lua脚本,Redis在服务器内嵌了一个Lua环境,并对这个Lua环境进行了一系列修改,从而确保这个Lua环境可以满足Redis服务器的需要。Redis服务器创建并修改Lua环境的整个过程由以下步骤组成:

  • 创建一个基础的Lua环境,之后的所有修改都是针对这个环境进行的。服务器首先调用Lua的C API函数lua_open,创建一个新的Lua环境。
  • 载入多个函数库到Lua环境里面,让Lua脚本可以使用这些函数库来进行数据操作
    • 基础库(base library):这个库包含Lua的核心(core)函数;
    • 表格库(table library):这个库包含用于处理表格的通用函数;
    • 字符串库(string library):这个库包含用于处理字符串的通用函数;
    • 数学库(math library):这个库是标准C语言数学库的接口;
    • 调试库(debug library):这个库提供了对程序进行调试所需的函数;
    • Lua CJSON库:这个库用于处理UTF-8编码的JSON格式;
    • Struct库:这个库用于在Lua值和C结构 (struct)之间进行转换;
    • Lua cmsgpack库:这个库用于处理 MessagePack格式的数据;
  • 创建全局表格redis,并将它设为全局变量。这个表格包含了对Redis进行操作的函数,比如用于在Lua脚本中 执行Redis命令的redis.call函数
  • 使用Redis自制的随机函数来替换Lua原有的带有副作用的随机函数,从而避免在脚本中引入副作用
  • 创建排序辅助函数__redis__compare_helper,Lua环境使用这个辅佐函数来对一部分Redis命令的结果进行排序,从而消除这些命令的不确定性
  • 创建redis.pcall函数的错误报告辅助函数,这个函数可以提供更详细的出错信息
  • 对Lua环境中的全局环境进行保护,防止用户在执行Lua脚本的过程中,将额外的全 局变量添加到Lua环境中
  • 将完成修改的Lua环境保存到服务器状态的lua属性中,等待执行服务器传来的Lua脚本

Lua环境协作组件

伪客户端

因为执行Redis命令必须有相应的客户端状态,所以为了执行Lua脚本中包含的Redis命令,Redis服务器专门为Lua环境创建了一个伪客户端,并由这个伪客户端负责处理Lua脚本中包含的所有Redis命令。Lua脚本使用redis.call函数或者redis.pcall函数执行一个Redis命令,需要完成以下步骤:

  1. Lua环境将redis.call函数或者redis.pcall函数想要执行的命令传给伪客户端
  2. 伪客户端将脚本想要执行的命令传给命令执行器
  3. 命令执行器执行伪客户端传给它的命令,并将命令的执行结果返回给伪客户端
  4. 伪客户端接收命令执行器返回的命令结果,并将这个命令结果返回给Lua环境
  5. Lua环境在接收到命令结果之后,将该结果返回给redis.call函数或者redis.pcall函数
  6. 接收到结果的redis.call函数或者redis.pcall函数会将命令结果作为函数返回值返回给 脚本中的调用者

lua_scripts字典

除了伪客户端之外,Redis服务器为Lua环境创建的另一个协作组件是lua_scripts字典:字典的键为某个Lua脚本的SHA1校验和(checksum),字典的值则是SHA1校验和对应的Lua脚本

1
2
3
4
5
struct redisServer {
// ...
dict *lua_scripts;
// ...
};

Redis服务器会将所有被EVAL命令执行过的Lua脚本,以及所有被SCRIPT LOAD命令载入过的Lua脚本都保存到lua_scripts字典里面,lua_scripts字典有两个作用:一个是实现SCRIPT EXISTS命令,另一个是实现脚本复制功能。

EVAL命令的实现

EVAL命令可以直接执行Lua脚本,执行过程可以分为以下三个步骤:

  • 根据客户端给定的Lua脚本,在Lua环境中定义一个Lua函数
  • 将客户端给定的脚本保存到lua_scripts字典,等待将来进一步使用
  • 执行刚刚在Lua环境中定义的函数,以此来执行客户端给定的Lua脚本

定义脚本函数

当客户端向服务器发送EVAL命令,要求执行某个Lua脚本的时候,服务器首先要做的就是在Lua环境中,为传入的脚本定义一个与这个脚本相对应的Lua函数,其中:

  • Lua函数的名字由f_前缀加上脚本的SHA1校验和(四十个字符长)组成
  • 函数的体(body)则是脚本本身

使用函数来保存客户端传入的脚本有以下好处:

  • 执行脚本的步骤非常简单,只要调用与脚本相对应的函数即可
  • 通过函数的局部性来让Lua环境保持清洁,减少了垃圾回收的工作量,并且避免了使用全局变量
  • 如果某个脚本所对应的函数在Lua环境中被定义过至少一次,那么只要记得这个脚本的SHA1校验和,服务器就可以在不知道脚本本身的情况下,直接通过调用Lua函数来执行脚本

将脚本保存到lua_scripts字典

EVAL命令要做的第二件事是将客户端传入的脚本保存到服务器的lua_scripts字典里面

执行脚本函数

为脚本定义函数,并且将脚本保存到lua_scripts字典之后,服务器还需要进行一些设置钩子传入参数之类的准备动作,才能正式开始执行脚本。整个准备和执行脚本的过程如下:

  • 将EVAL命令中传入的键名(key name)参数和脚本参数分别保存到KEYS数组和ARGV数组,然后将这两个数组作为全局变量传入到Lua环境里面
  • 为Lua环境装载超时处理钩子(hook),这个钩子可以在脚本出现超时运行情况时, 让客户端通过SCRIPT KILL命令停止脚本,或者通过SHUTDOWN命令直接关闭服务器
  • 执行脚本函数
  • 移除之前装载的超时钩子
  • 将执行脚本函数所得的结果保存到客户端状态的输出缓冲区里面,等待服务器将结果返回给客户端
  • 对Lua环境执行垃圾回收操作

EVALSHA命令

使用EVALSHA命令则可以根据脚本的SHA1校验和来对脚本进行求值,但这个命令要求校验和对应的脚本必须至少被EVAL命令执行过一次,或者这个校验和对应的脚本曾经被SCRIPT LOAD命令载入过。

EVALSHA命令的实现:每个被EVAL命令成功执行过的Lua脚本,在Lua环境里面都有一个与这个脚本相对应的Lua函数,函数的名字由f_前缀加上40个字符长的 SHA1校验和组成,例如f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91。只要脚本对应的函数曾经在Lua环境里面定义过,那么即使不知道脚本的内容本身,客户端也可以根据脚本的SHA1校验和来调用脚本对应的函数,从而达到执行脚本的目的,这就是EVALSHA命令的实现原理。可以用伪代码来描述这一原理:

1
2
3
4
5
6
7
8
9
10
11
12
def EVALSHA(sha1):
# 拼接出函数的名字
# 例如:f_5332031c6b470dc5a0dd9b4bf2030dea6d65de91
func_name = "f_" + sha1

# 查看这个函数在Lua 环境中是否存在
if function_exists_in_lua_env(func_name):
# 如果函数存在,那么执行它
execute_lua_function(func_name)
else:
# 如果函数不存在,那么返回一个错误
send_script_error("SCRIPT NOT FOUND")

脚本管理命令

SCRIPT FLUSH

SCRIPT FLUSH命令用于清除服务器中所有和Lua脚本有关的信息,这个命令会释放并重建lua_scripts字典,关闭现有的Lua环境并重新创建一个新的Lua环境。以下为SCRIPT FLUSH命令的实现伪代码:

1
2
3
4
5
6
7
8
9
def SCRIPT_FLUSH():
# 释放脚本字典
dictRelease(server.lua_scripts)
# 重建脚本字典
server.lua_scripts = dictCreate(...)
# 关闭Lua 环境
lua_close(server.lua)
# 初始化一个新的Lua 环境
server.lua = init_lua_env()

SCRIPT EXISTS

SCRIPT EXISTS命令根据输入的SHA1校验和,检查校验和对应的脚本是否存在于服务器中,SCRIPT EXISTS命令是通过检查给定的校验和是否存在于lua_scripts字典来实现的。以下是该命令的实现伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def SCRIPT_EXISTS(*sha1_list):
# 结果列表
result_list = []
#遍历输入的所有SHA1 校验和
for sha1 in sha1_list:
# 检查校验和是否为lua_scripts 字典的键
# 如果是的话,那么表示校验和对应的脚本存在
# 否则的话,脚本就不存在
if sha1 in server.lua_scripts:
# 存在用1 表示
result_list.append(1)
else:
# 不存在用0 表示
result_list.append(0)
# 向客户端返回结果列表
send_list_reply(result_list)

SCRIPT LOAD

SCRIPT LOAD命令只加载/保存脚本,但是不执行脚本。命令首先在Lua环境中为脚本创建相对应的函数,然后再将脚本对应的键值对保存到lua_scripts字典里面。举个例子,如果我们执行以下命令:

那么服务器将在Lua环境中创建以下函数:

并将键为”2f31ba2bb6d6a0f42cc159d2e2dad55440778de3”,值为”return’hi’”的键值对添加 到服务器的lua_scripts字典里面。

SCRIPT KILL

如果服务器设置了lua-time-limit配置选项,那么在每次执行Lua脚本之前,服务器都会在Lua环境里面设置一个超时处理钩子(hook)。超时处理钩子在脚本运行期间,会定期检查脚本已经运行了多长时间,一旦钩子发现脚本的运行时间已经超过了lua-time-limit选项设置的时长,钩子将定期在脚本运行的间隙中, 查看是否有SCRIPT KILL命令或者SHUTDOWN命令到达服务器。
下图展示了带有超时处理钩子的脚本的运行过程:

如果超时运行的脚本未执行过任何写入操作,那么客户端可以通过SCRIPT KILL命令来指示服务器停止执行这个脚本,并向执行该脚本的客户端发送一个错误回复。处理完SCRIPT KILL命令之后,服务器可以继续运行。如果脚本已经执行过写入操作,那么客户端只能用SHUTDOWN nosave命令来停止服务器,从而防止不合法的数据被写入数据库中。

脚本的复制

与其他普通Redis命令一样,当服务器运行在复制模式之下时,具有写性质的脚本命令也会被复制到从服务器,这些命令包括EVAL命令、EVALSHA命令、SCRIPT FLUSH命令,以 及SCRIPT LOAD命令。

复制EVAL命令、SCRIPT FLUSH命令、SCRIPT LOAD命令

Redis复制EVAL、SCRIPT FLUSH、SCRIPT LOAD三个命令的方法和复制其他普通Redis 命令的方法一样,当主服务器执行完以上三个命令的其中一个时,主服务器会直接将被执行的命令传播(propagate)给所有从服务器,如下图所示:

复制EVALSHA命令

EVALSHA命令是所有与Lua脚本有关的命令中,复制操作最复杂的一个。对于一个在主服务器被成功执行的EVALSHA命令来说,相同的EVALSHA命令在从服务器执行时却可能会出现脚本未找到(not found)错误。

Redis要求主服务器在传播EVALSHA命令的时候,必须确保EVALSHA命令要执行的脚本已经被所有从服务器载入过,如果不能确保这一点的话, 主服务器会将EVALSHA命令转换成一个等价的EVAL命令,然后通过传播EVAL命令来代替EVALSHA命令。传播EVALSHA命令,或者将EVALSHA命令转换成EVAL命令,都需要用到服务器状态的lua_scripts字典和repl_scriptcache_dict字典,接下来的小节将分别介绍这两个字典的作用, 并最终说明Redis复制EVALSHA命令的方法。

①判断传播EVALSHA命令是否安全的方法(repl_scriptcache_dict字典):主服务器使用服务器状态的repl_scriptcache_dict字典记录自己已经将哪些脚本传播给了所有从服务器:

1
2
3
4
5
struct redisServer {
// ...
dict *repl_scriptcache_dict;
// ...
};

字典的键是一个个Lua脚本的SHA1校验和,字典的值则全部都是 NULL。当一个校验和出现在repl_scriptcache_dict字典时,说明这个校验和对应的Lua脚本已经传播给了所有从服务器,主服务器可以直接向从服务器传播包含这个SHA1校验和的 EVALSHA命令,而不必担心从服务器会出现脚本未找到错误。

②清空repl_scriptcache_dict字典:每当主服务器添加一个新的从服务器时,主服务器都会清空自己的repl_scriptcache_dict字典,这是因为:随着新从服务器的出现,repl_scriptcache_dict字典里面记录的脚本已经不再被所有从服务器载入过,所以要强制自己重新向所有从服务器传播脚本,从而确保新的从服务器不会出现脚本未找到错误。

③EVALSHA命令转换成EVAL命令的方法:通过使用EVALSHA命令指定的SHA1校验和,以及lua_scripts字典保存的Lua脚本,服务器总可以将一个EVALSHA命令:EVALSHA <sha1> <numkeys> [key ...] [arg ...]转换成一个等价的EVAL命令:EVAL <script> <numkeys> [key ...] [arg ...]

具体的转换方法如下:

  • 根据SHA1校验和sha1,在lua_scripts字典中查找sha1对应的Lua脚本script
  • 将原来的EVALSHA命令请求改写成EVAL命令请求,并且将校验和sha1改成脚本script,至于numkeys、key、arg等参数则保持不变
  • 如果一个SHA1值所对应的Lua脚本没有被所有从服务器载入过,那么主服务器可以将EVALSHA命令转换成等价的EVAL命令,然后通过传播等价的EVAL命令来代替原本想要传播的EVALSHA命令,以此来产生相同的脚本执行效果,并确保所有从服务器都不会出现脚本未找到错误

④传播EVALSHA命令的方法:当主服务器成功在本机执行完一个EVALSHA命令之后,它将根据EVALSHA命令指定的SHA1校验和是否存在于repl_scriptcache_dict字典来决定是向从服务器传播EVALSHA命令还是EVAL命令

  • 如果EVALSHA命令指定的SHA1校验和存在于repl_scriptcache_dict字典,那么主服务器直接向从服务器传播EVALSHA命令
  • 如果EVALSHA命令指定的SHA1校验和不存在于repl_scriptcache_dict字典,那么主服务器会将EVALSHA命令转换成等价的EVAL命令,然后传播这个等价的EVAL命令,并将 EVALSHA命令指定的SHA1校验和添加到repl_scriptcache_dict字典里面

SORT

Redis的SORT命令可以对列表键、集合键或者有序集合键的值进行排序。

SORT 命令

对键key进行排序,默认不带任何选项的SORT:只可以对包含数字键的键key进行排序,且默认是升序排序。例如下面对一个包含3个数字的列表进行排序:

1
2
3
4
5
6
7
redis> RPUSH numbers 3 1 2
(integer) 3

redis> SORT numbers
1) "1"
2) "2"
3) "3"

SORT命令的实现(struct redisSortObject)

  • SORT命令为每个被排序的键都创建一个与键长度相同的数组,数组的每个项都是一个redisSortObject结构;
  • 遍历数组,将各个数组项的obj指针分别指向numbers列表的各个项,构成obj指针和列表项之间的一对一关系,如下图所示
  • 遍历数组,将各个obj指针所指向的列表项转换成一个double类型的浮点数,并将这 个浮点数保存在相应数组项的u.score属性里面,如下图所示
  • 根据数组项u.score属性的值,对数组进行数字值排序,排序后的数组项按u.score属 性的值从小到大排列,如下图所示
  • 遍历数组,将各个数组项的obj指针所指向的列表项作为排序结果返回给客户端,程 序首先访问数组的索引0,返回u.score值为1.0的列表项”1”;然后访问数组的索引1,返回 u.score值为2.0的列表项”2”;最后访问数组的索引2,返回u.score值为3.0的列表项”3”

以下是redisSortObject结构的完整定义:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct _redisSortObject {
//被排序键的值
robj *obj;

//权重
union {
//排序数字值时使用
double score;
//排序带有BY 选项的字符串值时使用
robj *cmpobj;
} u;
} redisSortObject;

ALPHA选项

命令格式:SORT <key> ALPHA

功能:默认的SORT只可以对包含数字的键进行排序,使用ALPHA选项可以对包含字符串值的键进行排序。例如下面对一个包含3个字符串值的集合键进行排序:

1
2
3
4
5
6
7
8
9
10
11
12
13
redis> SADD fruits apple banana cherry
(interger) 3

# 元素在集合中是乱序排放的
redis> SMEMBERS fruits
1) "apple"
2) "cherry"
3) "banana"

redis> SORT fruits ALPHA
1) "apple"
2) "banana"
3) "cherry"

过程:

  • 创建一个redisSortObject结构数组,数组的长度等于fruits集合的大小;
  • 遍历数组,将各个数组项的obj指针分别指向fruits集合的各个元素;
  • 根据obj指针所指向的集合元素,对数组进行字符串排序,排序后的数组项按集合元素的字符串值从小到大排列;
  • 遍历数组,依次将数组项的obj指针所指向的元素返回给客户端

ASC选项与DESC选项

命令格式:SORT <key> ASCSORT <key> DESC

功能:默认情况下SORT对排序结果进行升序结果(也就是ASC选项),但是使用DESC选项可以对排序的结果进行降序排序

BY选项

命令格式:SORT <key> BY <by-pattern>

功能:默认情况下SORT是根据键的元素的值作为权重来进行排序的,但是通过BY选项,SORT命令可以指定某些字符串键,或者某个哈希键所包含的某些域来作为元素的权重对一个键进行排序。例如,根据其他键的值作为权重来对fruits进行排序:

执行上面SORT fruits BY*-price命令的详细步骤如下:

  • 创建一个redisSortObject结构数组,数组的长度等于fruits集合的大小
  • 遍历数组,将各个数组项的obj指针分别指向fruits集合的各个元素,如下图所示
  • 遍历数组,根据各个数组项的obj指针所指向的集合元素,以及BY选项所给定的模式 *-price,查找相应的权重键:
    • 对于”apple”元素,查找程序返回权重键”apple-price”
    • 对于”banana”元素,查找程序返回权重键”banana-price”
    • 对于”cherry”元素,查找程序返回权重键”cherry-price”
  • 将各个权重键的值转换成一个double类型的浮点数,然后保存在相应数组项的u.score 属性里面,如下图所示
    • “apple”元素的权重键”apple-price”的值转换之后为8.0
    • “banana”元素的权重键”banana-price”的值转换之后为5.5
    • “cherry”元素的权重键”cherry-price”的值转换之后为7.0
  • 以数组项u.score属性的值为权重,对数组进行排序,得到一个按u.score属性的值从 小到大排序的数组,如下图所示
    • 权重为5.5的”banana”元素位于数组的索引0位置上
    • 权重为7.0的”cherry”元素位于数组的索引1位置上
    • 权重为8.0的”apple”元素位于数组的索引2位置上
  • 遍历数组,依次将数组项的obj指针所指向的集合元素返回给客户端

ALPHA选项与BY选项的配合使用

SORT <key> BY <by-pattern> ALPHA。在上面,我们介绍了BY选项可以根据其他权重键的值进行排序,但是其他权重键的值也是数字类型,如果其他权重键的值是字符串类型,那么就可以配合ALPHA选项来实现。

  • 创建一个redisSortObject结构数组,数组的长度等于fruits集合的大小
  • 遍历数组,将各个数组项的obj指针分别指向fruits集合的各个元素,如下图所示
  • 遍历数组,根据各个数组项的obj指针所指向的集合元素,以及BY选项所给定的模式 *-id,查找相应的权重键:
    • 对于”apple”元素,查找程序返回权重键”apple-id”
    • 对于”banana”元素,查找程序返回权重键”banana-id”
    • 对于”cherry “元素,查找程序返回权重键”cherry-id”
  • 将各个数组项的u.cmpobj指针分别指向相应的权重键(一个字符串对象),如下图所示
  • 以各个数组项的权重键的值为权重,对数组执行字符串排序,结果如下图所示
    • 权重为”FRUIT-13”的”cherry”元素位于数组的索引0位置上
    • 权重为”FRUIT-25”的”apple”元素位于数组的索引1位置上
    • 权重为”FRUIT-79”的”banana”元素位于数组的索引2位置上
  • 遍历数组,依次将数组项的obj指针所指向的集合元素返回给客户端

LIMIT选项

命令格式:SORT <key> LIMIT <offset> <count>。功能:使用LIMIT选项可以限制SORT命令返回的结果数量。从offset索引(索引从0开始)处开始返回count条结果。

GET选项

命令格式:SORT <key> GET <by-pattern>。功能:默认情况下SORT命令返回的是自己排序的结果,使用GET选项可以根据自己键的值来对别对的键进行排序。GET选项支持1个或多个,下面或依次介绍

STORE选项

命令格式:SORT <key> STORE <new_key>。功能:使用STORE选项,可以将排序的结果保存到一个新键中。

SORT命令选项的执行顺序

如果按照选项来划分的话,一个SORT命令的执行过程可以分为以下几步:

  • 排序:在这一步,命令会使用ALPHA、ASC或DESC、BY这几个选项,对输入键进 行排序,并得到一个排序结果集
  • 限制排序结果集的长度:在这一步,命令会使用LIMIT选项,对排序结果集的长度进 行限制,只有LIMIT选项指定的那部分元素会被保留在排序结果集中
  • 获取外部键:在这一步,命令会使用GET选项,根据排序结果集中的元素,以及 GET选项指定的模式,查找并获取指定键的值,并用这些值来作为新的排序结果集
  • 保存排序结果集:在这一步,命令会使用STORE选项,将排序结果集保存到指定的 键上面去
  • 向客户端返回排序结果集:在最后这一步,命令遍历排序结果集,并依次向客户端 返回排序结果集中的元素

二进制位数组操作

实现:Redis使用字符串对象来表示位数组,因为字符串对象使用的SDS数据结构是二进制安全的,所以程序可以直接使用SDS结构来保存位数组,并使用SDS结构的操作函数来处理位数组。

下图展示了用SDS表示的,一字节长的位数组:

  • redisObject.type的值为REDIS_STRING,表示这是一个字符串对象。
  • sdshdr.len的值为1,表示这个SDS保存了一个一字节长的位数组
  • buf数组中的buf[0]字节保存了一字节长的位数组
  • buf数组中的buf[1]字节保存了SDS程序自动追加到值的末尾的空字符’\0’

为了方便与表示二进制位,我们把buf[0]一字节表示为下面所示的状态(1字节8位)

备注(重点):

  • buf数组保存二进制位与我们平时表示的二进制为顺序是相反的
    • 例如在上图中我们的buf数组第1字节表示的二进制为10110010,实质上其表示的是01001101
    • 使用逆序来保存位数组可以简化SETBIT命令的实现(后面介绍SETBIT命令会解释)

GETBIT命令

功能:用于获取位数组指定偏移量上的二进制位的值

因为GETBIT命令执行的所有操作都可以在常数时间内完成,所以该命令的算法复杂度 为O(1)
格式:GETBIT <bitarray> <offset>

  • bitarray:二进制数组的名称
  • offset:偏移量(索引从0开始)

GETBIT命令的执行过程:

  • 计算byte= [offset÷8],byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节
  • 计算bit=(offset mod 8)+1,bit值记录了offset偏移量指定的二进制位是byte字节的 第几个二进制位
  • 根据byte值和bit值,在位数组bitarray中定位offset偏移量指定的二进制位,并返回这个位的值

举个例子,对于下图所示的位数组来说,执行以下命令:

1
GETBIT <bitarray> 3

将执行以下操作:

  • [3÷8]的值为0
  • (3 mod 8)+1的值为4
  • 定位到buf[0]字节上面,然后取出该字节上的第4个二进制位(从左向右数)的值
  • 向客户端返回二进制位的值1

SETBIT命令

功能:用于为位数组指定偏移量上的二进制位设置值,并将之前二进制位的旧值返回

因为SETBIT命令执行的所有操作都可以在常数时间内完成,所以该命令的时间复杂度为 O(1)
格式:SETBIE <bitarray> <offset> <value>

  • bitarray:二进制位数组
  • offset:偏移量(从0开始)
  • value:设置的值

SETBIT命令的执行过程:

  • 计算len=[offset÷8]+1,len值记录了保存offset偏移量指定的二进制位至少需 要多少字节
  • 检查bitarray键保存的位数组(也即是SDS)的长度是否小于len,如果是的话,将 SDS的长度扩展为len字节,并将所有新扩展空间的二进制位的值设置为0。
  • 计算byte=[offset÷8],byte值记录了offset偏移量指定的二进制位保存在位数 组的哪个字节
  • 计算bit=(offset mod 8)+1,bit值记录了offset偏移量指定的二进制位是byte字节的 第几个二进制位
  • 根据byte值和bit值,在bitarray键保存的位数组中定位offset偏移量指定的二进制位, 首先将指定二进制位现在值保存在oldvalue变量,然后将新值value设置为这个二进制位的值
  • 向客户端返回oldvalue变量的值

演示案例
首先,如果我们对下图所示的位数组执行命令:SETBIT <bitarray> 1 1

那么服务器将执行以下操作:

  • 计算[1÷8]+1,得出值1,这表示保存偏移量为1的二进制位至少需要1字节长 位数组
  • 检查位数组的长度,发现SDS的长度不小于1字节,无须执行扩展操作
  • 计算[1÷8],得出值0,说明偏移量为1的二进制位位于buf[0]字节
  • 计算(1 mod 8)+1,得出值2,说明偏移量为1的二进制位是buf[0]字节的第2个二进 制位
  • 定位到buf[0]字节的第2个二进制位上面,将二进制位现在的值0保存到oldvalue变 量,然后将二进制位的值设置为1
  • 向客户端返回oldvalue变量的值0

带有扩展操作的SETBIT命令演示案例

前面展示的SETBIT例子无须对位数组进行扩展,现在,让我们来看一个需要对位数组进行扩展的例子
假设我们对下图所示的位数组执行命令:SETBIT <bitarray> 12 1

那么服务器将执行以下操作:

  • 计算[12÷8]+1,得出值2,这表示保存偏移量为12的二进制位至少需要2字节长的位数组
  • 对位数组的长度进行检查,得知位数组现在的长度为1字节,小于最小长度2字节,所以程序会要求将位数组的长度扩展为2字节。不过,尽管程序只要求2字节长的位数组,但SDS的空间预分配策略会为SDS额外多分配2字节的未使用空间,再加上 为保存空字符而额外分配的1字节,扩展之后buf数组的实际长度为5字节,如下图所示
  • 计算[12÷8],得出值1,说明偏移量为12的二进制位位于buf[1]字节中
  • 计算(12 mod 8)+1,得出值5,说明偏移量为12的二进制位是buf[1]字节的第5个二进制位
  • 定位到buf[1]字节的第5个二进制位,将二进制位现在的值0保存到oldvalue变量,然 后将二进制位的值设置为1
  • 向客户端返回oldvalue变量的值0。 左图展示了SETBIT命令定位并设置指定二进制位的过程,而右图则展示了SETBIT 命令执行之后,位数组的样子

注意,因为buf数组使用逆序来保存位数组,所以当程序对buf数组进行扩展之后,写入操作可以直接在新扩展的二进制位中完成,而不必改动位数组原来已有的二进制位。相反地,如果buf数组使用和书写位数组时一样的顺序来保存位数组,那么在每次扩展 buf数组之后,程序都需要将位数组已有的位进行移动,然后才能执行写入操作,这比 SETBIT命令目前的实现方式要复杂,并且移位带来的CPU时间消耗也会影响命令的执行速度。

BITCOUNT命令

功能:用于统计位数组里面,值为1的二进制位的数量。

二进制位统计算法(1):遍历算法

实现BITCOUNT命令最简单直接的方法,就是遍历位数组中的每个二进制位,并在遇到值为1的二进制位时,将计数器的值增一,遍历算法虽然实现起来简单,但效率非常低。

二进制位统计算法(2):查表算法

优化检查操作的一个办法是使用查表法:对于一个有限集合来说,集合元素的排列方式是有限的,而对于一个有限长度的位数组来说,它能表示的二进制位排列也是有限的。根据这个原理,我们可以创建一个表,表的键为某种排列的位数组,而表的值则是相应位数组中,值为1的二进制位的数量。我们只需执行一次查表操作,就可以检查多个二进制位。

二进制位统计算法(3):variable-precision SWAR算法

BITCOUNT命令要解决的问题——统计一个位数组中非0二进制位的数量,在数学上被称为“计算汉明重量(Hamming Weight)”。以下是一个处理32位长度位数组的variable-precision SWAR算法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
uint32_t swar(uint32_t i) {
//步骤1
i = (i & 0x55555555) + ((i >> 1) & 0x55555555);

//步骤2
i = (i & 0x33333333) + ((i >> 2) & 0x33333333);

//步骤3
i = (i & 0x0F0F0F0F) + ((i >> 4) & 0x0F0F0F0F);

//步骤4
i = (i*(0x01010101) >> 24);

return i;
}

以下是调用swar(bitarray)的执行步骤:

  • 步骤1计算出的值i的二进制表示可以按每两个二进制位为一组进行分组,各组的十进制表示就是该组的汉明重量
  • 步骤2计算出的值i的二进制表示可以按每四个二进制位为一组进行分组,各组的十进制表示就是该组的汉明重量
  • 步骤3计算出的值i的二进制表示可以按每八个二进制位为一组进行分组,各组的十进制表示就是该组的汉明重量
  • 步骤4的i*0x01010101语句计算出bitarray的汉明重量并记录在二进制位的最高八位,而 >>24语句则通过右移运算,将bitarray的汉明重量移动到最低八位,得出的结果就是bitarray 的汉明重量

举个例子,对于调用swar(0x3A70F21B),程序在第一步将计算出值0x2560A116,这 个值的每两个二进制位的十进制表示记录了0x3A70F21B每两个二进制位的汉明重量,如下表所示

之后,程序在第二步将计算出值0x22304113,这个值的每四个二进制位的十进制表示记 录了0x3A70F21B每四个二进制位的汉明重量,如下表所示

接下来,程序在第三步将计算出值0x4030504,这个值的每八个二进制位的十进制表示 记录了0x3A70F21B每八个二进制位的汉明重量,如下表所示

在第四步,程序首先计算0x4030504*0x01010101=0x100c0904,将汉明重量聚集到二进 制位的最高八位,如下表所示

之后程序计算0x100c0904 >> 24,将汉明重量移动到低八位,最终得出值0x10,也即是 十进制值16,这个值就是0x3A70F21B的汉明重量,如下表所示

swar函数每次执行可以计算32个二进制位的汉明重量,它比之前介绍的遍历算法要快32 倍,比键长为8位的查表法快4倍,比键长为16位的查表法快2倍,并且因为swar函数是单纯 的计算操作,所以它无须像查表法那样,使用额外的内存。
另外,因为swar函数是一个常数复杂度的操作,所以我们可以按照自己的需要,在一次循环中多次执行swar,从而按倍数提升计算汉明重量的效率:

  • 例如,如果我们在一次循环中调用两次swar函数,那么计算汉明重量的效率就从之前的 一次循环计算32位提升到了一次循环计算64位
  • 又例如,如果我们在一次循环中调用四次swar函数,那么一次循环就可以计算128个二 进制位的汉明重量,这比每次循环只调用一次swar函数要快四倍!
  • 当然,在一个循环里执行多个swar调用这种优化方式是有极限的:一旦循环中处理的位数组的大小超过了缓存的大小,这种优化的效果就会降低并最终消失

二进制位统计算法(4):Redis的实现

BITCOUNT命令的实现用到了查表和variable-precisionSWAR两种算法:

  • 查表算法使用键长为8位的表,表中记录了从0000 0000到1111 1111在内的所有二进制位 的汉明重量
  • 至于variable-precision SWAR算法方面,BITCOUNT命令在每次循环中载入128个二进 制位,然后调用四次32位variable-precision SWAR算法来计算这128个二进制位的汉明重量

在执行BITCOUNT命令时,程序会根据未处理的二进制位的数量来决定使用那种算法:

  • 如果未处理的二进制位的数量大于等于128位,那么程序使用variable-precision SWAR算 法来计算二进制位的汉明重量
  • 如果未处理的二进制位的数量小于128位,那么程序使用查表算法来计算二进制位的汉 明重量

以下伪代码展示了BITCOUNT命令的实现原理:

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
#一个表,记录了所有八位长位数组的汉明重量
#程序将8 位长的位数组转换成无符号整数,并在表中进行索引
#例如,对于输入0000 0011 ,程序将二进制转换为无符号整数3
#然后取出weight_in_byte[3] 的值2
# 2 就是0000 0011 的汉明重量
weight_in_byte = [0,1,1,2,1,2,2,/*...*/,7,7,8]

def BITCOUNT(bits):
# 计算位数组包含了多少个二进制位
count = count_bit(bits)

# 初始化汉明重量为零
weight = 0

# 如果未处理的二进制位大于等于128 位
# 那么使用variable-precision SWAR 算法来处理
while count >= 128:
# 四个swar 调用,每个调用计算32 个二进制位的汉明重量
# 注意:bits[i:j] 中的索引j 是不包含在取值范围之内的
weight += swar(bits[0:32])
weight += swar(bits[32:64])
weight += swar(bits[64:96])
weight += swar(bits[96:128])

# 移动指针,略过已处理的位,指向未处理的位
bits = bits[128:]
# 减少未处理位的长度
count -= 128

# 如果执行到这里,说明未处理的位数量不足128 位
# 那么使用查表法来计算汉明重量
while count:
# 将8 个位转换成无符号整数,作为查表的索引(键)
index = bits_to_unsigned_int(bits[0:8])
weight += weight_in_byte[index]

# 移动指针,略过已处理的位,指向未处理的位
bits = bits[8:]
# 减少未处理位的长度
count -= 8

# 计算完毕,返回输入二进制位的汉明重量
return weight

这个BITCOUNT实现的算法复杂度为O(n),其中n为输入二进制位的数量。更具体一点,我们可以用以下公式来计算BITCOUNT命令在处理长度为n的二进制位输入时,命令中的两个循环需要执行的次数:

  • 第一个循环的执行次数可以用公式loop 1=n÷128」计算得出
  • 第二个循环的执行次数可以用公式loop 2=n mod 128计算得出

BITOP命令

功能:可以对多个位数组进行按位与(and)、按位或(or)、按位异或(xor)、取反(not)。

复杂度:

  • BITOP AND、BITOP OR、BITOP XOR三个命令可以接受多个位数组作为输入, 程序需要遍历输入的每个位数组的每个字节来进行计算,所以这些命令的复杂度为 O(n^2)
  • 因为BITOP NOT命令只接受一个位数组输入,所以它的复杂度为 O(n)

  • 在执行BITOP AND命令时,程序用&操作计算出所有输入二进制位的逻辑与结果,然后保存在指定的键上面。

  • 在执行BITOP OR命令时,程序用|操作计算出所有输入二进制位的逻辑或结果,然后保存在指定的键上面
  • 在执行BITOP XOR命令时,程序用^操作计算出所有输入二进制位的逻辑异或结果,然后保存在指定的键上面
  • 在执行BITOP NOT命令时,程序用~操作计算出输入二进制位的逻辑非结果,然后保存在指定的键上面。

举个例子,假设客户端执行命令:BITOP AND result x y。其中,键x保存的位数组如左图所示,而键y保存的位数组如右图所示:

BITOP命令将执行以下操作:

  • 创建一个空白的位数组value,用于保存AND操作的结果
  • 对两个位数组的第一个字节执行buf[0] & buf[0]操作,并将结果保存到value[0]字节
  • 对两个位数组的第二个字节执行buf[1] & buf[1]操作,并将结果保存到value[1]字节
  • 对两个位数组的第三个字节执行buf[2] & buf[2]操作,并将结果保存到value[2]字节
  • 经过前面的三次逻辑与操作,程序得到了下图所示的计算结果,并将它保存在键 result上面

慢查询日志

Redis的慢查询日志功能用于记录执行时间超过给定时长的命令请求,用户可以通过这个功能产生的日志来监视和优化查询速度。

慢查询日志选项:

  • slowlog-log-slower-than选项:指定执行时间超过多少微秒(1秒等于1000 000微秒)的命令请求会被记录到日志上
  • slowlog-max-len选项:指定服务器最多保存多少条慢查询日志

服务器使用先进先出的方式保存多条慢查询日志,当服务器存储的慢查询日志数量等于slowlog-max-len选项的值时,服务器在添加一条新的慢查询日志之前,会先将最旧的一条慢查询日志删除

慢查询日志的格式,以下面的图片为例:

  1. 日志的唯一标识符
  2. 命令执行时的UNIX时间戳
  3. 命令执行的时常(单位微秒)
  4. 命令以及命令参数
  5. 命令执行的客户端IP与端口

慢查询记录的保存

服务器状态中有几个变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct redisServer {
// ...
//下一条慢查询日志的ID
long long slowlog_entry_id;

//保存了所有慢查询日志的链表
list *slowlog;

//服务器配置slowlog-log-slower-than 选项的值
long long slowlog_log_slower_than;

//服务器配置slowlog-max-len 选项的值
unsigned long slowlog_max_len;
// ...
};

  • slowlog_entry_id属性的初始值为0,每当创建一条新的慢查询日志时,这个属性的值就会用作新日志的id值,之后程序会对这个属性的值增一
    例如,在创建第一条慢查询日志时,slowlog_entry_id的值0会成为第一条慢查询日志的 ID,而之后服务器会对这个属性的值增一;当服务器再创建新的慢查询日志的时候, slowlog_entry_id的值1就会成为第二条慢查询日志的ID,然后服务器再次对这个属性的值增一。
  • slowlog链表:保存了服务器中的所有慢查询日志,链表中的每个节点都保存了一个slowlogEntry结构,每个slowlogEntry结构代表一条慢查询日志
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    typedef struct slowlogEntry {
    //唯一标识符
    long long id;

    //命令执行时的时间,格式为UNIX 时间戳
    time_t time;

    //执行命令消耗的时间,以微秒为单位
    long long duration;

    //命令与命令参数
    robj **argv;

    //命令与命令参数的数量
    int argc;
    } slowlogEntry;
    每个slowlogEntry结构代表一条慢查询日志。举个例子:

下图展示了一个服务器状态中和慢查询功能有关的属性:

  • slowlog_entry_id的值为6,表示服务器下条慢查询日志的id值将为6
  • slowlog链表包含了id为5至1的慢查询日志,最新的5号日志排在链表的表头,而最旧的1 号日志排在链表的表尾,这表明slowlog链表是使用插入到表头的方式来添加新日志的
  • slowlog_log_slower_than记录了服务器配置slowlog-log-slower-than选项的值0,表示任何 执行时间超过0微秒的命令都会被慢查询日志记录
  • slowlog-max-len属性记录了服务器配置slowlog-max-len选项的值5,表示服务器最多储存五条慢查询日志

慢查询日志的阅览和删除

定义查看日志 的SLOWLOG GET命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def SLOWLOG_GET(number=None):
# 用户没有给定number 参数
# 那么打印服务器包含的全部慢查询日志
if number is None:
number = SLOWLOG_LEN()

# 遍历服务器中的慢查询日志
for log in redisServer.slowlog:
if number <= 0:
# 打印的日志数量已经足够,跳出循环
break
else:
# 继续打印,将计数器的值减一
number -= 1
# 打印日志
printLog(log)

查看日志数量的SLOWLOG LEN命令可以用以下伪代码来定义:

1
2
3
def SLOWLOG_LEN():
# slowlog 链表的长度就是慢查询日志的条目数量
return len(redisServer.slowlog)

另外,用于清除所有慢查询日志的SLOWLOG RESET命令可以用以下伪代码来定义:

1
2
3
4
5
def SLOWLOG_RESET():
# 遍历服务器中的所有慢查询日志
for log in redisServer.slowlog:
# 删除日志
deleteLog(log)

添加新日志(slowlogPushEntryIfNeeded函数)

在每次执行命令的之前和之后,程序都会记录微秒格式的当前UNIX时间戳,这两个时间戳之间的差就是服务器执行命令所耗费的时长,服务器会将这个时长作为参数之一传给slowlogPushEntryIfNeeded函数,而slowlogPushEntryIfNeeded函数则负责检查是否需要为这次执行的命令创建慢查询日志。以下伪代码展示了这一过程:

1
2
3
4
5
6
7
8
9
10
11
#记录执行命令前的时间
before = unixtime_now_in_us()

#执行命令
execute_command(argv, argc, client)

#记录执行命令后的时间
after = unixtime_now_in_us()

#检查是否需要创建新的慢查询日志
slowlogPushEntryIfNeeded(argv, argc, before-after)

slowlogPushEntryIfNeeded函数的作用有两个:

  • 检查命令的执行时长是否超过slowlog-log-slower-than选项所设置的时间,如果是的话,就为命令创建一个新的日志,并将新日志添加到slowlog链表的表头;
  • 检查慢查询日志的长度是否超过slowlog-max-len选项所设置的长度,如果是的话, 那么将多出来的日志从slowlog链表中删除掉

以下是slowlogPushEntryIfNeeded函数的实现代码,需要说明的是slowlogCreateEntry函数,该函数根据传入的参数,创建一个新的慢查询日志,并将redisServer.slowlog_entry_id的值增1

1
2
3
4
5
6
7
8
9
10
11
12
13
void slowlogPushEntryIfNeeded(robj **argv, int argc, long long duration) {
//慢查询功能未开启,直接返回
if (server.slowlog_log_slower_than < 0) return;

//如果执行时间超过服务器设置的上限,那么将命令添加到慢查询日志
if (duration >= server.slowlog_log_slower_than)
//新日志添加到链表表头
listAddNodeHead(server.slowlog,slowlogCreateEntry(argv,argc,duration));

//如果日志数量过多,那么进行删除
while (listLength(server.slowlog) > server.slowlog_max_len)
listDelNode(server.slowlog,listLast(server.slowlog));
}

监视器

通过执行MONITOR命令,客户端可以将自己变为一个监视器,实时地接收并打印出服务器当前处理的命令请求的相关信息:

每当一个客户端向服务器发送一条命令请求时,服务器除了会处理这条命令请求之外, 还会将关于这条命令请求的信息发送给所有监视器,如下图所示:

成为监视器

发送MONITOR命令可以让一个普通客户端变为一个监视器,MONITOR命令的实现原理可以用 以下伪代码来实现:

1
2
3
4
5
6
7
def MONITOR():
# 打开客户端的监视器标志
client.flags |= REDIS_MONITOR
# 将客户端添加到服务器状态的monitors 链表的末尾
server.monitors.append(client)
# 向客户端返回OK
send_reply("OK")

举个例子,如果客户端c10086向服务器发送MONITOR命令,那么这个客户端的REDIS_MONITOR标志会被打开,并且这个客户端本身会被添加到monitors链表的表尾。

向监视器发送命令信息

服务器在每次处理命令请求之前,都会调用replicationFeedMonitors函数,由这个函数将被处理的命令请求的相关信息发送给各个监视器,以下是replicationFeedMonitors函数的伪代码定义,函数首先根据传入的参数创建信息, 然后将信息发送给所有监视器:

1
2
3
4
5
6
7
8
def replicationFeedMonitors(client, monitors, dbid, argv, argc):
# 根据执行命令的客户端、当前数据库的号码、命令参数、命令参数个数等参数
# 创建要发送给各个监视器的信息
msg = create_message(client, dbid, argv, argc)
# 遍历所有监视器
for monitor in monitors:
# 将信息发送给监视器
send_message(monitor, msg)

举个例子,假设服务器在时间1378822257.329412,根据IP为127.0.0.1、端口号为56604 的客户端发送的命令请求,对0号数据库执行命令KEYS*,那么服务器将创建以下信息:

如果服务器monitors链表的当前状态如上图所示,那么服务器会分别将信息发送给c128、c256、c512和c10086四个监视器,如下图所示