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

343 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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) 查找结构