List 队列 — CodeRoadMapList 队列
LPUSH/RPOP、消息队列与最新列表。
List 列表:判题任务队列与最新动态
这一课你将能做到
- 熟练在列表两端做增、删、查、裁剪操作
- 用 List + 阻塞弹出搭一个简单的判题任务队列
- 说清楚 List 做消息队列的三个短板,以及什么时候该换 Stream
为什么要学
List 是按顺序排列的字符串链表(Redis 7 底层用 quicklist,兼顾两端操作和内存效率)。它最自然的两个用途:
- 队列:用户提交代码后,网站不直接判题,而是把任务 ID 塞进队列;判题机从另一头一个个取出来执行。高峰时任务在队列里排队,判题机不会被压垮,还能横向多加几台消费者。
- 最新动态:「最近 20 道被提交的题目」「用户最近的答题记录」,新内容往一端插,只保留固定条数。
注意:List 能做队列,但在「多消费者协作、消息不能丢、要知道哪些消息没处理完」这些要求面前它很勉强,这正是第 7 课 Stream 要解决的问题。
先建立图像
把 List 想成一根横着的管子,左右两端都能放东西和拿东西:
LPUSH(左进) RPOP(右出)
│ │
▼ ▼
┌─────┬─────┬─────┬─────┬─────┐
│ j-5 │ j-4 │ j-3 │ j-2 │ j-1 │ key: queue:judge:jobs
└─────┴─────┴─────┴─────┴─────┘
head tail
左进右出 = 队列(FIFO,先进先出)
左进左出 = 栈(LIFO,后进先出)
队列方向口诀:同端进出是栈,异端进出是队列。本站判题队列用「RPUSH 生产、LPOP 消费」或「LPUSH 生产、RPOP 消费」,全组保持统一即可。
跟着做
步骤 1:两端写入与查看
127.0.0.1:6379> RPUSH queue:judge:jobs job-1001 job-1002 job-1003
(integer) 3
127.0.0.1:6379> LPUSH queue:judge:jobs job-1000
(integer) 4
127.0.0.1:6379> LRANGE queue:judge:jobs 0 -1
1) "job-1000"
2) "job-1001"
3) "job-1002"
4) "job-1003"
LRANGE key 起始下标 结束下标 是查看利器,下标从 0 开始,-1 表示倒数第一个,所以 0 -1 是看全部。生产环境不要对超大列表无范围地看,先 LLEN 确认长度。
步骤 2:取出元素
模拟判题机取任务(左进右出,这里用 RPOP):
127.0.0.1:6379> RPOP queue:judge:jobs
"job-1003"
127.0.0.1:6379> LRANGE queue:judge:jobs 0 -1
1) "job-1000"
2) "job-1001"
3) "job-1002"
列表空了以后再取,返回 nil:
127.0.0.1:6379> RPOP queue:judge:jobs
(nil)
步骤 3:阻塞弹出,避免空轮询
消费者如果写 while(true) { RPOP },没任务时会疯狂打 Redis,这叫空轮询,浪费 CPU 和网络。BRPOP / BLPOP 可以阻塞等待:
终端 A(消费者,判题机):
127.0.0.1:6379> BRPOP queue:judge:jobs 30
此时连接挂起。终端 B(生产者,网站)写入:
127.0.0.1:6379> RPUSH queue:judge:jobs job-2001
(integer) 1
1) "queue:judge:jobs"
2) "job-2001"
(3.42s)
最后的 30 是超时秒数,填 0 表示永久等待。还可以同时监听多个队列做优先级:
BRPOP queue:judge:vip queue:judge:normal 0
左边的队列优先,VIP 队列有任务时永远先处理它。
步骤 4:按位置查、改、删
127.0.0.1:6379> RPUSH feed:user:1001 solved:501 solved:502 solved:503 solved:504
(integer) 4
127.0.0.1:6379> LLEN feed:user:1001
(integer) 4
127.0.0.1:6379> LINDEX feed:user:1001 0
"solved:501"
127.0.0.1:6379> LSET feed:user:1001 0 solved:501:ac
OK
127.0.0.1:6379> LREM feed:user:1001 1 solved:502
(integer) 1
LREM key count 值:count 为正数从头往后删,负数从尾往头删,0 删除全部匹配。
步骤 5:LTRIM 只保留最新 N 条
「用户最近 20 条做题动态」的经典套路:每次新动态 LPUSH 后立刻 LTRIM 保留前 20 条:
127.0.0.1:6379> LPUSH feed:user:1001 solved:505
(integer) 4
127.0.0.1:6379> LTRIM feed:user:1001 0 19
OK
这个列表长度永远不超过 20,内存恒定。再给键设置一个较长的 TTL(比如 30 天没活动就清除),就是一个完整的轻量动态流。
步骤 6:可靠一点的移动 LMOVE
判题机直接 RPOP 有个风险:任务取出来了,但判题机宕机,这个任务就凭空消失了。改进办法是取任务时同时放进一个「处理中队列」:
127.0.0.1:6379> RPUSH queue:judge:jobs job-3001 job-3002
(integer) 2
127.0.0.1:6379> LMOVE queue:judge:jobs queue:judge:processing RIGHT LEFT
"job-3002"
127.0.0.1:6379> LRANGE queue:judge:processing 0 -1
1) "job-3002"
任务处理成功后,从 processing 删掉;如果判题机宕机,监控程序可以扫 processing 队列,把超时任务重新塞回主队列。Redis 7 还有阻塞版 BLMOVE。注意这只是「尽力而为」的可靠性,要真正的消费确认机制,请用第 7 课的 Stream 消费组。
讲清楚要点
- 两端操作都是 O(1):头尾增删极快;但按中间位置操作(
LSET、LINDEX、LREM)是 O(N),列表长时要避免。
- RPUSH/LPOP 或 LPUSH/RPOP 构成队列,配合
BRPOP 阻塞消费;多队列参数天然实现优先级。
- LPUSH + LTRIM = 固定长度的最新列表,这是 List 在业务代码里出现最多的组合拳。
- List 队列的三个短板:一,消息取出即删,消费者宕机消息丢失(要用 LMOVE + 处理中队列打补丁);二,不支持多消费者协同,一个任务可能被多台机器重复取——实际上不会重复,但也没法像消费组那样瓜分并追踪进度;三,没有消息 ID、没有确认机制、查不到积压详情。
- 单键元素不宜过多:虽然 quicklist 比老式双端链表省内存,但百万元素的列表依然沉重,也难以分片处理,考虑按业务拆键或换 Stream。
你可能会懵的地方
-
LPOP 和 RPOP 到底用哪边?
完全取决于你从哪边塞。只要「塞」和「取」在异端,就是先进先出队列;在同端就是栈。团队定好一个方向,代码里保持一致即可。
-
BRPOP 期间 Redis 会一直占着线程吗?会不会影响性能?
不会。阻塞的连接只是被登记到等待队列里,不消耗命令线程;有数据到来时 Redis 再唤醒它。成千上万个阻塞连接也没问题。
-
消息被取走后消费者宕机怎么办?
朴素 RPOP 方案下消息会丢。用 LMOVE 原子地搬到 processing 队列,处理完再删;宕机任务由兜底程序重投。要求严格则用 Stream + 消费组 + XACK。
-
多个消费者同时 BRPOP 一个队列,一条消息会被几个人拿到?
只会被一个拿到。Redis 内部按等待顺序分发给其中一个连接,这就是「竞争消费」,多台判题机可以放心地监听同一队列做负载均衡。
-
List 和 Stream 怎么选?
任务量小、丢一两条无所谓、逻辑越简单越好,用 List;需要消息持久化、消费组、确认重试、积压可观测,用 Stream。第 7 课会完整对比。
错法对照
| 错法 | 问题 | 正解 |
|---|
| while 循环 RPOP 不空歇 | 空轮询打爆 CPU 和网络 | BRPOP/BLPOP 阻塞等待 |
| 消费者直接 RPOP 不做兜底 | 宕机即丢任务 | LMOVE 进处理中队列,或换 Stream |
| LPUSH 后不裁剪 | 动态列表无限增长 | LTRIM 0 19 固定长度 |
| 在超大列表里用 LREM 找值 | O(N) 扫描,阻塞主线程 | 换 Set/Stream,或控制列表长度 |
LRANGE key 0 -1 看百万队列 | 一次拉爆内存和网络 | 分页查看,如 LRANGE key 0 99 |
| 任务只塞队列不设监控 | 积压无人知晓 | 监控 LLEN 和 processing 滞留时间 |
卡点急救
- BRPOP 立刻返回 nil:不是出错,是超时时间内没有新任务。加大超时或填
0 永久等待,同时检查生产者是否真的写进了同一个键。
- 任务堆积、LLEN 持续上涨:消费速度跟不上生产。先加判题机(多消费者天然瓜分),再检查是不是某条任务让消费者反复崩溃——坏消息会让队列看起来「不动」。
- LSET/LINDEX 报 no such key 或 out of range:键不存在或下标超过长度,先
LLEN 确认,注意下标别越界。
面试常问
-
Redis List 实现消息队列的方式和缺点?
LPUSH/RPUSH 生产 + BRPOP 阻塞消费,可多队列做优先级。缺点:消息取出即删除、消费者宕机会丢消息;无消费确认和重试;无可观测的消费位点。高可靠场景应换 Stream。
-
如何用 List 保证任务尽量不丢?
用 LMOVE(或 BLMOVE)把任务从主队列原子搬到 processing 队列,处理成功再删除;兜底任务扫描 processing 中超时未完成的任务重新投递。
-
LPUSH + LTRIM 能解决什么问题?
维护一个长度恒定的「最新 N 条」列表,比如最近做题动态,写入后裁剪,内存可控、查询恒定。
自测三问
- 写出「生产者右进、消费者左出、空队列阻塞 60 秒」的完整命令。
- 为什么不建议消费者空轮询 RPOP?阻塞弹出的超时参数填 0 是什么含义?
- List 做队列的三个短板分别是什么?对应的替代结构是什么?
小结一句话
List 是 Redis 里最朴素的顺序结构,异端进出加阻塞弹出就是队列,配合 LMOVE 和 LTRIM 能覆盖轻量任务排队与最新动态,但可靠性要求一高就该让位给 Stream。
延伸