List 阻塞弹出与队列裁剪
List 是最早被当队列使用的 Redis 类型,Redis 7 中底层是 quicklist 套 listpack 节点。阻塞弹出、LMOVE、LTRIM 三个能力组合起来,可以实现安全队列和定长 feed 流,但每个命令都有不显眼的边界行为。本文以 Redis 7 为基准逐个拆解。
一、命令速览与复杂度
LPUSH/RPUSH 从头尾插入,返回插入后的列表长度;LPOP/RPOP 弹出头尾元素,Redis 6.2 起支持 count 参数一次弹多个;LRANGE 按下标区间取值,负索引 -1 表示最后一个元素;LREM 按值删除,count 为正表示从头到尾删 n 个、为负从尾向头、为 0 删全部;LPOS(Redis 6.2+)返回元素首次出现的下标,配合 RANK 可指定第几次出现。
两个隐含语义容易忽视:LPUSHX/RPUSHX 只在 key 已存在时插入,配合"key 不存在即队列未初始化"的判断,可以防止消费者误建队列;列表弹出最后一个元素后 key 自动删除,空队列不会残留一堆空 key,BLPOP 的阻塞语义也建立在这套生命周期之上。LINSERT 能在指定元素前后插入,但它要先定位再插,是 O(N) 操作,不要用于长列表。
127.0.0.1:6379> RPUSH mq a b c
(integer) 3
127.0.0.1:6379> LRANGE mq 0 -1
1) "a"
2) "b"
3) "c"
127.0.0.1:6379> LPOP mq 2
1) "a"
2) "b"
复杂度上,LPUSH/RPUSH/LPOP/RPOP 是 O(1),而 LINDEX、LSET、LREM 都是 O(N),因为定位要从头或尾逐节点走。List 只适合两端操作的场景,按下标随机读改应该换 Hash。
二、阻塞弹出:BLPOP/BRPOP 的行为细节
BLPOP/BRPOP 在 key 不存在或列表为空时阻塞等待,列表非空则立即返回,行为与 LPOP 一致。三条规则决定了线上表现:
- 唤醒顺序:多个客户端同时阻塞在同一个 key 上,新元素到来时按阻塞先后(先进先出)只唤醒一个,天然是公平的消费者分配。
- 多 key 优先级:
BLPOP k1 k2 5按 key 的书写顺序检查,返回第一个非空列表的结果,可以利用它做简易优先队列——高优先级队列放前面。 - 超时:timeout 单位秒,0 表示一直等,超时返回 nil。Cluster 模式下多个 key 必须落在同一槽,否则报错。BLPOP 支持一次传多个 key,生产者只管往对应优先级的队列 LPUSH,消费者一条命令同时守候多个队列,比轮询简单得多。
还有一条纪律:不要把阻塞命令放进事务或 Lua 里执行。Lua 脚本执行期间 Redis 串行处理、阻塞其他命令,脚本内的阻塞命令会导致脚本卡死,Redis 7 对脚本内调用阻塞命令直接返回错误。需要"有消息就处理、没有就返回"的语义时,在脚本里用 LPOP 判空即可,超时等待交给客户端侧的循环。
127.0.0.1:6379> BLPOP mq 5
1) "mq"
2) "c"
返回值第一项是来源 key,第二项是元素。BRPOPLPUSH 已经被 LMOVE/BLMOVE(Redis 6.2+)取代,新代码不要再使用旧命令。
注意:阻塞的消费者会一直占着一条连接和对应线程,连接池大小必须按阻塞消费者数量预留,否则会出现连接耗尽、业务线程全部卡在 borrow 上的事故。
三、LMOVE 与安全队列模式
直接 LPOP 拿走的消息随即从内存消失,消费者进程一旦崩溃,这条消息就丢了。安全队列的思路是"取出即转移":
# 消费者:从待处理队列左端弹出,立刻放进"进行中"队列右端,30 秒内无消息则超时
BLMOVE queue:task queue:doing LEFT RIGHT 30
# 处理成功后从进行中队列删除
LREM queue:doing 1 <payload>
进程崩溃时消息还留在 queue:doing 里,由巡检任务定期把它搬回 queue:task 重试。这套模式的可靠性完全由业务自己实现:重试次数、死信、幂等都要自己管,Redis 的 List 队列没有消费者组与 ACK 机制——需要这些能力时应该换 Stream(见本系列 17 篇)或专业消息队列,而不是在 List 上硬造。
巡检任务的写法:定时扫描 queue:doing,对每条记录检查业务侧状态——已完成就 LREM 清掉,超时未完成就 LMOVE 搬回 queue:task 并给消息里的重试计数加一,超过上限的转入 queue:dead 人工处理。巡检频率按业务容忍的恢复延迟定,通常 10 秒到 1 分钟一次足够,注意扫描要配合 LLEN 限制单轮数量,避免 doing 队列本身膨胀成大 key。
四、LTRIM 与定长列表
LPUSH 加 LTRIM 的组合可以维护一个只保留最近 N 条的列表:
127.0.0.1:6379> LPUSH feed:1001 "msg-1024"
(integer) 1025
127.0.0.1:6379> LTRIM feed:1001 0 99
OK
LTRIM 只保留区间 [0, 99] 内的元素,其余裁掉。最近动态、操作日志、失败记录这类"只要最新一段"的场景都适用,两条命令用 pipeline 合并保证每次写入后列表都被裁剪。分页展示用 LRANGE 按固定窗口取,例如第 3 页是 LRANGE feed:1001 20 29;页深越大成本越高(O(S+N)),翻页深的产品应该改用游标(记下上一页最后一个下标)或直接限制最大页数。
LTRIM 的区间是闭区间且支持负索引:LTRIM feed:1001 -100 -1 只保留最后 100 条,适合 RPUSH 追加、展示最新记录的写法;正索引写法则配合 LPUSH"新的在前"。两种方向选一种并全团队统一,混用会让下标语义混乱,裁错方向的 LTRIM 会把列表裁得只剩几条,属于很难恢复的操作。
常见坑 / 最佳实践
- 阻塞弹出必须匹配连接池与线程池容量。 每个 BLPOP 独占一条连接,消费者线程数、连接池 max-active、Redis maxclients 三者要一起评估,避免互相拖死。
- 禁用长列表上的 LRANGE 0 -1。 一次拉全列表既占带宽又阻塞主线程,长度超过几千的列表改分批 LRANGE,配合 LLEN 判断总量。
- 定长列表必须 LPUSH 后紧跟 LTRIM。 漏掉 LTRIM 列表会无限增长成大 key;裁剪窗口大小要按展示需求定,不要拍脑袋设 10 万。
- LSET/LINDEX 是 O(N),不要把 List 当数组用。 随机读写场景换 Hash 或本地结构,List 只做先进先出和定长窗口。
- 纯 LPOP 的队列在消费者崩溃时丢消息。 关键任务链路必须配 BLMOVE 安全队列加巡检任务,并保证消费逻辑幂等。
小结
- List 的头尾操作是 O(1),LINDEX/LSET/LREM 是 O(N),只把它当队列和定长窗口用,不要当数组。
- BLPOP/BRPOP 只在空列表上阻塞,多客户端按先来先服务唤醒,多 key 按书写顺序构成优先级。
- BLMOVE 实现"取出即转移"的安全队列,配巡检任务兜底;需要消费者组与 ACK 时改用 Stream。
- LPUSH + LTRIM 维护定长列表,分页用固定窗口 LRANGE,翻页深度要有上限。
- 点赞
- 收藏
- 关注作者
评论(0)