面试知识库
进阶

延迟消息实现#

一句话答案#

RocketMQ 原生支持 18 级延迟消息,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 触发),从队列头部开始读,比较消息的 deliverTimestamp 是否到期:
    • 到期 → 恢复原始 Topic/QueueId,回写到目标 Topic 的 CommitLog,建索引 → 消费者可见。
    • 未到期 → 队头都没到期,直接结束本轮(队列有序,后面更不会到),下次再扫。
  • 为什么只设 18 个固定级别?因为「按级别分队列」让每条队列内部时间有序,定时任务只看队头即可,避免对任意延迟做排序,实现极简。代价是不支持任意精度(5.0 时间轮方案才支持任意时间)。

时间轮算法(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 内置延迟级别(18 个固定级别)使用简单但不支持任意时间;时间轮(如 Kafka 的 TimingWheel)支持任意延迟且 O(1) 插入,但需要自行实现持久化
  • “延迟消息的精度能保证吗?”→ 不能保证精确到毫秒级,RocketMQ 定时线程每秒扫描一次,时间轮也有 tick 粒度限制;业务上一般容忍秒级误差,对精度要求极高的场景用定时任务框架更合适
  • “订单超时关闭用延迟消息还是定时扫表?”→ 延迟消息实时性好、不依赖数据库轮询,适合中小规模;定时扫表实现简单但有延迟且数据量大时性能差;大规模场景推荐 Redis+Lua 过期事件或 RocketMQ 延迟消息

易错点:

  • ❌ 只知道概念不知道原理——面试官会追问底层实现
  • ❌ 缺乏实际使用经验——结合项目场景回答更有说服力