很多 Redis 入门材料把 List 解释成「链表」,再顺手说一句 LPUSH 和 RPOP 都是 O(1)。这个说法在接口层可以帮助理解,但放到底层就过时了:现代 Redis 的 List 不是一条普通链表,而是 quicklist 串起多个 listpack。
这个差异能解释几类线上现象:为什么两端进出很稳,LRANGE 0 -1 却可能拖住主线程?为什么一个任务队列堆积到百万级以后,删除中间元素会变得很贵?为什么可靠消息最好不要只靠 List 硬撑?
先把机制边界说清楚
这一篇只讨论 List 的物理结构和使用边界,不把它讲成消息队列全家桶。List 适合轻量队列、最新列表和两端访问模型;一旦你需要确认、重试、消费组和堆积观测,就要把目光转向 Stream 或专业 MQ。
整体路径
上面这张图先把主线铺开:链表负责两端伸缩,listpack 负责单节点内存紧凑。所以判断一个 List 操作的代价,要看它落在哪个节点、被删元素离头尾有多远。
quicklist 是两层结构的合体
quicklist 这个名字容易让人以为它就是「快的链表」。更准确的理解是:它是一个双向链表,但每个链表节点里装的不是单个元素,而是一段紧凑的 listpack。这是一层在「纯链表指针太多」和「纯连续数组移动太贵」之间的折中。
- 外层是 quicklistNode:节点之间用
prev/next指针串联,是个标准双向链表。 - 内层是 listpack:一段连续内存,把多个元素紧挨着排列,靠每个元素自带的长度前缀来定位下一个,没有任何 next 指针。
为什么 Redis 要做这种两层结构?算一笔账就清楚了。在 64 位系统上,一个纯双向链表节点光是 prev 和 next 两个指针就要吃掉 16 字节,再加上节点头的分配开销和内存碎片,存短元素时指针开销可能比数据本身还大。
quicklist 的做法是:让一小批元素(默认受 list-max-listpack-size 控制)共用一对指针,内部用 listpack 的紧凑布局摊薄指针开销,同时保留链表两端 O(1) 伸缩的能力。这就是为什么 Redis 文档里写 List 头尾操作是 O(1),但又补一句「中间访问不是」——中间访问要先沿链表走到目标节点,再进 listpack 里定位,成本随数据量上升。
头尾操作为什么便宜,中间操作为什么贵
理解了双层结构,命令的成本曲线就一目了然。关键不是记住哪个命令是 O(1) 还是 O(N),而是理解成本随哪个变量上升。
LPUSH、RPUSH、LPOP、RPOP、LLEN 这些都是端点操作:只在头尾节点上动,不涉及链表遍历,成本恒定。这是 List 的舒适区,任务队列、最新动态列表、限流窗口都在这片区域里玩得很顺。
一旦访问点滑向中间,成本就开始爬升:
LINDEX按下标取元素:要沿 quicklist 走到对应节点,再进 listpack 定位。下标越靠中间、链表越长,走得越远。LRANGE取一段范围:返回量小还好,返回量大(尤其是LRANGE 0 -1全量)就是主线程杀手——它要在一次事件循环里把整条链表扫完。LREM按值删除、LINSERT在某元素前后插入:都是遍历 + 移动,元素越多越贵。
这类命令在测试环境几十条数据上看不出问题,上到百万级的生产队列,一次 LRANGE 0 -1 就足以让 SLOWLOG 里出现一条几百毫秒的记录,连带阻塞后面排队的命令。
取舍:fill 参数和压缩深度
quicklist 还有两个常被忽略的调节项,决定了「单个 listpack 多大」和「中间节点要不要压缩」。
- list-max-listpack-size(fill):控制单个 listpack 最多装多少元素或多大字节。值越大,内存越紧凑、缓存局部性越好,但单个节点的插入/删除移动成本越高;值越小则相反。默认
-2(约 8KB)是 Redis 调出来的经验值。 - list-compress-depth(compress):链表两端各保留多少个不压缩的节点,其余中间节点用 LZF 压缩。队列类场景(只从头尾进出)适合开 1,能省可观的内存;但频繁中间访问的场景开压缩反而增加解压成本。
这组参数本质是在回答一个问题:你的 List 是队列型还是列表型? 队列型把 fill 调大、compress 开 1;列表型(要随机访问)则保持默认甚至关掉压缩。
典型问题:用机制化例子排查
- 队列长度要设监控,
LLEN长期上升说明消费者能力或失败重试有问题。 - 避免在大 List 上执行
LRANGE 0 -1、LREM、LINSERT这类会扫大量元素的命令。 - 任务需要 ack、重试和消费者组时,用 Stream;需要强可靠时,用 Kafka、Pulsar 这类专门队列。
- 排查延迟时同时看
SLOWLOG、命令分布和单个 key 的长度,不要只盯 CPU。 - 存「最新 N 条」这类有界列表,用
LTRIM配合LPUSH把长度锁死,别让一个 key 无限长。
收束:一句判断
List 的核心判断很简单:两端访问是队列,中间访问就是成本。别拿它当数组用,也别拿它当可靠消息队列——前者会慢,后者会丢。
关于十三Tech
我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。
我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。
如果你想继续跟完这套「图解 Redis」,欢迎关注公众号 「十三Tech」。后续会继续按数据结构、底层机制、持久化、高可用和实战排查这条线更新。

