--- tags: [Redis, 底层数据结构, quicklist, List, 链表, ziplist] create time: 2026-05-25 19:11 author: 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 的配置参数就是帮你找到"合适的袋子大小"。 ## 二、整体结构 ```mermaid 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 ``` ### 核心结构体 ```go // 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 个节点不压缩,其余压缩 | ```mermaid 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` 遍历中间部分,就**不要开压缩**——解压开销会抵消收益。 ### 两个参数如何配合? ```go // Redis 启动时的默认配置 list-max-ziplist-size -2 // 每个节点最大 8KB list-compress-depth 0 // 不压缩 // 消息队列场景(大消息、头尾操作为主) list-max-ziplist-size -5 // 每个节点最大 64KB(消息体大) list-compress-depth 1 // 压缩中间节点,节省内存 ``` ## 四、核心操作 ### 插入(LPUSH / RPUSH) 以 `LPUSH` 从头部插入为例: ```mermaid 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 个元素: ```mermaid 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 个元素: ```go // 伪代码: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) ```mermaid 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 压缩: ```mermaid 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 节点对比 ```mermaid 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]] ## 关联笔记 - [[02-核心数据类型]] — 五种数据类型的编码切换全景 - [[02-1-ziplist与listpack]] — quicklist 节点内部的紧凑存储结构 - [[02-3-hashtable]] — Redis 最常用的 O(1) 查找结构