Redis(二):自定义数据结构

前言

上一篇介绍了 Redis 是如何存储和索引数据的,这一篇将介绍 Redis 定义的八大数据结构,这些高效的数据结构,不仅帮助 Redis 实现了高性能,更能够应用于各种场景。

八大数据结构如下:SDS、双向链表、压缩列表、哈希表、整数集合、跳表、quicklist、listpack。

SDS

simple dynamic string,简单动态字符串。

Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为 SDS 的数据结构来表示字符串,主要是因为 C 语言提供的字符串实现方式有部分缺陷。

C语言字符串缺陷

C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。最后一个字符是“\0”,表示字符串的结束

缺陷:

  • C 语言获取字符串长度的时间复杂度是 O(N),需要遍历数组寻找“\0”的位置;
  • 字符串的结尾是以 “\0” 字符标识,所以字符串里面不能含有 “\0” 字符,否则最先被程序读入的 “\0” 字符将被误认为是字符串结尾,这个限制使得 C 语言的字符串只能保存文本数据,不能保存像图片、音频、视频文化这样的二进制数据
  • C 语言标准库中字符串的操作函数既不高效又不安全,容易导致缓冲区溢出;
结构设计

SDS 结构中的每个成员变量如下:

  • len,记录了字符串长度,占 4 个字节。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1);
  • alloc,分配给字符数组的空间长度,占 4 个字节。用于自动扩容;
  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64;
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。为了兼容部分 C 语言标准库的函数, 结尾还是会加上 “\0” 字符,额外占 1 个字符。

flags 表示的是 SDS 类型,不同类型的区别在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同。之所以设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。

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

默认情况下,编译器是按照字节对齐的方式给变量分配内存的,比如一个 char 类型变量和一个 int 类型变量,会被分配到 8 个字节(int 占四个字节,char 占一个字节但是会被分配到四个字节)。

SDS 相比于 C 的原生字符串:

  • SDS 不仅可以保存文本数据,还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。
  • SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)
  • Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
自动扩容

在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小。就不会出现前面所说的缓冲区溢出的问题。

当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小:

  • 如果所需的 SDS 长度小于 1 MB,那么最后的扩容是按照翻倍扩容来执行的,即 2 倍的 newlen
  • 如果所需的 SDS 长度超过 1 MB,那么最后的扩容长度应该是 newlen + 1MB

在扩容 SDS 空间之前,SDS API 会优先检查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」,从而减少内存分配次数。

双向链表

C 语言本身没有链表这个数据结构的,所以 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(二):自定义数据结构_第1张图片

链表的缺陷:

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

因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现。

压缩列表

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

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

压缩列表在表头有三个字段,在表尾有一个字段:

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

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

Redis(二):自定义数据结构_第2张图片

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

  • prevlen,1 或 5 字节,记录了前一个节点的长度,目的是为了实现从后向前遍历;
  • encoding,1 字节,记录了当前节点实际数据的类型和长度,类型主要有两种:字符串和整数。
  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

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

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

prevlen 属性的空间大小跟前一个节点长度值有关,比如:

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

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

  • 如果当前节点的数据是整数,则 encoding 会使用 1 字节的空间进行编码。通过 encoding 确认了整数类型,就可以确认整数数据的实际大小了,比如如果 encoding 编码确认了数据是 int16 整数,那么 data 的长度就是 int16 的大小。
  • 如果当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节/2字节/5字节的空间进行编码,encoding 编码的前两个 bit 表示数据的类型,后续的其他 bit 标识字符串数据的实际长度,即 data 的长度。

Redis(二):自定义数据结构_第3张图片

压缩列表的缺陷:

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

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

这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」。

哈希表

哈希表是一种保存键值对(key-value)的数据结构。

结构设计

Redis(二):自定义数据结构_第4张图片

哈希表的结构在上一节中已经讲过了,这里就再简单提一提。

哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。同一个哈希槽中的节点会通过指针链接成一个链表,当哈希冲突发生时,会将节点添加到链表后面。

哈希冲突

当一个键值对的键经过哈希函数计算后得到哈希值,再将(哈希值 % 哈希表大小)取模计算,得到的结果值就是该 key-value 对应的数组元素位置。当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了哈希冲突

在哈希表大小固定的情况下,随着数据不断增多,哈希冲突的可能性会越高。带来的影响是查找的速度会越来越慢(因为每次都要遍历一个长链表)。

链式哈希

Redis 采用了「链式哈希」来解决哈希冲突。

每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来

不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加。

rehash

Redis 通过 rehash 策略来解决哈希冲突导致性能降低的问题。

在 dict 结构体里定义了两个哈希表(ht[2]),在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。

随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:

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

rehash 存在问题:如果哈希表的数据量非常大,那么在迁移的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。

rehash触发条件

rehash 的触发条件跟**负载因子(load factor)**有关系。负载因子 = 哈希表已保存节点数量 / 哈希表大小。

当负载因子大于 1,则说明一定出现了 hash 冲突,负载因子越大冲突的键值对越多。

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

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候(后续在持久化部分会讲到),就会进行 rehash 操作。
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。
渐进式rehash

为了避免 rehash 在数据迁移过程中,因拷贝大量数据而影响 Redis 性能,Redis 采用了渐进式 rehash,也就是将数据的迁移的工作分多次进行。

渐进式 rehash 步骤如下:

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

整数集合

整数集合用于保存整数型元素。

结构设计

整数集合本质上是一块连续内存空间,可以节省指针使用的空间,它的结构定义如下:

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

可以看到,保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组的真正类型取决于 encoding 属性的值。比如:

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

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

整数集合升级

整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(如 int32_t)比整数集合现有所有元素的类型(如 int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。

整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后再将每个元素按间隔类型大小分割。

Redis(二):自定义数据结构_第5张图片

整数集合升级可以节省内存资源,因为会尽量使用较小的整数集合来存储数据,在必要时再进行升级。

整数集合不支持降级操作,且内部仍然存在部分资源浪费,比如部分元素原本可以用更小的类型进行存储,但是都需要采用最大元素类型进行存储。

跳表

跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。跳表的查找复杂度可以达到 O(logN)。缺点是需要的存储空间比较大。

Redis(二):自定义数据结构_第6张图片

结构设计
// 跳表
typedef struct zskiplist {
    //头尾节点;
    struct zskiplistNode *header, *tail;
    //长度;
    unsigned long length;
    //最大层数;
    int level;
} zskiplist;

// 跳表节点
typedef struct zskiplistNode {
    //Sorted Set 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

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

查询过程

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

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

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

跳表层数

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

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

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

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

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

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

Sorted Set 的实现用跳表而不用平衡树(如 AVL树、红黑树等)?

  • 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p (0.25,即增加一层的概率)的大小。如果像 Redis 里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
  • 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
  • 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。

quicklist

quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个双向链表,而链表中的每个元素又是一个压缩列表。

Redis(二):自定义数据结构_第7张图片

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

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

但是这并没有完全解决连锁更新的问题。

listpack

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

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。

Redis(二):自定义数据结构_第8张图片

每个节点包含三个内容:

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

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

新版本的 Redis 中,已经将使用到压缩列表的地方替换为 listpack 了。

最后

本文介绍了 Redis 的八大数据结构。下一节将介绍在这些数据结构基础上实现的九大数据类型。

你可能感兴趣的:(Redis,数据结构,redis)