如何设计延迟队列?

chinaz
chinaz 初级会员超兽战士
发布于 2026-10-08 11:30 ·2 浏览 ·0 回复

延迟队列的设计没有银弹,选型只取决于三个硬指标:延迟精度、日均消息量、是否需要持久化。结论先给:日千万级以内、精度到秒的场景,用 Redis ZSet 就够;要求高可靠、量级上亿、延迟精度秒级,选 RocketMQ 5.0 定时消息;纯内存、精度 100ms 级的高频短延迟(几秒内),用 Netty HashedWheelTimer 这类时间轮。千万别用 Redis 的键过期监听(keyspace notification)做延迟队列,它是"最多一次"语义,Redis 重启或主从切换就丢事件。

延迟队列和定时任务有什么区别?

延迟队列是"每个任务带自己的延迟时间、动态插入",定时任务是"固定周期批量触发",两者不可互换。延迟任务比如"下单 30 分钟未支付自动关单",每笔订单的到期时间都不同;定时任务比如"每天凌晨 2 点跑对账"。用 cron 每分钟扫全表去查"快到期"的任务,在千万行数据下即使有索引也会拖垮数据库,这就是延迟队列要解决的问题——把"轮询扫描"换成"按到期时间精确唤醒"。

用 Redis ZSet 怎么实现延迟队列?

核心是三件事:ZADD 写入、ZRANGEBYSCORE 取到期、Lua 脚本原子地"取出并删除"。具体命令如下:


ZADD delay_queue 1735689600000 "order:10086"
# 消费端:取当前时间之前到期的 100 条
ZRANGEBYSCORE delay_queue 0 1735689600000 LIMIT 0 100

注意点有三个。第一,必须用 Lua 脚本把 ZRANGEBYSCORE 和 ZREM 包在一起,因为多个消费者并发拉取会拿到同一条,靠 ZREM 的返回值判断是否"抢到"(返回 1 才处理,返回 0 说明别人拿走了)。第二,精度等于轮询间隔,消费者循环 sleep 100ms~1s,想要 100ms 精度就 sleep 100ms,代价是空轮询的 QPS。第三,消息必须同时落一份 DB,Redis 挂了内存数据就没了,重启后从 DB 扫未完成的任务重建 ZSet。内存占用参考:一条 member 约 100 字节,100 万条延迟任务约占 100MB,单实例扛得住。

RabbitMQ 的 TTL + 死信队列有什么坑?

最大的坑是队列级 TTL 会造成"队头阻塞":RabbitMQ 只在队头消息过期时才把它转入死信交换机,如果队头是一条 1 小时延迟的消息,后面 1 分钟延迟的消息就得排队等 1 小时。所以不要用 x-message-ttl 做延迟。正确做法是用 rabbitmq_delayed_message_exchange 插件,声明 exchange 时 type=x-delayed-message、x-delayed-type=direct,发送时在 header 里写 x-delay: 60000(单位毫秒)。这个插件内部用的也是时间轮,支持任意延迟时间,但它是内存存储,Broker 重启未投递的延迟消息会丢。

RocketMQ 的延迟消息怎么用?

RocketMQ 4.x 只支持 18 个固定延迟级别:1s、5s、10s、30s、1m、2m、3m、4m、5m、6m、7m、8m、9m、10m、20m、30m、1h、2h,通过 messageDelayLevel 配置。发消息时 msg.setDelayTimeLevel(3) 就是延迟 10 秒,想延迟 45 秒只能向上取到 1 分钟。RocketMQ 5.0 改用了时间轮 + TimerLog 落盘,支持秒级的任意延迟时间,setDelayTimeMs(45000) 直接生效,并且重启不丢。结论:如果你需要"任意秒级延迟 + 高可靠",RocketMQ 5.0 是当前开源方案里最省心的。

时间轮算法怎么实现?

时间轮的本质是一个环形数组加一个指针,指针每个 tick 走一格,走到哪格就执行那一格上的任务。Netty 的 HashedWheelTimer 默认 tickDuration=100ms、wheelSize=512,单圈覆盖 51.2 秒,超过一圈的任务靠 remainingRounds 轮次计数等待,精度固定 100ms。要提升精度就把 tickDuration 调小,代价是 CPU 空转变多。Kafka 的 SystemTimer 默认 tickMs=1ms、wheelSize=20,它用分层时间轮,高层轮子的一格等于低层轮子的一整圈,层级按需自动扩展,所以延迟上限不受限制。

时间轮只有一个硬伤:内存态、进程重启就丢。工业级做法是"时间轮 + WAL":任务先写日志或 DB,再进时间轮;启动时回放日志重建。Kafka 就是这么做的(TimerLog 分片刷盘)。

怎么保证延迟消息不丢不重?

结论:不丢靠"先落盘后入队 + 消费成功再删除",不重靠"至少一次投递 + 业务幂等",两者必须同时做。落盘用 DB 记录任务状态(待投递 / 已投递 / 已完成),消费者处理完成后才删 Redis 或 ack MQ;再加一个兜底定时任务,每 5 分钟扫一次"状态为待投递且已超期"的记录重新投递。

不重的实现方式是每个任务带唯一业务键,消费端用 SETNX task:10086 1 EX 86400 去重,返回 0 就直接 ack 跳过;或者建一张 t_message_dedup 去重表,唯一索引冲突就忽略。延迟队列是"至少一次"语义,任何声称"恰好一次"的方案,本质上都是在你这边做幂等。

总结

延迟队列选型看三点:精度要求、消息量级、能不能丢。秒级精度千万级量级用 Redis ZSet + Lua + DB 落盘;要任意延迟且高可靠用 RocketMQ 5.0 定时消息;几秒内的海量短延迟用时间轮,但必须配 WAL。无论选哪个,幂等去重和兜底重投都不能省——延迟队列的可靠性从来不是队列本身给的,而是业务侧补偿出来的。

版权声明:本文来自 GJ站长论坛《如何设计延迟队列?》
原文链接:https://www.gj0.com/thread-928.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。

全部回复 0

还没有回复,来抢沙发~