返回文章列表

RocketMQ 第 10 章:延迟消息、定时消息与分布式任务调度

系统梳理 RocketMQ 4.x 固定 DelayLevel、5.x 投递时间戳模型、TimerMessageStore、时间轮、延迟精度、取消修改、订单超时关闭、任务调度选型和生产治理。

第 10 章:延迟消息、定时消息与分布式任务调度

版本基线:本章以 Apache RocketMQ Server 5.5.0 为当前实现基线,以 RocketMQ 4.9.8 说明经典固定延迟级别,并以官方 5.x gRPC Go SDK 的 SetDelayTimestamp API 演示。官方仓库在 2026 年 6 月列出的 Go SDK 为 golang/v5.1.4,该版本在 GitHub Release 页面标记为 Pre-release,生产使用时应结合组织的版本准入策略。

最重要的结论:延迟消息解决的是“到某个时间后触发一次消息投递”,不是硬实时定时器,更不是“到点保证业务完成”。

RocketMQ 延迟消息与定时消息速览总结图

本章去重边界与跳转

本章是延迟消息、定时消息、时间轮和任务调度选型的主讲章节。重复出现的幂等、事务、补偿和源码只在“定时触发”上下文中引用。

重复主题本章处理方式
至少一次、重复投递和消费幂等本章只讲延迟消息如何产生重复;通用可靠性闭环看 第 8 章:端到端消息可靠性
订单关闭、Outbox、状态机和业务补偿本章讲定时触发模式;业务架构设计看 第 19 章:复杂场景落地,事务消息看 第 11 章
4.x 与 5.x 延迟模型差异本章讲机制细节;整体版本演进看 第 17 章:4.x 到 5.x 架构演进
TimerMessageStore、TimerLog、TimerWheel 源码本章讲运行机制;源码调用链看 第 18 章:源码阅读
定时消息积压、同槽洪峰和监控本章讲设计风险;容量压测看 第 14 章,运维排障看 第 15 章

10.1 学习目标

学完本章,你应当能够:

  1. 区分延迟消息、定时消息、相对延迟和绝对投递时间。
  2. 解释 RocketMQ 4.x 固定 DelayLevel 的存储与重新投递流程。
  3. 解释 RocketMQ 5.x 基于时间戳、TimerMessageStoreTimerLog 和时间轮的实现。
  4. 判断延迟消息的精度边界、故障恢复行为以及可能产生的重复投递。
  5. 设计订单超时关闭、支付结果检查、延迟通知和失败重试方案。
  6. 正确回答“延迟消息是否保证准时”“延迟消息能否取消或修改”。
  7. 在 RocketMQ、数据库扫描、Redis ZSet 和自建调度器之间做技术选型。

10.2 场景导入:订单 30 分钟未支付自动关闭

用户创建订单后,订单系统写入一条状态为 WAIT_PAY、过期时间为 30 分钟后的记录。最直接的实现是每隔几秒扫描订单表,但订单量一大,数据库会持续承担范围扫描、排序和锁竞争。另一种思路是发送一条 30 分钟后到期的消息,由消费者在到期后检查订单。

注意这里的动词是检查,不是无条件关闭。

RocketMQ 第 10 章:延迟消息、定时消息与分布式任务调度 flow 1

为什么不能收到消息就执行 UPDATE orders SET status='CLOSED' WHERE id=??因为消息可能晚到、重复到,也可能与支付回调并发。订单状态必须由数据库中的当前事实和条件更新决定,而不能由一条历史消息单方面决定。


10.3 延迟消息与定时消息

10.3.1 基本定义

延迟消息是发送后不立即对消费者可见,而是在经过一段时间或到达指定时间后才进入可消费状态的消息。

定时消息强调在指定的绝对时间触发,例如“2026-06-20 18:00:00 投递”;延迟消息强调相对时间,例如“15 分钟后投递”。在 RocketMQ 5.x 的服务端模型中,两者最终都被转换为毫秒级 Unix 时间戳,因此本质上是同一种能力。[1]

10.3.2 相对延迟与绝对投递时间

表达方式示例优点主要风险
相对延迟当前时间后 30 分钟业务表达自然依赖生产者时钟;重试发送时必须避免重新延长 30 分钟
绝对时间expire_at=1781949600000可与数据库截止时间保持一致需要正确处理时区、时间同步和过期时间

在 5.x Go SDK 中,业务通常调用 time.Now().Add(30*time.Minute),但 SDK 发送给服务端的语义仍然是绝对投递时间。更稳妥的做法是先把订单的 expire_at 持久化,再将同一个值设置为消息投递时间,避免数据库和消息各算一次导致偏差。

10.3.3 两个必须直接回答的问题

延迟消息是否保证准时?

不保证。 RocketMQ 提供的是持久化、可扩展的到期触发能力,不是硬实时调度。到达设定时间只表示消息具备进入普通存储并被投递的条件。时间轮粒度、Broker 扫描和重新写入、磁盘与线程池负载、网络、消费者积压及业务处理时间都会形成额外延迟。故障恢复后通常会补投,但可能晚于目标时间。

RocketMQ 4.x 与 5.x 延迟消息有什么区别?

4.x 主要通过固定 DelayLevel 表达相对延迟,默认只有 18 个离散档位,服务端使用调度主题和按延迟级别划分的队列重新投递;5.x 面向用户提供毫秒 Unix 投递时间戳,使用 TimerMessageStoreTimerLog 和时间轮管理到期任务,可表达任意允许范围内的时间点。两者的 API、存储索引和调度模型均不同,不能混为一谈。


10.4 RocketMQ 4.x:固定 DelayLevel 模型

10.4.1 默认 18 个延迟级别

RocketMQ 4.x 官方默认提供以下 18 个级别:[2]

Level延迟Level延迟Level延迟
11 秒73 分钟139 分钟
25 秒84 分钟1410 分钟
310 秒95 分钟1520 分钟
430 秒106 分钟1630 分钟
51 分钟117 分钟171 小时
62 分钟128 分钟182 小时

客户端设置的是级别编号,而不是任意时间戳。例如 Level 3 表示 10 秒。Broker 配置项 messageDelayLevel 的默认值也对应这 18 个档位。开源 Broker 虽可调整该配置,但这仍然是“离散级别模型”,而不是 5.x 的任意时间戳模型;修改后还会增加跨环境、运维工具和客户端认知不一致的风险。

10.4.2 经典实现流程

4.x Broker 接收延迟消息后,不会让消息直接进入业务 Topic 的可消费队列,而是把真实 Topic、真实 QueueId 写入属性,将消息转入内部调度主题。一个延迟级别对应内部主题中的一个队列。ScheduleMessageService 为各级别推进消费位置,计算剩余时间;到期后恢复真实 Topic 和 QueueId,再次写入普通消息存储。

RocketMQ 第 10 章:延迟消息、定时消息与分布式任务调度 flow 2

从源码阅读角度,应关注 ScheduleMessageService 的三个位置:

  1. parseDelayLevel:解析 1s 5s 10s ... 2h,建立级别到毫秒数的映射。
  2. 定时投递任务:从内部调度队列读取消息物理位置,计算 deliverTimestamp-now
  3. messageTimeUp:重建消息,恢复业务 Topic 和 QueueId,再调用消息存储写入。

10.4.3 4.x 模型的限制

第一,无法原生表达“37 秒后”或“今天 18:03:27 投递”,只能选择最接近的档位。第二,默认最大档位是 2 小时,长周期任务不合适。第三,同一级别的消息集中在同一类调度队列中,热点级别或同一时刻的大批量任务会造成追赶延迟。第四,消息到期后还需要重新写入普通存储,因此“级别时间到达”与“消费者收到”之间仍有链路延迟。第五,已发送消息没有通用的原地修改和取消 API,业务通常只能通过状态表、任务版本或取消标记让消费者忽略旧任务。

因此,4.x 固定级别适合“几秒、几分钟、几小时后触发”的有限场景,但不适合通用分布式调度平台。


10.5 RocketMQ 5.x:投递时间戳模型

10.5.1 用户侧模型

5.x 延迟消息以毫秒级 Unix 时间戳表示投递时间。官方文档规定:时间戳必须晚于当前时间,并处于允许范围内;过早或超出范围的时间可能不生效并被立即投递。官方文档给出的默认最大范围是 24 小时,默认时间粒度是 1000 毫秒,延迟消息必须发送到消息类型为 Delay 的 Topic。[1]

这意味着下面三个概念不能混淆:

  • API 时间戳精度:字段能表达毫秒时间戳。
  • 默认调度粒度:官方文档为 1000 毫秒。
  • 业务端到端精度:还要叠加服务端和消费者链路延迟,远大于字段精度。

生产环境应把官方文档的 24 小时作为可移植、可支持的外部约束。5.5.0 源码中虽然存在 timerMaxDelaySec 等可配置实现参数,但 Proxy、SDK、运维策略和后续版本可能具有不同校验,不能仅凭修改 Broker 参数就把内部实现当成稳定公共契约。超过官方范围的任务,宜由数据库任务表或专业调度系统长期保存,在进入近期限窗口后再投递到 RocketMQ。

10.5.2 生命周期

RocketMQ 第 10 章:延迟消息、定时消息与分布式任务调度 flow 3

Timing 阶段与普通消息不同:消息尚未进入面向业务消费者的普通可见状态。到期后进入 Ready,才开始服从普通消息的拉取、消费、重试和积压语义。因而“定时阶段的延迟”和“消费者阶段的延迟”必须分别观察。


10.6 时间轮与 TimerMessageStore 源码模型

10.6.1 为什么需要时间轮

假设 Broker 保存一亿个定时任务。如果每秒对全部任务排序或扫描,成本不可接受。时间轮把时间轴切成固定大小的槽位,例如每 1 秒一个槽。任务根据到期时间映射到槽位,指针每次只推进一个槽,处理当前到期桶中的任务,时间复杂度更接近“当前到期任务数”,而不是“全部任务数”。

时间轮不是消息正文仓库,更像按时间组织的索引。RocketMQ 5.5.0 的 TimerWheel 使用映射文件保存槽位信息,槽位记录时间、首尾索引位置和数量;TimerLog 记录定时索引项,包括原消息物理位置、大小、前驱位置、到期时间和标记等;消息正文仍可通过物理位置读取。TimerCheckpoint 保存推进进度,供重启恢复。[4][5]

10.6.2 5.5.0 的主要组件

组件职责
TimerMessageStore定时消息总控,组织入轮、出轮、恢复和重新投递
TimerWheel将时间映射为槽位,保存槽位首尾索引与任务数量
TimerLog顺序记录定时索引项及链式关系
TimerCheckpoint持久化读时间、队列位置和刷盘进度
TimerEnqueueGetService从内部定时主题对应的消费队列读取待入轮消息
TimerEnqueuePutService将索引项追加到 TimerLog 并更新槽位
TimerDequeueGetService随时间推进并取出到期槽位
TimerDequeueGetMessageService根据物理位置读取原消息
TimerDequeuePutMessageService恢复业务属性并重新写入普通消息存储
TimerFlushService刷新时间轮、日志和检查点相关状态

10.6.3 5.x 不是 DelayLevel 扫描,而是时间戳驱动的内部链路

RocketMQ 第 10 章:延迟消息、定时消息与分布式任务调度 flow 4

可以把 RocketMQ 5.x 定时消息理解成一条明确的内部流水线:

  1. Producer 给消息设置未来投递时间戳。
  2. Broker 接收后不让它进入业务 Topic 的普通可见队列。
  3. Broker 将消息写入内部定时 Topic,并保留真实 Topic、Queue、投递时间等属性。
  4. TimerMessageStore 从内部定时 Topic 的 ConsumeQueue 读取待入轮消息。
  5. 入轮线程把消息物理位置、大小、到期时间写入 TimerLog,并更新 TimerWheel 槽位。
  6. 出轮线程按时间粒度推进,读取当前槽位中的 TimerLog 链式索引。
  7. Broker 根据索引回 CommitLog 读取原消息,恢复真实 Topic 和 Queue。
  8. 到期消息重新写入普通消息存储,构建普通 ConsumeQueue 后,Consumer 才能看到。

一句话说:5.x 不是固定 DelayLevel 轮询,而是“投递时间戳 → 内部定时 Topic → TimerLog 索引 → TimerWheel 分桶 → 到期出轮 → 恢复普通消息 → Consumer 消费”。

10.6.4 Producer 到 Broker:先持久化,但不进入业务可见队列

用户侧设置的是毫秒级 Unix 投递时间戳。Go SDK 中常见写法是:

msg.SetDelayTimestamp(expireAt)

这里的 expireAt 最好来自订单表中的 expire_at,而不是发送时临时再算一次。Broker 接收后,如果直接把消息写入业务 Topic 的普通 ConsumeQueue,Consumer 会立刻拉到,那就不是延迟消息。因此 5.x 会先把它转入内部定时体系。

源码层面,TimerMessageStore 定义内部定时 Topic,可理解为 RMQ_SYS_WHEEL_TIMER 这类系统 Topic。消息属性中会保存真实 Topic、真实 Queue、定时到期时间等信息,例如 TIMER_OUT_MSTIMER_ENQUEUE_MSTIMER_DEQUEUE_MS 这类定时链路属性。[4]

用订单超时检查来类比:

业务消息:
  Topic = OrderTimeoutCheck
  deliveryTimestamp = 10:30:00

Broker 内部定时消息:
  Topic = RMQ_SYS_WHEEL_TIMER
  属性保存真实 Topic / Queue / TIMER_OUT_MS
  消息正文仍然写入 CommitLog

这一步的核心不是“放内存等 30 分钟”,而是先持久化,再通过内部定时索引控制何时恢复为普通消息

10.6.5 入轮:从内部 ConsumeQueue 读出待调度任务

内部定时 Topic 写入后,会形成对应的 CommitLog 记录和内部 ConsumeQueue。此时消息已经持久化,但还没有成为时间轮中的定时任务。

TimerEnqueueGetService 会从内部定时 Topic 的 ConsumeQueue 按 offset 顺序读取 CqUnit,拿到原消息在 CommitLog 中的物理位置和大小,再回 CommitLog 读取消息属性,解析 TIMER_OUT_MS,封装成 TimerRequest 后交给入轮写线程。

这个阶段可以拆成:

内部定时 Topic 的 ConsumeQueue

读取 offsetPy / sizePy

回 CommitLog 读取原消息

解析 TIMER_OUT_MS

生成 TimerRequest

进入 TimerEnqueuePutService

这里要注意:内部 ConsumeQueue 只是“待入轮队列”,真正按时间组织任务的是后面的 TimerLog + TimerWheel

10.6.6 TimerLog:保存到期索引,不保存完整消息体

TimerEnqueuePutService 取得 TimerRequest 后,会执行类似 doEnqueue 的逻辑:根据目标投递时间找到时间轮槽位,把一条定时索引追加到 TimerLog,再更新槽位的首尾位置和数量。[4]

TimerLog 不是完整消息仓库,更像 append-only 的定时索引日志。它记录的重点是:

字段含义
prevPos当前槽位上一条 TimerLog 索引位置
magic普通任务、远期滚动、删除/召回等标记
currWriteTime写入定时索引时的时间
delayTime距离目标投递时间的延迟
offsetPy原消息在 CommitLog 中的物理 offset
sizePy原消息大小
realTopic hash真实 Topic 的哈希,用于统计或定位

因此,定时链路的数据分工是:

CommitLog:保存完整消息正文和属性
TimerLog:保存定时索引,指向 CommitLog 物理位置
TimerWheel:按时间槽保存 TimerLog 首尾位置和数量

也就是说,TimerLog 告诉 Broker“某个时间槽里有哪些消息该被取出来”,但真正的消息正文仍要通过 offsetPy / sizePy 回 CommitLog 读取。

10.6.7 TimerWheel:槽位不是数组,而是 TimerLog 链

时间轮的作用是把大量定时任务按时间分桶。假设 timerPrecisionMs = 1000,时间轴就可以按秒切成槽位。到期时间为 10:30:00 的任务,会映射到 10:30:00 对应的槽。

源码中 TimerWheel.getSlot(timeMs) 会按时间戳和精度计算槽位索引,类似:

slotIndex = timeMs / precisionMs % slots

一个槽位里可能有很多消息,RocketMQ 不是把这些消息正文塞进槽位,而是在槽位里保存 firstPoslastPos 和数量,指向 TimerLog 链。TimerLog 每条记录有 prevPos,新任务进入同一槽位时,会把旧的 slot.lastPos 写入新记录的 prevPos,再把 slot.lastPos 更新成新记录位置。[4][5]

结构近似为:

TimerWheel Slot
  firstPos / lastPos / num

TimerLog B
      ↓ prevPos
TimerLog A
      ↓ prevPos
TimerLog ...

这正是时间轮能避免全量扫描的原因:Broker 不需要每秒扫描所有定时消息,只需要推进到当前时间槽,再沿着该槽的 TimerLog 链读取本槽任务。

10.6.8 出轮:到期后恢复成普通消息,而不是直接调用 Consumer

出轮线程会按 timerPrecisionMs 推进读时间。当前读时间到达某个槽位后,TimerDequeueGetService 读取槽位信息,从 slot.lastPos 沿 prevPos 链读取 TimerLog 索引。随后 TimerDequeueGetMessageService 根据 offsetPy / sizePy 回 CommitLog 读取原消息。

读取出来后,还要看 magic 标记:

标记含义
MAGIC_DEFAULT普通到期任务,进入恢复投递流程
MAGIC_ROLL远期任务滚动记录,后续重新入轮
MAGIC_DELETE删除或 Recall 记录,用于让旧任务失效

普通到期任务不会被 Broker 直接“执行”,也不会直接回调 Consumer。Broker 会恢复真实 Topic、Queue 和业务属性,清理或转换定时属性,再把消息重新写入普通 CommitLog。普通 ConsumeQueue 构建完成后,Consumer 才能像消费普通消息一样拉取或接收它。

所以,5.x 定时消息的关键语义是:

未到期时,消息在 Broker 的定时存储中,业务 Consumer 不可见;到期后,它被恢复成普通消息,再进入普通投递、消费、ACK、重试和 DLQ 语义。

这也解释了为什么“到期”不等于“业务已完成”。到期只表示它具备转入普通投递链路的条件。

10.6.9 远期滚动、删除标记与重启恢复

5.5.0 源码中的几个默认值值得用于理解实现,但不应直接当作外部 SLA:[4][6]

源码项5.5.0 默认值/常量含义
timerPrecisionMs1000 ms时间轮推进精度
timerRollWindowSlot172800 个槽默认约 2 天滚动窗口
timerMaxDelaySec259200 秒Broker 内部默认最大延迟约 3 天
TIMER_WHEEL_TTL_DAY7 天时间轮固定槽位跨度相关常量

为什么存在“官方文档 24 小时”和“源码默认 3 天”的差异?前者是用户侧支持边界,后者是某一服务端版本的内部配置。完整链路还包含客户端、Proxy 和运维约束。架构设计时以官方文档和实测兼容矩阵为准,源码参数用于理解实现与排障,而不是擅自扩大业务承诺。

对于超过滚动窗口的远期任务,源码会将其先放到窗口边缘,后续再滚动重新入轮。这样可以避免时间轮无限增大,并刷新消息所在的存储位置。但自定义超长延迟会同时放大磁盘保留、恢复耗时、版本升级和极端停机风险,因此不建议把 RocketMQ 当作数月、数年的日历数据库。

MAGIC_DELETE 则对应取消或召回场景的底层思想:不是在原物理消息上“抹掉一行”,而是写入删除标记,让出轮链路识别旧任务已经失效。也正因为这种实现不是用户业务事务的一部分,消费者仍要以任务状态和版本号兜底。

Broker 重启时,定时任务也不是从内存 Timer 里恢复。TimerMessageStore 会结合 TimerLogTimerWheelTimerCheckpoint、内部 ConsumeQueue 和 CommitLog 修订进度。正常重启不应把定时任务当作内存任务丢失,但停机期间已经到期的任务只能在恢复后补投,补投本身又可能形成短时洪峰。[4]

面试或复盘时可以这样表达:

RocketMQ 5.x 的延迟/定时消息使用“投递时间戳 + 时间轮定时存储”模型。Producer 设置 delivery timestamp;Broker 先写内部定时 Topic;TimerMessageStore 读取内部 ConsumeQueue,解析 TIMER_OUT_MS,把 CommitLog offset、size、到期时间写入 TimerLog,并更新 TimerWheel 槽位。时间轮推进到目标槽后,Broker 沿 TimerLog.prevPos 链取出索引,回 CommitLog 读取原消息,恢复真实 Topic/Queue,重新写入普通存储。此后 Consumer 才能看到它。它不是硬实时,也不是 Exactly-once,业务仍要做幂等、状态校验和补偿。


10.7 到期不等于准时,更不等于业务完成

设业务期望时间为 T,实际业务完成时间可以近似分解为:

完成时间 = T + 时间槽误差 + 到期扫描积压 + 重新写入延迟 + 消息分发延迟 + 消费积压 + 网络延迟 + 业务执行时间

RocketMQ 第 10 章:延迟消息、定时消息与分布式任务调度 flow 5

因此,应至少区分三类指标:

  1. 到期转 Ready 延迟:普通消息重新写入时间减目标投递时间,反映 Broker 定时链路。
  2. Ready 到消费开始延迟:反映 Topic、ConsumerGroup 和客户端积压。
  3. 消费开始到业务提交延迟:反映下游数据库、RPC 和业务线程池。

10.7.1 精度

默认 1000 毫秒粒度不意味着每条消息都能在误差 1 秒内被消费者处理。粒度只是时间桶尺度。大量任务落入同一桶、Broker 磁盘繁忙、出轮线程赶不上或业务 Topic 积压时,尾延迟可能明显放大。

10.7.2 时钟漂移

5.x 使用绝对时间戳,生产者和 Broker 的时钟偏差会直接改变实际延迟。生产者时钟过快会把时间设得更远;过慢可能让时间戳接近甚至早于 Broker 当前时间,从而立即投递。Broker 时钟大幅前跳可能让一批消息同时到期;回拨则可能使到期判断停滞或恢复后集中追赶。所有节点应使用 NTP/chrony,并监控时钟偏差和时间同步状态。

10.7.3 Broker 重启

TimerLogTimerWheelTimerCheckpoint 都有持久化与恢复流程。5.5.0 启动时会加载定时日志、修订时间轮、校正内部队列位置和读时间。因此正常重启不应把所有定时状态当作内存任务丢失,但停机期间已经到期的消息只能在服务恢复后补投,恢复扫描又会增加迟延和瞬时负载。[4]

10.7.4 消费积压

即使 Broker 在 T 时刻及时把消息转为 Ready,消费者仍可能因实例不足、处理缓慢或下游故障而几分钟后才执行。面试中把“定时投递准时”和“业务执行准时”画等号,是典型错误。


10.8 延迟消息能否取消或修改

10.8.1 4.x

经典固定级别模型没有面向业务的通用原地取消、修改接口。常用方案是:任务表记录 ACTIVE/CANCELED/DONE,消息只携带任务 ID;消费者收到后查询状态,已取消则直接成功返回。需要修改执行时间时,更新任务版本并发送新消息,旧消息因版本过期而被忽略。

10.8.2 5.5.0 的真实开源能力

RocketMQ 5.5.0 服务端包含 RecallMessageProcessor,官方 Go SDK golang/v5.1.4 的发布说明和示例也包含 Producer.Recall。发送回执中可返回 RecallHandle,客户端在消息到期前使用该句柄发起召回。服务端并不是直接删除原物理记录,而是写入带删除唯一键和原投递时间的召回记录,由 TimerMessageStore 在时间线中识别删除标记。[7][8][10]

但必须同时看到限制:

  1. 5.5.0 Broker 的 recallMessageEnable 默认是 false,需要明确开启并核对 Proxy、客户端与 Broker 的兼容性。
  2. 消息已经到期或已进入普通可见状态后,召回可能失败或已经来不及阻止消费。
  3. 召回依赖 RecallHandle,句柄丢失、Topic 不匹配或 Broker 不匹配都会失败。
  4. “修改投递时间”不是原地更新。通常是召回旧消息,再发送新消息。
  5. 召回与重发不是一个原子事务:可能旧消息召回成功而新消息发送失败,也可能新消息发送成功但旧消息召回超时。
  6. 即使召回接口返回失败,也不能据此推断原消息一定会被消费;网络超时可能导致结果未知。

所以,召回是降低无效投递的优化能力,不应成为业务正确性的唯一保障。消费者仍要检查任务状态、版本号和业务事实。


10.9 典型业务场景

10.9.1 订单超时关闭

消息到期后查询订单,只有满足“当前状态仍为待支付、数据库认定已过期、任务版本仍有效”三个条件,才能执行条件关闭。支付回调也必须使用条件更新,让 WAIT_PAY → PAIDWAIT_PAY → CLOSED 在数据库中竞争,最终只有一个状态迁移成功。

10.9.2 支付结果检查

支付请求返回“处理中”时,可发送 10 秒、30 秒或 2 分钟后的查询任务。消费者调用支付渠道查询接口,确认成功后推进订单;仍处理中则按有上限的退避策略重新发送。不能无限重试,也不能把“渠道查询失败”当作“支付失败”。

10.9.3 延迟通知

例如注册后 10 分钟仍未完成实名认证,则发送提醒。通知类业务通常允许较宽时间窗口,可对到期时间增加小幅随机抖动,避免整点海量消息同时触发。发送前仍应检查用户是否已完成操作,避免过时通知。

10.9.4 失败重试

业务失败重试可以使用延迟消息,但应与 RocketMQ 自身的消费重试区分。中间件重试解决“本次消费未成功确认”;业务重试解决“明确记录了一次外部任务,计划稍后再次执行”。后者应携带 attemptnext_at、业务幂等键和最大次数,并采用指数退避加随机抖动,超过上限后进入人工处理或补偿流程。


10.10 Go 实战:订单超时检查

下面使用 5.x gRPC Go SDK。示例重点是时间戳、幂等标识和条件关闭,不展开工程目录。

10.10.1 发送延迟消息

package ordertimeout

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "time"

    rmq "github.com/apache/rocketmq-clients/golang/v5"
)

type TimeoutEvent struct {
    EventID        string    `json:"event_id"`
    OrderID        string    `json:"order_id"`
    ExpectedVersion int64    `json:"expected_version"`
    ExpireAt       time.Time `json:"expire_at"`
    Attempt        int       `json:"attempt"`
}

func SendTimeoutEvent(
    ctx context.Context,
    producer rmq.Producer,
    topic string,
    event TimeoutEvent,
) error {
    if producer == nil {
        return errors.New("producer is nil")
    }
    if topic == "" || event.EventID == "" || event.OrderID == "" {
        return errors.New("topic, event_id and order_id are required")
    }

    now := time.Now()
    if !event.ExpireAt.After(now) {
        return fmt.Errorf("expire_at must be in the future: %s", event.ExpireAt)
    }

    // 以官方文档的 24 小时范围作为可移植约束。
    if event.ExpireAt.Sub(now) > 24*time.Hour {
        return fmt.Errorf("delay exceeds documented 24-hour range")
    }

    body, err := json.Marshal(event)
    if err != nil {
        return fmt.Errorf("marshal timeout event: %w", err)
    }

    msg := &rmq.Message{
        Topic: topic,
        Body:  body,
    }
    msg.SetTag("ORDER_TIMEOUT_CHECK")
    msg.SetKeys(event.OrderID, event.EventID)
    msg.SetDelayTimestamp(event.ExpireAt)

    sendCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    receipts, err := producer.Send(sendCtx, msg)
    if err != nil {
        // 超时不代表 Broker 一定未接收。调用方应按 EventID 做发送去重或补偿核对。
        return fmt.Errorf("send timeout event: %w", err)
    }
    if len(receipts) == 0 {
        return errors.New("empty send receipt")
    }
    return nil
}

生产者初始化方式与普通 5.x Producer 相同:配置 Proxy Endpoint,使用 rmq.NewProducer 创建单例 Producer,调用 Start,退出时执行 GracefulStop。延迟 Topic 应提前创建为 Delay 类型,而不是依赖线上自动创建。[1][9]

10.10.2 消费端必须做条件状态迁移

package ordertimeout

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "time"
)

type CloseResult int

const (
    CloseNoop CloseResult = iota
    CloseSucceeded
    CloseTooEarly
)

type OrderStore interface {
    // 实现必须在数据库中原子判断:
    // status = WAIT_PAY、expire_at <= 数据库当前时间、version = expectedVersion。
    // 只有同时满足条件时才更新为 CLOSED,并递增 version。
    CloseIfUnpaidAndExpired(
        ctx context.Context,
        orderID string,
        expectedVersion int64,
    ) (CloseResult, error)
}

type TimeoutHandler struct {
    Store OrderStore
    Now   func() time.Time
}

func (h TimeoutHandler) Handle(ctx context.Context, body []byte) error {
    if h.Store == nil {
        return errors.New("order store is nil")
    }

    var event TimeoutEvent
    if err := json.Unmarshal(body, &event); err != nil {
        // 格式错误通常不可重试,应记录后转入人工或隔离流程。
        return fmt.Errorf("decode timeout event: %w", err)
    }
    if event.OrderID == "" || event.EventID == "" {
        return errors.New("invalid timeout event")
    }

    result, err := h.Store.CloseIfUnpaidAndExpired(
        ctx,
        event.OrderID,
        event.ExpectedVersion,
    )
    if err != nil {
        // 临时数据库错误返回失败,由消费重试机制再次投递。
        return fmt.Errorf("close order conditionally: %w", err)
    }

    switch result {
    case CloseSucceeded:
        // 后续库存释放、优惠券返还等操作也要幂等,宜通过事件或 Outbox 驱动。
        return nil
    case CloseNoop:
        // 已支付、已关闭或版本已变化,旧任务已经失效,按成功处理。
        return nil
    case CloseTooEarly:
        // 可能由时钟偏差或数据变更引起。应依据数据库 expire_at 重新发送检查任务,
        // 而不是在消费者线程中 Sleep。
        return nil
    default:
        return fmt.Errorf("unknown close result: %d", result)
    }
}

真正的数据库实现应使用一条带状态、过期条件和版本条件的原子更新,判断受影响行数,而不是先查询后无条件更新。使用数据库当前时间可以减少应用节点时钟漂移影响。即使同一事件被重复投递,第一次成功关闭后,后续更新因状态或版本不匹配而返回 CloseNoop

10.10.3 发送与订单事务之间仍有空窗

如果订单已经提交但延迟消息发送失败,订单可能永远没有检查任务;如果发送超时后盲目重发,又可能产生多条消息。可采用以下组合:

  • 订单事务同时写入 Outbox/任务表,由可靠发布器发送延迟消息。
  • 使用 EventID 作为幂等键,发布器允许至少一次发送。
  • 保留低频补偿扫描,查找已过期但仍为 WAIT_PAY 的订单。
  • 监控“即将到期任务数”和“已过期未关闭订单数”,而不只监控 MQ 发送成功率。

延迟消息降低了全表高频扫描压力,但不应删除最终兜底扫描。


10.11 RocketMQ、数据库扫描、Redis ZSet 与自建时间轮对比

维度RocketMQ 延迟消息数据库扫描Redis ZSet自建时间轮调度器
调度模型到期后转为消息投递周期查询到期行按 score 查询到期成员槽位推进并执行任务
持久化Broker 持久化并支持恢复数据库天然持久化取决于 AOF/RDB 与主从策略必须自行实现日志、快照和恢复
水平扩展Broker、Topic、消费者可扩展分片扫描与抢锁较复杂需分片、主从和领取协议分片、再平衡均需自研
精度默认秒级调度粒度,端到端非实时取决于扫描周期可做较细粒度轮询由 tick 决定
对主库压力低,消费时按主键检查可能持续范围扫描和锁竞争低于数据库,但增加 Redis 压力取决于任务存储
取消/修改4.x 弱;5.5.0 可选召回但有条件更新任务行最直接ZREM/改 score 较方便需自行维护句柄与并发一致性
重复与领取至少一次语义,业务幂等需设计状态和抢锁需解决原子领取、宕机归还全部自行处理
消费生态天然接入 MQ 重试、积压、监控还需任务执行队列还需工作队列或执行器还需完整执行平台
适合场景高并发近期限触发、异步业务任务量小、可审计、易修改需要频繁取消/改期的中等任务单进程或特定低延迟系统
主要风险到期洪峰、积压、版本边界数据库压力、扫描空耗数据丢失策略、热 Key、领取竞态研发和运维成本极高

选型不能只问“哪个更快”。任务是否需要频繁修改、保存多久、是否允许重复、是否要求审计、执行端是否天然使用 MQ,往往比单点吞吐更重要。

一个常见的混合方案是:数据库保存权威任务状态和远期任务;Redis 或数据库负责可取消、可改期的计划;任务进入未来 24 小时窗口后再发送 RocketMQ 延迟消息;RocketMQ 负责高并发到期触发和消费者扩展。


10.12 大量相同到期时间:定时消息的“惊群”

电商活动在 20:00 结束,如果一千万条消息都设置为 20:00:00,它们会落入相同或相邻时间槽。到期时 Broker 需要同时出轮、读取消息并重新写入普通存储,业务消费者和下游数据库也会瞬间承压。官方文档明确不建议给大量消息设置完全相同的投递时间。[1]

治理方法包括:

  1. 允许窗口的业务增加抖动:通知、缓存刷新、非关键重试可在数十秒窗口内随机分散。
  2. 按业务键分片 Topic 或队列:避免所有任务集中到单一 Broker,但不要为了分片制造无穷 Topic。
  3. 消费端限流与批处理:限制数据库并发,用批量查询或批量状态更新减少连接风暴。
  4. 提前容量评估:估算峰值到期 QPS,而不是只看全天平均发送 TPS。
  5. 分级降级:先处理支付、订单等核心任务,营销通知允许延后。
  6. 避免同步扇出:消费者不要收到一条消息后立即同步调用十个下游,可继续拆分事件并设置隔离线程池。

对于“必须整点生效”的规则,通常不需要为每个对象发送一条整点消息。更好的做法可能是先切换一条全局活动状态,再由请求路径读取状态;逐对象任务只承担确实需要逐项处理的工作。


10.13 重复投递与幂等

延迟消息仍遵循消息系统的至少一次现实:发送超时后重发、Broker 到期重新写入时的异常、消费者处理成功但 ACK 丢失、消费超时和重试,都可能导致重复。

幂等设计至少包含三层:

  • 消息层EventID 唯一,记录已处理事件或依赖业务状态天然去重。
  • 状态机层:只允许合法状态迁移,例如 WAIT_PAY → CLOSED,不能从 PAID → CLOSED
  • 副作用层:库存释放、退款、优惠券返还、通知发送各自使用业务幂等键,不能因为订单更新幂等就假定所有下游也幂等。

不要只用 Redis SETNX event_id 就宣称完成幂等。若 Redis 标记成功后数据库事务失败,重试可能被错误拦截;若数据库成功而标记失败,又可能重复执行。优先使用业务数据库的唯一约束、状态条件和同事务记录,Redis 可作为加速层而非唯一事实源。


10.14 故障场景推演

10.14.1 Broker 在到期前重启

消息状态可从持久化文件恢复。若重启在目标时间前完成,通常继续等待;若恢复时目标时间已过,则会在恢复和追赶后补投。应关注恢复耗时、定时读时间落后量和重新写入速率。

10.14.2 Broker 停机跨过大量到期点

恢复后可能形成集中补投。业务必须接受“晚到但仍执行检查”的语义,并对下游限流。对已经过期很久、业务价值已消失的通知,可以在消费者中根据 expire_at 丢弃;订单检查则仍应查询状态并完成必要补偿。

10.14.3 生产者时钟慢 5 分钟

它计算出的绝对投递时间可能比预期早 5 分钟,甚至被 Broker 视为过去时间而立即投递。因此订单消费者必须再次判断数据库过期条件。时间同步故障不能仅靠 MQ 配置修复。

10.14.4 Broker 时钟突然前跳

一批未来消息可能快速被判定为到期,引发洪峰。恢复时钟后,也不能假定已经投递的消息会自动“撤回”。必须监控系统时间跃迁,并在消费者侧保留业务条件。

10.14.5 消费者积压 20 分钟

Broker 可能准时完成到期转 Ready,但订单直到 20 分钟后才被检查。这时关闭操作仍应执行,因为订单确实过期;同时要用消费延迟指标定位消费者容量,而不是误判为时间轮故障。

10.14.6 召回请求超时

召回可能已在服务端成功,也可能未执行。不要立刻假定结果。把任务状态改为 CANCELED 或提升任务版本,使旧消息即使到达也无效;召回仅用于减少无效消息和资源消耗。


10.15 生产设计与观测清单

上线前至少回答以下问题:

  1. 目标是“尽量在某时刻触发”还是有明确的最晚完成时间?后者需要容量冗余和补偿通道。
  2. 任务是否超过官方 24 小时范围?远期任务放在哪里?
  3. 是否存在整点、活动结束或批量导入造成的同一到期时间洪峰?
  4. 生产者发送超时如何核对,是否有 Outbox 或补偿发布器?
  5. 消费者的幂等键、状态机、版本号和副作用幂等分别是什么?
  6. Broker 重启和消费者停机后,补投洪峰如何限流?
  7. 时钟同步由谁负责,偏差达到多少报警?
  8. 是否需要取消或改期?若使用 Recall,是否开启 Broker 配置并验证完整版本链路?
  9. 是否保留低频数据库兜底扫描?
  10. 是否分别监控到期转 Ready 延迟、消费积压和业务完成延迟?

建议核心告警包括:定时读时间落后、TimerLog/CommitLog 磁盘空间、到期重新写入失败、同槽任务数异常、业务 Topic 消费堆积、消费者处理 RT、数据库条件关闭失败率、已过期仍为待支付的订单数、节点时钟偏差以及 Recall 失败率。


10.16 常见误区

  1. “设置 10 秒,就会在第 10 秒准时执行。” 错。10 秒后只是进入可投递阶段,链路还有多段排队。
  2. “5.x 支持毫秒时间戳,所以业务精度是毫秒级。” 错。字段分辨率不等于调度 SLA,默认粒度为 1000 毫秒。
  3. “消息到了就说明订单一定过期。” 错。生产者时钟、数据变更和消息重复都可能使消息失真,数据库事实才权威。
  4. “延迟消息不会重复。” 错。它仍然处于至少一次投递链路中。
  5. “有延迟消息后可以完全删除补偿扫描。” 错。发送空窗、配置错误和长期故障都需要低频兜底。
  6. “源码参数能设 30 天,所以官方一定支持 30 天。” 错。源码实现参数、客户端校验和官方支持边界不是同一层契约。
  7. “Recall 成功就不需要消费幂等。” 错。召回存在时间窗口和竞态,且默认未开启。
  8. “修改时间就是更新原消息时间戳。” 错。当前做法是召回旧消息并发送新消息,两步不原子。
  9. “订单关闭成功,库存释放自然也只执行一次。” 错。每个跨服务副作用都要独立幂等。

10.17 面试题

题目去重:本节作为本章延迟/定时消息自测,只保留时间模型、时间轮、取消修改和调度选型题。跨章重复题、完整追问链和模拟面试统一跳转到 第 20 章:资深面试题库、追问链与模拟面试

1. 什么是延迟消息和定时消息?

标准回答:延迟消息在经过相对时间后可投递,定时消息在绝对时间点后可投递;RocketMQ 5.x 最终都用毫秒 Unix 投递时间戳表达。到期表示进入 Ready 的条件成立,不表示消费者已经完成业务。

追问:相对延迟重试发送时有什么风险?

易错点:每次重试都按“当前时间加 30 分钟”,导致截止时间不断后移。

2. 延迟消息是否保证准时?

标准回答:不保证硬实时准时。时间轮粒度、扫描积压、重新写入、消息分发、消费积压和业务处理都会产生延迟;Broker 故障恢复后通常补投,但可能晚到。

追问:应监控哪些阶段?

易错点:只看消费者收到时间,无法区分 Broker 定时延迟和消费积压。

3. 4.x 与 5.x 延迟消息的核心区别是什么?

标准回答:4.x 是固定 DelayLevel 和内部调度主题;5.x 是绝对投递时间戳和 TimerMessageStore 时间轮。4.x 默认只有 18 档,5.x 可表达允许范围内的任意时间点。

追问:5.x 服务端是否完全删除了经典调度代码?

易错点:把面向用户的 5.x 模型和所有兼容代码是否存在混为一谈。

4. 4.x 延迟消息为什么要重新写入 CommitLog?

标准回答:延迟阶段的消息存放在内部调度主题,业务消费者不可见;到期后恢复真实 Topic 和 QueueId,作为普通消息重新写入,才能构建业务 ConsumeQueue 并被消费。

追问:重新写入失败会怎样?

易错点:认为修改一个可见性标志即可,不需要第二次存储链路。

5. 时间轮为什么比全量扫描高效?

标准回答:时间被切成槽,任务按到期时间映射到槽位,指针只处理当前槽中的任务,成本主要与本次到期任务数相关,而不是与全部定时任务数相关。

追问:时间轮是否保存消息正文?

易错点:把时间索引与消息数据存储混为一体。

6. TimerWheelTimerLogTimerCheckpoint 分别做什么?

标准回答TimerWheel 保存时间槽及首尾索引,TimerLog 顺序记录定时索引项,TimerCheckpoint 持久化读时间和处理位置;三者配合完成入轮、出轮和重启恢复。

追问:为什么只保存内存时间轮不行?

易错点:忽略 Broker 重启后数百万任务的恢复与一致性。

7. 为什么到期消息还可能晚很久?

标准回答:到期后要扫描槽位、读取原消息、重新写入普通存储、构建消费索引,再等待消费者获取和执行。任何阶段积压都会放大尾延迟。

追问:如何判断问题在 Broker 还是消费者?

易错点:把 Consumer Lag 当作定时引擎故障。

8. 5.x 官方延迟时间范围是多少?

标准回答:当前官方 5.0 文档给出的默认最大范围为 24 小时,并要求时间晚于当前时间;架构设计应以该公开约束为准。

追问:源码中为什么看到 3 天?

易错点:直接用内部配置覆盖官方 API、Proxy 和 SDK 的支持边界。

9. 延迟消息能取消吗?

标准回答:4.x 没有通用召回;5.5.0 已有 Recall 链路,Go v5.1.4 也提供 API,但 Broker 默认关闭,且只能在有效窗口内尝试召回。业务仍需状态或版本兜底。

追问:召回超时后如何处理?

易错点:把超时等同于召回失败,再无条件执行一次相反操作。

10. 延迟消息能修改投递时间吗?

标准回答:当前不是原地修改。通常召回旧消息后发送新消息,两步不原子,应使用任务版本令旧消息失效,并用补偿流程处理部分成功。

追问:先召回还是先发送?

易错点:认为存在一个适合所有业务且无风险的固定顺序。

11. 为什么不能仅凭超时消息关闭订单?

标准回答:消息可能早到、晚到、重复,也可能与支付并发。必须在数据库中原子判断状态仍为 WAIT_PAY、确已过期且版本匹配,才允许关闭。

追问:支付和关闭同时发生怎么办?

易错点:先查后改,导致两个线程都基于旧状态执行成功。

12. 如何保证订单超时处理幂等?

标准回答:用事件 ID、订单状态机、版本号和数据库条件更新;关闭后的库存、优惠券、通知等副作用各自使用业务幂等键。

追问:只用 Redis SETNX 足够吗?

易错点:忽略 Redis 标记和数据库事务之间的原子性空窗。

13. Broker 重启后定时消息会丢吗?

标准回答:正常情况下定时日志、时间轮和检查点可恢复,已到期任务会在恢复后补投;但会产生延迟和追赶洪峰。极端停机、磁盘损坏或超出实现边界仍需容灾与补偿。

追问:业务层还要做什么?

易错点:把持久化等同于任何故障下绝对不丢。

14. 时钟回拨有什么影响?

标准回答:绝对时间模型依赖时钟。生产者偏差会设置错误时间,Broker 大幅回拨或前跳会影响到期判断和批量触发。需要 NTP/chrony、偏差告警和业务条件校验。

追问:为什么数据库时间更适合判断订单过期?

易错点:认为所有容器和 Broker 的 time.Now() 天然一致。

15. 大量消息同一时刻到期如何治理?

标准回答:对允许窗口的任务加抖动,按容量合理分片,消费者限流和批处理,核心与非核心任务隔离,并按峰值到期 QPS 容量规划。

追问:整点规则是否一定需要一对象一消息?

易错点:把全局状态切换错误地建模成千万条逐对象任务。

16. 延迟消息与数据库扫描如何选?

标准回答:高并发近期限触发更适合 MQ;任务量小、频繁改期、强审计或超长保存更适合数据库任务表。生产系统常用数据库保存权威状态、MQ 负责近期限触发的混合方案。

追问:为什么仍保留低频补偿扫描?

易错点:把二者视为必须二选一。

17. Redis ZSet 做延迟队列的难点是什么?

标准回答:除按 score 取到期任务外,还要解决原子领取、多实例竞争、执行中宕机归还、持久化、分片、热点和重复处理,不能只写一个 ZRANGEBYSCORE

追问:删除 ZSet 成员后消费者宕机怎么办?

易错点:先删后执行造成任务丢失,或先执行后删造成重复却没有幂等。

18. 延迟消息适合做 Cron 吗?

标准回答:它适合承载一次性到期触发。周期任务需要计算下一次时间、处理漏跑和重复、暂停与变更、时区和日历规则;复杂 Cron 更适合专业调度系统,再由调度系统向 RocketMQ 分发执行事件。

追问:每天 5 点任务如何防止重复创建下一周期?

易错点:让消费者无条件发送下一条消息,失败重试时产生多条未来任务。


10.18 练习题

  1. 为“优惠券 7 天后过期提醒”设计数据库任务表、近期限投递和取消流程,说明为什么不直接发送 7 天延迟消息。
  2. 假设 1000 万订单在 20:00 同时到期,计算 Broker 到期重写峰值和消费者数据库并发,提出削峰方案。
  3. 画出订单支付回调与超时关闭并发时的状态机,并给出条件更新规则。
  4. 设计 Recall 失败、重发成功和消费者已收到旧消息三个竞态下的任务版本方案。
  5. 分别定义“到期转 Ready 延迟”“消费延迟”“业务完成延迟”的监控指标和报警阈值。

10.19 本章总结

RocketMQ 4.x 延迟消息的本质是固定延迟级别 + 内部调度队列 + 到期重新写入;RocketMQ 5.x 的本质是绝对投递时间戳 + 持久化时间索引 + 时间轮推进 + 到期重新写入。两代模型都不提供硬实时保证。

延迟消息到期只表示消息可以进入普通投递链路。真正的业务完成还取决于 Broker 负载、消费积压、下游系统和业务事务。订单超时场景中,消息只能触发检查,数据库状态机和原子条件更新才决定是否关闭订单。

5.5.0 已出现开源 Recall 能力,但默认关闭、受时间窗口和版本链路约束,也不能原子修改投递时间。最稳健的架构始终是:消息至少一次、消费者幂等、任务带版本、业务状态权威、故障可补偿、全链路可观测。


10.20 版本与 Apache 官方来源

  • Apache RocketMQ Server:5.5.0,GitHub Release 标记为 Latest。[3]
  • 经典 4.x 源码参考:4.9.8 ScheduleMessageService。[11]
  • Go 5.x SDK:官方仓库 github.com/apache/rocketmq-clients/golang/v5;2026 年 6 月列出 golang/v5.1.4,Release 页面标记为 Pre-release,并包含延迟消息 Recall 支持。[8]
  1. Apache RocketMQ 5.0 Delay Message:https://rocketmq.apache.org/docs/featureBehavior/02delaymessage/
  2. Apache RocketMQ 4.x Delayed Message Sending:https://rocketmq.apache.org/docs/4.x/producer/04message3/
  3. Apache RocketMQ 5.5.0 Release:https://github.com/apache/rocketmq/releases/tag/rocketmq-all-5.5.0
  4. RocketMQ 5.5.0 TimerMessageStorehttps://github.com/apache/rocketmq/blob/rocketmq-all-5.5.0/store/src/main/java/org/apache/rocketmq/store/timer/TimerMessageStore.java
  5. RocketMQ 5.5.0 TimerWheelhttps://github.com/apache/rocketmq/blob/rocketmq-all-5.5.0/store/src/main/java/org/apache/rocketmq/store/timer/TimerWheel.java
  6. RocketMQ 5.5.0 MessageStoreConfighttps://github.com/apache/rocketmq/blob/rocketmq-all-5.5.0/store/src/main/java/org/apache/rocketmq/store/config/MessageStoreConfig.java
  7. RocketMQ 5.5.0 RecallMessageProcessorhttps://github.com/apache/rocketmq/blob/rocketmq-all-5.5.0/broker/src/main/java/org/apache/rocketmq/broker/processor/RecallMessageProcessor.java
  8. Apache RocketMQ Clients Releases:https://github.com/apache/rocketmq-clients/releases
  9. Go SDK Delay Producer Example:https://github.com/apache/rocketmq-clients/blob/master/golang/example/producer/delay/main.go
  10. Go SDK Delay Recall Example:https://github.com/apache/rocketmq-clients/blob/master/golang/example/producer/delay_recall/main.go
  11. RocketMQ 4.9.8 ScheduleMessageServicehttps://github.com/apache/rocketmq/blob/rocketmq-all-4.9.8/store/src/main/java/org/apache/rocketmq/store/schedule/ScheduleMessageService.java