《Redis 设计与实现:数据结构与对象》

1. 简单动态字符串

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

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

除了用来保存数据库中的字符串值之外,SDS 还被用作缓冲区(buffer):AOF 模块中的 AOF 缓冲区,以及客户端状态中的输入缓冲区,都是由 SDS 实现的。

1.1 SDS 的定义

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

1
2
3
4
5
6
7
8
9
10
11
12
13
struct sdshdr {

// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;

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

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

};

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

1.2 SDS 与 C 字符串的区别

1.2.1 常数复杂度获取字符串长度

因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为 $O(N)$ 。

通过使用 SDS 而不是 C 字符串,Redis 将获取字符串长度所需的复杂度从 $O(N) $ 降低到了 $O(1)$ ,这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。

1.2.2 杜绝缓冲区溢出

当 SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改所需的要求,如果不满足的话,API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出问题。

1.2.3 减少修改字符串时带来的内存重分配次数

空间预分配

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

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

  • 如果对 SDS 进行修改之后,SDS 的长度(也即是 len 属性的值)将小于 1 MB,那么程序分配和 len 属性同样大小的未使用空间,这时 SDS len 属性的值将和 free 属性的值相同。
  • 如果对 SDS 进行修改之后,SDS 的长度将大于等于 1 MB,那么程序会分配 1 MB 的未使用空间。

在扩展 SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API 就会直接使用未使用空间,而无须执行内存重分配。

通过这种预分配策略,SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。

惰性空间释放

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

通过惰性空间释放策略,SDS 避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。

与此同时,SDS 也提供了相应的 API ,让我们可以在有需要时,真正地释放 SDS 里面的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

1.2.4 二进制安全

C 字符串中的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾——这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。

SDS 的 API 都是二进制安全的(binary-safe):所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设——数据在写入时是什么样的,它被读取时就是什么样。

这也是我们将 SDS 的 buf 属性称为字节数组的原因—— Redis 不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

1.2.5 兼容部分 C 字符串函数

虽然 SDS 的 API 都是二进制安全的,但它们一样遵循 C 字符串以空字符结尾的惯例:这些 API 总会将 SDS 保存的数据的末尾设置为空字符,并且总会在为 buf 数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的 SDS 可以重用一部分 <string.h> 库定义的函数。

1.2.6 总结

C 字符串 SDS
获取字符串长度的复杂度为 $O(N)$。 获取字符串长度的复杂度为 $O(1)$。
API 是不安全的,可能会造成缓冲区溢出。 API 是安全的,不会造成缓冲区溢出。
修改字符串长度 N 次必然需要执行 N 次内存重分配。 修改字符串长度 N 次最多需要执行 N 次内存重分配。
只能保存文本数据。 可以保存文本或者二进制数据。
可以使用所有 <string.h> 库中的函数。

1.3 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 的长度。

2. 链表

2.1 链表和链表节点的实现

每个链表节点使用一个 adlist.h/listNode 结构来表示:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct listNode {

// 前置节点
struct listNode *prev;

// 后置节点
struct listNode *next;

// 节点的值
void *value;

} listNode;

多个 listNode 可以通过 prevnext 指针组成双端链表。

虽然仅仅使用多个 listNode 结构就可以组成链表,但使用 adlist.h/list 来持有链表的话,操作起来会更方便:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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,而 dupfreematch 成员则是用于实现多态链表所需的类型特定函数:

  • dup 函数用于复制链表节点所保存的值;
  • free 函数用于释放链表节点所保存的值;
  • match 函数则用于对比链表节点所保存的值和另一个输入值是否相等。

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

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

2.2 链表和链表节点的 API

函数 作用 时间复杂度
listSetDupMethod 将给定的函数设置为链表的节点值复制函数。 $O(1)$。
listGetDupMethod 返回链表当前正在使用的节点值复制函数。 复制函数可以通过链表的 dup 属性直接获得,$O(1)$
listSetFreeMethod 将给定的函数设置为链表的节点值释放函数。 $O(1)$
listGetFree 返回链表当前正在使用的节点值释放函数。 $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 为链表长度。

3. 字典

3.1 字典的实现

3.1.1 哈希表

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 数组的哪个索引上面。

3.1.2 哈希表节点

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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)的问题。

3.1.3 字典

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
20
21
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 时使用。

除了 ht[1] 之外,另一个和 rehash 有关的属性就是 rehashidx:它记录了 rehash 目前的进度,如果目前没有在进行 rehash ,那么它的值为 -1。

image

3.2 哈希算法

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

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;

3.3 解决键冲突

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

因为 dictEntry 节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为 $O(1)$),排在其他已有节点的前面。

3.4 rehash

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

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

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

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

3.5 渐进式 rehash

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

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

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

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

3.6 字典 API

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

4. 跳跃表

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

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

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

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

Redis 只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。

4.1 跳跃表的实现

Redis 的跳跃表由 redis.h/zskiplistNoderedis.h/zskiplist 两个结构定义,其中 zskiplistNode 结构用于表示跳跃表节点,而 zskiplist 结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针。

4.1.1 跳跃表节点

跳跃表节点的实现由 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
23
typedef struct zskiplistNode {

// 后退指针
struct zskiplistNode *backward;

// 分值
double score;

// 成员对象
robj *obj;

// 层
struct zskiplistLevel {

// 前进指针
struct zskiplistNode *forward;

// 跨度
unsigned int span;

} level[];

} zskiplistNode;
1. 层

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

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

2. 前进指针

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

3. 跨度

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

  • 两个节点之间的跨度越大,它们相距得就越远。
  • 指向 NULL 的所有前进指针的跨度都为 0,因为它们没有连向任何节点。

遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。

4. 后退指针

节点的后退指针(backward 属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。

5. 分值和成员

节点的分值(score 属性)是一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。

节点的成员对象(obj 属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个 SDS 值。

在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。

4.1.2 跳跃表

通过使用一个 zskiplist 结构来持有节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,又或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息。

zskiplist 结构的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct zskiplist {

// 表头节点和表尾节点
struct zskiplistNode *header, *tail;

// 表中节点的数量
unsigned long length;

// 表中层数最大的节点的层数
int level;

} zskiplist;

headertail 指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为 $O(1)$。

通过使用 length 属性来记录节点的数量,程序可以在 $O(1)$ 复杂度内返回跳跃表的长度。

level 属性则用于在 $O(1)$ 复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。

4.2 跳跃表 API

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

5. 整数集合

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

5.1 整数集合的实现

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

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

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct intset {

// 编码方式
uint32_t encoding;

// 集合包含的元素数量
uint32_t length;

// 保存元素的数组
int8_t contents[];

} intset;

contents 数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。

length 属性记录了整数集合包含的元素数量,也即是 contents 数组的长度。

虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值 —— contents 数组的真正类型取决于 encoding 属性的值:

  • 如果 encoding 属性的值为 INTSET_ENC_INT16 ,那么 contents 就是一个 int16_t 类型的数组,数组里的每个项都是一个 int16_t类型的整数值(最小值为 -32,768,最大值为 32,767)。
  • 如果 encoding 属性的值为 INTSET_ENC_INT32 ,那么 contents 就是一个 int32_t 类型的数组,数组里的每个项都是一个 int32_t类型的整数值(最小值为 -2,147,483,648,最大值为 2,147,483,647)。
  • 如果 encoding 属性的值为 INTSET_ENC_INT64 ,那么 contents 就是一个 int64_t 类型的数组,数组里的每个项都是一个 int64_t类型的整数值(最小值为 -9,223,372,036,854,775,808,最大值为 9,223,372,036,854,775,807)。

5.2 升级

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

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

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

因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为 $O(N)$。

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

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

5.3 降级

整数集合不支持降级操作。

5.4 整数集合 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)$

6. 压缩列表

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

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

6.1 压缩列表的构成

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

一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。

1
zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend
属性 类型 长度 用途
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),用于标记压缩列表的末端。

6.2 压缩列表节点的构成

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

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

每个压缩列表节点都由 previous_entry_lengthencodingcontent 三个部分组成:

1
previous_entry_length | encoding | content

6.2.1 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 属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。

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

6.2.2 encoding

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

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

6.2.3 content

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

6.3 连锁更新

对 ziplist 添加和删除节点时,可能引起连续多次空间扩展操作,称为 “连锁更新”。

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

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

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

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

6.4 压缩列表 API

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

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

7. 对象

7.1 对象的类型与编码

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

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

1
2
3
4
5
6
7
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
int refcount;
void *ptr;
} robj;

7.1.1 类型

对象的 type 属性记录了对象的类型,这个属性的值可以是

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

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

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

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

7.1.2 编码和底层实现

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

encoding 属性记录了对象所使用的编码,也即是说这个对象使用了什么数据结构作为对象的底层实现,这个属性的值可以是:

编码常量 编码所对应的底层数据结构
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 命令可以查看一个数据库键的值对象的编码。不同编码的对象所对应的 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 可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。

7.2 字符串对象

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

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

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

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

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

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

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

最后要说的是,可以用 long double 类型表示的浮点数在 Redis 中也是作为字符串值来保存的:如果我们要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串值,然后再保存起转换所得的字符串值。在有需要的时候,程序会将保存在字符串对象里面的字符串值转换回浮点数值,执行某些操作,然后再将执行操作所得的浮点数值转换回字符串值,并继续保存在字符串对象里面。

7.2.1 编码的转换

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

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

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

7.2.2 字符串命令的实现

命令 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 拷贝对象所保存的整数值,将这个拷贝转换成字符串值,然后取出并返回字符串指定索引上的字符。 直接取出并返回字符串指定索引上的字符。 直接取出并返回字符串指定索引上的字符。

7.3 列表对象

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

ziplist 编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素。

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

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

7.3.1 编码转换

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

  1. 列表对象保存的所有字符串元素的长度都小于 64 字节;
  2. 列表对象保存的元素数量小于 512 个;

不能满足这两个条件的列表对象需要使用 linkedlist 编码。

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

7.3.2 列表命令的实现

命令 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 函数,定位到双端链表指定索引上的节点,然后通过赋值操作更新节点的值。

7.4 哈希对象

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

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

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

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

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

7.4.1 编码转换

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

  1. 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;
  2. 哈希对象保存的键值对数量小于 512 个;

不能满足这两个条件的哈希对象需要使用 hashtable 编码。

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

7.4.2 哈希命令的实现

命令 ziplist 编码实现方法 hashtable 编码的实现方法
HSET 首先调用 ziplistPush 函数,将键推入到压缩列表的表尾,然后再次调用 ziplistPush 函数,将值推入到压缩列表的表尾。 调用 dictAdd 函数,将新节点添加到字典里面。
HGET 首先调用 ziplistFind 函数,在压缩列表中查找指定键所对应的节点,然后调用 ziplistNext 函数,将指针移动到键节点旁边的值节点,最后返回值节点。 调用 dictFind 函数,在字典中查找给定键,然后调用 dictGetVal 函数,返回该键所对应的值。
HEXISTS 调用 ziplistFind 函数,在压缩列表中查找指定键所对应的节点,如果找到的话说明键值对存在,没找到的话就说明键值对不存在。 调用 dictFind 函数,在字典中查找给定键,如果找到的话说明键值对存在,没找到的话就说明键值对不存在。
HDEL 调用 ziplistFind 函数,在压缩列表中查找指定键所对应的节点,然后将相应的键节点、以及键节点旁边的值节点都删除掉。 调用 dictDelete 函数,将指定键所对应的键值对从字典中删除掉。
HLEN 调用 ziplistLen 函数,取得压缩列表包含节点的总数量,将这个数量除以 2 ,得出的结果就是压缩列表保存的键值对的数量。 调用 dictSize 函数,返回字典包含的键值对数量,这个数量就是哈希对象包含的键值对数量。
HGETALL 遍历整个压缩列表,用 ziplistGet 函数返回所有键和值(都是节点)。 遍历整个字典,用 dictGetKey 函数返回字典的键,用 dictGetVal 函数返回字典的值。

7.5 集合对象

集合对象的编码可以是 intset 或者 hashtable

intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。

另一方面,hashtable 编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为 NULL

7.5.1 编码的转换

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

  1. 集合对象保存的所有元素都是整数值;
  2. 集合对象保存的元素数量不超过 512 个;

不能满足这两个条件的集合对象需要使用 hashtable 编码。

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

7.5.2 集合命令的实现

命令 intset 编码的实现方法 hashtable 编码的实现方法
SADD 调用 intsetAdd 函数,将所有新元素添加到整数集合里面。 调用 dictAdd ,以新元素为键,NULL 为值,将键值对添加到字典里面。
SCARD 调用 intsetLen 函数,返回整数集合所包含的元素数量,这个数量就是集合对象所包含的元素数量。 调用 dictSize 函数,返回字典所包含的键值对数量,这个数量就是集合对象所包含的元素数量。
SISMEMBER 调用 intsetFind 函数,在整数集合中查找给定的元素,如果找到了说明元素存在于集合,没找到则说明元素不存在于集合。 调用 dictFind 函数,在字典的键中查找给定的元素,如果找到了说明元素存在于集合,没找到则说明元素不存在于集合。
SMEMBERS 遍历整个整数集合,使用 intsetGet 函数返回集合元素。 遍历整个字典,使用 dictGetKey 函数返回字典的键作为集合元素。
SRANDMEMBER 调用 intsetRandom 函数,从整数集合中随机返回一个元素。 调用 dictGetRandomKey 函数,从字典中随机返回一个字典键。
SPOP 调用 intsetRandom 函数,从整数集合中随机取出一个元素,在将这个随机元素返回给客户端之后,调用 intsetRemove 函数,将随机元素从整数集合中删除掉。 调用 dictGetRandomKey 函数,从字典中随机取出一个字典键,在将这个随机字典键的值返回给客户端之后,调用 dictDelete 函数,从字典中删除随机字典键所对应的键值对。
SREM 调用 intsetRemove 函数,从整数集合中删除所有给定的元素。 调用 dictDelete 函数,从字典中删除所有键为给定元素的键值对。

7.6 有序集合对象

有序集合的编码可以是 ziplist 或者 skiplist

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

压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。

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

1
2
3
4
5
6
7
typedef struct zset {

zskiplist *zsl;

dict *dict;

} zset;

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

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

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

7.6.1 编码的转换

当有序集合对象可以同时满足以下两个条件时,对象使用 ziplist 编码:

  1. 有序集合保存的元素数量小于 128 个;
  2. 有序集合保存的所有元素成员的长度都小于 64 字节;

不能满足以上两个条件的有序集合对象将使用 skiplist 编码。

对于使用 ziplist 编码的有序集合对象来说,当使用 ziplist 编码所需的两个条件中的任意一个不能被满足时,程序就会执行编码转换操作,将原本储存在压缩列表里面的所有集合元素转移到 zset 结构里面,并将对象的编码从 ziplist 改为 skiplist

7.6.2 有序集合命令的实现

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

7.7 类型检查与命令多态

7.7.1 类型检查的实现

在执行一个类型特定的命令之前,Redis 会先检查输入键的类型是否正确,然后再决定是否执行给定的命令。

类型特定命令所进行的类型检查是通过 redisObject 结构的 type 属性来实现的:

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

7.7.2 多态命令的实现

Redis 除了会根据值对象的类型来判断键是否能够执行指定命令之外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令。

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

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

借用面向对象方面的术语来说,我们可以认为 LLEN 命令是多态(polymorphism))的:只要执行 LLEN 命令的是列表键,那么无论值对象使用的是 ziplist 编码还是 linkedlist 编码,命令都可以正常执行。

DELEXPIRE 等命令和 LLEN 等命令的区别在于, 前者是基于类型的多态——一个命令可以同时用于处理多种不同类型的键, 而后者是基于编码的多态——一个命令可以同时用于处理多种不同编码。

7.8 内存回收

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

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

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

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

修改对象引用计数的 API:

函数 作用
incrRefCount 将对象的引用计数值增一。
decrRefCount 将对象的引用计数值减一, 当对象的引用计数值等于 0 时, 释放对象。
resetRefCount 将对象的引用计数值设置为 0, 但并不释放对象, 这个函数通常在需要重新设置对象的引用计数值时使用。

7.9 对象共享

在 Redis 中,让多个键共享同一个值对象需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

目前来说,Redis 会在初始化服务器时,创建一万个字符串对象,这些对象包含了从 0 到 9999 的所有整数值, 当服务器需要用到值为 0 到 9999 的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象。

另外,这些共享对象不单单只有字符串键可以使用,那些在数据结构中嵌套了字符串对象的对象(linkedlist 编码的列表对象、hashtable 编码的哈希对象、hashtable 编码的集合对象、以及 zset 编码的有序集合对象)都可以使用这些共享对象。

当服务器考虑将一个共享对象设置为键的值对象时,程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同,只有在共享对象和目标对象完全相同的情况下,程序才会将共享对象用作键的值对象,而一个共享对象保存的值越复杂,验证共享对象和目标对象是否相同所需的复杂度就会越高,消耗的 CPU 时间也会越多:

  • 如果共享对象是保存整数值的字符串对象,那么验证操作的复杂度为 $O(1)$;
  • 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为 $O(N)$;
  • 如果共享对象是包含了多个值(或者对象的)对象,比如列表对象或者哈希对象,那么验证操作的复杂度将会是 $O(N^2)$。

因此,尽管共享更复杂的对象可以节约更多的内存,但受到 CPU 时间的限制,Redis 只对包含整数值的字符串对象进行共享。

7.10 对象的空转时长

lru 属性记录了对象最后一次被命令程序访问的时间。

OBJECT IDLETIME 命令可以打印出给定键的空转时长,这一空转时长就是通过将当前时间减去键的值对象的 lru 时间计算得出的。OBJECT IDLETIME 命令的实现是特殊的,这个命令在访问键的值对象时,不会修改值对象的 lru 属性。

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