Files
cs-note/hhs/Redis/02-核心数据类型/02-4-quicklist.md
T
2026-05-25 20:51:57 +08:00

14 KiB
Raw Blame History

tags, create time, author
tags create time author
Redis
底层数据结构
quicklist
List
链表
ziplist
2026-05-25 19:11 hhs

quicklist(快速列表)

概述

quicklist 是 Redis List 类型的统一底层编码——它是一个双向链表,但每个节点不是一个单独的元素,而是一个ziplist(或 listpack)。

简单理解:quicklist = 链表的骨架 + 紧凑块的填充。它既保留了链表的插入/删除灵活性,又通过打包多个元素到连续内存中,大幅降低了指针开销。

[!tip] 快速定位

  • 如果你是 List 类型用户:底层就是 quicklist,无需关心——但了解它能帮你做出更好的配置调优。
  • 如果你在做技术面试准备:重点掌握"为什么需要 quicklist"(链表 vs 数组的折中)和"listpack 如何消除级联更新"。
  • 如果你在做性能优化:重点关注 list-max-ziplist-size 和 list-compress-depth 两个配置参数。

一、为什么需要 quicklist?

[!question] 普通链表有什么问题? 考虑存 10000 个元素:普通双向链表需要 10000 个节点,每个节点有 prev 和 next 两个指针(64 位系统各占 8 字节),光是指针就吃掉 160KB——而数据本身可能才几十 KB。

Redis 设计者想要的是一种"中间态":

结构 内存效率 随机访问 插入/删除
普通双向链表 ❌ 指针开销大 ❌ O(N) ✅ O(1)
ziplist(纯数组) ✅ 极紧凑 ❌ O(N) ❌ 可能级联更新
quicklist ✅ 折中方案 ❌ O(N) ✅ 局部影响

quicklist 的核心思想:把"散装"的链表节点打包成"整箱"的 ziplist——既省掉了大部分指针,又让每个 ziplist 足够小,即使触发级联更新,影响范围也有限。

[!insight] 一个形象的比喻 普通链表像一排散装零食,每个都要单独包装;quicklist 像把零食分装进几个密封袋,袋与袋之间用链子连起来。袋子太大不好管理(级联更新风险),袋子太小又失去了打包的意义。quicklist 的配置参数就是帮你找到"合适的袋子大小"。

二、整体结构

flowchart TB
    QL["quicklist"] --> HEAD["head, 指向第一个节点"]
    QL --> TAIL["tail, 指向最后一个节点"]
    QL --> LEN["len, 节点数量"]
    QL --> FILL["fill, 每个节点的最大元素数"]

    HEAD --> N1["quicklistNode 1"]
    N1 --> ZP1["ziplist / listpack"]
    N1 --> NEXT1["next"]

    N1 --> N2["quicklistNode 2"]
    N2 --> ZP2["ziplist / listpack"]
    N2 --> NEXT2["next"]

    N2 --> N3["quicklistNode N"]
    N3 --> ZP3["ziplist / listpack"]

    classDef ql fill:#e1f5fe,stroke:#2196f3
    classDef node fill:#fff3e0,stroke:#ff9800
    classDef zp fill:#e8f5e9,stroke:#4caf50
    class QL,HEAD,TAIL,LEN,FILL ql
    class N1,N2,N3,NEXT1,NEXT2 node
    class ZP1,ZP2,ZP3 zp

核心结构体

// quicklist 整体
type quicklist struct {
    head  *quicklistNode   // 头节点
    tail  *quicklistNode   // 尾节点
    len   uint64           // 节点数量(不是元素数量!)
    fill  int32            // 每个节点的元素上限(来自 list-max-ziplist-size)
    compress uint16        // LZF 压缩深度(来自 list-compress-depth)
}

// 每个节点
type quicklistNode struct {
    prev  *quicklistNode
    next  *quicklistNode
    entry *ziplist         // Redis 7.2+ 改为 listpack
    sz    uint32           // entry 指向的 ziplist 总字节大小
    count uint16           // 当前 ziplist 中的元素数量
    encoding uint16        // 编码方式:原生 or LZF 压缩
    container uint16       // 存储容器:ziplist or listpack
}

[!question] len 是节点数还是元素数? quicklist.len 是节点数(即 ziplist 块的数量),不是 List 中元素的总数。要获取元素总数,需要把所有节点的 count 加起来。

三、配置参数详解

list-max-ziplist-size

控制每个 quicklistNode 中 ziplist 的大小。注意:这个参数同时支持正数和负数,含义不同:

值 含义 示例
-5 每个 ziplist 最大 64 KB 大元素场景
-4 每个 ziplist 最大 32 KB 默认推荐
-3 每个 ziplist 最大 16 KB —
-2 每个 ziplist 最大 8 KB 默认值
-1 每个 ziplist 最大 4 KB —
正数 N 每个 ziplist 最多 N 个元素 精确控制元素数

[!question] 为什么有正数和负数两种模式? 正数按元素个数限制,适合元素大小相对均匀的场景;负数按字节数限制,适合元素大小差异大的场景。默认 -2(8KB)是一个平衡点——每个节点占用的空间不大,内存分配友好,且元素数量足够多来摊薄指针开销。

list-compress-depth

控制从头尾开始的多少个节点不压缩,中间的节点使用 LZF 算法压缩:

值 含义
0 默认,不压缩任何节点
1 头尾各 1 个节点不压缩,其余压缩
2 头尾各 2 个节点不压缩,其余压缩
flowchart LR
    subgraph D0["compress-depth = 0"]
        direction LR
        A0["节点1, 未压缩"] --> B0["节点2, 未压缩"] --> C0["节点3, 未压缩"] --> D0N["节点4, 未压缩"]
    end

    subgraph D1["compress-depth = 1"]
        direction LR
        A1["节点1, 未压缩"] --> B1["节点2, LZF 压缩"] --> C1["节点3, LZF 压缩"] --> D1N["节点4, 未压缩"]
    end

    classDef plain fill:#e8f5e9,stroke:#4caf50
    classDef compressed fill:#ffebee,stroke:#f44336
    class A0,B0,C0,D0N,A1,D1N plain
    class B1,C1 compressed

[!tip] 什么时候开启压缩? List 元素较大(如存储 JSON 消息体)且访问集中在头尾(典型的队列场景)时,开启 list-compress-depth 1 或 2 可以显著节省内存。中间的节点被 LZF 压缩后,取到时再解压,对冷数据的访问多一次解压开销,但对热数据(头尾)完全无影响。

如果你的 List 经常做 LRANGE 遍历中间部分,就不要开压缩——解压开销会抵消收益。

两个参数如何配合?

// Redis 启动时的默认配置
list-max-ziplist-size -2       // 每个节点最大 8KB
list-compress-depth 0          // 不压缩

// 消息队列场景(大消息、头尾操作为主)
list-max-ziplist-size -5       // 每个节点最大 64KB(消息体大)
list-compress-depth 1          // 压缩中间节点,节省内存

四、核心操作

插入(LPUSH / RPUSH)

以 LPUSH 从头部插入为例:

flowchart TB
    S1["LPUSH key value"] --> S2{"head 节点的 ziplist 未满?"}
    S2 -->|"是"| S3["直接在 head 的 ziplist 头部插入元素"]
    S2 -->|"否"| S4["新建 quicklistNode, 创建新 ziplist, 插入元素, 设为新 head"]

    classDef step fill:#e1f5fe,stroke:#2196f3
    classDef check fill:#fff3e0,stroke:#ff9800
    class S1,S3,S4 step
    class S2 check

[!question] 插入会导致级联更新吗? 会,但影响范围被限制在单个 ziplist 内部。即使 ziplist 的级联更新把一个节点"撑大"了,它最多导致这一个节点重新分配内存,不会波及其他节点。这就是 quicklist 比纯 ziplist 更适合大数据量的根本原因。

随机访问(LINDEX)

LINDEX key 500 需要找到 List 中第 500 个元素:

flowchart TB
    S1["LINDEX key 500"] --> S2["从 head 开始遍历 quicklistNode"]
    S2 --> S3{"目标元素在当前节点内?"}
    S3 -->|"否"| S4["跳过整个节点, count 累加"]
    S4 --> S3
    S3 -->|"是"| S5["在节点的 ziplist 内做偏移定位, 取出元素"]

    classDef step fill:#e1f5fe,stroke:#2196f3
    classDef check fill:#fff3e0,stroke:#ff9800
    class S1,S2,S4,S5 step
    class S3 check

[!caution] LINDEX 是 O(N) 即使 quicklistNode 的数量不多,定位到具体节点后还需要在 ziplist 内做 O(K) 的线性偏移(K 是节点内的元素数)。所以 LINDEX 整体仍然是 O(N)。如果你需要频繁按索引随机访问 List,说明你可能选错了数据结构——考虑用 Hash 或 ZSet。

范围查询(LRANGE)

LRANGE key 0 99 取前 100 个元素:

// 伪代码:LRANGE 的执行逻辑
func (ql *quicklist) lrange(start, stop int) []any {
    node := ql.head
    offset := start
    result := make([]any, 0, stop-start+1)

    // 1. 跳到包含 start 的节点
    for node != nil && offset >= node.count {
        offset -= node.count
        node = node.next
    }

    // 2. 从该节点开始,逐节点取元素直到满足 stop
    for node != nil && len(result) <= stop-start {
        // 在当前节点的 ziplist 中从 offset 开始取
        fetched := ziplistRange(node.entry, offset, ...)
        result = append(result, fetched...)
        offset = 0           // 后续节点从头开始取
        node = node.next
    }
    return result
}

[!tip] LRANGE 的效率优化 虽然理论复杂度是 O(N),但 quicklist 的连续内存布局对 CPU 缓存友好——遍历单个 ziplist 时,数据在内存中连续排列,cache miss 很少。相比普通链表的"跳来跳去",实际速度快得多。

从两端弹出(LPOP / RPOP)

flowchart TB
    S1["LPOP key"] --> S2{"head 的 ziplist 有多个元素?"}
    S2 -->|"是"| S3["移除并返回 ziplist 的第一个元素"]
    S2 -->|"否, 只剩一个"| S4["返回元素, 释放整个 quicklistNode, head 指向 next"]

    classDef step fill:#e1f5fe,stroke:#2196f3
    classDef check fill:#fff3e0,stroke:#ff9800
    class S1,S3,S4 step
    class S2 check

[!question] 为什么不直接用 ziplist 做 List? 如果整个 List 只用一个 ziplist,插入/删除导致的级联更新会影响整个列表。而且当 List 很大时,ziplist 需要大块连续内存,内存分配失败的概率增大。quicklist 把大块拆成小块,每块独立分配,既降低了碎片化风险,又把级联更新限制在单个节点内。

五、操作复杂度速查

操作 命令 时间复杂度 说明
头部插入 LPUSH O(1) ziplist 未满时直接插入;满则新建节点
尾部插入 RPUSH O(1) 同上
头部弹出 LPOP O(1) ziplist 未空时直接移除;空则释放节点
尾部弹出 RPOP O(1) 同上
按索引访问 LINDEX O(N) 需遍历节点 + 节点内偏移
范围查询 LRANGE O(S+N) S 为偏移跳过量,N 为返回数量
获取长度 LLEN O(1) 直接读取 quicklist 的长度元数据

[!caution] 避免滥用 LINDEX / LRANGE 大范围扫描 quicklist 的强项是队列语义(头尾操作 O(1))。如果你发现 LINDEX 或大范围 LRANGE 成为瓶颈,大概率是数据结构选型的问题——考虑换用 Hash(按 key 直接 O(1) 查)或 ZSet(按 score 范围 O(logN) 查)。

六、LZF 压缩机制

当 list-compress-depth > 0 时,中间节点的 ziplist 会被 LZF 压缩:

flowchart LR
    subgraph READ["访问压缩节点"]
        direction TB
        R1["读取压缩节点"] --> R2{"encoding == LZF?"}
        R2 -->|"是"| R3["解压 ziplist, 还原到内存"]
        R3 --> R4["在解压后的 ziplist 中定位元素"]
        R2 -->|"否"| R4
    end

    subgraph WRITE["写入压缩节点"]
        direction TB
        W1["写入压缩节点"] --> W2["先解压, 修改 ziplist"]
        W2 --> W3{"节点不在首尾, 且 compress-depth 允许?"}
        W3 -->|"是"| W4["重新 LZF 压缩"]
        W3 -->|"否"| W5["保持原样"]
    end

    classDef check fill:#fff3e0,stroke:#ff9800
    classDef step fill:#e1f5fe,stroke:#2196f3
    class R2,W3 check
    class R1,R3,R4,W1,W2,W4,W5 step

[!insight] 压缩的收益有多大? LZF 对文本型数据(如 JSON 消息)的压缩率通常在 50%~70%。一个存储 1000 条消息的 List,中间节点压缩后内存占用可以降低一半以上。代价是访问中间节点时的解压开销——但对于队列场景(只操作头尾),这个开销为零。

七、Redis 7.0+ 的演进

Redis 7.2 开始,quicklist 的节点内部从 ziplist 迁移到 listpack。结构体中的 container 字段标识当前使用的是哪种:

container 值 存储格式 说明
2 ziplist 旧格式,仍兼容
3 listpack 新格式,去掉了 prevlen,彻底消除级联更新

[!question] 迁移是自动的吗? 是的。当一个 quicklistNode 被修改(插入/删除元素)时,Redis 会检查并将其转换为 listpack 格式。未修改的节点保持原样。整个过程对用户完全透明。

ziplist vs listpack 节点对比

flowchart LR
    subgraph ZP["ziplist 节点"]
        direction TB
        Z1["prevlen, 1 或 5 字节"]
        Z1 --> Z2["encoding + len"]
        Z2 --> Z3["data"]
        Z3 --> Z4["prevlen, 1 或 5 字节"]
        Z4 --> Z5["..."]
    end

    subgraph LP["listpack 节点"]
        direction TB
        L1["encoding + len"]
        L1 --> L2["data"]
        L2 --> L3["backlen, 只记录自身长度"]
        L3 --> L4["encoding + len"]
        L4 --> L5["..."]
    end

    classDef zpStyle fill:#ffebee,stroke:#f44336
    classDef lpStyle fill:#e8f5e9,stroke:#4caf50
    class Z1,Z2,Z3,Z4,Z5 zpStyle
    class L1,L2,L3,L4,L5 lpStyle

[!tip] 级联更新是怎么消失的? ziplist 的 prevlen 记录前一个节点的长度——前一个节点变大时,prevlen 可能从 1 字节变 5 字节,引发后续节点连锁反应。listpack 用 backlen 只记录自身的长度,修改任何节点都不会影响其他节点,级联更新从结构上被消除了。

详见 02-1-ziplist与listpack

关联笔记