23. 深度解密 Redis 基础数据类型(String、List、Hash、Set、ZSet)的底层实现,如何设计出一个高效的数据结构

楔子

我们知道 Redis 是一款 QPS 能达到 10w 级别的内存数据库,具有如此高性能的原因有很多,除了所有的操作都在内存中进行之外,其数据类型的底层设计也起到了很大的作用,这也是我们接下来的重点。

我们知道 Redis 中有 5 种基础数据类型,分别是:String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 ZSet(有序集合),这些数据类型底层都使用了哪些数据结构呢,这些数据结构都有哪些特点呢,为什么能够这么快呢,下面我们就来聊一聊。

不同版本的 Redis,底层的数据结构也会有所差异,比如在 3.0 的时候 List 对象的底层数据结构是由「双向链表」或「压缩列表」实现,在 3.0 之后则换成了 quicklist。并且在最新版的 Redis 中,压缩列表(ziplist)已经废弃了,改成了 listpack 数据结构。

下面我们就来介绍一下这几种数据结构。

Redis 的 key、value 存在什么地方?

首先在 Redis 中,String、List、Hash、Set、ZSet 都有自己的 key,key 不可以重名,我们举个栗子:

127.0.0.1:6379> SET name satori
OK
127.0.0.1:6379> LPUSH scores 90 95 93
(integer) 3
127.0.0.1:6379> HSET students satori 99
(integer) 1
127.0.0.1:6379> 

这里的 name、scores、students 都属于 key,它们的 value 是不同的类型,那么问题来了,这些 key、value 存在什么地方呢?其实不难得出,既然 Redis 是键值对数据库,查询的效率又这么高,那么肯定是存在哈希表当中的,事实上也确实如此。

以上代码位于 Redis 源码的 src/server.c 文件中,里面的 server 就相当于 Redis 的服务端。另外我们知道 Redis 默认有 16 个库,而 server.db[j] 就是获取 j 号库,所以此时相信你应该知道上面的代码所做的事情是什么了,就是为每一个库创建多个哈希表,用于存储相关的 key、value。而我们使用命令新创建的 key、value 都会存储在 server.db[j].dict 里面,就是绿色框框里面创建的哈希表。

所以结论很清晰了,每一个库都有一个全局的哈希表 server.db[j].dict,专门负责存储设置 key、value,其中 key 永远为 String 类型,value 可以是任意类型。这也侧面说明了我们设置的 key 不会重复,因为哈希表里面的 key 是不重复的。

# 当 name 不存在时,会自动往全局的哈希表中加入一个键值对,key 为 name,value 为 List
# 但此时 name 已经存在了,并且值为 String 类型,那么执行 LPUSH 就会报错
127.0.0.1:6379> LPUSH name 1 2 3
(error) WRONGTYPE Operation against a key holding the wrong kind of value

现在我们知道 key、value 是存在哈希表里面了,但具体是怎么存储的呢?我们来看一张图:

一个哈希表可以是一个数组,数组里面的每一个存储单元都叫做哈希桶(Bucket),比如数组第一个位置(索引为 0)被编为哈希桶 0,第二个位置(索引为 1)被编为哈希桶 1,以此类推。当我们写入一个键值对的时候,会根据 key 和 value 的指针构建一个 dictEntry 结构体实例,然后通过对 key 进行哈希运算来计算出桶的位置,最后将 dictEntry 结构体实例的指针写入哈希表中。

关于 Redis 哈希表的具体细节一会再说,目前只需要知道 Redis 中的 key、value 是存在哈希表中的即可。当然啦,更准确地说应该是 key、value 的指针所构建的 dictEntry 结构体实例的指针是存在哈希表当中的,当我们通过 key 进行查找的时候,会先对 key 进行哈希运算,找到对应的哈希桶中存储的 dictEntry *,然后再根据 value 获取对应的值。整个过程可以先这么理解,至于如何将 key 映射成索引、以及哈希冲突相关的话题,后面再详细说。

最后还需要特别补充一下,Redis 中所有的对象其实都是一个 redisObject 结构体实例,其结构如下:

不管是 String 对象、还是 List 对象、Hash 对象等等,它们其实都是一个 redisObject 对象,里面有一个 type 字段来标识这个对象的所属类型。

127.0.0.1:6379> TYPE name
string
127.0.0.1:6379> TYPE students
hash
127.0.0.1:6379> TYPE scores
list

我们可以用 TYPE 命令查看对象的类型,虽然显示的类型不同,但你要知道它们都是 redisObject 这个结构体的实例对象。内部的 encoding 表示该对象所使用的底层数据结构,ptr 表示指向底层数据结构的指针,而根据 encoding 和 ptr 的不同,type 可以是 string、list、hash、set 等等。

比如我们执行 sadd box 1 2 3 1,我们会说 box 是一个 Set 对象,这种说法是正确的。但是我们应该知道它其实是一个 redisObject,里面的 type 字段等于 "set",这背后的细节要搞清楚。但是后续我们还是会按照 String、List、Hash 之类的来称呼,就不用 redisObject 了,只要明白背后的关系就行。

SDS

下面我们就来分析底层数据结构了,首先是字符串,字符串在 Redis 中是很常用的,键值对中的键是字符串类型,值有时也是字符串类型。

我们知道 Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的字符数组(char *)来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串,也就是说 Redis 的 String 数据类型的底层数据结构是 SDS。既然 Redis 设计了 SDS 结构来表示字符串,肯定是 C 语言的字符数组存在一些缺陷。

要了解这一点,得先来看看字符数组的结构。

C 语言字符串的缺陷

C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符,比如下图就是字符串 "koishi" 的字符数组结构:

我们看到 s 只是一个指针,它指向了字符数组的起始位置,那么问题来了,C 语言要如何得知一个字符数组的长度呢?于是 C 语言会默认在每一个字符数组后面加上一个 \0,来表示字符串的结束。因此 C 语言标准库中的字符串操作函数就是通过判断字符是不是 \0 来决定要不要停止操作,如果当前字符不是 \0 ,说明字符串还没结束,可以继续操作,如果当前字符是 \0 是则说明字符串结束了,就要停止操作。

举个例子,C 语言获取字符串长度的函数 strlen,就是通过字符数组中的每一个字符,并进行计数,当遇到字符 \0 时停止遍历,然后返回已经统计到的字符个数,即为字符串长度。下图显示了 strlen 函数的执行流程:

如果用代码实现的话:

#include <stdio.h>

size_t strlen(const char *s) {
    size_t count = 0;
    while (*s != '\0') count++;
    return count;
}

int main() {
    printf("%lu\n", strlen("koishi"));  // 6
}

很明显,C 语言获取字符串长度的时间复杂度是 O(N),并且使用 \0 作为字符串结尾标记有一个缺陷。但如果某个字符串中间恰好有一个 \0,那么这个字符串就会提前结束,举个栗子:

#include <stdio.h>
#include <string.h>

int main() {
    // 字符串相关操作函数位于标准库 string.h 中
    printf("%lu\n", strlen("abcdefg"));  // 7
    printf("%lu\n", strlen("abc\0efg"));  // 3
}

所以在 C 中 \0 是字符串是否结束的标准,因此如果使用 C 的字符数组,只能让 C 在字符串结尾自动帮你加上 \0,我们创建的字符串内部是不可以包含 \0 的,否则就会出问题,因为字符串会提前结束。这个限制使得 C 语言的字符串只能保存文本数据,不能保存像图片、音频、视频之类的二进制数据。

另外 C 语言标准库中字符串的操作函数是很不安全的,对程序员很不友好,稍微一不注意,就会导致缓冲区溢出。举个例子,strcat 函数是可以将两个字符串拼接在一起。

#include <stdio.h>
#include <string.h>

//将 src 字符串拼接到 dest 字符串后面
char *strcat(char *dest, const char* src);

int main() {
    char buf[12] = "hello ";
    printf("%s\n", buf);  // hello
    strcat(buf, "world");
    printf("%s\n", buf);  // hello world
}

"hello world" 占 11 个字符,加上 \0 一共 12 个,buf 的长度为 12,刚好能容纳的下。但如果我们将 buf 的长度改成 11,就会发生缓冲区溢出,可能造成程序终止。因此 C 语言的字符串不会记录自身的缓冲区大小,它假定我们在执行这个函数时,已经为 dest 分配了足够多的内存。而且 strcat 函数和 strlen 函数类似,时间复杂度也是 O(N) 级别,也是需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加,所以整个字符串的操作效率是不高的。

我们还是手动实现一下 strcat,看一看整个过程:

#include <stdio.h>

char *strcat(char *dest, const char *src) {
    char *head = dest;
    // 遍历字符串,直到结尾
    while (*head != '\0') head++;
    // 循环结束之后,head 停在了 \0 的位置,然后将 src 对应的字符数组中的字符逐个拷贝过去
    // 将 src 的最后一个字符 \0 拷贝过去之后循环结束
    while ((*head++ = *src++) != '\0');
    // 最后返回 dest
    return dest;
}

int main() {
    char buf[12] = "hello ";
    printf("%s\n", buf);  // hello
    strcat(buf, "world");
    printf("%s\n", buf);  // hello world
}

好了, 通过以上的分析,我们可以得知 C 语言的字符串不足之处以及可以改进的地方:

  • 获取字符串长度的时间复杂度为 O(N)
  • 字符串的结尾是以 \0 作为字符标识,使得字符串里面不能含有 \0 字符,因此不能保存二进制数据
  • 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止

而 Redis 实现的 SDS 结构体就把上面这些问题解决了,接下来我们一起看看 Redis 是如何解决的。

SDS 结构设计

我们先来看一看 SDS 长什么样子?

结构中的每个成员变量分别介绍下:

len:记录了字符串的长度,这样后续在获取的时候只需返回这个成员变量的值即可,时间复杂度为 O(1)。

alloc:分配给字符数组的空间长度,这样后续对字符串进行修改时(比如追加一个字符串),可以通过 alloc 减去 len 计算出剩余空间大小,来判断空间是否满足修改需求。如果不满足,就会自动将 SDS 内的 buf 进行扩容(所谓扩容就是申请一个更大的 buf,然后将原来 buf 的内容拷贝到新的 buf 中,再将原来的 buf 给释放掉),然后执行修改操作。通过这种方式就避免了缓冲区溢出的问题,而且事先可以申请一个较大的 buf,避免每次追加的时候都进行扩容。

flags:用来表示不同类型的 SDS,SDS 总共有 5 种,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面说明它们之间的区别。所以 SDS 只是一个概念,它并不是真实存在的结构体,sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 才是底层定义好的结构体,相当于 SDS 的具体实现,当然它们都可以叫做 SDS。

buf[]:字符数组,用来保存实际数据,不仅可以保存字符串,也可以保存二进制数据。之所以可以保存二进制数据是因为在计算字符串长度的时候不再以 \0 为依据,因为 SDS 中的 len 字段在时刻维护字符串的长度。

总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。所以 SDS 在获取长度时的时间复杂度为 O(1)、并且是二进制安全的、不会发生缓冲区溢出、以及节省内存空间,至于原因,上面已经解释过了。不过为了加深记忆,我们再啰嗦一遍。

O(1) 时间复杂度获取字符串长度

C 语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。而 Redis 的 SDS 结构因为加入了 len 成员变量,会时刻维护字符串的长度,所以获取字符串长度的时候,直接返回这个成员变量的值就行,所以复杂度只有 O(1)

二进制安全

因为 SDS 不需要用 \0 字符来标识字符串结尾,而是有个专门的 len 成员变量来记录长度,所以可存储包含 \0 的数据。但是 SDS 为了兼容部分 C 语言标准库的函数, SDS 字符串结尾还是会加上 \0 字符。

因此, SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。

不会发生缓冲区溢出

C 语言的字符串标准库提供的字符串操作函数,大多数(比如 strcat 追加字符串函数)都是不安全的,因为这些函数把缓冲区大小是否满足操作需求的工作交由开发者来保证,程序内部并不会判断缓冲区大小是否足够用,当发生了缓冲区溢出就有可能造成程序异常结束。所以 Redis 的 SDS 结构里引入了 alloc 和 len 成员变量,这样 SDS API 通过 alloc 减去 len 可以计算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够用。

当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小,以满足修改所需的大小。当然准确的说,扩容的是 SDS 内部的 buf 数组,扩容规则是:当小于 1MB 翻倍扩容,大于 1MB 按 1MB 扩容。并且在扩展 SDS 空间的时候,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」。这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,从而有效地减少内存分配次数。

所以在使用 SDS 即不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。

节省内存空间

SDS 结构中有个 flags 成员变量,表示的是 SDS 类型,Redis 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。而这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同。

比如 sdshdr16 和 sdshdr32 这两个类型,它们的定义分别如下:

struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;
    uint16_t alloc; 
    unsigned char flags; 
    char buf[];
};


struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len;
    uint32_t alloc; 
    unsigned char flags;
    char buf[];
};

可以看到 sdshdr16 类型的 len 和 alloc 的数据类型都是 uint16_t,表示字符数组长度和分配空间大小不能超过 2 的 16 次方;sdshdr32 则都是 uint32_t,表示表示字符数组长度和分配空间大小不能超过 2 的 32 次方。

之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如在保存小字符串时,len、alloc 这些元数据的占用空间也会比较少。

除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed)),它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐

内存对齐是为了减少数据存储和读取的工作量,现在的 64 位处理器默认按照 8 字节进行对齐。所以相同的结构体,如果字段顺序不同,那么占用的大小也不一样,我们举个栗子:

#include <stdio.h>

typedef struct {
    int a;
    long b;
    char c;
} S1;

typedef struct {
    long a;
    int b;
    char c;
} S2;


int main() {
    printf("%lu %lu\n", sizeof(S1), sizeof(S2));  // 24 16
}

两个结构体的内部都是 3 个成员,类型为 int、long、char,但因为顺序不同导致整个结构体的大小不同,这就是内存对齐导致的。

关于内存对齐的具体细节这里不再赘述,总之它的核心就是:虽然现代计算机的内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们默认会要求这些数据的首地址的值是 8 的倍数(64 位机器),这就是所谓的内存对齐。

我们在 C 中可以通过 #pragma pack(4) 来指定对齐的字节数,这里表示按照 4 字节对齐。当然啦,还可以像 Redis 那样在声明结构体的时候指定  __attribute__ ((packed)) 来禁止内存对齐,此时会结构体中的字段都是紧密排列的,不会出现空洞。

#include <stdio.h>

typedef struct {
    int a;
    long b;
    char c;
} S1;

typedef struct {
    long a;
    int b;
    char c;
} S2;

typedef struct __attribute__ ((packed)) {
    long a;
    int b;
    char c;
} S3;


int main() {
    printf("%lu %lu %lu\n", sizeof(S1), sizeof(S2), sizeof(S3));  // 24 16 13
}

我们看到在禁止内存对齐之后,结构体占 13 个字节,就是每个成员的大小相加。

链表

相信各位最熟悉的数据结构除了数组之外,应该就是链表了,Redis 的 List 对象的底层实现之一就是链表。但是 C 语言本身没有链表这个数据结构的,所以 Redis 自己设计了一个链表数据结构。

注:如果只能通过前一个节点找到后一个节点,那么该链表被称为单向链表;如果不仅能通过前一个节点找到后一个节点,还能通过后一个节点找到前一个节点,那么该链表被称为双向链表。

那么我们来看一下 Redis 中的链表是如何设计的,首先链表是由一个个节点组成的,我们先来看看节点在 Redis 中是如何定义的。

typedef struct listNode {
    // 前继节点
    struct listNode *prev;
    // 后继节点
    struct listNode *next;
    // 节点的值
    void *value;
} listNode;

有前继节点和后继节点,可以看出节点之间会形成一个双向链表。

有了 ListNode 之后,Redis 在其基础之上又进行了封装,这样操作起来会更加方便。

typedef struct list {
    // 链表头节点
    listNode *head;
    // 链表尾节点
    listNode *tail;
    // 节点值复制函数
    void *(*dup)(void *ptr);
    // 节点值释放函数
    void (*free)(void *ptr);
    // 节点值比较函数
    int (*match)(void *ptr, void *key);
    // 链表中节点的数量
    unsigned long len;
} list;

Redis 封装了一个数据结构叫 list ,该结构提供了链表头节点 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup、free、match 函数。

举个栗子,下图是由 list 和 3 个 ListNode 组成的双向链表。

结构还是比较简单和清晰的,毕竟链表算是最常见的数据结构之一了。那么问题来了,双向链表它的优缺点是什么呢?

优点

  • listNode 链表节点的结构里带有 prev 和 next 指针,获取某个节点的前置节点或后置节点的时间复杂度只需 O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表
  • list 结构因为提供了链表头节点 head 和链表尾节点 tail,所以获取链表的表头节点和表尾节点的时间复杂度只需O(1)
  • list 结构因为提供了链表节点数量 len,所以获取链表中的节点数量的时间复杂度只需O(1)
  • listNode 链表节使用 void * 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此链表节点可以保存各种不同类型的值(指针)

缺点

  • 和数组不同,链表每个节点之间的内存都是不连续的,这意味着链表无法像数组那样很好地利用 CPU 缓存来加速访问。
  • 还有一点,每一个链表节点除了保存值之外,还包含了 prev 和 next 两个指针,因此会有额外的内存开销

在 Redis 3.0 中,List 会使用双向链表作为底层数据结构的实现,但如果 List 对象的数据量比较少,那么会采用「压缩列表」来实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。不过压缩列表存在性能问题(具体什么问题,下面马上会说),总之「压缩列表」和「双向链表」都不够好,所以 Redis 在 3.2 版本设计了新的数据结构 quicklist,并将 List 对象的底层数据结构改由 quicklist 实现,「双向链表」就废弃了。

不过「压缩列表」还保留着,因为它除了可以作为 List 对象的底层数据结构,还可以作为 Hash 对象和 ZSet 对象的底层数据结构。不过我们说它虽然省内存,但性能不够好也是无法忍受的,于是在 Redis 在 5.0 的时候又设计了新的数据结构 listpack,它不仅沿用了压缩列表紧凑型的内存布局,还提升了性能。最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成了 listpack。

虽然「压缩列表」被替代了,但我们该说还是要说的,下面就来看一下「压缩列表」。

压缩列表

压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。但缺陷也很明显:

  • 不能保存过多的元素,否则查询效率就会降低;
  • 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题;

因此在 Redis 3.0 的时候,只有当 Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。

那么接下来,我们就来看一下压缩列表是如何设计的?

压缩列表的结构设计

压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。

  • zlbytes:记录整个压缩列表占用的内存字节数,该字段占 4 个字节;
  • zltail:记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen:记录压缩列表包含的节点数量;
  • zlend:标记压缩列表的结束点,固定值 0xFF(十进制255);

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 级别,因此压缩列表不适合保存过多的元素

然后压缩列表的每一个节点叫做一个 entry,是一个结构体,其内部字段如下:

压缩列表节点包含三部分内容:

  • prevlen:记录了「前一个节点」的长度;
  • encoding:记录了当前节点实际数据的类型以及长度;
  • data:记录了当前节点的实际数据;

当我们往压缩列表中插入数据时,压缩列表就会根据数据是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的

分别说下,prevlen 和 encoding 是如何根据数据的大小和类型来进行不同的空间大小分配。首先压缩列表里的每个节点中的 prevlen 字段都记录了「前一个节点的长度」,而且 prevlen 字段的空间大小跟前一个节点长度值有关,比如:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 字段需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 字段需要用 5 字节的空间来保存这个长度值;

encoding 字段的空间大小跟数据是字符串还是整数,以及字符串的长度有关:

  • 如果当前节点的数据是整数,encoding 会使用 1 字节的空间进行编码;
  • 如果当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节 / 2 字节 / 5 字节的空间进行编码;

连锁更新

压缩列表除了查询时的时间复杂度高之外,还有一个问题。

压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。

前面提到,压缩列表节点的 prevlen 属性会根据前一个节点的长度进行不同的空间大小分配:

  • 如果前一个节点的长度小于 254 字节,那么 prevlen 字段需要用 1 字节的空间来保存这个长度值;
  • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 字段需要用 5 字节的空间来保存这个长度值;

现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:

因为这些节点长度值小于 254 字节,所以 prevlen 字段需要用 1 字节的空间来保存这个长度值。这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 entry1 的前继节点,如下图所示:

因为 entry1 节点的 prevlen 字段只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 entry1 节点的 prevlen 字段从原来的 1 字节大小扩展为 5 字节大小,因此 entry1 节点的大小相比之前会增加 4 字节。而一旦增加,那么 entry1 也大于等于 254 字节,所以此时就要扩展 entry2 的 prevlen 字段。而一旦扩展 entry2 的 prevlen 字段,那么会有什么结果相信你已经猜到了,就像多米诺骨牌一样,连锁效应一发不可收拾。

空间扩展就意味着重新分配内存,所以一旦出现「连锁更新」,就会导致压缩列表占用的内存空间被多次重新分配,这回直接影响到压缩列表的访问性能。

所以说,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,会导致内存重新分配,最糟糕的是会有「连锁更新」的问题。因此压缩列表只会用于保存节点数量不多的场景,只要节点数量足够小,即使发生连锁更新也是能接受的。

不过虽说如此,但压缩列表毕竟存在较大缺陷,所以 Redis 针对压缩列表在设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist(Redis 3.2 引入) 和 listpack(Redis 5.0 引入)。这两种数据结构的设计目标,就是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。

哈希表

哈希表是一种保存键值对(key-value)的数据结构,当中的每一个 key 都是独一无二的,程序可以根据 key 查找到与之关联的 value,或者通过 key 来更新 value,又或者根据 key 来删除整个 key-value等等。

上面在说压缩列表的时候,提到过 Redis 的 Hash 对象的底层实现之一是压缩列表(最新 Redis 代码已将压缩列表替换成 listpack),而 Hash 对象的另外一个底层实现就是哈希表。

哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。至于原因我们已经解释过了,哈希表可以理解为一个数组,在存储的时候会通过 Hash 函数对 key 进行运算,计算出桶的编号(也可以理解为索引),然后将元素存进去。至于在根据 key 获取元素的时候,也是同样的道理,也是先对 key 进行哈希运算找到桶的位置,然后将里面的元素取出来。

当然上面说的是理想情况,因为我们知道哈希运算是随机的,有可能不同的 key 被映射到同一个桶,此时我们就说出现了哈希冲突。而常见的解决哈希冲突的方式有两种,分别是「分离链接法(separate chaining)」和「开放寻址法(open addressing)」。

  • 「分离链接法」是为每一个哈希桶维护一个链表,出现冲突时,所有哈希到同一个桶的元素通过一个链表连接起来。
  • 「开放寻址法」是当哈希到某一个桶的时候,发现这个桶里面已经有其它元素了(出现冲突),那么会执行探测函数进行二次探查,重新找一个桶。而探测函数也有多种,比如线性探测、平方探迭代探测等等。

那么 Redis 采用的是哪一种做法呢?答案是「分离链接法」,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链表,以便这些数据在表中仍然可以被查询到。好了,那么我们接下来就来看看 Redis 中的哈希表的结构设计。

哈希表的结构设计

Redis 的哈希表结构如下:

typedef struct dictht {
    // 数组的首地址,我们说哈希表是通过数组实现的
    // 而数组中的每个元素都是一个 dictEntry *,所以这里 table 的类型是 dictEntry **
    dictEntry **table;
    // 哈希表大小
    unsigned long size;  
    // 哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    // 该哈希表已有的节点数量
    unsigned long used;
} dictht;

我们说哈希函数在映射的时候是随机的,因此当元素越多就越可能出现哈希冲突,虽然映射到同一个桶的元素可以通过链表组织起来,但是这个链表不可能无限长,否则就失去了哈希表的意义。为避免这一点,当元素过多的时候,就需要对哈希表进行扩容,申请一个新的哈希表,并将老哈希表中的元素拷贝过去。并且在申请新的哈希表的时候,可以适当将空间申请的大一些,目的就是为了减少哈希冲突的频率。所以上面的 size 成员指的就是哈希表的空间大小,而 used 成员指的是哈希表中已存储的节点数量,当 used 快超过 size 的时候就意味着哈希表要扩容了。

Python 的字典在底层也是通过哈希表实现的,不过 Python 的哈希表在出现冲突时使用的是「开放寻址法」,并且当 used 数量达到哈希表空间大小的三分之二的时候,就会发生扩容。

我们再用一张图来描述一下 Redis Dict 的结构:

整个结构还是很好理解的,这里需要注意 dictEntry,里面还有一个 next 字段,用于指向下一个 dictEntry。因为 Redis 要通过「分离链接法」解决哈希冲突的问题,所以需要维护一个链表,也就是所谓的「链式哈希」。

然后我们再来看一下 dictEntry,我们知道它是一个结构体,但是 value 字段和我们想象的有些不一样。

typedef struct dictEntry {
    // 指向键值对中的键
    void *key;

    // 指向键值对中的值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    // 指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

我们看到 value 不单纯是一个指针,而是一个共同体,因此 value 可以是一个指向实际值的指针,也可以是一个无符号的 64 位整数、有符号的 64 位整数或 double 类的浮点数。这么做的好处就是可以节省内存,当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构体里,无需再用一个指针指向实际的值,从而节省了内存空间。

哈希冲突与链式哈希

这里再来聊一聊哈希冲突,最开始的时候说过,哈希表实际上就是一个数组,数组中的每一个存储单元就是一个哈希桶(也可以称之为哈希槽),桶的编号和索引保持一致。写入一个键值对时,会对 key 进行哈希映射得到桶的编号,然后将键值对(dictEntry *)写入对应的桶中。

哈希映射,可以简单理解为先对 key 进行哈希运算,得到一个很大的数,然后再对数组的长度进行取模运算,即可得到一个合法的索引,整个过程就称为哈希映射。因此在实现哈希表的时候,如何设计一个好的哈希函数是非常关键的,它能直接影响哈希表的效率。

而哈希冲突则是两个不同的 key 最终被映射到同一个桶中,举个栗子,有一个可以存放 5 个桶的哈希表,key1 和 key3 都被映射到了 2 号哈希桶。

此时 key1 和 key3 对应到了相同的哈希桶中,而当有两个及以上的 key 被分配到了哈希表中同一个哈希桶时,这些 key 就发生了冲突。而解决哈希冲突,我们说 Redis 采用了「链式哈希」,也即是「分离链接法」。

实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。

还是以上面的哈希冲突为例,key1 和 key3 经过哈希计算后,都落在同一个哈希桶,链式哈希的话,key1 就会通过 next 指针指向 key3,形成一个单向链表。

不过链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)。而要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展,接下来就看看 Redis 是如何实现 rehash 的。

rehash

哈希表结构设计的这一小节,我们说 Redis 使用 dictht 结构体表示哈希表。不过在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了两个哈希表

typedef struct dict {
    …
    // 两个Hash表,交替使用,用于 rehash 操作
    dictht ht[2]; 
    …
} dict;

之所以定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。

在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。但随着数据逐步增多,触发了 rehash 操作,「哈希表 2」就闪亮登场了,整个过程分为三步:

  • 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
  • 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
  • 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备;

我们用一张图展示一下整个过程:

过程不难理解,就是哈希表 1 满了之后,为哈希表 2 申请更大的空间,然后将哈希表 1 的元素拷贝过去,再释放哈希表 1,最后将哈希表 2 和哈希表 1 交换位置。之后容量再满了的话,则继续重复此过程,周而复始。

不过这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。那么要怎么解决呢?于是 Redis 采用了渐进式 rehash。

渐进式 rehash

为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,核心思想就是数据迁移的工作不再是一次性完成,而是分多次迁移。

渐进式 rehash 步骤如下:

  • 给「哈希表 2」 分配空间;
  • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的 key-value 迁移到「哈希表 2」 上。当然这是分批次完成的,客户端对哈希表每发起一次请求,就迁移一部分;
  • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点,会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作

这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。但是在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。

比如查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。

rehash 触发条件

介绍了这么多 rehash ,但是我们还没说什么时情况下会触发 rehash 操作呢。首先 rehash 操作的触发条件和负载因子(load factor)有关,其计算方式如下:

触发 rehash 操作的条件,主要有两个:

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作;
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作;

整数集合

整数集合是 Set 对象的底层实现之一,当一个 Set 对象只包含整数值元素,并且元素数量不多时,就会使用整数集这个数据结构作为底层实现。

整数集合结构设计

整数集合本质上是一块连续内存空间,它的结构定义如下:

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

可以看到保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 字段的值。比如:

  • 如果 encoding 字段为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t;
  • 如果 encoding 字段为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t;
  • 如果 encoding 字段为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t;

不同类型的 contents 数组,意味着数组的大小也会不同。

整数集合的升级操作

整数集合有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(比如是 int32_t)比整数集合现有所有元素的类型(比如 int16_t)都要长时,整数集合需要先进行升级。因为 C 中的数组只能保存相同类型的元素,所以为了能够存储 int32_t,那么整个集合中已存在的所有元素都要变成 int32_t 类型,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。

注意:整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后再将每个元素转成指定类型。如果 encoding 属性值为 INTSET_ENC_INT32,则每个元素的大小就是 32 位(4 字节)。

举个例子,假设有一个整数集合里有 3 个类型为 int16_t 的元素。

现在需要往这个整数集合中加入一个新元素 65535,这个新元素需要用 int32_t 类型来保存,所以整数集合要进行升级操作,首先需要为 contents 数组扩容,在原本空间的大小之上再扩容多 80 个位(10 字节)。因为新来的元素占 4 字节,之前的 3 个元素本来是 int16_t(2 字节),变成 int32_t 之后每个元素大小要增加 2 字节,所以总共要扩容 10( 4 + 3 * 2 )字节,这样才能保存 4 个 int32_t 类型的数据。

扩容完 contents 数组空间大小后,需要将之前的三个元素转换为 int32_t 类型,并将转换后的元素放置到正确的位上面,并且需要维持底层数组的有序性不变,整个转换过程如下:

逻辑不难理解,重点是整数集合升级的具体实现,也是非常考验编程功底的地方,可以自己尝试一下。那么问题来了,整数集合升级有什么好处呢?不用想绝对是省内存。

如果要让一个数组同时保存 int16_t、int32_t、int64_t 类型的元素,最简单做法就是直接使用 int64_t 类型的数组。不过这样的话,当如果元素都是 int16_t 类型的,就会造成内存浪费的情况。整数集合升级就能避免这种情况,如果一直向整数集合添加 int16_t 类型的元素,那么整数集合的底层实现就一直是用 int16_t 类型的数组,只有在我们要将 int32_t 类型或 int64_t 类型的元素添加到集合时,才会对数组进行升级操作。

因此整数集合升级的好处就是是节省内存资源

另外,整数集合不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。比如前面的升级操作的例子,如果删除了 65535 元素,整数集合的数组还是 int32_t 类型的,并不会因此降级为 int16_t 类型。

跳表

Redis 只有在 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。另外 ZSet 对象比较特殊,它是唯一一个同时使用了两个数据结构来实现的对象,这两个数据结构分别是哈希表和跳表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

ZSet 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。

当元素不多的时候,ZSet 对象采用压缩列表实现,但如果有序集合保存的元素的长度大于 64 字节、或者元素个数超过128个时,就会改成跳表+哈希表。可以通过配置文件中的 zset-max-ziplist-entries(默认 128)和 zset-max-ziplist-value(默认 64)来设置有序集合使用 ziplist 存储的临界值。

接下来我们就来详细地说一下跳表。

跳表结构设计

我们知道链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是 O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层级」的有序链表,这样的好处是能快读定位数据。

那么我们下面就来用一张图,展示一下跳表的结构。

当我们在跳跃表中查询值 62 时,执行流程如下:

  • 从最上层开始找,首先 1 比 62 小,那么在当前层向后移动一个节点、也就是 27,然后再进行比较;
  • 比较时发现 27 仍比 62 小,那么在当前层继续向后移动一个节点,也就是 100,继续比较;
  • 比较时发现 100 大于 62,所以要以 27 为目标,移动到下一层(图中的第二层)继续向后比较;
  • 50 小于 62,继续向后移动查找,对比 100 大于 62,因此以 50 为目标,移动到下一层(图中的最后一层)继续向后比较;
  • 对比 62 等于 62,值被顺利找到。

从上面的流程可以看出,跳跃表会先从最上层开始找起,依次向后查找,如果本层的节点大于要找的值,或者本层的节点为 NULL 时,以该节点的上一个节点为目标,往下移一层继续向后查找并循环此流程,直到找到满足条件的节点并返回,如果对比到最后一层仍未找到,则返回 NULL。

所以如果回顾整个过程,我们会发现跳表有点类似于二分查找,而跳表在查找元素时的时间复杂度也是 O(logN)。实际上 ZSet 还可以使用 AVL 树或红黑树实现,但由于性能相近,并且跳表更加的好实现,于是 Redis 选择了跳表。

那么问题来了,我们说跳表是多层级的有序链表,比如上图中的跳表总共有 3 层,但其实图中画的还不够完全。因为当前层找不到的时候,会跳到下一层去找,但问题是怎么跳到下一层呢?所以每一层的有序链表之间应该还需要一个指针建立连接,但连接是单向的,我们只需要从上一次指向下一层即可。我们将上面的图补充完整:

接下来我们就来看看 Redis 中的跳表节点是如何定义的,因为跳表也是由一个个的节点组成的,只不过这些节点分散在多层级的链表上,并且部分节点之间可以在不同层级之间跳跃,因此叫跳表。

补充一点:我们上图画的跳表结构和 Redis 实际实现的跳表还是有点区别的,我们后面会说明,但总之跳表的核心思想就是图中展示的那样。

typedef struct zskiplistNode {
    // Zset 对象的元素值
    sds ele;
    // 元素的权重值,或者就按照单词的意思理解为分数也行,谁分高谁 NB,(#^.^#),开个玩笑
    double score;
    // 比如我们执行命令 ZADD test_zset 1 satori 2 koishi 3 sakuya,那么就会产生 3 个节点
    // 第一个节点的 ele 就是 "satori",score 就是 1
    // 第二个节点的 ele 就是 "koishi",score 就是 2
    // 第三个节点的 ele 就是 "sakuya",score 就是 13   
    
    // 后向指针,指向前一个节点
    struct zskiplistNode *backward;
    // 节点的 level 数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        // 前向指针,指向后一个节点
        struct zskiplistNode *forward;
        // 跨度
        unsigned long span;
    } level[];
} zskiplistNode;

ZSet 对象要同时保存元素和元素的权重,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。每个跳表节点都有一个后向指针,指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。

另外我们说跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层,所以查找的时候虽然是从上往下,但是计算层数的时候其实是从下往上计算的,也就是最下层才是第一层。可以想象一下走楼梯,越往上层数越高。然后 zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。

光说的话可能不好理解,我们画一张图就清晰了,并且接下来画的才是 Redis 中的跳表节点对应的结构。

这里我们单独解释一下跨度,它存在的意义就在于计算这个节点在跳表中的排位,那么具体是怎么计算的呢?首先跳表中的节点都是按照顺序排列的,那么计算某个节点排位的时候,把从头结点到该节点的查询路径中沿途访问过的所有层的跨度加起来,得到的结果就是该节点在跳表中的排位。

比如查找 score 为 27 的节点在跳表中的排位,我们会从 1 开始查找,经过一个层即可找到 27,并且层的跨度是 3,所以节点 27 在跳表中的排位就是 3。

上面显示的就是跳表中的每一个节点,那么下面再来看看跳表在底层的定义:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

跳表结构体里包含了:

  • 跳表的头尾节点,便于在 O(1) 时间复杂度内访问跳表的头节点和尾节点;
  • 跳表的长度,便于在 O(1) 时间复杂度获取跳表节点的数量;
  • 跳表的最大层数,也即是所有节点的 span 的最大值,便于在 O(1) 时间复杂度获取跳表中层数最高的节点的层数量;

跳表节点的查询过程

查找一个跳表节点时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:

  • 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点;
  • 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点;

如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续向后查找,这就相当于跳到了下一层接着查找。这里第一个条件很好理解,但第二个条件估计会有人困惑,我们解释一下。

首先对于 ZSet 而言,它节点内部的元素是不可以重复的,比如有一个内部元素(ele)叫 "xxx" 的节点,那么跳表中就不可能有第二个内部元素也叫 "xxx" 的节点。不过虽然元素不可以重复,但是权重可以重复,也就是不同的元素可以具有相同的 score 值。在跳表中的节点会按照内部 score 值从小到大的顺序进行组织,如果 score 相同,那么会按照内部元素(ele)的字典序进行组织。

我们举个栗子,我们上面的图中只画了权重,然后我们起个名字吧。

127.0.0.1:6379> ZADD zset_test 1 n1 5 n2 11 n3 20 n4 27 n5 33 n6 50 n7 62 n8 100 n9
(integer) 9
127.0.0.1:6379> 

我们按照 n1、n2、n3······ 的顺序和每个权重进行了组合,接下来我们进行查找:

# ZSCORE key value:获取元素对应的 score,这个查询和跳表没有多大关系,是通过哈希表实现的
127.0.0.1:6379> ZSCORE zset_test n2
"5"
# ZRANGE key start end [WITHSCORES]:获取指定范围的元素,递增排列,这个就相当于遍历跳表的第一层
# 加上 WITHSCORES 会同时返回 score,同理还有 ZREVRANGE,输出结果递减排列
127.0.0.1:6379> ZRANGE zset_test 0 2
1) "n1"
2) "n2"
3) "n3"
# ZRANGEBYSCORE key 开始score 结束score:获取 >=开始score 并 <=结束score 的元素,递增排列
# 也可以加上 WITHSCORES 同时返回 score,也有 ZREVRANGEBYSCORE,输出结果递减排列
127.0.0.1:6379> ZRANGEBYSCORE zset_test 5 60
1) "n2"
2) "n3"
3) "n4"
4) "n5"
5) "n6"
6) "n7"
127.0.0.1:6379> 

显然 ZRANGEBYSCORE 是用到了跳表的结构,那么它是怎么做的呢?首先从最高层的 1 开始查找,发现 1 小于 5 ,于是移动到下一个节点,但发现 27 大于 5,于是回到 score 为 1 的节点中的下一层,也就是第 2 层。然而第 2 层的下一个节点为 11,也大于 5,于是回到 score 为 1 的节点中再下一层,也就是第 1 层。此时发现下一个节点的 score 为 5,正好匹配,所以左边界对应的节点就找到了。如果没有 score 等于 5 的节点,那么就去找第一个 score 大于 5 的节点作为边界。

左边界找到了之后是不是该去找右边界了呢?答案是不需要,只需要从左边界对应的节点的第一层开始不断向后遍历即可,当出现第一个 score 大于右边界的节点时停止遍历即可。因为跳表不像数组,即使你找到了右边界,也依旧需要从左边界开始遍历一遍。

跳表节点层数设置

实际使用跳表的时候需要维护相邻两层的节点数量,让它们保持一个合理的比例关系,因为跳表的相邻两层的节点数量的比例会影响跳表的查询性能。

注意:这里我们说相邻两层的节点其实不太严谨,因为给人一种感觉,好像是不同层的节点之间彼此独立一样。但我们知道,在 Redis 的跳表中多层共用一个节点,每个节点内部通过数组维护多个指针来实现多层级。当然了,这里我们为了方便描述,就使用这种不严谨的说法了,心中理解就好。

比如第二层的节点只有 1 个,但第一层有 100 个,这显然不太合理,因为此时就类似于链表了。最终需要在第一层的节点中依次顺序查找,复杂度就变成了 O(N),因此为了避免跳表退化成链表,我们需要维持相邻层之间的节点数量关系。

跳表的相邻两层的节点数量最理想的比例是 2: 1,这样查找复杂度可以降低到 O(logN)。

不过话虽如此,即使将相邻两层的节点数量维持在了 2:1,但还是有一点需要注意,我们举个栗子:

这里相邻两层的节点数量大概也在 2:1,但很明显它起不到任何加速查询的效果,或者说这种结构不具备任何跳表的性质,查询速度甚至还不如链表。因此跳表的层数越高,节点不仅越少,而且节点之间也要越稀疏,应该像二分查找一样,每次都能过滤掉一部分元素。如果相邻两层的节点数量控制在 2:1,并且跳表的最高层只有 3 个节点(首、尾、中间),那么此时就可以认为其等价于二分查找。

那怎样才能维持相邻两层的节点数量的比例为 2: 1 呢?

如果采用新增节点或者删除节点的方式,来调整不同层级比例的方法的话,会带来额外的开销。所以 Redis 采用了一种巧妙的方法,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2: 1 的情况。

具体的做法是,跳表在创建节点时候,会生成范围为 [0-1] 的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。

quicklist

在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表,然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。而 quicklist 其实就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。

在前面介绍压缩列表的时候,我们也提到了压缩列表的不足,虽然压缩列表是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计,如果保存的元素数量增加,或者元素变大了,压缩列表会有「连锁更新」的风险,一旦发生,会造成性能下降。

而 quicklist 的解决办法是通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。

quicklist 结构设计

quicklist 的结构体跟链表的结构体类似,都包含了表头和表尾,区别在于 quicklist 的节点是 quicklistNode。

typedef struct quicklist {
    // quicklist 的链表头
    quicklistNode *head;      
    // quicklist 的链表尾
    quicklistNode *tail; 
    // 所有压缩列表中的总元素个数
    unsigned long count;
    // quicklistNodes 的个数
    unsigned long len;       
    ...
} quicklist;

接下来看看,quicklistNode 的结构定义:

typedef struct quicklistNode {
    //前一个 quicklistNode
    struct quicklistNode *prev;     
    //下一个 quicklistNode
    struct quicklistNode *next;     
    // quicklistNode 指向的压缩列表
    unsigned char *zl;              
    // 压缩列表的的字节大小
    unsigned int sz;                
    // 压缩列表的元素个数
    unsigned int count : 16;      
    ....
} quicklistNode;

可以看到,quicklistNode 结构体里包含了前一个节点和下一个节点指针,这样每个 quicklistNode 形成了一个双向链表。但是链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表,所以 quicklistNode 结构体里有个指向压缩列表的指针 *zl。

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。

所以 quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。

listpack

我们 quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。

于是 Redis 在 5.0 的时候新设计了一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

虽然 listpack 是在 5.0 的时候设计的,但却并没有马上应用,在 6.2 之后才将用到压缩列表的 Redis 对象的底层数据结构替换成 listpack。

那么我们就来 listpack 是怎么设计的?

listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了。每个 listpack 节点结构如下:

主要包含三个字段:

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • data,实际存放的数据;
  • len,encoding+data的总长度;

可以看到 listpack 的结构和压缩列表(ziplist)还是很相似的,只是没有记录前一个节点长度的字段了,listpack 只记录当前节点的长度。当我们向 listpack 加入一个新元素的时候,不会影响其它节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

总结

以上就是 Redis 基础数据类型的底层实现,作为一款高性能的内存键值对数据库,高效的数据结构是必不可少的,了解这些数据结构之后,在工作中我们也可以参考并应用在自己的项目中。关于数据结构,其实是一个比较大的话题,也是面试中经常问题的点。

当然啦,Redis 之所以高效,数据结构只是原因之一,至于其它方面的因素我们改天再聊吧。

posted @ 2021-12-09 15:50  古明地盆  阅读(1262)  评论(0编辑  收藏  举报