Redis 的 quicklist 底层结构是怎样的

在 Redis 的众多数据结构中,List(列表)是非常常用的一种。早期版本的 Redis 使用 ziplist 和 linkedlist 两种编码方式来实现 List,但在 Redis 3.2 之后,引入了一种新的底层结构——quicklist,它结合了 ziplist 和 linkedlist 的优点,成为 List 类型的默认实现。本文将深入剖析 quicklist 的底层结构,帮助你在面试中从容应对相关问题。

为什么需要 quicklist

在理解 quicklist 之前,有必要先回顾一下它要解决的问题。

ziplist(压缩列表) 是一块连续的内存空间,所有元素紧凑地排列在一起。它的优点是内存利用率极高,没有额外的指针开销。但它也有明显的缺点:插入和删除操作可能触发连锁更新(cascade update),在最坏情况下时间复杂度退化为 O(n²);而且当元素数量较多时,ziplist 会变得非常庞大,内存重新分配的成本很高。

linkedlist(双向链表) 的优点是插入和删除操作非常高效,时间复杂度为 O(1)。但它的缺点是每个节点都需要额外的 prev 和 next 指针,内存开销大;而且每个节点都是独立的内存块,容易产生内存碎片。

quicklist 的设计思路就是:将多个 ziplist 通过双向链表串联起来。这样既保留了 ziplist 的内存紧凑性,又通过链表的分段避免了单个 ziplist 过大带来的性能问题。

quicklist 的整体结构

quicklist 的核心定义在 quicklist.h 中,其结构可以简化为:

typedef struct quicklist {
    quicklistNode *head;    // 头节点
    quicklistNode *tail;    // 尾节点
    unsigned long count;    // 所有 ziplist 中的元素总数
    unsigned long len;      // quicklistNode 的个数
    int fill : 16;          // 每个节点的填充因子
    unsigned int compress : 16; // 压缩深度
} quicklist;

可以看到,quicklist 本身就是一个双向链表,持有 head 和 tail 指针,同时记录了节点数量和元素总数。

每个链表节点的结构如下:

typedef struct quicklistNode {
    struct quicklistNode *prev;  // 前驱节点
    struct quicklistNode *next;  // 后继节点
    unsigned char *zl;           // 指向 ziplist
    unsigned int sz;             // ziplist 的字节大小
    unsigned int count : 16;     // ziplist 中的元素个数
    unsigned int encoding : 2;   // 编码方式:RAW 或 LZF
    unsigned int container : 2;  // 容器类型:NONE 或 ZIPLIST
    unsigned int recompress : 1; // 是否被解压过
    unsigned int attempted_compress : 1; // 是否尝试过压缩
    unsigned int extra : 10;     // 预留字段
} quicklistNode;

每个 quicklistNode 内部持有一个 ziplist 指针 zl,这就是 quicklist 的基本单元。整个结构示意图如下:

quicklist
   |
   +-- head --> [quicklistNode] <--> [quicklistNode] <--> [quicklistNode] --> tail
                    |                      |                      |
                 [ziplist]              [ziplist]              [ziplist]

quicklist 的关键参数

fill:填充因子

fill 参数控制每个 quicklistNode 中 ziplist 的最大容量。它可以在 redis.conf 中通过 list-max-ziplist-size 配置,取值为负数时有特殊含义:

  • -1:每个 ziplist 最大 4KB
  • -2:每个 ziplist 最大 8KB(默认值)
  • -3:每个 ziplist 最大 16KB
  • -4:每个 ziplist 最大 32KB
  • -5:每个 ziplist 最大 64KB

如果取正数,则表示每个 ziplist 最多包含多少个元素。例如 fill = 128 表示每个 ziplist 最多存 128 个元素。

这个参数的设计非常关键:ziplist 太小,链表节点过多,内存碎片和指针开销增大;ziplist 太大,插入删除时内存重新分配的成本变高。默认值 8KB 是一个经过权衡的经验值。

compress:压缩深度

compress 参数控制是否对中间节点进行 LZF 压缩,通过 list-compress-depth 配置。其含义是:

  • 0:不压缩(默认值)
  • 1:首尾各 1 个节点不压缩,中间的节点压缩
  • 2:首尾各 2 个节点不压缩,中间的节点压缩
  • 以此类推

为什么要保留首尾节点不压缩?因为 List 的常用操作(如 LPUSHRPUSHLPOPRPOP)大多集中在两端,保持两端节点未压缩可以避免频繁的解压和压缩操作。而中间节点访问频率低,压缩后可以节省大量内存。

quicklist 的常用操作

插入操作

当执行 LPUSHRPUSH 时,quicklist 会检查头节点或尾节点的 ziplist 是否还有空间(根据 fill 参数判断)。如果有空间,直接插入到 ziplist 中;如果没有空间,则新建一个 quicklistNode,将新元素插入到新的 ziplist 中,并将该节点加入链表。

删除操作

当执行 LPOPRPOP 时,直接从头部或尾部的 ziplist 中删除元素。如果删除后 ziplist 为空,则删除该 quicklistNode 节点。

查找操作

由于 quicklist 是链表结构,查找某个索引位置的元素需要遍历链表。但 Redis 做了一些优化:如果索引在前半部分,从头节点开始遍历;如果在后半部分,从尾节点开始遍历。找到对应的 quicklistNode 后,再在 ziplist 内部进行查找。

quicklist 与 ziplist 的转换

需要注意的是,quicklist 中的每个节点并不一定始终是 ziplist。当 ziplist 过大或经过 LZF 压缩后,节点可能以 RAW 编码存储。encoding 字段标识了当前节点的编码方式:

  • ZIPLIST:节点持有一个未压缩的 ziplist
  • LZF:节点持有的数据是经过 LZF 压缩的

当需要访问被压缩的节点时,Redis 会先解压,访问完毕后再根据 recompress 标志决定是否重新压缩。

面试常见追问

1. quicklist 和 ziplist、linkedlist 的关系是什么?

quicklist 是两者的结合体:宏观上是双向链表(linkedlist),微观上每个节点是 ziplist。它取两者之长,避两者之短。

2. 为什么 Redis 3.2 要用 quicklist 替代 linkedlist 和 ziplist?

因为纯 linkedlist 内存开销太大,纯 ziplist 在元素多时性能退化严重。quicklist 在内存和性能之间取得了更好的平衡。

3. quicklist 中每个 ziplist 的大小如何确定?

fill 参数控制,默认每个 ziplist 不超过 8KB。这个值可以根据实际场景调整,追求内存效率可以调小,追求性能可以调大。

4. quicklist 的压缩机制是怎样的?

通过 compress 参数控制,首尾节点不压缩以保证操作效率,中间节点使用 LZF 算法压缩以节省内存。

总结

quicklist 是 Redis 为 List 类型设计的一种精巧的底层结构。它以双向链表为骨架,以 ziplist 为血肉,通过 fillcompress 两个参数实现了内存与性能的灵活权衡。理解 quicklist 的结构,不仅能帮助你在面试中脱颖而出,更能在实际开发中合理配置 Redis,优化 List 类型的使用效率。

未经允许不得转载:任鹏个人博客 » Redis 的 quicklist 底层结构是怎样的

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏