ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis List 命令、队列与大 key 实战指南

Redis List 命令、队列与大 key 实战指南

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 或者 Hash,List 不管去重。
  • 需要消费确认、失败重试、多消费组的消息系统: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(S+N),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 19

2.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.6+

RPOPLPUSH的关键价值在于它是原子操作。如果分成RPOP+LPUSH两步写,中间可能因为客户端崩溃导致任务丢失;用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) 4

RANK用来指定“找第几个匹配项”,COUNT用来一次性返回多个位置(COUNT 0表示返回全部),MAXLEN用来限制最多扫描多少个元素,扫描到上限就停。这几个参数的价值在于控制成本——不加MAXLEN,LPOS最坏情况要扫完整个列表,在长列表上就是一次 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做半秒超时。

几个必须知道的细节:

第一,返回的是两元素数组,不是单个元素。因为可以同时监听多个 key,Redis 必须告诉你“到底是从哪个 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-1001"

3.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(host="127.0.0.1", port=6379, decode_responses=True) 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_ascii=False) # 使用 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(host="127.0.0.1", port=6379, decode_responses=True) def worker_loop(): while True: # 从主队列搬到 processing 队列,最多阻塞 5 秒 # 这里用 LMOVE 的非阻塞版本 + 外层 sleep 也可以,看你的实时性要求 item = r.brpoplpush("queue:file_process", "queue:file_processing", timeout=5) 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-lru
list-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(S+N)S 是起始偏移,N 是返回数量
LINDEXO(N)会从较近的一端开始遍历
LSETO(N)同上
LINSERTO(N)需要全表扫描找 pivot
LREMO(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设成 10000(10ms),SLOWLOG 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 会带 b

5. 常见问题与排查实录:这些坑我基本都踩过

5.1 问题速查表

现象大概率原因排查动作
LRANGE返回空,但确定写过数据下标算错,或 start > stop,或 key 名拼错LLEN确认长度,EXISTS确认 key 存在
LSET报ERR no such keykey 不存在,LSET 不会创建初始化时用RPUSH,不要用LSET
LPUSH后顺序和自己预期相反多元素插入从头部逐个插入导致反转改参数顺序,或换成RPUSH
LINSERT返回 -1pivot 值不在列表里先确认 pivot 存在,注意全等匹配(不做类型转换)
LREM删多了count 传了 0,删除了所有匹配项明确需求,只删一条用 count 1
BRPOP立即返回 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(1+N),虽然取一个元素的开销很小,但在高频路径上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)。超出这两个场景去硬撑,你会发现自己在用一堆命令拼凑别的数据结构的功能,代码越来越绕,性能越来越差,最后还是要换方案。与其绕一圈,不如一开始就想清楚。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进