Redis List底层结构与实战:从quicklist到消息队列的正确用法
这是Redis系列教程的第八篇。按顺序写到今天String、Hash、Set、ZSet 都已经聊完了List 我一直故意放到最后讲。原因是它使用门槛最低——LPUSH、RPUSH、LPOP、LRANGE 这些名字一看就懂和编程语言里的链表、数组太像了——但真正用对的人其实不多。很多人拿 List 当万能数据结构乱折腾最后做出一个又一个慢查询。这篇文章我打算用偏实战的角度把 List 的底层形态、常用命令、以及我在真实项目里踩过的坑一次讲清楚看完你至少能回答一个问题List 到底该用来做什么不该用来做什么。1. 先弄清 List 在 Redis 底层长什么样插入快、随机访问却不便宜很多教程上来就教命令从来不解释数据结构长什么样结果读者遇到慢查询只能瞎猜。List 表面上很像普通的双向链表但 Redis 内部并不是简单拿一个 linkedlist 硬扛。1.1 quicklist 的由来早期的 Redis 其实维护着两种编码。当列表比较小、元素个数少的时候用 ziplist紧凑的连续内存块存储一旦超过阈值就转成真正的 linkedlist。ziplist 省内存但插入删除导致 realloc 时代价高linkedlist 操作灵活但每个节点都要维护两个指针内存碎片和占用都不理想。3.2 版本之后Redis 引入了一个折中方案quicklist。它是双向链表 每节点嵌套一个小型压缩 list的结构。你可以把它理解成一列火车每节车厢是一个紧凑的 listpack7.0 之前是 ziplist车厢之间用指针连接。这样头部和尾部的插入删除仍然是 O(1)因为只需要操作第一节或最后一节车厢而内存方面由于每节车厢内部是连续存储又比纯链表省很多指针开销。到了 7.0zliplist 正式被 listpack 替代但 quicklist 的整体思路没变。这也是为什么 List 在头顶和屁股上操作飞快但你要通过 LINDEX 去取中间某个元素时Redis 必须沿着链表找到对应的节点再在节点内部顺序查找复杂度是 O(N)。很多人不清楚这一点习惯性地拿大 List 当数组用这是最常见的误用。1.2 关键配置与对命令的影响和 List 相关的有两个配置值得关注list-max-listpack-size和list-compress-depth。list-max-listpack-size控制单节车厢最多能容纳多少元素。配置为正数时表示每个 listpack 最多允许的元素个数默认是 128配置为负数时表示按字节数限制比如 -1 表示每节不超过 4KB-2 表示不超过 8KB。某个节点一旦写满Redis 就在 quicklist 后面再接一个新节点。list-compress-depth控制链表两端的节点是否压缩。默认 0 表示不压缩设为 1表示首尾各 1 个节点保持不压缩中间的节点用 LZF 算法压缩存储。这个配置很实用如果你用 List 做消息队列队列中间的大量历史数据其实很久才会被访问压缩可以显著节省内存而两端频繁读写不受影响。从使用角度看这些底层细节带来的实际影响是List 适合两端操作、范围遍历的工作负载不适合频繁随机索引。如果你的业务主要靠 LINDEX 取中间数据通常意味着你选错了类型Hash 或者其他结构可能更合适。2. 入列和出列的四个基本动作LPUSH/RPUSH/LPOP/RPOP 的方向感List 的命令非常多但真正构成核心骨架的只有四个LPUSH、RPUSH、LPOP、RPOP。名字里的 L 和 R 指的不是 left/right 的左右而是 Linked List 的头和尾。我建议你直接脑内映射L 是头部R 是尾部。2.1 压入命令LPUSH 与 RPUSHLPUSH key element [element ...] RPUSH key element [element ...]这两个命令都可以一次压入多个元素返回值是列表的新长度。复杂度跟单元素压入一样是 O(1)多个元素也只是 O(M)M 是元素个数。一个特别容易搞错的点LPUSH 多个元素时元素的顺序会翻转。比如执行LPUSH mylist a b c最终列表的顺序是c b a。原因是命令逐个把元素往头部塞先塞 a再塞 b再塞 c。所以如果你需要保持原始顺序应该用 RPUSH 逐个追加或者反过来写参数。我平时写时间线列表时非常依赖 LPUSH。新来的内容永远往头部压这样LRANGE key 0 9拿出来的就是最新的十条天然按时间倒序排列。2.2 弹出命令LPOP 与 RPOPLPOP key [count] RPOP key [count]Redis 6.2 之后POP 命令可以带 count 参数一次弹出多个元素。不带 count 时命令返回被弹出的单个元素如果列表为空返回 nil。复杂度方面单独弹一个元素是 O(1)弹出 N 个元素是 O(N)。有个容易被忽略的行为差异LPOP 头部弹出RPOP 尾部弹出。如果你用 LPUSH 入列、LPOP 出列这其实实现的是一个栈只有 LPUSH RPOP 或者 RPUSH LPOP才是一个先进先出的队列。很多新手看到 POP 就以为默认是队列操作结果方向搞反数据顺序全乱。2.3 让列表转圈RPOPLPUSH 和 LMOVERPOPLPUSH source destination LMOVE source destination LEFT|RIGHT LEFT|RIGHTRPOPLPUSH 的作用是原子地从 source 的尾部弹出一个元素再把它压入 destination 的头部。整个操作在 Redis 内部一步完成中间状态对外不可见。LMOVE 是 6.2 之后更通用的版本可以分别指定从哪边弹出、向哪边压入。这个命令最适合做循环队列把 source 和 destination 指定成同一个 key元素就会从尾巴弹出、又回到头部实现轮流消费同一批数据。我在轮询广告位播放列表时用过这个技巧每条内容展示完回到队尾等下一轮再出现比在应用层做数组翻转容易多了。3. 读列表不只 LRANGELINDEX/LLEN/LSET 的按位操作LRANGE 是最常用的读取命令但只靠它远远不够。List 还有几个按位置操作的工具学会它们之后很多功能其实可以少写大量业务代码。3.1 按位置取元素LINDEX 与 LLENLINDEX key index LLEN keyLINDEX 返回指定索引的元素支持负数索引-1 表示最后一个元素。虽然语义上很方便但必须记住它是 O(N) 操作——中间元素的访问需要遍历节点。如果列表很长比如几十万条高频 LINDEX 会拖垮 Redis。LLEN 就完全不一样它是 O(1) 的因为 quicklist 本身维护了长度字段。判断一个列表是否存在且非空LLEN mylist比LRANGE mylist 0 0高效得多后者也要走 O(1) 的头部读取但前者更直观、也更节省网络返回数据。很多项目做未读消息数之类的角标其实根本不需要单独维护计数器直接在 List 上 LLEN 就能拿到结果省掉一个 String 键的同步更新逻辑。3.2 定位与修改LSET、LPOSLSET key index value LPOS key element [RANK rank] [COUNT count] [MAXLEN len]LSET 做的事情是按索引修改元素值不影响列表长度。它的复杂度同样是 O(N)因为需要先找到目标节点。但修改值不会触发元素移动所以常数部分比 LINDEX 删除再插入要小。LPOS 是 6.0.6 引入的查找命令按值返回第一个匹配元素的索引。它和 LREM 的区别是LREM 直接删除LPOS 只告诉你位置。借助 RANK 参数可以从头或从尾开始搜COUNT 参数可以返回多个位置。这个命令在实现找到某个中奖用户并查看它在队列里的位置这类场景时很好用。3.3 范围读取的正确姿势LRANGE key start stopLRANGE 返回 start 到 stop 之间的元素两个索引都支持负数。很多人的第一直觉是LRANGE key 0 -1——一次性拿出整个列表。这在开发和测试阶段没问题但生产环境一旦列表有几万条这命令就是灾难大块内存被占用网络传输全部压到同一连接上Redis 的单线程模型也会被这次慢查询阻塞。正确做法是分页读。比如要展示前 20 条LRANGE key 0 19翻页就按偏移量继续。这里有一个性能规律start 越大命令要跳过的节点越多每次 LRANGE 的开销也随之增大。所以对于长期存在的大 List越靠后的数据越不适合频繁用 LRANGE 去翻。4. 阻塞版本 BLPOP/BRPOP本质是通知机制不是轮询替代品如果你只掌握了非阻塞命令写出的消费者代码大概长这样定时 LLEN发现列表不为空再 LPOP。这种轮询模型很糟糕——要么空转浪费 CPU要么把时间间隔调大导致消费不及时。Redis 专门提供了 BLPOP 和 BRPOP 来解决这个痛点。4.1 阻塞语义细节BLPOP key [key ...] timeout BRPOP key [key ...] timeout执行 BLPOP 时Redis 会从左到右检查传入的 key找到第一个非空列表并立即弹出头部元素。如果传入的所有列表都为空则客户端进入阻塞状态直到某个 key 被推入新元素或超时返回 nil。timeout 为 0 表示永久阻塞等待直到有数据到来。需要注意的是多个 key 里同时有数据时命令优先操作排在左边的 key。这个细节在做多优先级队列时非常有用例如把高优先级任务放在第一个 key 里低优先级任务放在后面的 key 里消费者会优先处理高优先级任务。阻塞命令返回的是一个长度为 2 的数组第一个元素是实际弹出元素的 key 名第二个是弹出的值。而且BLPOP 不会在列表为空时创建这个 key所以不用担心阻塞命令会留下大量空键。4.2 可靠转移BRPOPLPUSH 与 BLMOVE阻塞命令最大的问题是弹出即消失。如果消费者在弹出任务之后、处理完成之前崩溃这条数据就永久丢失了。要解决这个问题标准姿势是把弹出和备份放进同一个原子操作BRPOPLPUSH source backup timeout BLMOVE source destination LEFT|RIGHT LEFT|RIGHT timeoutBRPOPLPUSH 是阻塞版 RPOPLPUSH从 source 尾部弹出元素压入 destination 头部如果 source 为空则阻塞等待。BLMOVE 则是通用的阻塞版 LMOVE。使用时consumer 执行BRPOPLPUSH taskqueue processing 0元素被原子地移动到 processing 列表处理成功后再用LREM processing 1 taskId把它删除。如果消费进程中途崩溃任务还留在 processing 列表里下次启动扫描一下 processing 就能恢复未完成任务。这里我要特别提醒一点阻塞命令会长期占用一个客户端连接。连接池如果很小比如只有 5 个连接那 5 个消费者一启动连接池就空了其他命令全卡住。所以用阻塞命令时要么给它们单独开设连接要么把连接池上限调大设计上也要接受连接被消费占用是常态。5. 修剪与删除LTRIM/LREM/LINSERT 的使用时机List 的删除操作比想象中简单也比想象中容易踩坑。因为它没有一个按位置删除的命令很多人第一次想删掉某个元素时会愣住。实际上 Redis 提供了三种不同思路的删改方式按场景选即可。5.1 LTRIM保留指定范围内的元素LTRIM key start stopLTRIM 用一句话描述就是只留下 start 到 stop 之间的元素其余全部删掉。这个命令非常高效因为它是按照节点粒度裁剪真正需要处理的范围之外整个 quicklist 节点可以直接丢弃。它最常见的用法是配合 LPUSH 做定长列表。拿一个只保留最新 100 条用户动态的 key 举例LPUSH user_timeline dynamic_101 LTRIM user_timeline 0 99这两条命令组合后无论之前列表多长最终都只会留下最新的 100 条。LTRIM 把磁盘上不可能用到的旧元素从内存里释放掉是整个 List 家族里最被低估的命令。5.2 LREM按值删除LREM key count valueLREM 会从列表中删除值为 value 的元素count 的含义是count 0从头到尾最多删除 count 个count 0从尾到头最多删除 |count| 个count 0删除所有匹配的元素。复杂度是 O(N)因为它要遍历查找目标值。在短列表上无所谓但如果列表很长删除一次可能成为慢查询。另外要提醒的是value 是精确匹配不支持通配符。从业务角度看LREM 最适合的是取消订阅场景。你用一个 List 保存用户关注的账号 ID取消关注时执行LREM follow_list 0 account_123一次清掉所有重复记录就完成了。5.3 LINSERT在指定值前后插入LINSERT key BEFORE|AFTER pivot valueLINSERT 会在第一个值为 pivot 的元素之前或之后插入新元素。如果找不到 pivot返回 -1不执行任何插入。复杂度依然是 O(N)需要先遍历定位 pivot。这个命令的一个典型场景是排序展示列表。比如你用 List 维护首页 Banner 的展示顺序需要把一张新图插在没有下架的那张图后面一条 LINSERT 就能搞定不用把整个列表拉出来重排。6. List 做队列和栈真实项目里的组合套路与边界单个命令只是积木真正的价值在于怎么组合。这一节我总结几个我自己在项目里反复使用的套路也聊聊每种套路到底适合什么场景。6.1 消息队列从简单到可靠的两级方案最简单的异步任务队列是这样生产者 LPUSH 任务到队列消费者 BRPOP 取出来执行。一组命令就把消息队列跑起来了延迟基本在微秒级吞吐可以轻松上万。对于内部定时任务、日志异步写入这类不需要严格投递语义的场景这套方案足够。但如果你要处理的是订单创建后的优惠券发放、支付回调后的通知这类业务必须考虑消费端崩溃导致消息丢失的问题。这时候要升级成可靠队列生产者 LPUSH 到 task 队列消费者 BRPOPLPUSH 从 task 转移到 processing 队列处理成功再 LREM 删除 processing 里的记录。代价是每个消息多了一次移动和一次删除换来的是任务至少被处理一次。这里想明确一点List 做 MQ 有天然上限。它没有消费组、没有消息确认机制、没有死信队列Redis 本身也不是为了持久化消息设计的。如果消息量很大、需要按消费者群体分配、或者要求严格的 At-Least-Once/Exactly-Once 语义老老实实用 Redis Stream或者上专业消息队列。List 适合的永远是轻量、临时、内部的任务分发。6.2 最新动态/时间线列表这是 List 最经典的应用场景LPUSH LTRIM 维护最新 N 条LRANGE 分页读取。LPUSH user:1001:feed post_501 LTRIM user:1001:feed 0 499 LRANGE user:1001:feed 0 19每条新动态推入头部只保留最近 500 条用户首页展示时读取前 20 条。这样写的好处是写入和裁剪都在 O(1) 左右完成无论数据量多大内存占用都有上限。一个性能细节是如果每个用户维护一条 List热点用户写入量特别大要注意 key 的粒度拆分。比如按月份分 keyfeed:202506过期后再由定时任务清理避免单一 key 无限膨胀。6.3 栈和循环队列栈就是 LPUSH LPOP或者 RPUSH RPOP方向一致。这个模式适合撤销记录、面包屑导航这类后进先出的需求。循环队列则利用 RPOPLPUSH 自己前面已经提过。我后来在广告素材轮播里用它代替了应用层的 index 游标每个素材播完自动排到队尾下一次从头部继续取逻辑清爽很多。7. 踩过再说的几个坑大 Key、连接池、消息与长度最后一部分专门讲坑。这些坑我大多数都是真正踩过才明白的如果你正好准备在生产环境用 List提前看到能省不少事。7.1 大 Key 问题List 上最容易出现的大 Key 有两种来源一是忘了做 LTRIM让列表无限增长二是业务设计里每个元素塞了大字符串比如把整个图片 Base64 直接拿来压列表。这两种都会让 Key 的内存占用飙到几百 MB 甚至更多。大 Key 的代价很直接删除它时会阻塞 Redis复制时会占用大量带宽和内存LRANGE 0 -1读一次就能把客户端内存打爆。我的处理姿势是先通过MEMORY USAGE key估算占用大 Key 尽量做拆分或者用 LPOP/RPOP 配合分批清理绝不能一个 DEL 甩过去。7.2 阻塞命令拖垮连接池前面提过 BLPOP/BRPOP 会长期占用连接这里再展开说。很多业务框架的 Redis 连接池默认只有 10 到 50 个连接如果你起了 10 个消费线程去 BRPOP那么生产环境的全部其他 Redis 操作都在等待空闲连接最终接口超时。解决思路有两种一是独立部署一个只用来跑阻塞命令的 Redis 客户端连接数与消费线程一致二是谨慎设置 timeout比如阻塞 30 秒就超时返回避免连接无限期失效。如果把阻塞命令和普通命令混在同一个连接池我强烈建议把连接池的 maxTotal 设置为普通连接需求 阻塞连接需求之和。7.3 消息丢失的风险非阻塞 POP 取完任务后进程崩溃任务就再也回不来了阻塞 POP 弹出但没来得及处理同样丢失。无论你用哪种方式只要队列里的数据有业务价值就必须引入 BRPOPLPUSH 或 BLMOVE 的中转列表模式。还有一个容易被忽视的丢消息场景多个消费者同时 BRPOP 同一个 key 时Redis 只会把弹出的消息发给其中一个消费者这是期望行为。但消费者的处理成功和删除备份之间如果不存在原子性实际可能在 LREM 之前又崩溃导致消息被重复处理。所以设计上要么接受重复要么在业务处理逻辑里做幂等。7.4 长度失控与数据序列化List 的每个元素本质上还是一个 Redis String。如果你往里存对象通常要先 JSON 序列化这就涉及到读取时反序列化失败的问题。我见过线上事故发布版本临时改了对象结构老数据 JSON 反序列化直接抛异常消费者一直重试队列越积越长。对于这种场景我现在的经验是进 List 的数据尽量是不可变事件比如任务 ID、通知 ID不直接把变更后的完整对象塞进去。消费者拿到 ID 后去业务数据库查最新数据这样即使对象结构变了也不会阻塞队列里的历史任务。最后一个经验是给 List 设置长度上限的习惯一定要养成。就算暂时不清楚合理的上限是多少也应该先用一个粗略值把 LTRIM 配上等业务量大了再调。List 的爽快之处在于简单但简单也最容易失控。上线前问自己三个问题谁往里面写谁从里面读它最长能长到多少想明白这三个问题List 就能用得既稳又省。