Redis数据结构之List&Ziplist&Quicklist&Listpack

97 阅读20分钟

Redis 中 List 数据结构的底层为什么使用 Ziplist 呢?为什么不直接用原始的双向链表呢?为什么又出现了 Quicklist 以及 Listpack?

先说历史:

Redis 3.2 之前,List 底层实现是 LinkedList 或者 ZipList。 Redis 3.2 之后,引入了 LinkedList 和 ZipList 的结合 QuickList,List 的底层实现变为 QuickList(其下为 Ziplist)。从 Redis 7.0 开始, ZipList 被 ListPack 取代。

先来看看 List 最初的形态:双向链表

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

在 ListNode 这个结构体之上,又封装了 list 这个数据结构,增加了很多字段,方便操作:

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;

list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup、free、match 函数。

这种设计的意义(了解即可):

  1. 通过函数指针实现了类似 C++多态的效果

  2. 将数据操作逻辑与数据结构分离,符合单一职责原则

  3. 提供了扩展性,用户可以根据存储的数据类型自定义处理方式

  4. 避免硬编码特定类型的处理逻辑,使链表可以存储任意类型数据

来个栗子:

下面我们来分析一下 list 原始底层数据结构 双向链表 的优点以及痛点:

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 是基于内存工作的,各种数据以一种数据结构的形式存储在内存中,而我们平常对这些数据的操作底层都是通过 CPU 来完成的,如 LPOP key命令底层是二进制指令,这些指令由 CPU 来执行。但是内存的速度比 CPU 慢的多,如果 CPU 每次需要数据都要直接去内存取,那就会因为等待内存而浪费大量时间。

为了解决这个问题,CPU 设计者引入了缓存(L1、L2、L3 多级缓存),简单介绍一下多级缓存(我们这里不具体考虑多级缓存的工作原理):

缓存是一种容量小但速度极快的存储器,它位于 CPU 和内存之间,CPU 会把最近使用或者可能很快会用到的数据从内存复制到缓存中,当 CPU 需要数据时,首先去缓存中查找,存在则为缓存命中,不存在这为缓存为命中

下面来简单说一下缓存的工作原理:局部性原理

  • 时间局部性:如果一个数据项在某个时间点被访问,那么不久的将来它很可能再次被访问。

  • 空间局部性:如果一个数据项被访问,那么它的邻近数据项也很可能很快被访问。

CPU 缓存的设计就是为了利用这些局部性原理。当 CPU 从内存中读取数据时,它不是只读取一个字节或一个字,而是会读取一个缓存行大小的数据块,缓存行是内存和缓存之间数据传输的基本单位,它定义了每次从内存加载到缓存的数据量(它是一个物理上的数据块,是内存地址空间被划分成的固定大小的单元)。一个缓存行通常是几十到几百个字节。这样做是因为,如果 CPU 访问了某个地址的数据,根据空间局部性原理,它很可能很快就会访问这个地址附近的数据。把整个缓存行的数据都加载到缓存中,可以一次性把这些可能用到的数据都带进来,减少后续的内存访问次数。(看到缓存行的介绍,是不是想起什么东西了?没座!就是 mysql 的数据页,数据页是定义了 mysql 中内存与磁盘的最小交互单元,但他不仅仅是一个大小的定义,还是一个数据结构,内部还有数据行等结构。但它与缓存行在利用空间局部性原理、以固定大小数据块进行数据传输的思想上是异曲同工的!)

现在再来看问题:为什么在链表结构中无法很好的利用 CPU 的缓存功能

我想你心中已经有了答案:链表节点在内存中的分布不是连续的,当 CPU 以缓存行为数据单位从内存中读取一个节点信息的时候,与该节点相邻的内存存储的很可能并不是其相邻的节点(或者说是该链表的其他节点),即该缓存行中,除了当前节点的数据之外,很可能包含了与该链表不相关的其他数据。这些数据对于当前链表的遍历来说是无效的,但它们仍然被加载到了缓存中。因此,缓存空间被填充了大量对于链表遍历来说无用的数据,导致真正需要的链表节点数据无法有效地被缓存,降低了缓存的利用率。

既然明确了根本问题所在:链表节点在内存中的不连续性导致 CPU 缓存利用率很低。我们应该怎么解决这个痛点呢?

你可能会想到:既然根本问题是链表节点的不连续性,我们直接换成连续的数组结构不就好了!!!

恭喜你,成功了一半🎉

我们来看看为什么 redis 设计者没有直接选用数组结构:

  1. 内存效率考量:

    • 数组的固定大小问题:redis 的数据结构经常需要动态增长,这会导致 要么频繁重新分配内存(性能问题),要么预先过度分配(内存浪费)

    • 指针开销问题:存储对象引用数组会有指针开销(64 为操作系统每个指针 8 字节)

  2. 数据类型多样性需求:Redis 需要支持不同长度的字符串、整数等多种数据类型

针对以上问题,我们看看 redis 自身设计的新数据结构 Ziplist - 压缩列表:

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

压缩列表的四个字段:

  • zlbytes,记录整个压缩列表占用对内存字节数;

  • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;

  • zllen,记录压缩列表包含的节点数量(entry);

  • zlend,标记压缩列表的结束点,固定值 0xFF(十进制 255)。

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

再来看看 entry,本身是个包含三个字段的结构体:

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

  • prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;

  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。

  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

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

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

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

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

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

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

  • 如果当前节点的数据是整数,则 encoding 会使用 1 字节的空间进行编码,也就是 encoding 长度为 1 字节。通过 encoding 确认了整数类型,就可以确认整数数据的实际大小了,比如如果 encoding 编码确认了数据是 int16 整数,那么 data 的长度就是 int16 的大小。

  • 如果当前节点的数据是字符串,根据字符串的长度大小,encoding 会使用 1 字节/2 字节/5 字节的空间进行编码,encoding 编码的前两个 bit 表示数据的类型,后续的其他 bit 标识字符串数据的实际长度,即 data 的长度。

终极权衡:用 5%的大数据场景性能下降,换取 95%的小数据场景内存节省,这正是 Redis 作为内存数据库的核心优化哲学。

来俩栗子说一下 ziplist 的工作原理:

从头遍历:

  1. zlbytes 获取 ziplist 起始地址 p = start + 10(假设 header 占 10 字节)。

  2. 读取 entry1

    • prevlen = 0x00(前驱长度为 0)

    • encoding = 0x80(5 字节字符串)

    • 读取 content = "hello"

  3. 计算 entry2 的位置:

    • entry1 总长度 = 1 (prevlen) + 1 (encoding) + 5 (content) = 7

    • 所以 entry2 的地址 = p + 7

  4. 读取 entry2

    • prevlen = 0x07(前驱长度 7)

    • encoding = 0x80(5 字节字符串)

    • content = "redis".

  5. 同理找到 entry3 并读取 "world"

从尾遍历:

  1. zltail 获取最后一个节点 entry3 的地址 p = tail

  2. 读取 entry3

    • prevlen = 0x07(前驱长度 7)

    • encoding = 0x80(5 字节字符串)

    • content = "world".

  3. 关键:通过 prevlen 找到前一个节点

    • prevlen = 7,所以 entry2 的地址 = p - 7
  4. 读取 entry2

    • prevlen = 0x07(前驱长度 7)

    • encoding = 0x80(5 字节字符串)

    • content = "redis".

  5. 同理找到 entry1 并读取 "hello"

但是,压缩链表除了查找复杂度高的问题,还有一个问题:

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

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

因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。

这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图:

因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。就会有连锁反应:

我们来总结一下压缩列表的优缺点:

优点:

缺点:

面对 ziplist 这些痛点,你能视而不见到此为止吗?显然不可以,我们先来自己尝试一下如何解决 ziplist 的这些痛点:

通过分析上述 ziplist 的缺点大家也许察觉到了,问题隐约都在指向:ziplist 当下的设计不适合数据量大的情况!!

那我们直接再设计一个数据结构在 ziplist 数据量达到一定程度的时候使用不就好了吗,就如同 Java 的 HashMap 的底层数据结构,在红黑树和链表直接来回跳转😄。

这里留给大家一个思考题:你会设计一个怎么样的数据结构来作为数据量大的时候 list 的底层存储呢?为什么?

这里展示一下我的拙见,大家可以略过哦

再次分析一下 ziplist 痛点的本质以及可能的解决方案:

  • 访问中间节点 O(n)的复杂度

    • 能想到的访问节点为 O(1)的数据结构以及场景只有:数组的下标访问,map 的特定值访问

    • 分析数组和 map 的底层,额....都是连续的内存结构。(这里分析一下为什么 ziplist 连续内存结构访问不是 O(1):找区别吧,貌似就是一个位置的大小是固定且位置的大小都一样,ziplist 对于 entry 的类型没有做固定要求,大小也就会不一样,所以没办法像数组一样)

    • 除了底层用数组可以实现 O(1)的访问,底层就是 起始位置 + 下标*元素大小 以及 map 的 hash 映射实现特定元素的 O(1)访问,其他的我想不到了。。。

    • 问了 AI:目前能实现 O(1)访问的也只有上述两种结构,且没有同时满足两种场景(下标和特定元素)的 O(1)访问(其实 map 是可以的,但没有实际意义,因为我们使用下标访问大概率是知道这个下标下存的是我们知道的一个元素,所以我们才会以特定下标去访问,但 map 存的时候是 hash 映射,这是无规律的,所以在 map 下通过下标获取的话只有一种场景,那就是获取 map 中的一个随机值!--- 这里的 map 就是个数组结构,不以拉链法解决 hash 冲突)

    • 那就需要创建新的数据结构来满足要求了,嘻嘻,直接数组+索引,即一个数组在加一个 map 集合,这会消耗很多内存,且插入删除时需要同时更新数组和索引结构,性能不高,不适合频繁修改的场景。这对于 redis 的 list 类型是不行的。

    • 目前我想不到....

  • 连锁更新问题和修改操作导致节点空间重新分配

    • 这里问题的根本都是因为 ziplist 的结构是内存连续的,可以当成数组来分析,删除一个元素要修改后面所有元素的位置,且 ziplist 修改元素要重新分配空间,因为查询时依靠节点的实际大小来决定的。那么是否可以容忍 ziplist 存在空洞,对其紧凑性要求不那么严格呢?这个直接说我思考的结论吧:感觉这样不行,如果这样做的话虽然不会影响 ziplist 双向遍历的功能,但可能会导致获取尾节点不再是 O(1),且会大大增加处理逻辑的复杂度,以及会浪费内存(内存这个词在 redis 中是很敏感的,感觉 redis 的设计宗旨就是觉不浪费每 1bit 内存!开玩笑😄)

    • 这里已经发现了连续内存的痛点,但又无法直接用链表(CPU 利用率低,这是我们最初的问题),我想的是用俩数据结构,一个链表结构来规避内存分配的问题,一个连续内存的结构来规避 CPU 利用率低的问题。具体怎么实现呢?

    • 这里我想到了 mysql 的底层数据结构,感觉这些关于数据的存与取的问题在很多技术中都存在,只是根据自身产品的业务场景选择了不同的解决方案(大多只是细节层面有差别)。mysql 中的底层结构数据页、数据行之间都是用链表实现的,原因是其不需要太考虑空间问题,因为这家伙主要是存在磁盘中的,而数据行中的元素字段存储所使用的数据结构类似于 SDS,数据行与行之间是链表关系,而上层的数据页申请的是连续的空间,页与页之间也是链表关系。

    • 到这里,我想可不可以抄 mysql 的作业,设计一个数据结构,先是链表,内部再是一个连续的内存存储结构(类似于 SDS、数组、ziplist 等)。接下来我就思考不动了,实力有限,问了 AI:他说这种先链表,内部再是一个连续的内存结构的这种组合通常被称为“分块链表”或“块状链表”,(这里想到了 ConcurrentHashMap,有点点相似吧),内部的连续内存结构不一定非得是 ziplist。它可以是简单的数组、动态数组(如 C++ 的 std::vector)、或者像 MySQL 中数据行那样更复杂的结构。选择哪种取决于具体的需求。分块链表一般的场景为:文本编辑器、数据库、某些缓存系统

    • 到这里感觉🧠有点不够用了。。。就到这里吧,看看 redis 具体是怎么解决的吧!!!

拨云见日!ziplist 的痛点解决方案之 Quicklist!!!

上图:

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

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

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

typedef struct quicklistNode {
    //前一个quicklistNode
    struct quicklistNode *prev;//前一个quicklistNode
    //下一个quicklistNode
    struct quicklistNode *next;//后一个quicklistNode
    //quicklistNode指向的压缩列表
    unsigned char *zl;
    //压缩列表的的字节大小
    unsigned int sz;
    //压缩列表的元素个数
    unsigned int count : 16;//ziplist中的元素个数
    //...还有其他字段,这里不展开
} quicklistNode;

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

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

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

到这里,我感觉已经很能打了,而且我也有点累了😮‍💨,但是 redis 并没有放弃!!!

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

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

见证新王的诞生,彻底解决连锁更新的问题,listpack!!!

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

我们先看看 listpack 结构:

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

每个 listpack 节点结构如下:

主要包含三个方面内容:

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;

  • data,实际存放的数据;

  • len,encoding+data 的总长度;

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

(listpack 比 ziplist 更像数组了,在 listpack 中,无论是插入、删除还是修改元素(导致节点内存变大或变小),只要当前节点的存储长度发生变化,都可能需要移动后续所有元素的内存位置。这是 listpack 为了彻底解决 ziplist 的连锁更新问题所付出的代价。但 QuicklistNode 规定了 listpack 的大小,降低修改操作对 listpack 造成的影响,这很能打了,也算是找到了平衡,没有十全十美的数据结构。)

再看 ziplist&listpack:ziplist 的连锁更新问题导致后续节点重新分配内存,现在的 listpack 修改节点导致后续节点内存地址偏移量修改,但不是重新分配内存哦,而且是所有节点内存地址都移动相同的偏移量,在这方面虽然都操作了后续节点,listpack 性能略高!

完结,撒花🎉!!!

本文参考文章链接:

本文由博客一文多发平台 OpenWrite 发布!