延迟消息实现#
一句话答案#
RocketMQ 4.x 原生支持 18 级延迟消息,5.x 起支持任意时刻的定时消息(时间轮 + 定时存储);Kafka 无原生支持需自行实现(时间轮/Redis ZSET/DB 轮询)。
核心要点
RocketMQ 18 级延迟实现(4.x 经典方案):
- 18 个固定级别:
1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,发消息时设delayLevel(1~18)。 - Broker 收到延迟消息后偷梁换柱:备份原始 Topic/QueueId,把 Topic 改成内部的
SCHEDULE_TOPIC_XXXX,并按 delayLevel 分队列(queueId = delayLevel - 1,即每个级别一条独立队列,队列内消息到期时间天然递增)。 - 每个延迟队列有一个 定时任务(ScheduleMessageService,启动后首次延迟 1s,之后队头未到期时每 100ms 再检查一次),从队列头部开始读,比较消息的
deliverTimestamp是否到期:- 到期 → 恢复原始 Topic/QueueId,回写到目标 Topic 的 CommitLog,建索引 → 消费者可见。
- 未到期 → 队头都没到期,直接结束本轮(队列有序,后面更不会到),下次再扫。
- 为什么只设 18 个固定级别?因为「按级别分队列」让每条队列内部时间有序,定时任务只看队头即可,避免对任意延迟做排序,实现极简。代价是不支持任意精度(5.0 时间轮方案才支持任意时间)。
RocketMQ 5.x 定时消息: 发消息时直接设投递时间戳(如 setDeliveryTimestamp),Broker 用 TimerWheel + TimerLog 存储调度,默认精度 1s(timerPrecisionMs=1000);最大定时时长有上限(开源 Broker 配置 timerMaxDelaySec 默认 3 天,官方功能文档写默认 24 小时),不能无限远。4.x 的 delayLevel 写法仍兼容。
RabbitMQ: 常用 TTL + 死信交换机(DLX)实现延迟;官方的 rabbitmq-delayed-message-exchange 插件已于 2026-04 归档停止维护(依赖的 Mnesia 在 4.3 开发周期被移除,且不适合大量或跨天的延迟),新项目不要再依赖它。
时间轮算法(TimingWheel,Kafka/Netty 用):
- 结构:一个环形数组,每个格子是一个槽(bucket / slot),挂一条到期任务的链表;一根指针随时间
tick(每过一个时间格走一格)。 - 插入:任务延迟 / tickMs 算出落在哪个槽,O(1) 直接挂链表;删除任务也是 O(1)(链表摘除)。指针扫到某槽 → 触发该槽里所有到期任务。
- 相比「堆/优先队列」插入删除 O(log n),时间轮插入/删除/到期触发都是 O(1),适合海量短延迟定时任务(如超时检测、心跳)。
- 多层时间轮(处理大跨度延迟):单层轮的覆盖范围 = 槽数 × tickMs,超出范围的任务放到上一层粒度更粗的轮(类似时钟的「秒针、分针、时针」)。任务先在高层轮转,临近到期时降级(rehash)到低层轮再精确触发。Kafka 用「多层时间轮 + DelayQueue 推进指针」避免空转 tick 浪费 CPU。
通用方案: 时间轮(HashedWheelTimer) / 延迟队列(Redis ZSET) / DB 定时扫描
订单超时场景: RocketMQ 延迟消息 > Redis ZSET > 定时扫描
面试回答(2分钟版)
延迟消息是指消息发送后不立即投递给消费者,而是在指定时间后才可被消费,最典型的场景就是订单30分钟未支付自动取消。RocketMQ原生支持延迟消息,提供18个固定延迟级别从1秒到2小时,实现原理是消息先投递到内部的SCHEDULE_TOPIC_XXXX队列,由定时任务扫描到期的消息再转投到目标Topic。RocketMQ 5.0开始支持任意时刻的定时消息,默认秒级精度、有最大定时时长上限。Kafka没有原生延迟消息支持,需要自行实现,常见方案有三种:一是时间轮算法比如Netty的HashedWheelTimer,适合大量短延迟任务,内存开销小;二是Redis的ZSET用时间戳作为score,定时任务ZRANGEBYSCORE取出到期的消息,适合中等规模;三是数据库定时扫描,用一张延迟任务表定时轮询,简单可靠但性能一般。选择策略上,如果用RocketMQ就直接用原生延迟消息最省事;否则Redis ZSET是性价比最高的方案,兼顾了可靠性和性能。
追问与易错
追问方向:
- “RocketMQ 延迟消息和时间轮方案各自的优缺点?”→ RocketMQ 4.x 内置延迟级别(18 个固定级别)使用简单但不支持任意时间(5.x 已支持任意时刻);时间轮(如 Kafka 的 TimingWheel)支持任意延迟且 O(1) 插入,但需要自行实现持久化
- “延迟消息的精度能保证吗?”→ 不能保证精确到毫秒级,RocketMQ 5.x 定时消息默认精度 1s,时间轮也有 tick 粒度限制;业务上一般容忍秒级误差,对精度要求极高的场景用定时任务框架更合适
- “订单超时关闭用延迟消息还是定时扫表?”→ 延迟消息实时性好、不依赖数据库轮询,适合中小规模;定时扫表实现简单但有延迟且数据量大时性能差;大规模场景推荐 RocketMQ 延迟/定时消息或 Redis ZSET;不要依赖 Redis key 过期事件(Pub/Sub 不持久、过期删除本身有延迟,订阅方断线就丢事件),最好再配一个低频扫表做补偿
易错点:
- ❌ Redis key 过期事件能当可靠的延迟队列——过期事件走 Pub/Sub,订阅方断线期间的事件直接丢失;而且只在 key 真正被删除时才触发,惰性删除加定期删除会让触发时间明显滞后
- ❌ RocketMQ 4.x 能设任意延迟——4.x 只有 18 个固定级别(1s~2h),任意时间要用 5.x 定时消息
- ❌ RabbitMQ 用 TTL + 死信时每条消息设不同 TTL 就能任意延迟——队列只检查队头消息是否过期,前面一条 TTL 长的消息会挡住后面 TTL 短的;不同延迟要放进不同队列