2.2 quicklist与listpack:List的两副面孔 本节摘要:List 是双端队列,底层是 quicklist——多个 listpack 小块用双向链表串起来。小列表整体塞进一个 listpack 省内存,大了切块伸缩。两端操作恒为 O(1),中部访问是 O(n),这个不对称决定了 List 的正确用法。 为什么不直接用链表 普通双向链表每个节点前后两个指针,64 位系统上指针开销 16 字节,存一个几字节的元素时指针比数据还贵。老版本 Redis 用 ziplist(压缩列表)解决:一块连续内存上每个元素紧挨着排,靠"前一项长度"字段回溯定位。但 ziplist 有个隐患——前一项长度字段在某些增长边界下需要变宽,可能引发后面所有元素级联更新,最坏 O(n²)。 7.
本节摘要:List 是双端队列,底层是 quicklist——多个 listpack 小块用双向链表串起来。小列表整体塞进一个 listpack 省内存,大了切块伸缩。两端操作恒为 O(1),中部访问是 O(n),这个不对称决定了 List 的正确用法。
普通双向链表每个节点前后两个指针,64 位系统上指针开销 16 字节,存一个几字节的元素时指针比数据还贵。老版本 Redis 用 ziplist(压缩列表)解决:一块连续内存上每个元素紧挨着排,靠"前一项长度"字段回溯定位。但 ziplist 有个隐患——前一项长度字段在某些增长边界下需要变宽,可能引发后面所有元素级联更新,最坏 O(n²)。
7.0 起 listpack 取代了 ziplist:每个元素只记自己的长度,改自己不影响邻居,级联更新问题从结构上根除。

分块的好处是折中:块内紧凑省内存,块间链表伸缩灵活。插入只影响所在块,最坏也就重排一个小块。
List 的两副面孔用 OBJECT ENCODING 能亲眼看到。背景:往一个新列表里持续推入元素;操作与结果:
> DEL feed > RPUSH feed a b c > OBJECT ENCODING feed "listpack" # 短列表:整个列表就是一个 listpack 小块 # 持续 RPUSH 到超过单块体积上限(默认约8KB或填充因子超限)…… > OBJECT ENCODING feed "quicklist" # 长列表:切块,双向链表串联 > LLEN feed (integer) 512
解读:转换是单向的、由小结构升级为大结构,方向永远是"内存紧凑让位于伸缩能力"。变式实验:把 list-max-listpack-size 类参数调大,同一个列表会停留在 listpack 编码更久——但注意这只是延后了切块,元素总量摆在那里,靠调参数硬撑紧凑编码,省的内存有限,涨的中部访问代价却是实打实的。
老 ziplist 的结构里,每个元素头部记着"前一个元素的长度"。这个字段平时用 1 字节就够,但前一元素膨胀到超过 254 字节时,字段得扩成 5 字节——而它自己变宽 4 字节,又可能让"它的下一个"记录它的长度字段同样变宽,连锁反应一路向尾部传播,最坏情况整块内存里每个元素都要搬家,复杂度 O(n²)。
[entry1][entry2][entry3][entry4] 头部记录前一元素长度:1字节 → 元素1膨胀超过254字节 entry2 的 prevlen 字段 1字节→5字节 → entry2 自身变宽 entry3 记录 entry2 的新长度 → 也要变宽 → 一路传染到尾部
listpack 的改法一句话:每个元素只记自己的长度,不记前一个的。向后遍历怎么办?元素的"自身长度"字段放在尾部冗余一份,回退时从尾部读自己的总长即可。邻居膨胀与我无关,级联更新在结构上就不存在了。7.0 起所有用到 ziplist 的地方统一换成 listpack,Quicklist 的节点内部也是它——一次教科书级的"改数据布局消灭一类最坏情况"的重构。
> LPUSH queue task3 task2 task1 # 左端批量入,O(1)至O(k) > RPUSH queue task4 > RPOP queue # 右端出,O(1) > LPOP queue > LRANGE queue 0 2 # 下标区间读,中部访问代价高 > LLEN queue # 计数器直接读,O(1) > BLPOP queue 5 # 阻塞式弹出,空列表时挂起等待
两端快、中间慢,这个形状决定了三个经典用法:
| 用法 | 命令组合 | 结构解释 |
|---|---|---|
| 任务队列 | LPUSH 入队 RPOP 出队 | 双端天然先进先出 |
| 最新动态流 | LPUSH 后 LTRIM 0 999 | 只保留头部长度,尾部自动裁掉 |
| 阻塞消费 | BLPOP / BRPOP | 空队列时客户端挂起,来数据即唤醒,免轮询 |
一个容易被忽略的细节:BLPOP 的客户端在阻塞期间几乎不耗服务端资源——它被登记在该键的等待者列表里,事件驱动唤醒,不是忙等。
阻塞语义还有两个易错点值得展开。其一,超时参数 0 表示无限期等待,客户端挂到该键上有新元素为止——生产代码里慎用,消费进程可能因此永久卡死;给个有限值(比如 5 秒)再配合外层循环,把"永远等"变成"等一小会儿就去干别的"。其二,BLPOP 可以同时监听多个键,哪个先来弹哪个,返回值里带键名——一个消费进程同时守任务队列与控制队列的常见写法就靠它。
长列表上最容易进慢日志的是三件事:LINDEX 随机下标访问要从头走链;LREM 扫全表找值删除;LRANGE 大区间一次性搬运。治理思路统一是"别在 List 上做 List 不擅长的事":
| 症状 | 元凶 | 替代 |
|---|---|---|
| 慢日志出现 LINDEX | 深下标随机读 | 改 Hash 按字段取,或拆键 |
| LREM 耗时百毫秒 | 大列表全扫 | 用 ZREM 精确删,或 LPOP 流式处理 |
| LRANGE 0 -1 大响应 | 全量导出 | SCAN 式分批或干脆改设计 |
判断标准回到结构:两端 O(1)、中部 O(n),任何需求若必须频繁碰中部,它就不该长在 List 上。
List 能当队列,但有两个硬伤:没有消费组(一条消息只能被一个客户端抢到,无法多消费者独立消费同一流);没有确认机制(BRPOP 弹出即消失,消费者崩溃消息就丢)。轻量场景够用,严肃的消息流转请用 3.3 的 Stream,它在结构上就是为"日志加消费组"设计的。
💡 关键直觉:每当你想用 LRANGE 翻页读一个很长的列表,先问自己是不是选错了结构——需要随机访问的数据更应该放 Hash 或 ZSet。