2.2 quicklist与listpack:List的两副面孔


文档摘要

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

2.2 quicklist与listpack:List的两副面孔

本节摘要:List 是双端队列,底层是 quicklist——多个 listpack 小块用双向链表串起来。小列表整体塞进一个 listpack 省内存,大了切块伸缩。两端操作恒为 O(1),中部访问是 O(n),这个不对称决定了 List 的正确用法。

为什么不直接用链表

普通双向链表每个节点前后两个指针,64 位系统上指针开销 16 字节,存一个几字节的元素时指针比数据还贵。老版本 Redis 用 ziplist(压缩列表)解决:一块连续内存上每个元素紧挨着排,靠"前一项长度"字段回溯定位。但 ziplist 有个隐患——前一项长度字段在某些增长边界下需要变宽,可能引发后面所有元素级联更新,最坏 O(n²)。

7.0 起 listpack 取代了 ziplist:每个元素只记自己的长度,改自己不影响邻居,级联更新问题从结构上根除。

quicklist:链表串联多个 listpack

quicklist:链表串联多个 listpack

分块的好处是折中:块内紧凑省内存,块间链表伸缩灵活。插入只影响所在块,最坏也就重排一个小块。

编码转换亲历

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 的历史包袱,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 当消息队列的边界

List 能当队列,但有两个硬伤:没有消费组(一条消息只能被一个客户端抢到,无法多消费者独立消费同一流);没有确认机制(BRPOP 弹出即消失,消费者崩溃消息就丢)。轻量场景够用,严肃的消息流转请用 3.3 的 Stream,它在结构上就是为"日志加消费组"设计的。

💡 关键直觉:每当你想用 LRANGE 翻页读一个很长的列表,先问自己是不是选错了结构——需要随机访问的数据更应该放 Hash 或 ZSet。

本节要点回顾

  • quicklist = 双向链表串多个 listpack,兼顾内存紧凑与伸缩
  • listpack 只记自身长度,根除了 ziplist 的级联更新
  • 两端 O(1)、中部 O(n),命令选择要顺着结构形状走
  • BLPOP 是挂起等待不是轮询,做简单队列很省
  • 队列的严肃需求交给 Stream,List 队列止步于轻量场景

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U