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

Redis ZSET 做延迟队列靠谱吗:score 时间窗、轮询频率和重复消费边界

来源:17golang原创

时间:2026-08-26 23:38:09 232浏览 收藏

订单延迟任务最容易被低估:把任务放进 Redis 只要一条命令,真正麻烦的是“什么时候算到期”“谁先取走”“取走后进程崩了怎么办”。用 Redis ZSET 做延迟队列是靠谱的轻量方案,但它不是带确认机制的完整消息队列。把 score 定义成到期时间,再配合短轮询、原子取出和业务幂等,边界就能说清楚。

要点速览
  • ZSET 的 score 存 Unix 时间戳或毫秒时间戳,成员保存唯一 task_id,不要把业务大 JSON 直接塞进成员。
  • 轮询窗口使用当前时间作为上界;轮询间隔决定额外延迟,批量大小决定单轮压力。
  • Redis 5.0 及以上可用 ZPOPMIN 原子取出最低分任务,取出成功不等于业务处理成功。
  • 取出、业务调用、确认不是一个原子事务,必须用 task_id、状态表或结果键抵抗重复消费。

先把 ZSET 延迟队列的职责划清楚

Redis Sorted Set 会按浮点 score 排序,同时保证 member 唯一。延迟队列可以把“到期时间”放进 score,把任务编号放进 member:

ZADD delay:orders 1766745600000 task:8f31

这里的数字只是示例时间戳。生产环境要统一使用毫秒还是秒,写入端和消费端不能各自理解。任务详情建议放在 Hash、数据库或业务表中,ZSET 只承担排队索引,这样重试和审计更好处理。

Redis ZSET delay:orders 按到期时间排序,worker 用轮询窗口取出待处理任务并对比轮询延迟指标

写入阶段:score 只表达什么时候可以取

延迟队列的入队流程通常分成两步:先保存任务正文,再写入 ZSET 索引。两步之间若允许短暂不一致,必须由补偿扫描发现;如果任务不能丢,建议把正文和排队记录放进同一个数据库事务,再由投递器补写 Redis。

-- 到期时间为毫秒时间戳
ZADD delay:orders 1766745600000 task:8f31
HSET task:8f31 status pending payload "..."

不要用订单金额、重试次数之类会变化的字段当 score。score 只负责排序,任务状态应该单独保存,否则一次重试改分数后,很难判断它到底是新任务还是旧任务。

消费阶段:轮询窗口决定额外延迟

最小可用的消费逻辑是:读取当前时间,把已经到期的任务取出来,然后交给工作线程处理。轮询每 1 秒一次,理论上额外等待时间落在 0 到 1 秒之间;但 Redis 繁忙、批量处理过大或工作线程排队都会继续放大延迟。

ZRANGE delay:orders 0 19 BYSCORE -inf 1766745600000 WITHSCORES

这个命令适合观察和诊断。真正消费时,不能先查询再稍后删除:两个消费者可能读到同一批成员。Redis 5.0 及以上可以用 ZPOPMIN 直接原子取出最低分成员;取出后还要检查它的 score 是否已经到期,避免把未来任务提前交给业务。

轮询间隔和批量大小怎么定

参数影响起步建议
轮询间隔越短越及时,也越频繁访问 Redis先从 500ms 至 1s 压测
单轮数量越大吞吐越高,但业务瞬时并发会上升从 20 或 50 开始
处理超时决定失败后多久进入重试按下游接口 SLA 设置

取出任务后崩溃,为什么一定要做幂等

ZPOPMIN 成功返回,只能说明任务从 ZSET 中移除了。假设 worker 刚取出 task:8f31,调用支付服务后还没写入本地成功状态就进程退出,这条任务不会自动回到 ZSET。反过来,如果业务已经成功、确认请求却超时,重试又可能造成重复业务调用。

比较稳妥的做法是给每个任务一个稳定的 task_id,在业务落库时建立唯一约束或幂等结果键:

SET task:result:8f31 success NX EX 86400

只有第一次写入成功的 worker 才继续执行“完成一次”的动作。对于扣库存、发券、发送通知这类操作,最好把幂等键落在业务数据库或下游系统,而不是只依赖 Redis 的短期键。

Redis ZSET 任务被 ZPOPMIN 取出后业务失败,经过 task_id 幂等校验进入重试并最终成功

失败处理:把丢失窗口和重复窗口分开

这套方案有两个不同风险。第一是“取出后不再排队”,需要在任务表里记录 processing 状态,并让超时任务重新进入 ZSET。第二是“业务成功但响应丢失”,需要幂等键或唯一业务号挡住重复执行。两个问题不能只靠增加轮询次数解决。

可以按下面的状态变化实现:

  1. 任务入库并写入 ZSET,状态为 pending
  2. worker 用 ZPOPMIN 取出后,把状态改为 processing,记录 claim 时间。
  3. 业务处理成功后写入结果并改为 done
  4. 定时补偿扫描超过处理超时仍为 processing 的任务,增加重试次数后重新入队。

补偿任务也要带上重试上限和退避时间。超过上限后进入人工可见的 dead-letter 状态,别让它无休止地回到 ZSET。

什么时候应该换成 Streams 或专用消息队列

如果任务只需要“到点触发、允许业务幂等、失败可以补偿”,ZSET 的结构简单、依赖少,很适合提醒、缓存刷新和低峰异步处理。若需要消费者组、消息确认、消费历史、积压可观察性或跨服务可靠投递,Redis Streams 或专用消息队列更合适。

判断标准不是“Redis 能不能做”,而是失败后谁负责找回任务、谁记录处理证据、谁接受重复。把这三个问题写进设计文档,方案通常就不会在上线后才暴露边界。

相关问题

轮询间隔设成 0 会不会更及时?

不会。忙等只会制造 Redis 请求压力,任务处理仍受线程池和下游耗时影响。应按可接受延迟设定间隔,再用批量大小控制吞吐。

ZPOPMIN 能保证业务一定只执行一次吗?

不能。它只保证从 ZSET 取出动作的原子性,业务调用和结果写入仍可能在中途失败,必须额外设计幂等。

同一个 task_id 重新 ZADD 会发生什么?

同名 member 仍是一个成员,新的 score 会覆盖旧排序位置。重试时应记录 attempt 和原因,不要把同一任务悄悄当成新任务。

落地前的检查清单

  • 确认 score 单位统一,并用 Redis TIME 或应用时钟策略处理时钟偏差。
  • 确认取出、状态更新、业务成功和补偿扫描都有可查日志。
  • 确认重复请求不会重复扣款、扣库存或发放权益。
  • 确认任务超过重试上限后能被发现,而不是静默消失。
声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>