Redis 数据结构

218 阅读1小时+

数据库这么多,为啥 Redis 能有这么突出的表现呢?

  • 一方面,这是因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。
  • 另一方面,这要归功于它的数据结构。这是因为,键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,所以高效的数据结构是 Redis 快速处理数据的基础。

数据结构

Redis数据类型和底层数据结构的对应关系

Redis数据类型

String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)

底层数据结构

底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。

简单来说,底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。它们和数据类型的对应关系如下图所示:

键和值用什么结构组织?

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。

一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

看到这里,你可能会问了:“如果值是集合类型的话,作为数组元素的哈希桶怎么来保存呢?”其实,哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。

在下图中,可以看到,哈希桶中的 entry 元素中保存了key和value指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过*value指针被查找到。

因为这个哈希表保存了所有的键值对,所以,称它为全局哈希表。哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。

SDS

简单动态字符串(Simple Dynamic String,SDS)的结构,用来表示字符串。相比于C语言中的字符串实现,SDS这种字符串的实现方式,会提升字符串的操作效率,并且可以用来保存二进制数据。

SDS的设计思想

因为Redis是使用C语言开发的,所以为了保证能尽量复用C标准库中的字符串操作函数,Redis保留了使用字符数组来保存实际的数据。但是,和C语言仅用字符数组不同,Redis还专门设计了SDS(即简单动态字符串)的数据结构

SDS结构设计

首先,SDS结构里包含了一个字符数组buf[],用来保存实际数据。同时,SDS结构里还包含了三个元数据,分别是字符数组现有长度len、分配给字符数组的空间长度alloc,以及SDS类型flags。其中,Redis给len和alloc这两个元数据定义了多种数据类型,进而可以用来表示不同类型的SDS。

下图显示了SDS的结构,你可以先看下。

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

  • len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。
  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

buf属性是一个数组,最后一个字节则保存了空字符'\0'

struct sdshdr {
    //记录buf数组中已使用字节的数量
    //等于SDS所保存字符串的长度
    uint8_t len; /* used */
    // alloc: 分配的buf数组长度,不包括头和空字符结尾
    uint8_t alloc; /* excluding the header and null terminator */
    // 标志位, 最低3位表示header类型,另外5个位没有使用。
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    //字节数组,用于保存字符串
    char buf[];
};

另外,如果你在Redis源码中查找过SDS的定义,那你可能会看到,Redis使用typedef给char*类型定义了一个别名,这个别名就是sds,如下所示:

typedef char *sds;

其实,这是因为SDS本质还是字符数组,只是在字符数组基础上增加了额外的元数据。在Redis中需要用到字符数组时,就直接使用sds这个别名。

同时,在创建新的字符串时,Redis会调用SDS创建函数sdsnewlen。sdsnewlen函数会新建sds类型变量(也就是char*类型变量),并新建SDS结构体,把SDS结构体中的数组buf[] 赋给sds类型变量。最后,sdsnewlen函数会把要创建的字符串拷贝给sds变量。下面的代码就显示了sdsnewlen函数的这个操作逻辑。

sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;  //指向SDS结构体的指针
    sds s;     //sds类型变量,即char*字符数组

    ...
    sh = s_malloc(hdrlen+initlen+1);   //新建SDS结构,并分配内存空间
    ...
    s = (char*)sh+hdrlen;              //sds类型变量指向SDS结构体中的buf数组,sh指向SDS结构体起始位置,hdrlen是SDS结构体中元数据的长度
    ...
    if (initlen && init)
        memcpy(s, init, initlen);    //将要传入的字符串拷贝给sds变量s
    s[initlen] = '\0';               //变量s末尾增加\0,表示字符串结束
    return s;

C字符串和SDS之间的区别

获取字符串长度的时间复杂度

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

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

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

遵循空字符结尾这一惯例的好处是, SDS 可以直接重用一部分 C 字符串函数库里面的函数。

以字符串追加操作为例。Redis中实现字符串追加的函数是sds.c文件中的sdscatlen函数。这个函数的参数一共有三个,分别是目标字符串s、源字符串t和要追加的长度len,源码如下所示:

sds sdscatlen(sds s, const void *t, size_t len) {
    //获取目标字符串s的当前长度
    size_t curlen = sdslen(s);
    //根据要追加的长度len和目标字符串s的现有长度,判断是否要增加新的空间
    s = sdsMakeRoomFor(s,len);
    if (s == NULL) return NULL;
    //将源字符串t中len长度的数据拷贝到目标字符串结尾
    memcpy(s+curlen, t, len);
    //设置目标字符串的最新长度:拷贝前长度curlen加上拷贝长度
    sdssetlen(s, curlen+len);
    //拷贝后,在目标字符串结尾加上\0
    s[curlen+len] = '\0';
    return s;
}

通过分析这个函数的源码,我们可以看到sdscatlen的实现较为简单,其执行过程分为三步:

  • 首先,获取目标字符串的当前长度,并调用sdsMakeRoomFor函数,根据当前长度和要追加的长度,判断是否要给目标字符串新增空间。这一步主要是保证,目标字符串有足够的空间接收追加的字符串。
  • 其次,在保证了目标字符串的空间足够后,将源字符串中指定长度len的数据追加到目标字符串。
  • 最后,设置目标字符串的最新长度。

因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1),如果使用char*,需要遍历存储了多少个字符,需要一个一个便利 时间复杂O(N)。

二进制安全

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

因此, SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。

通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。

防止缓冲区溢出(buffer overflow)

C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出 (buffer overflow) 。举个例子,<string.h>/strcat函数可以将src字符串中的内容拼接到dest字符串的末尾

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

因为C字符串不记录自身的长度,所以strcat假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出

比如 strcpy()函数:strcpy()函数将源字符串复制到缓冲区。如果源字符串碰巧来自用户输入,且没有专门限制其大小,则有可能会造成缓冲区溢出。

举个例子,如果内存中s1,s2两个字符串是紧邻的:

如果过程中,用户想对s1进行扩展

strcat(s1, " Cluster");

由于没有注意到s预分配的空间大小,执行以后会导致s1的内容缓冲到s2,以至于s2的内容被意外修改成Cluster,这种错误对数据库的使用过程致命的。

而SDS在sdscat时会预先检查空间是否足够,不够的话会通过sdsMakeRoomFor自动为要拼接的字符串扩展空间.

Redis中实现字符串追加的函数是sds.c文件中的sdscatlen函数。这个函数的参数一共有三个,分别是目标字符串s、源字符串t和要追加的长度len,源码如下所示:

sds sdscatlen(sds s, const void *t, size_t len) {
    //获取目标字符串s的当前长度
    size_t curlen = sdslen(s);
    //根据要追加的长度len和目标字符串s的现有长度,判断是否要增加新的空间
    s = sdsMakeRoomFor(s,len);
    if (s == NULL) return NULL;
    //将源字符串t中len长度的数据拷贝到目标字符串结尾
    memcpy(s+curlen, t, len);
    //设置目标字符串的最新长度:拷贝前长度curlen加上拷贝长度
    sdssetlen(s, curlen+len);
    //拷贝后,在目标字符串结尾加上\0
    s[curlen+len] = '\0';
    return s;
}

hisds hi_sdsMakeRoomFor(hisds s, size_t addlen)
{
    ... ...
    // s目前的剩余空间已足够,无需扩展,直接返回
    if (avail >= addlen)
        return s;
    //获取目前s的长度
    len = hi_sdslen(s);
    sh = (char *)s - hi_sdsHdrSize(oldtype);
    //扩展之后 s 至少需要的长度
    newlen = (len + addlen);
    //根据新长度,为s分配新空间所需要的大小
    if (newlen < HI_SDS_MAX_PREALLOC)
        //新长度<HI_SDS_MAX_PREALLOC 则分配所需空间*2的空间
        newlen *= 2;
    else
        //否则,分配长度为目前长度+1MB
        newlen += HI_SDS_MAX_PREALLOC;
       ...
}

通过分析这个函数的源码,我们可以看到sdscatlen的实现较为简单,其执行过程分为三步:

  • 首先,获取目标字符串的当前长度,并调用sdsMakeRoomFor函数,根据当前长度和要追加的长度,判断是否要给目标字符串新增空间。这一步主要是保证,目标字符串有足够的空间接收追加的字符串。
  • 其次,在保证了目标字符串的空间足够后,将源字符串中指定长度len的数据追加到目标字符串。
  • 最后,设置目标字符串的最新长度。

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

因为 C 字符串的长度和底层数组的长度之间存在着这种关联性, 所以每次增长或者缩短一个 C 字符串, 程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:

  • 如果程序执行的是增长字符串的操作, 比如拼接操作(append), 那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。
  • 如果程序执行的是缩短字符串的操作, 比如截断操作(trim), 那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。

举个例子,如果我们持有一个值为"Redis"的C字符串s,那么为了将s的 值改为"Redis Cluster",在执行:

strcat(s," Cluster");

之前,我们需要先使用内存重分配操作,扩展s的空间。

之后,如果我们又打算将s的值从"Redis Cluster"改为"Redis Cluster Tutorial"那么在执行:

strcat(s," Tutorial");

之前,我们需要再次使用内存重分配扩展s的空间,诸如此类

因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作:

  • 在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接受的。
  • 但是Redis作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁地发生的话,可能还会对性能造成影响。

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

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

1.空间预分配

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

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

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

在扩展SDS空间之前, SDS API 会先检查未使用空间是否足够, 如果足够的话, API 就会直接使用未使用空间, 而无须执行内存重分配。通过这样的空间预分配策略, Redis可以减少连续执行字符串增长操作所需的内存重分配次数。

2.惰性空间释放

惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是把缩短后的长度记录在len属性中,剩余空间用于未来扩展字符串用。

例如这个sdstrim函数,

/* Example:
 *
 * s = sdsnew("AA...AA.a.aa.aHelloWorld     :::");
 * s = sdstrim(s,"Aa. :");
 * printf("%s\n", s);
 *
 * Output will be just "Hello World".
 */
sds sdstrim(sds s, const char *cset) {
    char *start, *end, *sp, *ep;
    size_t len;
    sp = start = s;
    ep = end = s+sdslen(s)-1;
    while(sp <= end && strchr(cset, *sp)) sp++;
    while(ep > sp && strchr(cset, *ep)) ep--;
    len = (sp > ep) ? 0 : ((ep-sp)+1);
    if (s != sp) memmove(s, sp, len);
    s[len] = '\0';
    sdssetlen(s,len);
    return s;
}

可以看出程序结束时并没有reallocate内存空间,而是修改结构体中len的属性。
当用户真正想free掉空闲空间时,可以使用sdsRemoveFreeSpace函数:

sds sdsRemoveFreeSpace(sds s) {
    void *sh, *newsh;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
    size_t len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    type = sdsReqType(len);
    hdrlen = sdsHdrSize(type);
    if (oldtype==type) {
        newsh = s_realloc(sh, hdrlen+len+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {
        newsh = s_malloc(hdrlen+len+1);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        sdssetlen(s, len);
    }
    sdssetalloc(s, len);
    return s;
}

因为内存重分配涉及复杂的算法, 并且可能需要执行系统调用, 所以它通常是一个比较耗时的操作:
在一般程序中, 如果修改字符串长度的情况不太常出现, 那么每次修改都执行一次内存重分配是可以接受的。但是 Redis 作为数据库, 经常被用于速度要求严苛、数据被频繁修改的场合, 如果每次修改字符串的长度都需要执行一次内存重分配的话, 那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分, 如果这种修改频繁地发生的话, 可能还会对性能造成影响。

为了避免 C 字符串的这种缺陷, SDS 通过已使用空间解除了字符串长度和底层数组长度之间的关联,在更改字符串的时候可以只更改len属性,而不重新分配内存

节省内存空间

紧凑型字符串结构

SDS 结构中有个 flags 成员变量,表示的是 SDS 类型。

Redis 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。

这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同。

因为sdshdr5这一类型Redis已经不再使用了,所以我们这里主要来了解下剩余的4种类型。以sdshdr8为例,它的定义如下所示:

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 字符数组现有长度*/
    uint8_t alloc; /* 字符数组的已分配空间,不包括结构体和\0结束字符*/
    unsigned char flags; /* SDS类型*/
    char buf[]; /*字符数组*/
};

我们可以看到,现有长度len和已分配空间alloc的数据类型都是uint8_t。uint8_t是8位无符号整型,会占用1字节的内存空间。当字符串类型是sdshdr8时,它能表示的字符数组长度(包括数组最后一位\0)不会超过256字节(2的8次方等于256)。

而对于sdshdr16、sdshdr32、sdshdr64三种类型来说,它们的len和alloc数据类型分别是uint16_t、uint32_t、uint64_t,即它们能表示的字符数组长度,分别不超过2的16次方、32次方和64次方。这两个元数据各自占用的内存空间在sdshdr16、sdshdr32、sdshdr64类型中,则分别是2字节、4字节和8字节。

实际上,SDS之所以设计不同的结构头(即不同类型),是为了能灵活保存不同大小的字符串,从而有效节省内存空间。因为在保存不同大小的字符串时,结构头占用的内存空间也不一样,这样一来,在保存小字符串时,结构头占用空间也比较少。

假设SDS都设计一样大小的结构头,比如都使用uint64_t类型表示len和alloc,那么假设要保存的字符串是10个字节,而此时结构头中len和alloc本身就占用了16个字节了,比保存的数据都多了。所以这样的设计对内存并不友好,也不满足Redis节省内存的需求。

除了设计不同类型的结构头,Redis在编程上还使用了专门的编译优化来节省内存空间。在sdshdr8结构定义中,我们可以看到,在struct和sdshdr8之间使用了__attribute__ ((__packed__)),如下所示:

struct __attribute__ ((__packed__)) sdshdr8

其实这里,__attribute__ ((__packed__))的作用就是告诉编译器,在编译sdshdr8结构时,不要使用字节对齐的方式,而是采用紧凑的方式分配内存。这是因为在默认情况下,编译器会按照8字节对齐的方式,给变量分配内存。也就是说,即使一个变量的大小不到8个字节,编译器也会给它分配8个字节。

假设我定义了一个结构体s1,它有两个成员变量,类型分别是char和int,如下所示:

#include <stdio.h>
	int main() {
	   struct s1 {
	      char a;
	      int b;
	   } ts1;
	   printf("%lu\n", sizeof(ts1));
	   return 0;
	}

虽然char类型占用1个字节,int类型占用4个字节,但是如果你运行这段代码,就会发现打印出来的结果是8。这就是因为在默认情况下,编译器会给s1结构体分配8个字节的空间,而这样其中就有3个字节被浪费掉了。

为了节省内存,Redis在这方面的设计上可以说是精打细算的。所以,Redis采用了__attribute__ ((__packed__))属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。

比如,用__attribute__ ((__packed__))属性定义结构体s2,同样包含char和int两个类型的成员变量,代码如下所示:

#include <stdio.h>
	int main() {
	   struct __attribute__((packed)) s2{
	      char a;
	      int b;
	   } ts2;
	   printf("%lu\n", sizeof(ts2));
	   return 0;
	}

当你运行这段代码时,你可以看到,打印的结果是5,表示编译器用了紧凑型内存分配,s2结构体只占用5个字节的空间。

SDS数据结构代码实现

redis5.0 之后的sds结构

struct __attribute__ ((__packed__)) sdshdr8 {
    //当前字符数组的长度
    uint8_t len; /* used */
		//当前字符数组总共分配的内存大小
    uint8_t alloc; /* excluding the header and null terminator */
		//当前字符数组的属性、用来标识到底是 sdshdr8 还是 sdshdr16 等
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
		//字符串真正的值
    char buf[];
};

redis 3.2 之前的sds结构

/*
 * 保存字符串对象的结构
 */
struct sdshdr {
    // buf 中已占用空间的长度
    unsigned int len;
    // buf 中剩余可用空间的长度
    unsigned int free;
    // 数据空间
    char buf[];
};

链表数据结构

链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度

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

链表在Redis中的应用非常广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时Redis就会使用链表作为列表键的底层实现。

链表和链表节点的实现

链表节点

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

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

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

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

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

list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型特定函数

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

由一个List结构和三个ListNode结构组成的链表

链表的优缺点

Redis 的链表实现优点如下:

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

链表的缺点:

  • 链表每个节点之间的内存都是不连续的,意味着无法很好利用 CPU 缓存。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。
  • 还有一点,保存一个链表节点的值都需要一个链表节点结构头的分配,内存开销较大。

因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。

不过,压缩列表存在性能问题,所以 Redis 在 3.2 版本设计了新的数据结构 quicklist,并将 List 对象的底层数据结构改由 quicklist 实现。

然后在 Redis 5.0 设计了新的数据结构 listpack,沿用了压缩列表紧凑型的内存布局,最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成由 listpack 实现。

压缩列表

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

但是,压缩列表的缺陷也是有的:

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

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

压缩列表的结构

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

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

类型长度用途
zlbytesuint32_t4字节记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配.或者计算 zlend 的位置时使用
zltailuint32_t4字节记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址
zllenuint16_t2字节记录了压缩列表包含的节点数量:当这个属性的值小于 UINT16 MAX(65535)时,这个属性的值就是压缩列表包含节点的数量:当这个值等于UINT16 MAX 时,节点的真实数量需要遍历整个压缩列表才能计算得出
entryx列表节点不定压缩列表包含的各个节点,节点的长度由节点保存的内容决定
zlenduint8_t1字节特殊值 OXEE(十进制 255 ),用于标记压缩列表的未端

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

一个例子

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

ziplist列表项包括三部分内容,分别是

  • prevlen:(previous_entry_length的缩写),记录了「前一个节点」的长度,目的是为了实现从后向前遍历
  • encoding:记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
  • content:记录了当前节点的实际数据,类型和长度都由 encoding 决定

使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。

分别说下,prevlen 和 encoding 是如何根据数据的大小和类型来进行不同的空间大小分配。

压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如:

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

压缩列表的代码

压缩列表的函数定义和实现代码分别在ziplist.h和ziplist.c中。

不过,我们在ziplist.h文件中其实根本看不到压缩列表的结构体定义。这是因为压缩列表本身就是一块连续的内存空间,它通过使用不同的编码来保存数据。

创建函数ziplistNew,如下所示:

unsigned char *ziplistNew(void) {
    //初始分配的大小
    unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
    unsigned char *zl = zmalloc(bytes);
    …
   //将列表尾设置为ZIP_END
    zl[bytes-1] = ZIP_END;
    return zl;
}

实际上,ziplistNew函数的逻辑很简单,就是创建一块连续的内存空间,大小为ZIPLIST_HEADER_SIZE和ZIPLIST_END_SIZE的总和,然后再把该连续空间的最后一个字节赋值为ZIP_END,表示列表结束。

在上面代码中定义的三个宏ZIPLIST_HEADER_SIZE、ZIPLIST_END_SIZE和ZIP_END,在ziplist.c中也分别有定义,分别表示ziplist的列表头大小、列表尾大小和列表尾字节内容,如下所示。

//ziplist的列表头大小,包括2个32 bits整数和1个16bits整数,分别表示压缩列表的总字节数,
//列表最后一个元素的离列表头的偏移,以及列表中的元素个数
#define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))

//ziplist的列表尾大小,包括1个8 bits整数,表示列表结束。
#define ZIPLIST_END_SIZE        (sizeof(uint8_t))

//ziplist的列表尾字节内容
#define ZIP_END 255

那么,在创建一个新的ziplist后,该列表的内存布局就如下图所示。注意,此时列表中还没有实际的数据。

压缩列表节点的结构

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

每个压缩列表节点都由previous entry length、encoding、content三个部分组成

ziplist列表项包括三部分内容,分别是

  • prevlen:(previous_entry_length的缩写)表示前一项的长度
  • encoding:当前项长度信息的编码结果
  • content:当前项的实际数据

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

  • 长度小于等于 字节的字节数组
  • 长度小于等于字节的字节数组;
  • 长度小于等于字节的字节数组:

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

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

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 属性的压缩列表节点,属性的值为0x05,表示前一节点的长度为5字节。

下图展示了一个包含五字节长previous_entry_length属性的压缩节点,属性的值为 OxFE00002766,其中值的最高位字节0xFE表 示这是一个五字节长的 previous_entry_length属性,而之后的四字节0x00002766 (十进制值10086)才是前一节点的实际长度。

因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。

p = c - c.prev_len

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

encoding

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

  • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码: 这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;
  • 一字节长,值的最高位以11开头的是整数编码: 这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录;
字节数组编码
编码编码长度content 属性保存的值
00bbbbbb1字节长度小于等于 63 字节的字节数组
01bbbbbb xxxxxxxx2字节长度小于等于 16 383 字节的字节数组
10------ aaaaaaaa bbbbbbbb cccccccc dddddddd5字节长度小于等于 4 294 967 295 的字节数组
整数编码
编码编码长度content属性保存的值
1100 00001个字节int16_t类型的整数
1101 00001个字节int32_t类型的整数
1110 00001个字节int64_t类型的整数
1111 00001个字节24位有符号整数
1111 11101个字节8位有符号整数
1111 xxxx1个字节使用这一编码的节点没有相应的 content 属性,因为编码本身的 xxxx四个位已经保存了一个介于 0 和 12 之间的值,所以它无须 content 属性

content

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

上图展示了一个保存字节数组的节点示例:

  • 编码的最高两位00表示节点保存的是一个字节数组。
  • 编码的后六位001011记录了字节数组的长度11。
  • content属性保存着节点的值"hello world"。

连锁更新

每个节点的previous_entry_length属性都记录了前一个节点的长度

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

现在,考虑这样一种情况: 在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点entry1至entryN,如图所示,

因为entry1至entryN的所有节点的长度都小于254字节,所以记录这些节点的长度只需要1字节长的 previous_entry_length属性,换句话说,entry1至 entryN的 所有节点的previous_entry_length属性都是1字节长的。

这时,如果我们将一个长度大于等于254字节的新节点newEntry设置为压缩列表的表头节点,那么newEntry将成为entry1的前置节点,如图所示。

因为entry1的previous_entry_length属性仅长1字节,它没办法保存新节点newEntry的长度,所以程序将对压缩列表执行空间重分配操作,并将entry1节点的previous_entry_length属性从原来的1字节长扩展为5字节长

现在,麻烦的事情来了,entry1原本的长度介于250字节至253字节之间,在为previous_entry_length属性新增四个字节的空间之后,entry1的长度就变成了个于254字节至257字节之间,而这种长度使用1字节长的previous_entry_length属性是没办法保存的。

因此,为了让entry2的previous_entry_length属性可以记录下entry1的长度,程序需要再次对压缩列表执行空间重分配操作,并将entry2节点的previous_entry_length从原来的1字节长扩展为5字节长.

正如扩展entry1引发了对entry2的扩展一样,扩展entry2也会引发对entry3的扩展,而扩展entry3又会引发对entry4的扩展......为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到entryN为止。

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

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

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

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

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

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

ziplist的不足

一个ziplist数据结构在内存中的布局就是一块连续的内存空间。这块空间的起始部分是大小固定的10字节元数据,其中记录了ziplist的总字节数、最后一个元素的偏移量以及列表元素的数量,而这10字节后面的内存空间则保存了实际的列表数据。在ziplist的最后部分,是一个1字节的标识(固定为255),用来表示ziplist的结束,如下图所示:

虽然ziplist节省了内存开销,可它也存在两个设计代价:一是不能保存过多的元素,否则访问性能会降低;二是不能保存过大的元素,否则容易导致内存重新分配,甚至可能引发连锁更新的问题。所谓的连锁更新,简单来说,就是ziplist中的每一项都要被重新分配内存空间,造成ziplist的性能降低。

不过,虽然ziplist通过紧凑的内存布局来保存数据,节省了内存空间,但是ziplist也面临着随之而来的两个不足:查找复杂度高和潜在的连锁更新风险。

查找复杂度高

因为ziplist头尾元数据的大小是固定的,并且在ziplist头部记录了最后一个元素的位置,所以,当在ziplist中查找第一个或最后一个元素的时候,就可以很快找到。

但问题是,当要查找列表中间的元素时,ziplist就得从列表头或列表尾遍历才行。而当ziplist保存的元素过多时,查找中间数据的复杂度就增加了。更糟糕的是,如果ziplist里面保存的是字符串,ziplist在查找某个元素时,还需要逐一判断元素的每个字符,这样又进一步增加了复杂度。

也正因为如此,我们在使用ziplist保存Hash或Sorted Set数据时,都会在redis.conf文件中,通过hash-max-ziplist-entries和zset-max-ziplist-entries两个参数,来控制保存在ziplist中的元素个数。

不仅如此,除了查找复杂度高以外,ziplist在插入元素时,如果内存空间不够了,ziplist还需要重新分配一块连续的内存空间,而这还会进一步引发连锁更新的问题。

连锁更新风险

ziplist在新插入元素时,会计算其所需的新增空间,并进行重新分配。而当新插入的元素较大时,就会引起插入位置的元素prevlensize增加,进而就会导致插入位置的元素所占空间也增加。

连锁更新一旦发生,就会导致ziplist占用的内存空间要多次重新分配,这就会直接影响到ziplist的访问性能。

虽然ziplist紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,ziplist就会面临性能问题。

因此,针对ziplist在设计上的不足,Redis代码在开发演进的过程中,新增设计了两种数据结构:quicklist和listpack。这两种数据结构的设计目标,就是尽可能地保持ziplist节省内存的优势,同时避免ziplist潜在的性能下降问题。

Redis如何实现链式哈希?

不过,在开始学习链式哈希的设计实现之前,我们还需要明白Redis中Hash表的结构设计是啥样的,以及为何会在数据量增加时产生哈希冲突,这样也更容易帮助我们理解链式哈希应对哈希冲突的解决思路。

字典

字典,又称为映射(map) ,是一种用于保存键值对 (key-value pair) 的抽象数据结构

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

哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。怎么做到的呢?将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。

举个例子,当我们执行命令

redis> SET key "hello world"
OK

在数据库中创建一个键为"key",值为"hello world"的键值对时,这个键值对就是保存在代表数据库的字典里面的。

字典的实现

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

Hash表应用如此广泛的一个重要原因,就是从理论上来说,它能以O(1)的复杂度快速查询数据。Hash表通过Hash函数的计算,就能定位数据在表中的位置,紧接着可以对数据进行操作,这就使得数据操作非常快速。

Hash表这个结构也并不难理解,但是在实际应用Hash表时,当数据量不断增加,它的性能就经常会受到哈希冲突和rehash开销的影响。而这两个问题的核心,其实都来自于Hash表要保存的数据量,超过了当前Hash表能容纳的数据量。

哈希表的数据结构

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

typedef struct dictht {
  	// 哈希表数组
    dictEntry **table; 
  	// Hash表大小
    unsigned long size; 
  	// 哈希表大小掩码,用于计算索引值
  	// 总是等于size-1
    unsigned long sizemask;
  	// 该哈希表已有节点的数量
    unsigned long used;
} dictht;
  • table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
  • size属性记录了哈希表的大小,也即是table数组的大小,
  • used属性则记录了哈希表目前已有节点(键值对)的数量。
  • sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。

下图展示了一个大小为4的空哈希表 (没有包含任何键值对)

哈希表节点的数据结构

那么为了实现链式哈希, Redis在每个dictEntry的结构设计中,除了包含指向键和值的指针,还包含了指向下一个哈希项的指针。如下面的代码所示,dictEntry结构体中包含了指向另一个dictEntry结构的指针*next,这就是用来实现链式哈希的:

typedef struct dictEntry {
  	// 键
    void *key;
    // 值
		union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
  	// 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;
  • *key:属性保存着键值对中的键,
  • v:由一个联合体v定义的。这个联合体v中包含了指向实际值的指针*val,还包含了无符号的64位整数、有符号的64位整数,以及double类的值。

这种实现方法是一种节省内存的开发小技巧。因为当值为整数或双精度浮点数时,由于其本身就是64位,就可以不用指针指向了,而是可以直接存在键值对的结构体中,这样就避免了再用一个指针,从而节省了内存空间。

下图展示了一个大小为4的哈希表 (包含3键值对)

dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。

dictEntry 结构里键值对中的值是一个「联合体 v」定义的,因此,键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。

字典的数据结构

在redis中的字典是由哈希表来实现的。

redis中的哈希表就是存放有很多哈希节点的表结构,一个哈希节点就保存了字典中的键值对。因此从上而下的结构关系是:字典 –> 哈希表 –> 哈希节点 –> 字典的键值对

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

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

字典的属性成员有:

  1. type: 这是一个指针,指向dicttype结构,每个 dictType 结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数。我们后面会用到到hashFunction这个函数
  2. private:私有数据,保存了需要传给类型函数的参数。
  3. ht[2]: 哈希表,每个字典有两个哈希表,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[o]哈希表,ht[1]哈希表只会在对ht[o]哈希表进行rehash时使用。
  4. rehashidx: rehash索引,当rehashidx==-1的时候,说明不在进行rehash。
  5. iterators: 记录当前的迭代次数

下图所示是一个完整的字典,这个字典是处于普通状态(没有进行rehash)

hash算法

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

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

#使用字典设置的哈希函数,计算键key的哈希值
hash = dict->type->hashFunction(key);
#使用哈希表的sizemask属性和哈希值,计算出索引值
#根据情况不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dict->ht[x].sizemask;

空字典

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

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

计算键k0的哈希值

假设计算得出的哈希值为8,那么程序会继续使用语句:

index = hash&dict->ht[0].sizemask = 8 & 3 = 1000 & 0011 = 0 :

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

解决hash冲突

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

从以下两个方面解决哈希冲突:

  • 链式哈希。如果链式哈希的链不能太长,否则会降低Hash表性能。
  • 当链式哈希的链长达到一定长度时,可以使用rehash。不过,执行rehash本身开销比较大,所以就需要采用渐进式rehash设计。

链式哈希

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

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

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

rehash

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

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

  1. 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量 (也即是ht[0].used属性的值)
    1. 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2n (2的n次方幂)
    2. 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2n
  1. 将保存在ht[0]中的所有键值对rehash到ht[1]上面: rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。
  2. 当ht[0]包含的所有键值对都迁移到了ht[1]之后 (ht[0]变为空表) ,释放ht[0].将ht[1]设置为ht[0],并在htl1]新创建一个空白哈希表,为下一次rehash做准备
static unsigned long _dictNextPower(unsigned long size)
{
    //哈希表的初始大小
    unsigned long i = DICT_HT_INITIAL_SIZE;
    //如果要扩容的大小已经超过最大值,则返回最大值加1
    if (size >= LONG_MAX) return LONG_MAX + 1LU;
    //扩容大小没有超过最大值
    while(1) {
        //如果扩容大小大于等于最大值,就返回截至当前扩到的大小
        if (i >= size)
            return i;
        //每一步扩容都在现有大小基础上乘以2
        i *= 2;
    }
}

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

  1. ht[0].used当前的值为4,4*2=8,而8 恰好是第一个大于等于4的2的n次方,所以程序会将ht[1]哈希表的大小设置为8。如下图展示了ht[1]在分配空间之后字典的样子。

  1. 将ht[0]中的键值对进行rehash
    1. 若 k0&7 = 0
    2. 若 k1&7 = 5
    3. 若 k2&7 = 6
    4. 若 k3&7 = 3

  1. 释放ht[0],并将ht[1]设置为ht[0],然后为ht[1]分配一个空白哈希表,如下图所示。至此,对哈希表的扩展操作执行完毕,程序成功将哈希表的大小从原来的4改为了现在的8

什么时候触发rehash?

首先要知道,Redis用来判断是否触发rehash的函数是 _dictExpandIfNeeded。所以接下来我们就先看看,_dictExpandIfNeeded函数中进行扩容的触发条件;然后,我们再来了解下_dictExpandIfNeeded又是在哪些函数中被调用的。

实际上,_dictExpandIfNeeded函数中定义了三个扩容条件。

  • 条件一:ht[0]的大小为0。
  • 条件二:ht[0]承载的元素个数已经超过了ht[0]的大小,同时Hash表可以进行扩容。
  • 条件三:ht[0]承载的元素个数,是ht[0]的大小的dict_force_resize_ratio倍,其中,dict_force_resize_ratio的默认值是5。
#负载因子=哈希表已保存节点数量/哈希表大小
load_factor = ht[0].used / ht[0].size

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

另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

下面的代码就展示了_dictExpandIfNeeded函数对这三个条件的定义。

//如果Hash表为空,将Hash表扩为初始大小
if (d->ht[0].size == 0) 
   return dictExpand(d, DICT_HT_INITIAL_SIZE);
 
// 如果Hash表承载的元素个数超过其当前大小,并且可以进行扩容,
// 或者Hash表承载的元素个数已是当前大小的5倍
if (d->ht[0].used >= d->ht[0].size &&(dict_can_resize ||
              d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
    return dictExpand(d, d->ht[0].used*2);
}

那么,对于条件一来说,此时Hash表是空的,所以Redis就需要将Hash表空间设置为初始大小,而这是初始化的工作,并不属于rehash操作。

条件二和三就对应了rehash的场景。因为在这两个条件中,都比较了Hash表当前承载的元素个数(d->ht[0].used)和Hash表当前设定的大小(d->ht[0].size),这两个值的比值一般称为负载因子(load factor)。也就是说,Redis判断是否进行rehash的条件,就是看load factor是否大于等于1和是否大于5。

实际上,当load factor大于5时,就表明Hash表已经过载比较严重了,需要立刻进行库扩容。而当load factor大于等于1时,Redis还会再判断dict_can_resize这个变量值,查看当前是否可以进行扩容。

这里的dict_can_resize变量值是在dictEnableResize和dictDisableResize两个函数中设置的,它们的作用分别是启用和禁止哈希表执行rehash功能,如下所示:

void dictEnableResize(void) {
    dict_can_resize = 1;
}
 
void dictDisableResize(void) {
    dict_can_resize = 0;
}

这两个函数又被封装在了updateDictResizePolicy函数中。

updateDictResizePolicy函数是用来启用或禁用rehash扩容功能的,这个函数调用dictEnableResize函数启用扩容功能的条件是:当前没有RDB子进程,并且也没有AOF子进程。这就对应了Redis没有执行RDB快照和没有进行AOF重写的场景。你可以参考下面给出的代码:

void updateDictResizePolicy(void) {
    if (server.rdb_child_pid == -1 && server.aof_child_pid == -1)
        dictEnableResize();
    else
        dictDisableResize();
}

到这里我们就了解了_dictExpandIfNeeded对rehash的判断触发条件,那么现在,我们再来看下Redis会在哪些函数中,调用_dictExpandIfNeeded进行判断。

首先,通过在dict.c文件中查看_dictExpandIfNeeded的被调用关系,我们可以发现,_dictExpandIfNeeded是被_dictKeyIndex函数调用的,而_dictKeyIndex函数又会被dictAddRaw函数调用,然后dictAddRaw会被以下三个函数调用。

  • dictAdd:用来往Hash表中添加一个键值对。
  • dictRelace:用来往Hash表中添加一个键值对,或者键值对存在时,修改键值对。
  • dictAddorFind:直接调用dictAddRaw。

因此,当我们往Redis中写入新的键值对或是修改键值对时,Redis都会判断下是否需要进行rehash。

简而言之,Redis中触发rehash操作的关键,就是_dictExpandIfNeeded函数和updateDictResizePolicy函数。

  • _dictExpandIfNeeded函数会根据Hash表的负载因子以及能否进行rehash的标识,判断是否进行rehash
  • updateDictResizePolicy函数会根据RDB和AOF的执行情况,启用或禁用rehash。

rehash扩容扩多大?

在Redis中,rehash对Hash表空间的扩容是通过调用dictExpand函数来完成的。dictExpand函数的参数有两个,一个是要扩容的Hash表,另一个是要扩到的容量,下面的代码就展示了dictExpand函数的原型定义:

int dictExpand(dict *d, unsigned long size);

那么,对于一个Hash表来说,我们就可以根据前面提到的_dictExpandIfNeeded函数,来判断是否要对其进行扩容。而一旦判断要扩容,Redis在执行rehash操作时,对Hash表扩容的思路也很简单,就是如果当前表的已用空间大小为size,那么就将表扩容到size*2的大小。

如下所示,当_dictExpandIfNeeded函数在判断了需要进行rehash后,就调用dictExpand进行扩容。这里你可以看到,rehash的扩容大小是当前ht[0]已使用大小的2倍。

dictExpand(d, d->ht[0].used*2);

而在dictExpand函数中,具体执行是由_dictNextPower函数完成的,以下代码显示的Hash表扩容的操作,就是从Hash表的初始大小(DICT_HT_INITIAL_SIZE),不停地乘以2,直到达到目标大小。

static unsigned long _dictNextPower(unsigned long size)
{
    //哈希表的初始大小
    unsigned long i = DICT_HT_INITIAL_SIZE;
    //如果要扩容的大小已经超过最大值,则返回最大值加1
    if (size >= LONG_MAX) return LONG_MAX + 1LU;
    //扩容大小没有超过最大值
    while(1) {
        //如果扩容大小大于等于最大值,就返回截至当前扩到的大小
        if (i >= size)
            return i;
        //每一步扩容都在现有大小基础上乘以2
        i *= 2;
    }
}

好,下面我们再来看看Redis要解决的第三个问题,即rehash要如何执行?而这个问题,本质上就是Redis要如何实现渐进式rehash设计。

渐进式rehash如何实现?

扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht[1]里面但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。

这样做的原因在于,如果ht[0]里只保存着四个键值对,那么服务器可以在瞬间就将这些键值对全部rehash到htl1]; 但是,如果哈希表里保存的键值对数量不是四个而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht[1]的话,会有很多键就需要从原来的位置拷贝到新的位置。而在键拷贝时,由于Redis主线程无法执行其他请求,所以键拷贝会阻塞主线程,这样就会产生rehash开销。

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

简单来说,渐进式rehash的意思就是Redis并不会一次性把当前Hash表中的所有键,都拷贝到新位置,而是会分批拷贝,每次的键拷贝只拷贝Hash表中一个bucket中的哈希项。这样一来,每次键拷贝的时长有限,对主线程的影响也就有限了。

流程

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

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

下图展示了一次完整的渐进式rehash过程,注意观察在整个rehash过程中,字典的rehashidx属性是如何变化的。

rehash索引0上的键值对

rehash索引1上的键值对

rehash索引2上的键值对

rehash索引3上的键值对

rehash执行完成

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

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

代码实现

那么,渐进式rehash在代码层面是如何实现的呢?这里有两个关键函数:dictRehash和_dictRehashStep。

dictRehash

dictRehash函数执行键拷贝,它的输入参数有两个,分别是全局哈希表(即前面提到的dict结构体,包含了ht[0]和ht[1])和需要进行键拷贝的桶数量(bucket数量)。

dictRehash函数的整体逻辑包括两部分:

  • 首先,该函数会执行一个循环,根据要进行键拷贝的bucket数量n,依次完成这些bucket内部所有键的迁移。当然,如果ht[0]哈希表中的数据已经都迁移完成了,键拷贝的循环也会停止执行。
  • 其次,在完成了n个bucket拷贝后,dictRehash函数的第二部分逻辑,就是判断ht[0]表中数据是否都已迁移完。如果都迁移完了,那么ht[0]的空间会被释放。因为Redis在处理请求时,代码逻辑中都是使用ht[0],所以当rehash执行完成后,虽然数据都在ht[1]中了,但Redis仍然会把ht[1]赋值给ht[0],以便其他部分的代码逻辑正常使用。
  • 而在ht[1]赋值给ht[0]后,它的大小就会被重置为0,等待下一次rehash。与此同时,全局哈希表中的rehashidx变量会被标为-1,表示rehash结束了(这里的rehashidx变量用来表示rehash的进度,稍后我会给你具体解释)。

dictRehash函数的主要执行逻辑。

int dictRehash(dict *d, int n) {
    int empty_visits = n*10;
    ...
    //主循环,根据要拷贝的bucket数量n,循环n次后停止或ht[0]中的数据迁移完停止
    while(n-- && d->ht[0].used != 0) {
       ...
    }
    //判断ht[0]的数据是否迁移完成
    if (d->ht[0].used == 0) {
        //ht[0]迁移完后,释放ht[0]内存空间
        zfree(d->ht[0].table);
        //让ht[0]指向ht[1],以便接受正常的请求
        d->ht[0] = d->ht[1];
        //重置ht[1]的大小为0
        _dictReset(&d->ht[1]);
        //设置全局哈希表的rehashidx标识为-1,表示rehash结束
        d->rehashidx = -1;
        //返回0,表示ht[0]中所有元素都迁移完
        return 0;
    }
    //返回1,表示ht[0]中仍然有元素没有迁移完
    return 1;
}

rehashidx变量表示的是当前rehash在对哪个bucket做数据迁移。比如,当rehashidx等于0时,表示对ht[0]中的第一个bucket进行数据迁移;当rehashidx等于1时,表示对ht[0]中的第二个bucket进行数据迁移,以此类推。

而dictRehash函数的主循环,首先会判断rehashidx指向的bucket是否为空,如果为空,那就将rehashidx的值加1,检查下一个bucket。

那么,有没有可能连续几个bucket都为空呢?其实是有可能的,在这种情况下,渐进式rehash不会一直递增rehashidx进行检查。这是因为一旦执行了rehash,Redis主线程就无法处理其他请求了。

所以,渐进式rehash在执行时设置了一个变量empty_visits,用来表示已经检查过的空bucket,当检查了一定数量的空bucket后,这一轮的rehash就停止执行,转而继续处理外来请求,避免了对Redis性能的影响。下面的代码显示了这部分逻辑。

while(n-- && d->ht[0].used != 0) {
    //如果当前要迁移的bucket中没有元素
    while(d->ht[0].table[d->rehashidx] == NULL) {
        //
        d->rehashidx++;
        if (--empty_visits == 0) return 1;
    }
    ...
}

而如果rehashidx指向的bucket有数据可以迁移,那么Redis就会把这个bucket中的哈希项依次取出来,并根据ht[1]的表空间大小,重新计算哈希项在ht[1]中的bucket位置,然后把这个哈希项赋值到ht[1]对应bucket中。

这样,每做完一个哈希项的迁移,ht[0]和ht[1]用来表示承载哈希项多少的变量used,就会分别减一和加一。当然,如果当前rehashidx指向的bucket中数据都迁移完了,rehashidx就会递增加1,指向下一个bucket。下面的代码显示了这一迁移过程。

while(n-- && d->ht[0].used != 0) {
    ...
    //获得哈希表中哈希项
    de = d->ht[0].table[d->rehashidx];
    //如果rehashidx指向的bucket不为空
    while(de) {
        uint64_t h;
        //获得同一个bucket中下一个哈希项
        nextde = de->next;
        //根据扩容后的哈希表ht[1]大小,计算当前哈希项在扩容后哈希表中的bucket位置
        h = dictHashKey(d, de->key) & d->ht[1].sizemask;
        //将当前哈希项添加到扩容后的哈希表ht[1]中
        de->next = d->ht[1].table[h];
        d->ht[1].table[h] = de;
        //减少当前哈希表的哈希项个数
        d->ht[0].used--;
        //增加扩容后哈希表的哈希项个数
        d->ht[1].used++;
        //指向下一个哈希项
        de = nextde;
    }
    //如果当前bucket中已经没有哈希项了,将该bucket置为NULL
    d->ht[0].table[d->rehashidx] = NULL;
    //将rehash加1,下一次将迁移下一个bucket中的元素
    d->rehashidx++;
}

dictRehash函数本身是按照bucket粒度执行哈希项迁移的,它内部执行的bucket迁移个数,主要由传入的循环次数变量n来决定。

_dictRehashStep

_dictRehashStep,这个函数实现了每次只对一个bucket执行rehash。

从Redis的源码中我们可以看到,一共会有5个函数通过调用_dictRehashStep函数,进而调用dictRehash函数,来执行rehash,它们分别是:dictAddRaw,dictGenericDelete,dictFind,dictGetRandomKey,dictGetSomeKeys。

其中,dictAddRaw和dictGenericDelete函数,分别对应了往Redis中增加和删除键值对,而后三个函数则对应了在Redis中进行查询操作。

但你要注意,不管是增删查哪种操作,这5个函数调用的_dictRehashStep函数,给dictRehash传入的循环次数变量n的值都为1,下面的代码就显示了这一传参的情况。

static void _dictRehashStep(dict *d) {
		// 给dictRehash传入的循环次数参数为1,表明每迁移完一个bucket ,就执行正常操作
    if (d->iterators == 0) dictRehash(d,1);
}

这样一来,每次迁移完一个bucket,Hash表就会执行正常的增删查请求操作,这就是在代码层面实现渐进式rehash的方法。

Intset

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

redis> SADD numbers 1 3 5 7 9
(integer) 5
redis> OBJECT ENCODING numbers
"intset"

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

Intset数据结构

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

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;
  • contents数组是整数集合的底层实现: 整数集合的每个元素都是contents数组的个数组项 (item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
  • length属性记录了整数集合包含的元素数量,也即是contents数组的长度

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

  • 如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值 (最小值为-32768,最大值为32767)
  • 如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值 (最小值为-2147483648,最大值为2147483647)
  • 如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64 t类型的数组,数组里的每个项都是一个int64_t类型的整数值 (最小值为-9223372036854775808,最大值为9223372036854775807)

  • encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实现为int16_t类型的数组,而集合保存的都是int16_t类型的整数值。
  • length属性的值为5,表示整数集合包含五个元素
  • contents数组按从小到大的顺序保存着集合中的五个元素

因为每个集合元素都是int16_t类型的整数值,所以contents数组的大小等于sizeof (int16_t) 5=165=80位

升级

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

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

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

举例说明

因为每个元素都占用16位空间,所以整数集合底层数组的大小为5*16=90位。

0至15位16至31位32至47位48至63位64至80位
元素12345

现在,假设我们要将类型为int32_t的整数值65535添加到整数集合里面,因为65535的类型int32_t比整数集合当前所有元素的类型都要长,所以在将65535添加到整数集合之前,程序需要先对整数集合进行升级

升级首先要做的是,根据新类型的长度,以及集合元素的数量 (包括要添加的新元素在内),对底层数组进行空间重分配

整数集合目前有三个元素,再加上新元素65535,整数集合需要分配四个元素的空间,因为每个int32_t整数值需要占用32位空间,所以在空间重分配之后,底层数组的大小将是32*6=192位,如表所示。虽然程序对底层数组进行了空间重分

0至15位16至31位32至47位48至63位64至80位81至191位
元素12345新分配的空间
0至15位16至31位32至47位48至63位64至80位96至127位128至159位160至191位
元素12345新分配的空间新分配的空间新分配的空间
  • 因为元素 5在1、2、3、4、5、65535 六个元素中排名第5,所以它将被移动到contents数组的索引位置上,也即128位至159位的空间内。
  • 因为元素 4 在1、2、3、4、5、65535 六个元素中排名第4,所以它将被移动到contents数组的索引位置上,也即96位至127位的空间内。
  • 因为元素 2 在1、2、3、4、5、65535 六个元素中排名第3,所以它将被移动到contents数组的索引位置上,也即64位至96位的空间内。相应位置的元素已经被迁移。
  • 因为元素 3 在1、2、3、4、5、65535 六个元素中排名第2,所以它将被移动到contents数组的索引位置上,也即96位至127位的空间内。相应位置的元素已经被迁移。
  • 因为元素 1 在1、2、3、4、5、65535 六个元素中排名第1,所以它将被移动到contents数组的索引位置上,也即96位至127位的空间内。相应位置的元素已经被迁移。
0至3132至63位64至95位96至127位128至159位160至191位
元素12345新分配的空间

最后将 65535插入到第六个位置上,也即160位至191位的空间内。

0至3132至63位64至95位96至127位128至159位160至191位
元素1234565535

最后,程序将整数集合encoding属 性的值从INTSET_ENC_INT16改 为INTSET_ENC_INT32,并将length属性的值从5改为6,设置完成之后的整数集合如图所示

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

升级之后新元素的摆放位置

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

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

升级的好处

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

提升灵活性

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

例如,我们一般只使用int16_t类型的数组来保存int16_t类型的值,只使用int32_t类型的数组来保存int32_t类型的值,诸如此类

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

节约内存

当然,要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样-来,即使添加到整数集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。

而整数集合现在的做法既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,这可以尽量节省内存。

例如,如果我们一直只向整数集合添加int16_t类型的值,那么整数集合的底层实现就会一直是int16_t类型的数组,只有在我们要将int32_t类型或者int64_t类型的值添加到集合时,程序才会对数组进行升级.

降级

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

quicklist

在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现

quicklist的设计,其实是结合了链表和ziplist各自的优势。简单来说,一个quicklist就是一个链表,而链表中的每个元素又是一个ziplist。

quicklist的数据结构,在quicklist.h文件中定义的,而quicklist的实现是在quicklist.c文件中。

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

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

quicklistNode的数据结构

首先,quicklist元素的定义,也就是quicklistNode。因为quicklist是一个链表,所以每个quicklistNode中,都包含了分别指向它前序和后序节点的指针*prev*next。同时,每个quicklistNode又是一个ziplist,所以,在quicklistNode的结构体中,还有指向ziplist的指针*zl

此外,quicklistNode结构体中还定义了一些属性,比如ziplist的字节大小、包含的元素个数、编码格式、存储方式等。下面的代码显示了quicklistNode的结构体定义。

typedef struct quicklistNode {
    struct quicklistNode *prev;     //前一个quicklistNode
    struct quicklistNode *next;     //后一个quicklistNode
    
		// 指向ziplist的指针
    unsigned char *zl;              //quicklistNode指向的ziplist
    unsigned int sz;                //ziplist的字节大小
    unsigned int count : 16;        //ziplist中的元素个数 
    unsigned int encoding : 2;   //编码格式,原生字节数组或压缩存储
    unsigned int container : 2;  //存储方式
    unsigned int recompress : 1; //数据是否被压缩
    unsigned int attempted_compress : 1; //数据能否被压缩
    unsigned int extra : 10; //预留的bit位
} quicklistNode;

quicklist的数据结构

quicklist作为一个链表结构,在它的数据结构中,是定义了整个quicklist的头、尾指针,这样一来,我们就可以通过quicklist的数据结构,来快速定位到quicklist的链表头和链表尾。

此外,quicklist中还定义了quicklistNode的个数、所有ziplist的总元素个数等属性。quicklist的结构定义如下所示:

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

然后,从quicklistNode和quicklist的结构体定义中,我们就能画出下面这张quicklist的示意图。

而也正因为quicklist采用了链表结构,所以当插入一个新的元素时,quicklist首先就会检查插入位置的ziplist是否能容纳该元素,这是通过 _quicklistNodeAllowInsert函数来完成判断的。

_quicklistNodeAllowInsert函数会计算新插入元素后的大小(new_sz),这个大小等于quicklistNode的当前大小(node->sz)、插入元素的大小(sz),以及插入元素后ziplist的prevlen占用大小。

在计算完大小之后,_quicklistNodeAllowInsert函数会依次判断新插入的数据大小(sz)是否满足要求,即单个ziplist是否不超过8KB,或是单个ziplist里的元素个数是否满足要求。

只要这里面的一个条件能满足,quicklist就可以在当前的quicklistNode中插入新元素,否则quicklist就会新建一个quicklistNode,以此来保存新插入的元素。

下面代码显示了是否允许在当前quicklistNode插入数据的判断逻辑。

unsigned int new_sz = node->sz + sz + ziplist_overhead;
if (likely(_quicklistNodeSizeMeetsOptimizationRequirement(new_sz, fill)))
    return 1;
else if (!sizeMeetsSafetyLimit(new_sz))
    return 0;
else if ((int)node->count < fill)
    return 1;
else
    return 0;

这样一来,quicklist通过控制每个quicklistNode中,ziplist的大小或是元素个数,就有效减少了在ziplist中新增或修改元素后,发生连锁更新的情况,从而提供了更好的访问性能。

而Redis除了设计了quicklist结构来应对ziplist的问题以外,还在5.0版本中新增了listpack数据结构,用来彻底避免连锁更新。

listpack

quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。

因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。

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

listpack也叫紧凑列表,它的特点就是用一块连续的内存空间来紧凑地保存数据,同时为了节省内存空间,listpack列表项使用了多种编码方式,来表示不同长度的数据,这些数据包括整数和字符串。

listpack相关的实现文件是listpack.c,头文件包括listpack.h和listpack_malloc.h。

listpack的整体结构

我们先来看下listpack的创建函数lpNew,因为从这个函数的代码逻辑中,我们可以了解到listpack的整体结构。

lpNew函数创建了一个空的listpack,一开始分配的大小是LP_HDR_SIZE再加1个字节。LP_HDR_SIZE宏定义是在listpack.c中,它默认是6个字节,其中4个字节是记录listpack的总字节数,2个字节是记录listpack的元素数量。

此外,listpack的最后一个字节是用来标识listpack的结束,其默认值是宏定义LP_EOF。和ziplist列表项的结束标记一样,LP_EOF的值也是255。

unsigned char *lpNew(void) {
    //分配LP_HRD_SIZE+1
    unsigned char *lp = lp_malloc(LP_HDR_SIZE+1);
    if (lp == NULL) return NULL;
    //设置listpack的大小
    lpSetTotalBytes(lp,LP_HDR_SIZE+1);
    //设置listpack的元素个数,初始值为0
    lpSetNumElements(lp,0);
    //设置listpack的结尾标识为LP_EOF,值为255
    lp[LP_HDR_SIZE] = LP_EOF;
    return lp;
}

你可以看看下面这张图,展示的就是大小为LP_HDR_SIZE的listpack头和值为255的listpack尾。当有新元素插入时,该元素会被插在listpack头和尾之间。

listpack列表项的设计

了解了listpack的整体结构后,我们再来看下listpack列表项的设计。

和ziplist列表项类似,listpack列表项也包含了元数据信息和数据本身。不过,为了避免ziplist引起的连锁更新问题,listpack中的每个列表项不再像ziplist列表项那样,保存其前一个列表项的长度,它只会包含三个方面内容,分别是当前元素的编码类型(entry-encoding)、元素数据(entry-data),以及编码类型和元素数据这两部分的长度(entry-len),如下图所示。

主要包含三个方面内容:

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

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

listpack避免连锁更新的实现方式

最后,我们再来了解下listpack列表项是如何避免连锁更新的。

在listpack中,因为每个列表项只记录自己的长度,而不会像ziplist中的列表项那样,会记录前一项的长度。所以,当我们在listpack中新增或修改元素时,实际上只会涉及每个列表项自己的操作,而不会影响后续列表项的长度变化,这就避免了连锁更新。

不过,你可能会有疑问:如果listpack列表项只记录当前项的长度,那么listpack支持从左向右正向查询列表,或是从右向左反向查询列表吗?

其实,listpack是能支持正、反向查询列表的。

当应用程序从左向右正向查询listpack时,我们可以先调用lpFirst函数。该函数的参数是指向listpack头的指针,它在执行时,会让指针向右偏移LP_HDR_SIZE大小,也就是跳过listpack头。你可以看下lpFirst函数的代码,如下所示:

unsigned char *lpFirst(unsigned char *lp) {
    lp += LP_HDR_SIZE; //跳过listpack头部6个字节
    if (lp[0] == LP_EOF) return NULL;  //如果已经是listpack的末尾结束字节,则返回NULL
    return lp;
}

然后,再调用lpNext函数,该函数的参数包括了指向listpack某个列表项的指针。lpNext函数会进一步调用lpSkip函数,并传入当前列表项的指针,如下所示:

unsigned char *lpNext(unsigned char *lp, unsigned char *p) {
    ...
    p = lpSkip(p);  //调用lpSkip函数,偏移指针指向下一个列表项
    if (p[0] == LP_EOF) return NULL;
    return p;
}

最后,lpSkip函数会先后调用lpCurrentEncodedSize和lpEncodeBacklen这两个函数。

lpCurrentEncodedSize函数是根据当前列表项第1个字节的取值,来计算当前项的编码类型,并根据编码类型,计算当前项编码类型和实际数据的总长度。然后,lpEncodeBacklen函数会根据编码类型和实际数据的长度之和,进一步计算列表项最后一部分entry-len本身的长度。

这样一来,lpSkip函数就知道当前项的编码类型、实际数据和entry-len的总长度了,也就可以将当前项指针向右偏移相应的长度,从而实现查到下一个列表项的目的。

下面代码展示了lpEncodeBacklen函数的基本计算逻辑,你可以看下。

unsigned long lpEncodeBacklen(unsigned char *buf, uint64_t l) {
    //编码类型和实际数据的总长度小于等于127,entry-len长度为1字节
    if (l <= 127) {
        ...
        return 1;
    } else if (l < 16383) { //编码类型和实际数据的总长度大于127但小于16383,entry-len长度为2字节
       ...
        return 2;
    } else if (l < 2097151) {//编码类型和实际数据的总长度大于16383但小于2097151,entry-len长度为3字节
       ...
        return 3;
    } else if (l < 268435455) { //编码类型和实际数据的总长度大于2097151但小于268435455,entry-len长度为4字节
        ...
        return 4;
    } else { //否则,entry-len长度为5字节
       ...
        return 5;
    }
}

我也画了一张图,展示了从左向右遍历listpack的基本过程,你可以再回顾下。

好,了解了从左向右正向查询listpack,我们再来看下从右向左反向查询listpack。

首先,我们根据listpack头中记录的listpack总长度,就可以直接定位到listapck的尾部结束标记。然后,我们可以调用lpPrev函数,该函数的参数包括指向某个列表项的指针,并返回指向当前列表项前一项的指针。

跳跃表 (skiplist)

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

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

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

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

skiplist的设计

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

那跳表长什么样呢?下图展示了一个层级为 3 的跳表。

图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:

  • L0 层级共有 5 个节点,分别是节点1、2、3、4、5;

  • L1 层级共有 3 个节点,分别是节点 2、3、5;

  • L2 层级只有 1 个节点,也就是节点 3 。

如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。

可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。

skiplist的实现

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

zskiplistNode的数据结构

跳跃表节点的实现由redis.h/zskiplistNode结构定义

ypedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;
  • ele:节点的成员对象
  • score:节点的分值 (score属性) 是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
  • *backward:每个跳表节点都有一个后向指针(struct zskiplistNode *backward),指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。
  • level[]:数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。
    • *forward:指向下一个跳表节点的指针
    • span:跨度时用来记录两个节点之间的距离

比如,下面这张图,展示了各个节点的跨度。

第一眼看到跨度的时候,以为是遍历操作有关,实际上并没有任何关系,遍历操作只需要用前向指针(struct zskiplistNode *forward)就可以完成了。

跨度实际上是为了计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。

举个例子,查找图中节点 3 在跳表中的排位,从头节点开始查找节点 3,查找的过程只经过了一个层(L2),并且层的跨度是 3,所以节点 3 在跳表中的排位是 3。

另外,图中的头节点其实也是 zskiplistNode 跳表节点,只不过头节点的后向指针、权重、元素值都没有用到,所以图中省略了这部分。

zskiplist的数据结构

由谁定义哪个跳表节点是头节点呢?这就介绍zskiplist结构体了,如下所示:

typedef struct zskiplist {
    //表头节点和表尾节点
    structz skiplistNode *header, *tail;
    //表中节点的数量
    unsigned long length;
    //表中层数最大的节点的层数
    int level;
} zskiplist;
  • header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O (1)
  • ength属性来记录节点的数量,程序可以在O (1) 复杂度内返回跳跃表的长度。
  • level属性则用于在O (1) 复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。

跳表节点查询过程

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

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

如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。

举个例子,下图有个 3 层级的跳表。

如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的:

  • 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
  • 但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
  • 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
  • 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。

跳表节点层数设置

跳表的相邻两层的节点数量的比例会影响跳表的查询性能。

举个例子,下图的跳表,第二层的节点数量只有 1 个,而第一层的节点数量有 6 个。

这时,如果想要查询节点 6,那基本就跟链表的查询复杂度一样,就需要在第一层的节点中依次顺序查找,复杂度就是 O(N) 了。所以,为了降低查询复杂度,我们就需要维持相邻层结点数间的关系。

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

下图的跳表就是,相邻两层的节点数量的比例是 2 : 1。

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

如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。

Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。

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

这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。

虽然我前面讲解跳表的时候,图中的跳表的「头节点」都是 3 层高,但是其实如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点。

如下代码,创建跳表时,头节点的 level 数组有 ZSKIPLIST_MAXLEVEL个元素(层),节点不存储任何 member 和 score 值,level 数组元素的 forward 都指向NULL, span值都为0。

/* Create a new skiplist. */
zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl));
    zsl->level = 1;
    zsl->length = 0;
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL;
    zsl->tail = NULL;
    return zsl;
}

其中,ZSKIPLIST_MAXLEVEL 定义的是最高的层数,Redis 7.0 定义为 32,Redis 5.0 定义为 64,Redis 3.0 定义为 32。

什么用跳表而不用平衡树?

这里插一个常见的面试题:为什么 Zset 的实现用跳表而不用平衡树(如 AVL树、红黑树等)?

对于这个问题(opens new window),Redis的作者 @antirez 是怎么说的:

There are a few reasons:

  1. They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
  2. A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
  3. They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

简单翻译一下,主要是从内存占用、对范围查找的支持、实现难易程度这三方面总结的原因:

  • 它们不是非常内存密集型的。基本上由你决定。改变关于节点具有给定级别数的概率的参数将使其比 btree 占用更少的内存。
  • Zset 经常需要执行 ZRANGE 或 ZREVRANGE 的命令,即作为链表遍历跳表。通过此操作,跳表的缓存局部性至少与其他类型的平衡树一样好。
  • 它们更易于实现、调试等。例如,由于跳表的简单性,我收到了一个补丁(已经在Redis master中),其中扩展了跳表,在 O(log(N) 中实现了 ZRANK。它只需要对代码进行少量修改。