登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

Redis LMOVE 怎么做可靠队列:processing list、LREM 与重复消费边界

来源:17golang原创

时间:2026-08-17 09:40:13 300浏览 收藏

用 Redis 列表搭轻量任务队列,最容易写出的版本是生产者 LPUSH queue task,消费者 RPOP queue。它在演示环境跑起来完全没问题,但消费者刚拿到任务就崩溃时,任务已经从队列里消失,后续没有任何地方能找回。

更稳妥的做法是把“取出任务”和“移入处理中状态”合并成一次原子搬运操作:用 LMOVE queue processing RIGHT LEFT,业务处理成功后再用 LREM processing 1 task 确认删除。这样就能把进程崩溃导致的丢任务问题,转变成“待恢复的处理中任务”场景,要付出的额外代价就是需要处理重复消费和超时回收逻辑。

要点速览

  • LMOVE 把出队和写入 processing list 合并成一次原子操作,BLMOVE 适合队列为空时阻塞等待的场景。
  • 确认删除的动作必须放在业务副作用执行成功之后,搭配唯一任务 ID 和幂等写入逻辑抵御重复消费。
  • 回收任务前必须先做租约校验或者处理时间判断,不能把还在运行的慢任务直接放回待处理队列。
  • Redis Cluster 场景下两个列表要满足多键命令的同槽约束,生产环境上线前建议先用 CLUSTER KEYSLOT 验证。

先把丢任务现场还原出来

假设待发送的通知任务都放在 notify:ready。消费者取出 task:order-1042 后,需要调用下游服务、记录发送结果,再把任务从列表里删掉。

LPUSH notify:ready task:order-1042
RPOP notify:ready
# 进程在调用下游服务前退出

第二条命令的返回值还没返回业务侧,进程就已经退出的话,重启消费者后,notify:ready 里根本找不到这条任务,业务只能依赖额外的数据库记录或者人工补单兜底。这个模型根本算不上“至少一次投递”,本质是“取出即可能丢”。

Redis LMOVE 将待处理队列原子搬入 processing list,并在消费者崩溃时保留任务线索

LMOVE 为什么能保留处理中任务

LMOVE source destination RIGHT LEFT 会从源列表的指定端弹出一个元素,同时把它推入目标列表的指定端。Redis 官方对这个动作的定义是原子操作,单次执行复杂度为 O(1)。消费者不再直接删除任务,而是先完成“待处理 → 处理中”的状态迁移。

LPUSH notify:ready task:order-1042
LMOVE notify:ready notify:processing RIGHT LEFT
# 返回 task:order-1042
LRANGE notify:processing 0 -1

如果消费者在调用支付通知接口前就意外退出,任务还完整留在 notify:processing。后续的恢复程序可以根据任务载荷里的时间戳、租约字段或者外部存储的状态判断要不要重试。这里要注意,“保留任务”只是 LMOVE 的原子搬运带来的特性,它不会自动帮你判断任务是否过期,也不会主动发起重试。

没有任务时用 BLMOVE

反复轮询 LLEN 再执行 LMOVE 不仅会增加网络往返次数,两个命令之间还天然存在竞态窗口。Redis 6.2 及以上版本可以直接用 BLMOVE notify:ready notify:processing RIGHT LEFT 5,队列为空时最多等待 5 秒,拿到元素后还是会完成同一次原子移动。超时后会返回空值,消费者收到空值应该切回心跳、优雅退出或者重新进入等待逻辑,不要把空值当成任务执行失败处理。

确认删除要晚于业务成功

处理函数的执行顺序必须遵守这个规则:先读取任务、校验任务 ID,再执行可重试的业务动作,最后才从 processing list 里删除对应任务。把 LREM 放在业务调用之前,相当于重新制造了“任务已确认但业务副作用没执行完”的丢任务窗口。

# task:order-1042 是完整任务载荷或可回查的唯一 ID
BLMOVE notify:ready notify:processing RIGHT LEFT 5

# 1. 以 task_id 做幂等判断
# 2. 调用下游通知服务
# 3. 记录成功状态
LREM notify:processing 1 task:order-1042

LREM key count element 按元素值删除列表成员。示例里的 1 表示只删除一个匹配项,避免同一任务因为异常重复入列时,一次就清掉所有副本。实际项目里最好让列表元素带上唯一任务 ID,把业务状态存在数据库或者专门的幂等存储里;列表本身并不适合充当完整的审计账本。

Redis processing list 中的 LREM 确认、超时回收和幂等边界

重复消费不是 LMOVE 的 bug

消费者执行完下游动作、还没来得及调用 LREM 就崩溃,任务还是会留在 processing list 里。恢复程序下次重新投递时,就会出现重复消费。这是“至少一次投递”模型的正常表现,不能靠把确认动作提前的错误方式来规避。

幂等键可以选 task_id,例如 order-1042:notify:paid。下游服务成功返回后先往数据库里写一条带唯一约束的完成记录,重复执行时发现这条记录已经存在,就直接跳过重复的副作用逻辑,再继续清理 processing list。如果下游接口支持自定义幂等请求头,也请把同一个任务 ID 贯穿到所有重试请求里。

回收前先判断租约

一个简易的 processing list 只会存任务内容,不会记录每个元素的独立领取时间。生产环境可以把任务领取记录存在 task:lease:{task_id},记录消费者标识、开始处理时间和租约截止时间;定时回收程序只处理明确超过租约时长的任务。没有这层判断的话,慢查询、网络抖动和正常的长任务都可能被误判为“僵尸任务”,导致同一条任务被并发处理。

# 伪代码:回收者必须先核对租约和业务状态
if lease_expired(task_id) and not business_succeeded(task_id):
    LPUSH notify:ready task_id
    LREM notify:processing 1 task_id

回收用到的两条命令不是原子动作,多个回收进程并发运行时,还需要加分布式锁、唯一回收标记或者 Lua/Functions 方案做保护。本文介绍的 LMOVE 方案只解决任务领取阶段的原子迁移问题,不要把它当成能实现 exactly-once 语义的完整队列组件。

上线前核对四个边界

  1. 空队列:BLMOVE 超时返回空值时,消费者是否只记录等待状态而不是写入空任务?
  2. 业务成功后崩溃:幂等记录是否比重试副作用先生效,LREM 是否配置为只删除一个任务副本?
  3. 慢任务回收:是否提前存好了租约截止时间,回收程序能不能正确区分正在运行的慢任务和僵尸任务?
  4. 集群路由:Redis Cluster 环境里 notify:{mail}:readynotify:{mail}:processing 是否使用了同一个 hash tag?上线前可以用 CLUSTER KEYSLOT 对比两者的槽位是否一致。

如果后续任务量、延迟指标和历史追踪需求进一步提升,Redis Streams 的消息 ID、Pending Entries List 和消费组语义会更适配。用列表实现的队列优势是命令少、延迟低,很容易嵌入已有 Redis 依赖的项目里;它的能力边界也很清晰:处理中列表、租约、幂等和回收逻辑都需要业务侧自行实现和维护。

常见问题

LMOVE 和 RPOPLPUSH 有什么区别?

LMOVE 是更通用的列表两端搬运命令,可以自由指定源端和目标端;它覆盖了 RPOPLPUSH 的所有常见用法,也更容易和 BLMOVE 配对使用。

BLMOVE 超时会删除任务吗?

不会。源列表为空且等待超时只会返回空值,不会在处理列表里生成任何元素。只有成功拿到元素的前提下,才会完成那次原子搬运。

为什么处理成功后还要保留幂等记录?

因为进程有可能在业务副作用执行成功后、执行 LREM 前意外退出。幂等记录能让下一次消费直接跳过重复的副作用逻辑,再安全清理掉 processing list 里的残留任务。

Redis Cluster 能直接搬运两个列表吗?

多键命令要求相关键满足集群的路由规则。把两个列表放在同一个 hash tag 下,并用 CLUSTER KEYSLOT 在部署前校验,不能只凭键名前缀就猜测它们会落在同一个槽位。

把确认和恢复写成可验收的契约

可靠列表队列的核心从来不是多调用了一条 Redis 命令,而是明确划分三种状态:待处理、处理中、业务已确认。LMOVE/BLMOVE 负责第一步的原子迁移,LREM 负责处理完成后的清理,幂等键和租约逻辑专门应对崩溃、重试与回收场景。只要每个状态都留好可观测的记录,轻量队列完全可以在不宣称自己是专业消息中间件的前提下稳定运行。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>