
Redis 里最容易被低估、也最容易被用错的数据结构List 绝对排得上号。很多人学 Redis 的第一个命令就是 LPUSH最后一个真正用对的场景却是拿它当队列——结果线上出了一堆问题才发现队列的坑根本不在“能不能塞进去”而在“取出来之后没人处理怎么办”。这篇东西不打算把官方文档抄一遍而是按我这些年实际用 List 的顺序从它到底是个什么结构讲起把写入、读取、定位、删除这几组命令按用途拆开再把阻塞命令和队列落地那套完整走一遍最后聊复杂度、大 key 和排查手段。适合刚接触 Redis 想系统过一遍命令的朋友也适合已经用了几年、但一直把 List 当万能容器的同行对照着看看有没有踩过同样的坑。文中所有示例都基于 Redis 6.x 和 7.x 的实际行为版本差异我会单独标出来命令都是可以直接粘到 redis-cli 里跑的。1. 先把 List 当回事结构本质决定了它能干什么1.1 从双向链表说起List 的物理形态Redis 的 List 是一个有序的字符串元素集合允许重复元素按插入位置维护顺序支持在两端做 O(1) 的插入和弹出。这句话里有三个关键词值得掰开有序、可重复、两端快。有序意味着它和 Set 有本质区别Set 是无序去重的集合你没法问 Set“第 5 个元素是谁”可重复意味着它和 Hash 的 field 也不同同一个值可以出现十次八次两端快则是它真正的价值所在——头部和尾部的操作都不需要移动其他元素这是链表结构的天然优势。很多人脑子里把 List 想成一个数组于是下意识地认为“按下标访问应该很快”。这是个致命的误解。List 的底层不是数组按下标访问是 O(N) 的LINDEX key 500000这种操作在大 List 上会实打实地消耗 CPU。我见过有同学拿 List 存了几十万条数据然后做分页前端一翻到后面几页接口响应直接从 5ms 涨到 400ms原因就在这。所以在动手敲命令之前先把心智模型立住List 是一根两头都能进出的管子中间的位置不要随便去捅。1.2 底层编码的演进为什么你看到的是 quicklist早期版本的 Redis 在 List 上做过一次比较大的结构选择元素少且短的时候用 ziplist一块连续内存省指针开销元素多或长的时候切换成 linkedlist真正的双向链表。问题是这个切换是二选一中间没有过渡一旦切到 linkedlist每个节点都要额外维护前后指针和内存分配器的元数据内存放大非常明显。3.2 版本引入了quicklist思路很直白用双向链表把多个 ziplist 串起来。链表负责“两端快”每个节点内部的小块连续内存负责“省内存”。这个设计一直延续到现在只是 7.0 之后节点内部从 ziplist 换成了listpack解决了 ziplist 在极端情况下连锁更新导致性能抖动的问题。你现在用OBJECT ENCODING去看一个 List可能会看到两种返回值返回值含义常见触发条件listpack单个 listpack 直接存整个 List元素数量少、单个元素短7.0 小 List 的优化路径quicklist双向链表 每节点 listpack元素较多或元素较长超过阈值后自动切换两个关键配置决定了这个切换边界很多人用了几年都没调过# 每个 quicklist 节点的大小上限负数表示按内存字节数限制 # -1: 4KB -2: 8KB(默认) -3: 16KB -4: 32KB -5: 64KB # 正数表示每个节点最多存多少个元素 list-max-listpack-size -2 # 链表两端各有多少个节点不压缩0 表示全部压缩 list-compress-depth 0提示list-compress-depth用 LZF 算法压缩中间节点适合“只在两端操作、中间数据基本不动”的长 List。但它会增加 CPU 开销而且两端节点不压缩的设定意味着你频繁访问的头部尾部本来就是解压好的这个设计挺聪明。1.3 哪些场景该用 List哪些趁早换掉搞清楚结构之后选型其实就有了依据。我把常见场景分成“确实合适”和“看着像但不合适”两类这个划分是我踩过坑之后总结的不是照抄文档。适合用 List 的最新 N 条列表评论区的“最新 50 条”、系统的“最近 100 条操作日志”。典型写法是LPUSH写入后立刻LTRIM只留头部 N 条内存恒定。简单任务队列生产者LPUSH推任务消费者BRPOP阻塞取任务轻量、够用、不需要额外的中间件。栈和队列语义LPUSH LPOP是栈LPUSH RPOP是队列同一条 List 换个方向就是另一种数据结构。时间线/消息流按时间顺序堆叠只需要按区间读取最近的若干条。不适合用 List 的需要随机访问或频繁按下标更新的数据LINDEX、LSET都是 O(N)几十万元素的 List 上做这个操作等于给 Redis 单线程塞了一个定时炸弹。需要去重的集合直接用 Set 或者 HashList 不管去重。需要消费确认、失败重试、多消费组的消息系统List 原生没有 ack 机制强行模拟出来的方案复杂度不低还不如直接上 Stream。需要频繁按值删除中间元素的数据LREM是 O(N) 扫描一次两次还行做成高频操作就是自找麻烦。一句话概括List 的优势区间是“两端进出、顺序不重要或天然有序、不需要精确定位中间元素”的场景脱离这个区间它的表现会让你失望。2. 命令分组拆解按用途记忆比按字母背强十倍List 的命令有二十来个按字母顺序背下来除了应付面试没别的用。我习惯按“写入 / 读取 / 定位修改 / 删除搬移”四组来记每组内部的行为逻辑是一致的记一组就能推一组。2.1 两端写入LPUSH 的多元素顺序是个经典陷阱写入命令的核心就三个LPUSH、RPUSH以及它们的“仅当 key 存在才写入”版本LPUSHX、RPUSHX。LPUSH key element [element ...] RPUSH key element [element ...] LPUSHX key element [element ...] RPUSHX key element [element ...]LPUSH从左侧头部插入RPUSH从右侧尾部插入返回的是插入之后 List 的长度。这个返回值很多人忽略其实它挺有用——你可以用它直接判断“当前队列积压了多少条”不需要额外发一条LLEN。这里必须重点强调一个坑多元素写入时元素的插入顺序和参数顺序是反的。127.0.0.1:6379 DEL mylist 127.0.0.1:6379 LPUSH mylist a b c (integer) 3 127.0.0.1:6379 LRANGE mylist 0 -1 1) c 2) b 3) a你写的是a b c存进去是c b a。原因很简单LPUSH的语义是“逐个从头部插入”先插 a列表变成[a]再插 b 到头部变成[b, a]再插 c 到头部变成[c, b, a]。同理RPUSH mylist a b c存进去就是正常的a b c。我在实际项目里见过因为这个顺序问题导致“日志列表倒着显示”的情况排查了半天以为是前端渲染逻辑的问题。记住这条规则多元素 LPUSH 等价于把参数列表反转后再按顺序 RPUSH 到头部写代码的时候要么保证参数顺序是反的要么就老老实实一次推一条。LPUSHX和RPUSHX的行为差异值得单独说一句它们只在 key 已存在时才写入key 不存在时什么也不做返回 0。这特性适合“已经有这个列表了才往里追加”的场景比如给已存在的会话列表加一条消息但不小心写错 key 名时也不会凭空创建一个垃圾 key——某种程度上算是一种防误写的保护。注意List 的长度上限是 2^32 - 1也就是 4294967295 个元素。理论上限之外真正的约束是内存。一个百万级的 List 如果元素都是几 KB 的字符串占用的内存会非常可观动手之前先估算。2.2 区间与下标读取LRANGE 是万金油也是大坑读取侧的主力是这三个LRANGE key start stop # 按区间取闭区间支持负数下标 LINDEX key index # 按下标取单个元素 LLEN key # 取长度下标规则统一从 0 开始-1 表示最后一个元素-2 表示倒数第二个。LRANGE key 0 -1就是取全部LRANGE key 0 9取前 10 条LRANGE key -10 -1取最后 10 条。LRANGE的边界处理比较宽容start超过列表长度或start stop返回空列表不报错。stop超过末尾自动截断到末尾。负数下标超出范围也是自动 clamp 到 0。这种“不报错”的设计对使用者的容错友好对排查问题就是灾难——你写错了参数它不吭声只给你一个空结果。我有一次排查“最新消息列表为空”最后发现是代码里LRANGE key 0 0的常量算错了一直取的是第一条而不是前 10 条接口返回结构看着正常就是内容少。LRANGE的复杂度是O(SN)S 是 start 的偏移量。这意味着LRANGE key 0 9很快LRANGE key 100000 100009需要先跳过十万个元素才能开始取。这就是为什么 List 不适合做深分页——越往后翻越慢而且慢得没有上限。LINDEX是 O(N)从头部或尾部往中间走Redis 内部会判断离哪头近就从哪头开始遍历算是个小优化。单个LINDEX偶尔用用没问题放到循环里遍历整个 List 就是自残行为遍历请用LRANGE批量取。LLEN是 O(1)可以放心高频调用。# 典型组合取最新 20 条 127.0.0.1:6379 LRANGE feed:user:1001 0 192.3 定位修改LINSERT、LSET、LREM、LTRIM 的适用边界这四个命令都属于“在中间或特定位置动手”共同点是复杂度都不低用之前得想清楚。LINSERT key BEFORE|AFTER pivot element # 在某个值的前/后插入 LSET key index element # 按下标覆盖 LREM key count element # 删除指定值的元素 LTRIM key start stop # 保留区间其余全删LINSERT会从头到尾扫描找到第一个等于pivot的元素然后在它前面或后面插入新元素返回插入后的长度。找不到 pivot 返回 -1。复杂度 O(N)。这个命令在实际业务里用得不多因为“等值定位”本身就不是个高频需求而且一旦列表里有重复值你没法指定“在第几个匹配项前插入”。真要精确定位不如整体取出来处理再写回。LSET是按下标覆盖O(N)。两个特殊行为要记住key 不存在时报ERR no such key下标越界时报ERR index out of range。它不是一个“可以用于初始化”的命令别指望LSET newlist 0 xxx能创建列表。LREM的count参数是最容易搞混的地方我用表格列清楚count 取值行为count 0从头部开始扫描最多删除 count 个等于 element 的元素count 0从尾部开始扫描最多删除 |count| 个count 0删除所有等于 element 的元素返回值是实际删除的数量。写 LREM 的时候一定要显式考虑 count我见过有人想“只删一条”结果传了个 0 把列表里的同值元素全清了事后才发现。LTRIM是我认为 List 里性价比最高的命令之一。它把 List 裁剪成指定区间区间外的元素全部丢弃复杂度 O(N)其中 N 是被删除的元素数量。为什么说性价比高因为它是“固定长度列表”这个模式的基石127.0.0.1:6379 LPUSH latest:orders order:99001 (integer) 1 127.0.0.1:6379 LTRIM latest:orders 0 99 OK这两条命令的组合保证了latest:orders永远只有最新的 100 条订单 ID。不加LTRIM这个 List 会随着时间无限膨胀最后变成压垮实例的大 key。所有“只保留最近 N 条”的场景写入后必须跟一条 LTRIM这不是可选项。2.4 搬移与查找RPOPLPUSH、LMOVE、LPOS 的实战价值这三个命令里有两个是“稍微进阶一点但极其好用”的值得单独拿出来说。RPOPLPUSH source destination # 从 source 尾部弹出推到 destination 头部 LMOVE source destination LEFT|RIGHT LEFT|RIGHT # 6.2 通用版 LPOS key element [RANK rank] [COUNT num] [MAXLEN len] # 6.0.6RPOPLPUSH的关键价值在于它是原子操作。如果分成RPOPLPUSH两步写中间可能因为客户端崩溃导致任务丢失用RPOPLPUSH任务在“从源列表移除”和“加入目标列表”之间不存在空窗期。这就是所谓“可靠队列”的基础主队列取任务同时把它扔进一个 processing 列表处理完再从这个 processing 列表删掉。LMOVE是 6.2 引入的通用版本可以自由指定从哪头出、往哪头进四个参数组合出四种搬移方式。RPOPLPUSH source dest等价于LMOVE source dest RIGHT LEFT。LPOS是 6.0.6 加入的查找命令返回元素在列表中的下标找不到返回 nil。127.0.0.1:6379 RPUSH ranklist a b c b a (integer) 5 127.0.0.1:6379 LPOS ranklist b (integer) 1 127.0.0.1:6379 LPOS ranklist b RANK 2 (integer) 3 127.0.0.1:6379 LPOS ranklist a COUNT 0 1) (integer) 0 2) (integer) 4RANK用来指定“找第几个匹配项”COUNT用来一次性返回多个位置COUNT 0表示返回全部MAXLEN用来限制最多扫描多少个元素扫描到上限就停。这几个参数的价值在于控制成本——不加MAXLENLPOS最坏情况要扫完整个列表在长列表上就是一次 O(N) 的阻塞。注意LPOS的复杂度要按最坏情况算。查询一个不存在的元素时它必须扫完整个区间才能确定“没有”该给MAXLEN就给别心疼。3. 阻塞命令与轻量队列这才是 List 的高光区3.1 BLPOP/BRPOP 的阻塞语义以及那个反直觉的细节轮询式的队列消费长这样127.0.0.1:6379 RPOP tasks (nil) 127.0.0.1:6379 RPOP tasks (nil)没任务就返回 nil客户端只能写个 while 循环不停问既浪费 CPU 又增加网络往返。阻塞命令就是来解决这个问题的BLPOP key [key ...] timeout BRPOP key [key ...] timeout语义是按顺序检查给出的 key哪个有元素就从哪弹一个出来返回[key, element]两元素数组全都空就阻塞等待直到有元素进来或者超时超时返回 nil。timeout单位是秒0表示永久阻塞。6.0 之后支持小数可以写BLPOP tasks 0.5做半秒超时。几个必须知道的细节第一返回的是两元素数组不是单个元素。因为可以同时监听多个 keyRedis 必须告诉你“到底是从哪个 key 拿到的”。这一条看着简单实际写代码时忽略它的概率相当高尤其是在从RPOP迁移到BRPOP的时候。第二多个 key 的检查顺序就是参数顺序。如果key1和key2同时有数据永远优先从key1拿。利用这个特性可以实现优先级队列高优先级队列放前面低优先级放后面一条BRPOP high normal low 0就完成了优先消费。第三在 MULTI/EXEC 事务里阻塞命令不会阻塞会立即返回 nil。因为事务要一次性执行完所有命令阻塞在那里等于把整个事务挂死Redis 选择了这个行为。同理在 Lua 脚本里也是立即返回。这个细节文档里有但踩坑的人不少。# 终端 A 执行会卡住 127.0.0.1:6379 BRPOP task:queue 0 # 终端 B 执行 127.0.0.1:6379 LPUSH task:queue job-1001 (integer) 1 # 终端 A 立刻返回 1) task:queue 2) job-10013.2 BLMOVE 与“可靠队列”的雏形BLPOP有个绕不过去的缺陷元素一旦弹出就从 Redis 里彻底消失了。消费者拿到任务后如果进程崩了、或者处理逻辑抛了异常这个任务就永久丢失了没有任何补偿机制。这个问题在生产环境是致命的。解决办法是搬移而不是弹出——把任务从待处理队列搬到一个“处理中”队列处理成功后再从处理中队列删掉# 消费者原子地把任务从主队列搬到 processing 队列最多等 5 秒 127.0.0.1:6379 BLMOVE task:queue task:processing LEFT RIGHT 5 1) job-1001 # 处理成功后把它从 processing 队列移除 127.0.0.1:6379 LREM task:processing 1 job-1001 (integer) 1如果消费者在BLMOVE之后、LREM之前挂掉任务还老老实实躺在task:processing里。这时候起一个巡检任务定期扫描 processing 队列里“待了太久”的条目把它们重新推回主队列就实现了最基本的失败重投。# 巡检任务把处理中队列里超过一定时间的任务搬回主队列 # 注意 LIST 没有时间戳这一步通常需要靠额外的结构如 ZSET记录入队时间 127.0.0.1:6379 RPOPLPUSH task:processing task:queue这里要坦白一点纯 List 实现的重投机制很粗糙因为 List 本身不存储元素的时间信息你没法直接判断“这条任务在 processing 里待了多久”。实践中的做法通常是额外维护一个以任务 ID 时间戳为成员的 ZSET用ZRANGEBYSCORE找出超时的任务。一旦需要引入这套东西就该认真考虑是不是直接换 Stream 更划算——Stream 原生提供了XPENDING查未确认消息、XCLAIM转移所有权省掉一大半胶水代码。3.3 从零搭一个可用的任务队列完整实操光说不够走一遍完整的落地过程。场景是一个后台系统要处理用户上传的文件上传接口只负责把任务塞进队列另起若干 worker 消费。第一步确定队列方向。生产端用LPUSH推消费端用BRPOP取这样是先入先出FIFO。如果你想反过来后进先出把BRPOP换成BLPOP就行。我习惯用LPUSH BRPOP因为BRPOP的写法在多人协作的项目里不容易被改错方向。第二步生产者代码。import redis import json r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def submit_task(task_id, file_path, user_id): payload json.dumps({ task_id: task_id, file_path: file_path, user_id: user_id, }, ensure_asciiFalse) # 使用 BLMOVE 的兄弟命令 LMOVE 做原子搬移这里生产端直接 LPUSH length r.lpush(queue:file_process, payload) # 顺手打个监控点队列积压超过阈值就告警 if length 10000: print(f[WARN] 队列积压: {length}) return length第三步消费者代码。这里用BLMOVE做搬移保留失败重投的可能性import redis import json import time r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def worker_loop(): while True: # 从主队列搬到 processing 队列最多阻塞 5 秒 # 这里用 LMOVE 的非阻塞版本 外层 sleep 也可以看你的实时性要求 item r.brpoplpush(queue:file_process, queue:file_processing, timeout5) if item is None: continue task json.loads(item) try: handle_file(task) # 处理成功从 processing 队列移除 r.lrem(queue:file_processing, 1, item) except Exception as e: # 处理失败先记录交给巡检任务重投 print(f任务 {task[task_id]} 处理失败: {e}) # 注意这里不要直接 LREM让它留在 processing 里BRPOPLPUSH是老命令6.2 之后更推荐用BLMOVE source dest RIGHT LEFT timeout语义更清晰。两者行为一致新项目直接上BLMOVE。第四步配置层面的几个关键点。队列类 List 的内存参数和普通 List 不太一样我一般会单独确认这几项参数建议值理由maxmemory-policynoeviction或volatile-lru队列数据被淘汰等于任务丢失绝对不能用allkeys-lrulist-max-listpack-size保持默认-2队列元素一般不大默认 8KB 节点够用list-compress-depth0队列两端都在频繁操作压缩收益低还费 CPU队列长度监控阈值告警积压往往是消费端挂了的第一个信号第五步压测验证。起 4 个 worker生产者灌 10 万条任务观察两个指标队列长度的下降速率以及queue:file_processing的残留量。正常情况下 processing 队列应该长期保持在个位数如果它持续增长说明有 worker 在处理中卡住了或者崩了。3.4 这套方案的边界在哪什么时候必须换聊清楚怎么搭也得聊清楚它什么时候不够用。List 队列的硬伤有这么几条没有消费确认机制。只能靠RPOPLPUSH搬到 processing 列表来变通模拟但这个“模拟”需要你自己实现超时扫描、重投、去重代码量不小且容易出 bug。没有消费组概念。多个 worker 抢同一个 List是“谁抢到算谁的”没法做“同一类消息每个 worker 都收到一份”的广播语义。没有消息堆积查询。你想知道“现在队列里有哪些消息”只能LRANGE全量拉出来几十万条的时候就别想了。没有消息 ID 和回溯能力。消费过了就是消费过了想重新消费历史消息是做不到的。一旦业务上出现“需要重试”“需要多消费组”“需要查历史消息”中的任意一条就该考虑 Stream 了。Stream 的XADD/XREADGROUP/XACK/XPENDING四件套把这几个问题都覆盖了代价是命令复杂度和运维成本上升。我的经验判断标准很简单如果队列里丢一条消息你能接受比如日志采集用 List如果丢一条要背锅比如订单、支付别硬扛上 Stream 或者专业消息队列。4. 复杂度、内存与大 key性能这条线怎么守住4.1 命令复杂度对照速查Redis 是单线程处理命令的指主线程的命令执行路径所以任何一条慢命令都会阻塞所有客户端。搞清楚每条命令的复杂度比事后看慢日志有用得多。命令复杂度说明LPUSH/RPUSHO(1) 每元素多元素时是 O(N)N 为元素个数LPOP/RPOPO(1)带 count 参数时是 O(N)LLENO(1)放心高频调用LRANGEO(SN)S 是起始偏移N 是返回数量LINDEXO(N)会从较近的一端开始遍历LSETO(N)同上LINSERTO(N)需要全表扫描找 pivotLREMO(N)需要遍历删除匹配项LTRIMO(N)N 是被删除的元素数量RPOPLPUSH/LMOVEO(1)原子搬移成本极低LPOSO(N)最坏情况扫完整个列表BLPOP/BRPOP/BLMOVEO(1)阻塞期间不占 CPU我能给出的最实用的一条建议把LRANGE的 S起始偏移控制在两位数以内把LINDEX和LSET从循环里彻底删掉。这两件事做到了List 用起来基本不会出性能事故。4.2 大 key 是怎么长出来的大 key 指的是单个 key 占用的内存过大或者元素数量过多。对 List 来说常见的判定线是元素数量超过 5000或者总内存超过 10MB具体阈值各家团队不太一样但量级差不多。大 key 的危害有三个方面。一是内存分布不均集群模式下会导致数据倾斜某个分片内存爆满而其他分片空着。二是操作变慢一条LRANGE key 0 -1在大 key 上可能阻塞几百毫秒这期间所有其他请求都排队。三是删除成本高DEL一个大 key 是同步删除会卡住主线程需要用UNLINK做异步删除把内存回收放到后台线程。大 key 是怎么长出来的我把遇到的案例归了归基本上就这几种忘记加 LTRIM。这是最高频的。写了个“最近消息列表”只LPUSH不裁剪跑了三个月变成两百万条。修复方式是在写入路径上加LTRIM key 0 999同时写个脚本慢慢清理存量。用 List 存不该用 List 存的东西。比如拿一条 List 存某个用户的所有文章 ID用户发文越多这个 List 越长。这种场景应该考虑用 ZSET 分页查询或者干脆不缓存在 Redis 里。元素本身太大。有人往 List 里塞完整的 JSON 对象甚至 base64 编码的图片单个元素几十 KB几千条就撑到几百 MB。List 适合存 ID、短文本、序列化后的小结构大对象应该走对象存储Redis 里只留引用。# 检查大 key几种常用手段 127.0.0.1:6379 MEMORY USAGE mylist # 单个 key 的内存占用 127.0.0.1:6379 LLEN mylist # 元素数量 127.0.0.1:6379 OBJECT ENCODING mylist # 编码类型 127.0.0.1:6379 --bigkeys # redis-cli 内置扫描会遍历全库慎用 127.0.0.1:6379 MEMORY USAGE mylist SAMPLES 100 # 抽样估算大 key 上用这个更快注意redis-cli --bigkeys会扫描整个 keyspace在生产环境上跑要挑低峰期或者用SCAN分批自己做。别在工作时间随手敲。清理大 key 的正确姿势是分批渐进删除而不是一把DEL# 每次裁掉 1000 条循环执行直到 LLEN 返回 0 127.0.0.1:6379 LTRIM mylist 1000 -1 127.0.0.1:6379 LLEN mylist这样每次删除的量可控主线程不会被一次长耗时的内存释放卡住。如果 Redis 版本支持UNLINK直接UNLINK mylist更省事它只是把 key 从 keyspace 摘掉真正的内存释放交给后台。4.3 监控指标怎么盯List 相关的监控我一般只盯三个数一是 key 的元素数量分布。定期统计所有 List 类型 key 的LLEN找出明显偏离常规的。这个可以用SCAN配合TYPE来做采样即可不需要全量。二是慢日志。slowlog-log-slower-than设成 1000010msSLOWLOG GET 100看看有哪些 List 命令上榜。LRANGE和LINDEX是大户。三是阻塞客户端数量。INFO clients里的blocked_clients字段。数量异常高说明消费端处理跟不上或者有 worker 卡死了。127.0.0.1:6379 INFO clients # Clients connected_clients:42 blocked_clients:8 127.0.0.1:6379 CLIENT LIST TYPE normal # 可以看每个客户端的 cmd 和 flags阻塞中的客户端 flags 会带 b5. 常见问题与排查实录这些坑我基本都踩过5.1 问题速查表现象大概率原因排查动作LRANGE返回空但确定写过数据下标算错或 start stop或 key 名拼错LLEN确认长度EXISTS确认 key 存在LSET报ERR no such keykey 不存在LSET 不会创建初始化时用RPUSH不要用LSETLPUSH后顺序和自己预期相反多元素插入从头部逐个插入导致反转改参数顺序或换成RPUSHLINSERT返回 -1pivot 值不在列表里先确认 pivot 存在注意全等匹配不做类型转换LREM删多了count 传了 0删除了所有匹配项明确需求只删一条用 count 1BRPOP立即返回 nil在 MULTI 事务或 Lua 脚本里执行阻塞命令不能放在事务里BRPOP一直拿不到数据生产端用了LPUSH消费端应该用BRPOP或 key 名不一致确认两端 key 完全一致注意环境前缀List 内存持续增长没加LTRIM写入路径加裁剪清理存量BLPOP多 key 时总是拿同一个检查顺序就是参数顺序前面的 key 有数据就不会看后面的确认优先级顺序是否符合预期5.2 几个真实排查过程的复盘案例一队列积压但 worker 说没活干。现象是监控显示queue:tasks有十几万条但四个 worker 的 CPU 都趴在 5%日志里没有任务处理记录。第一反应是 worker 挂了检查进程都在。登上去手工执行LRANGE queue:tasks 0 0发现数据是有的。后来把 worker 的消费代码打出来看问题找到了生产端用的是RPUSH消费端用的是BRPOP。RPUSH往尾部加BRPOP从尾部取——这是在同一个口上进出后进先出而且因为两边的速度差异元素在尾部堆积BRPOP取到的永远是最新那条老数据就在旁边躺着没人管。正确的配对是RPUSH BLPOP或者LPUSH BRPOP方向必须相反。这个案例给我的教训是队列的两端方向一定是反的写代码的时候把生产和消费放在同一屏里看一遍再提交。案例二LRANGE 导致接口偶发超时。某接口从 List 里取最近 100 条消息代码是LRANGE feed 0 99看着没问题。但线上偶发 800ms 的慢请求慢日志里全是这条命令。为什么取前 100 条会这么慢原因是这个 List 的节点编码问题。list-max-listpack-size被人改成了正数 1000意味着每个 quicklist 节点存 1000 个元素而节点内是 listpack 连续内存。虽然LRANGE 0 99只需要访问第一个节点但当这个 List 频繁在头部插入删除时节点会不断分裂加上 listpack 的编码开销单次操作的耗时会比默认配置高不少。改回-2之后恢复。这件事让我意识到默认配置往往是经过大量验证的改之前最好知道自己在优化什么。案例三processing 队列里堆了几万条僵尸任务。前面那套BRPOPLPUSH的可靠队列方案跑了半个月以后queue:file_processing里积了 3 万多条。查下来是某次网络抖动一批 worker 在拿到任务后连接断了任务留在了 processing 队列里而巡检重投的逻辑一直没实现——搭的时候赶进度想着“后面补上”结果就忘了。补打巡检任务的时候还有个细节要注意重投的时候不能简单地把 processing 队列整个搬回主队列因为里面可能有正在处理的任务搬回去会导致重复处理。我的做法是给每条任务加一个入队时间戳字段巡检时用LRANGE分批扫出来只把超时超过 10 分钟的重投。这个教训很实在任何“先上线后面补”的机制都要在任务系统里留一条 TODO 并设置截止时间否则永远不会被补上。5.3 几个不那么显眼但很有用的小技巧技巧一用LPUSH的返回值做轻量监控。生产端每次推入都会拿到当前长度攒够一定量或者超过阈值就直接打日志/上报指标比单独起一个定时任务去LLEN实时性更好也不增加额外请求。技巧二用RPOPLPUSH做无锁的 FIFO 轮转。比如有一批候选服务器 IP需要轮流分配可以把它们放在一个 List 里每次RPOPLPUSH pool pool就能原子地“取一个并放回尾部”天然实现轮询且不会出现并发下的重复分配。这个用法很小众但在资源池分配的场景里挺顺手。技巧三LTRIM也能用来快速清空。LTRIM key 1 0的结果是保留一个空的区间等价于删除全部元素但 key 还在仍然保持 List 类型。如果你想让 key 保留、只清内容用这个想彻底删掉 key用DEL或UNLINK。技巧四判断“列表是否为空”不要用LRANGE key 0 0。语义没错但LLEN是 O(1)LRANGE是 O(1N)虽然取一个元素的开销很小但在高频路径上LLEN更干净。更重要的是LRANGE key 0 0在 key 不存在和列表为空时都返回空数组你在代码里没法区分而LLEN返回 0 同样区分不了所以真要判断 key 是否存在得用EXISTS。技巧五批量推入用一条命令别循环。LPUSH key a b c d e一次网络往返就搞定比写for循环推五次要快得多。Redis 的单条命令处理是原子的批量推入也不会带来一致性问题唯一的注意点就是前面说的顺序反转。技巧六调试时用MONITOR看实际发出去的 key。排查“明明写了却读不到”这类问题时很多时候不是 Redis 的问题是客户端拼接 key 的时候多了个空格或者前缀不一致。MONITOR会把服务端收到的所有命令原样打出来一眼就能看出差异。生产环境慎用它会显著增加负载。6. 关于版本差异和选型的补充说明有几个版本差异点在实际使用中会撞上单独列一下省得你翻文档。LPOP/RPOP在 6.2 之前不支持count参数只能一次弹一个6.2 之后可以LPOP key 10一次弹 10 个并返回数组。如果你要兼容老版本注意别用这个语法。LMOVE和BLMOVE都是 6.2 引入的老版本只能用RPOPLPUSH/BRPOPLPUSH这两组命令的行为基本等价新版项目直接用新命令就行。LPOS是 6.0.6 引入的如果你的环境是 6.0.0 到 6.0.5这个命令还不存在。7.0 之后 List 的内部编码从 ziplist 切到 listpack对外命令行为完全一致唯一可能观察到差异的是OBJECT ENCODING的返回值以及极端场景下的内存占用数值不要把这个当成 bug。选型上我的立场比较明确List 用在它擅长的两个场景里非常舒服一个是定长的时间线/最新列表LPUSH LTRIM一个是轻量的任务队列LPUSH BRPOP需要可靠性就加 RPOPLPUSH/BLMOVE。超出这两个场景去硬撑你会发现自己在用一堆命令拼凑别的数据结构的功能代码越来越绕性能越来越差最后还是要换方案。与其绕一圈不如一开始就想清楚。