一. RocketMQ 整体架构

RocketMq的整体架构核心有四个角色:
-
Producer :负责发送消息
Producer发送消息前,需要知道消息应该发到哪个Broker上。它会先从NameServer获取Topic的路由信息,然后选择对应的MessageQueue,把消息发送到Broker。Producer常见发送方式有:- 同步发送:发消息后 等 Broker 返回结果
- 异步发送:发消息后 不阻塞主线程,但通过回调拿结果
- 单向发送:发消息后 不等结果,也没有回调
-
NameServer :路由发现,
NameServer类似一个轻量级注册中心。它主要负责保存RocketMQ的路由信息:有哪些Broker、每个Broker上有哪些Topic、每个Topic有哪些Queue、Broker的地址是什么。Broker启动后,会定期向NameServer注册自己。Producer和Consumer会从NameServer拉取路由信息,然后根据路由信息连接Broker。NameServer本身不存储消息,也不转发消息。 -
Broker : 消息存储转发
Broker是RocketMQ最核心的组件。它负责:接收Producer发送的消息、持久化消息、维护Topic和Queue、向Consumer提供消息拉取、管理消费进度、处理消费重试和死信队列、Broker可以理解为真正保存消息的服务器。RocketMQ的消息最终会写入Broker的磁盘文件中,主要包括:- CommitLog:消息主体存储文件
- ConsumeQueue:消息消费索引
- IndexFile:消息查询索引
生产者和消费者真正通信的对象都是 Broker。
-
Consumer : 消费消息
Consumer负责消费消息。消费者是RocketMQ中用来接收并处理消息的运行实体。 消费者通常被集成在业务系统中,从RocketMQ服务端获取消息,并将消息转化成业务可理解的信息,供业务逻辑处理。
还有一些其他的基本概念,大家可以参考官网:https://rocketmq.apache.org/zh/docs/introduction/02concepts/
二. Producer 消息发送机制
在 RocketMQ 中,Producer 负责生产并发送消息。它并不是直接把消息随便发给某台 Broker,而是会先从 NameServer 获取 Topic 的路由信息,再根据路由信息选择具体的 MessageQueue,最后把消息发送到对应的 Broker。
MessageQueue 是 RocketMQ 对外暴露的逻辑队列概念;它对应的是 Broker 上的 ConsumeQueue 文件;消息正文仍然存储在 CommitLog 中。MessageQueue 可以理解为一个逻辑标识:Topic + BrokerName + QueueId,MessageQueue 的消费索引存储在磁盘上的 ConsumeQueue 文件中。
MessageQueue是一个逻辑概念或者说逻辑队列,Producer 发送消息时,会指定或选择一个 MessageQueue,例如:
bash
order_topic / queue-0
消息写入 CommitLog 时,消息元数据里会记录:
bash
Topic
QueueId
QueueOffset
然后 Broker 的后台线程会根据 CommitLog 中的消息信息,构建对应的 ConsumeQueue 索引。
一条消息从 Producer 发出,大致会经历以下步骤:
- Producer 启动后连接 NameServer
- Producer 根据 Topic 查询路由信息
- NameServer 返回 Topic 对应的 Broker 和 MessageQueue 信息
- Producer 从多个 MessageQueue 中选择一个队列
- Producer 将消息发送到该队列所在的 Broker
- Broker 接收消息并写入存储
- Broker 将发送结果返回给 Producer
在这个过程中,Topic 是消息的逻辑分类,MessageQueue 是消息真正分布和负载均衡的基本单位。一个 Topic 通常会有多个 MessageQueue,Producer 会把消息分散发送到不同的队列中,从而提升整体吞吐量。在Producer 发送消息时,也不会每次都从NameServer 查询Topic的路由信息,Producer 启动后或本地无缓存时,会从 NameServer 拉取 Topic 路由信息,并缓存在本地。后续发送消息时,Producer 通常直接基于本地路由缓存选择 MessageQueue,将消息发送到对应 Broker。
Producer 发送普通消息时,默认会基于本地缓存的 Topic 路由信息,在多个 MessageQueue 之间轮询选择队列,从而实现消息的均匀分布。发送失败或 Broker 响应较慢时,Producer 可能会在 重试过程 中避开异常 Broker(就是上次发送失败的那个在重试的时候尽量不再次选择)。对于顺序消息,则需要业务方通过 MessageQueueSelector 按业务 Key 选择固定队列,以保证同一业务维度下的消息顺序。
三. Broker 存储机制
Broker 是 RocketMQ 中真正负责消息存储、转发和消费进度管理的核心节点。Producer 发送的消息最终会写入 Broker,Consumer 消费消息时也是从 Broker 拉取。
从架构上看,RocketMQ 的存储设计不是"每个 Topic 一个文件",而是采用:
CommitLog顺序写入消息主体,是RocketMQ最核心的存储文件ConsumeQueue构建逻辑消费索引,ConsumeQueue是RocketMQ为每个Topic、每个Queue构建的逻辑消费索引。IndexFile构建消息查询索引,IndexFile本质上是一个哈希索引文件,这样Broker可以根据Key快速定位消息在CommitLog中的位置,主要作用就是按Key查询消息
也就是说所有消息统一写入 CommitLog,不同 Topic / Queue 的消费视图通过 ConsumeQueue 构建,这样做的核心原因是:顺序写磁盘性能远高于随机写磁盘 。Kafka就是因为一个Topic多个Partition,每个Partition是一个日志文件,然后数据写入就是在Partition级别顺序写,但是这样带来的坏处就是Kafka的Topic一旦太多就会退化成随机写磁盘。
对于 RocketMQ和Kafka在数据写入Broker这个点,他们做法的区别是:
- RocketMQ:所有 Topic / Queue 的消息混合顺序写入 CommitLog
- Kafka:每个 Topic-Partition 独立维护自己的日志文件
| 对比点 | RocketMQ | Kafka |
|---|---|---|
| 物理存储 | 所有消息混写 CommitLog | 每个 Partition 独立日志 |
| 消费索引 | ConsumeQueue 指向 CommitLog | Consumer 直接读 Partition Log |
| 顺序单位 | MessageQueue | Partition |
| 消费并发单位 | MessageQueue | Partition |
| 复制单位 | Broker / CommitLog 相关机制 | Partition Replica |
| 设计重点 | 统一顺序写 + 逻辑队列索引 | Partition 日志模型 + 分区复制 |
CommitLog 不是一个无限大的文件,而是由多个固定大小的文件组成。常见默认情况下,一个 CommitLog 文件大小是 1GB。文件名通常是该文件第一条消息的物理偏移量。
如果消息都混在 CommitLog 里,Consumer 怎么按 Topic 消费?答案就是 ConsumeQueue。ConsumeQueue 是RocketMQ 为每个 Topic、每个 Queue 构建的逻辑消费索引。
它的组织方式是:Topic + QueueId -> ConsumeQueue,例如:
bash
order_topic / queue-0 / ConsumeQueue
order_topic / queue-1 / ConsumeQueue
payment_topic / queue-0 / ConsumeQueue
payment_topic / queue-1 / ConsumeQueue
ConsumeQueue 中不保存完整消息,只保存消息在 CommitLog 中的位置索引。具体怎么消费在下一小节详细说明吧。
总结来说Producer 发送消息到 Broker 后,Broker 的写入流程可以概括为:
- Broker 接收消息请求
- 校验 Topic、权限、消息大小等
- 为消息分配 QueueOffset
- 将消息追加写入 CommitLog
- 根据刷盘策略决定是否等待刷盘
- 根据主从复制策略决定是否等待 Slave
- 返回发送结果给 Producer
- 后台线程异步构建 ConsumeQueue 和 IndexFile
因为存在最后一步:后台线程异步构建 ConsumeQueue 和 IndexFile,极端情况下,Producer 已经收到发送成功,但 Consumer 暂时还拉不到这条消息,原因可能是 ConsumeQueue 还没来得及构建完成。这个异步操作正常是不会出错的,但是也有极限的情况下,比如正在构建这个ConsumeQueue 或 IndexFile 的时候断电、宕机等情况,那么这条消息就会读取不到,但是ConsumeQueue 和 IndexFile 都可以根据 CommitLog 重建或修复,在Broker 重启时,会进行恢复流程:
- 扫描 CommitLog
- 校验消息格式
- 找到最后一条有效消息
- 校正 CommitLog 写入位置
- 校正或重建 ConsumeQueue
- 恢复 IndexFile
所以只要 CommitLog 数据还在,ConsumeQueue 理论上可以重新构建。
Broker 写 CommitLog 时,并不是每次都直接把数据同步写到物理磁盘,而是先写入内存映射区域,数据会进入操作系统的 PageCache,之后再由操作系统或 RocketMQ 的刷盘线程把脏页刷到磁盘。流程可以理解为:
bash
Broker 写入 mmap 映射内存
↓
数据进入 PageCache
↓
后台刷盘线程或操作系统将脏页刷入磁盘
mmap 是一种内存映射文件机制,正常读写文件时,应用程序通常需要通过 read、write 这样的系统调用,把数据在用户态缓冲区和内核态缓冲区之间来回拷贝。而 mmap 会把磁盘文件的一段区域映射到进程的虚拟内存地址空间中。应用程序访问这块内存,就像操作普通内存一样;操作系统会负责把这块内存和底层文件关联起来。
RocketMQ 的 CommitLog 是顺序追加写文件,非常适合使用 mmap。Broker 会将 CommitLog 文件映射到内存中,写消息时直接写入映射区域。由于映射区域背后对应的是文件,操作系统会把修改过的页标记为脏页,后续再刷入磁盘。这样带来的好处是:
- 减少系统调用开销
- 减少用户态和内核态之间的数据拷贝
- 充分利用 PageCache
- 顺序写入性能高
- 热点消息读取可以直接命中 PageCache
对于刚写入不久的消息,Consumer 很可能马上就来拉取。这时消息数据通常还在 PageCache 中,Broker 读取 CommitLog 时不需要真正访问磁盘,而是直接从内存中读取,因此消费性能也会很高。
Kafka 也大量依赖 PageCache,但它的方式和 RocketMQ 有区别。Kafka 的日志文件是按 Partition 组织的,每个 Partition 都是一组顺序追加的 Segment 文件。Kafka 写入日志时主要依赖普通文件 IO 和操作系统 PageCache,并通过顺序写来获得高吞吐。消费时,Kafka 还可以利用 sendfile 实现零拷贝,把 PageCache 中的数据直接发送到网络 socket,减少数据从内核态拷贝到用户态再写回内核态的开销。Kafka和RocketMq的区别在于:
bash
RocketMQ:mmap + PageCache,重点优化 CommitLog 写入和读取
Kafka:FileChannel / PageCache + sendfile,重点优化 Partition Log 读取和网络发送
RocketMQ 选择统一 CommitLog + mmap,是因为它希望把多 Topic、多 Queue 的写入尽量聚合成顺序追加写。
Kafka 选择 Partition Log + PageCache + sendfile,是因为 Kafka 的核心模型就是 Partition,每个 Partition 本身就是独立日志,消费时可以非常自然地从 Partition Segment 中顺序读取并通过零拷贝发送给 Consumer。
而RocketMQ没有选择使用sendfile原因就是RocketMQ的数据全局存储在CommitLog 中的,读取一个Topic下的数据时并不是取连续的数据,因为sendfile的语义大致是:sendfile(socket, file, offset, length),也就是从某个文件的 offset 位置开始,连续读取 length 字节,直接发送到 socket,就是这个不连续的消息存储导致RocketMQ没有使用sendfile,所以在极限情况下消费吞吐上可能不如 Kafka。
四. Consumer 消费机制
RocketMQ 的 Consumer 负责从 Broker 拉取消息并执行业务逻辑。虽然从使用者角度看,RocketMQ 有"Push 消费 "和"Pull 消费 "两种模式,但从底层实现看,Consumer 并不是被 Broker 真正主动推送消息,而是客户端通过拉取请求 + 长轮询的方式获取消息。
Consumer 有几个核心概念:
Topic:Topic 是消息主题,表示一类业务消息MessageQueue:一个 Topic 下会有多个 MessageQueue,MessageQueue 是 RocketMQ 中消费负载均衡和顺序消费的基本单位。同一个 Consumer Group 中,一个 MessageQueue 在同一时刻通常只会分配给一个 Consumer 消费。Consumer Group:同一个 Consumer Group 内的多个 Consumer 共同消费一个 Topic,同一条消息在同一个 Consumer Group 内只会被一个 Consumer 消费。如果是不同 Consumer Group,则各自都会消费一份
Consumer 有两种消费模式:
- 集群消费:集群消费是最常用的模式,同一个 Consumer Group 内,一条消息只会被其中一个 Consumer 消费,绝大多数业务场景都使用集群消费
- 广播消费:广播消费是同一个 Consumer Group 内,每个 Consumer 都会消费一份消息,但广播消费一般不适合作为核心业务消息消费模式,因为它不强调组内负载均衡,也不适合依赖 Broker 端统一重试
Consumer 启动后,消费一条消息大概会经历如下步骤:
bash
Consumer 启动
↓
从 NameServer 获取 Topic 路由
↓
向 Broker 注册订阅关系
↓
Consumer Group 内进行 Rebalance
↓
分配 MessageQueue
↓
为每个 MessageQueue 创建 PullRequest
↓
PullMessageService 向 Broker 拉消息
↓
Broker 读取 ConsumeQueue
↓
Broker 根据 CommitLog offset 读取完整消息
↓
Broker 返回消息
↓
Consumer 放入 ProcessQueue
↓
消费线程池调用 MessageListener
↓
业务处理成功:更新 offset
↓
业务处理失败:进入重试 / 死信流程
同一个 Consumer Group 内,一个 MessageQueue 同一时刻通常只能被一个 Consumer 消费,所以消费并发度受 Queue 数量限制。如果想提高消费并发能力,不能只增加 Consumer 实例,还要保证 Topic 的 MessageQueue 数量足够。
Consumer 拉消息时,会带上几个关键信息:
bash
Topic
MessageQueue
Consumer Group
QueueOffset
BatchSize
订阅表达式
Broker 收到后,会按这个路径读取消息:
bash
1. 定位 Topic + QueueId 对应的 ConsumeQueue
2. 根据 QueueOffset 找到 ConsumeQueue 中的索引位置
3. 读取一批 ConsumeQueue 条目
4. 拿到 CommitLog Offset 和 Message Size
5. 到 CommitLog 中读取完整消息
6. 根据 Tag / SQL 过滤
7. 返回给 Consumer
其中关键就是这个Topic表示消费进度的逻辑offset,offset 是对应:Consumer Group + Topic + MessageQueue,也就是说一个消费者组消费一个Topic的一个队列,会产生一个offset。:
- 在集群模式下:
offset通常由Broker管理,Consumer会定期把消费进度提交给Broker。好处是:Consumer宕机后,其他Consumer接管Queue时可以从已提交offset继续消费,Consumer Group内消费进度统一管理。 - 在广播模式下:每个
Consumer都要消费一份消息,因此offset通常保存在Consumer本地。因为每个节点的消费进度是独立的。
在Consumer 端消费的过程中,通常不是每消费一条消息就同步提交一次 offset,而是:消费成功后更新客户端本地 offset,客户端定期持久化 offset 到 Broker,Consumer 正常关闭时也会尝试持久化,Rebalance 队列被移除时也会提交并清理。举例如下:
bash
拉取消息 100 - 109
↓
放入 ProcessQueue
↓
消费线程池处理
↓
处理成功后从 ProcessQueue 移除
↓
计算可以推进的最小 offset
↓
更新本地 offset
↓
定期提交到 Broker
在上面这个例子中可以看出来:offset 推进通常表示这个位置之前的消息已经处理完,下一次可以从新位置继续,如果 Consumer 宕机前还没提交到 Broker,那么 Broker 上可能还停留在旧 offset,后续会重复消费。
当消费失败时,在并发消费模式下,RocketMQ 会尝试把消息发回 Broker 的重试队列,之后延迟重新投递,原队列 offset 可以继续推进。在顺序消费的模式下,不能简单把失败消息扔到重试队列然后继续推进,因为要保证顺序,所以顺序消息在消费失败时会暂停一下,然后从失败位置继续重试,所以顺序消费更容易因为一条慢消息或异常消息导致队列阻塞。
但是也会有其他的情况导致消费异常,可能会有如下情况:
- 消费成功但 offset 没提交:这是最常见的重复消费来源,所以客户端需要做消息消费幂等校验
- offset 提交了,但业务没真正成功:这个会导致消息丢失,比较严重,消费端尽量保证消息消费成功后才返回
CONSUME_SUCCESS - Rebalance 导致重复消费:Consumer Group 发生 Rebalance 时,MessageQueue 会重新分配,此时如果 Consumer A 之前拉了一批消息但还没提交 offset,Consumer B 会从 Broker 上保存的 offset 继续拉,所以还是要做幂等校验
- 本地 ProcessQueue 堆积过多:Consumer 拉消息比消费快时,本地 ProcessQueue 会堆积,此时如果Consumer 端宕机等,已拉取的消息就会重新被投递。
- 提交 offset 失败:Consumer 提交 offset 到 Broker 也可能失败,比如网络异常等,这个也是会重复消费,保证幂等即可
- 新 Consumer Group 从哪里开始消费:如果一个 Consumer Group 第一次订阅某个 Topic,Broker 上没有历史 offset,这时从哪里开始消费,取决于消费位点配置,例如:
CONSUME_FROM_LAST_OFFSET(从最新位置开始)、CONSUME_FROM_FIRST_OFFSET(从最早位置开始)、CONSUME_FROM_TIMESTAMP(从指定时间点开始)。如果配置不当,可能出现:新 Group 没消费历史消息或者新 Group 从很早的位置开始消费大量历史消息。 - Offset 被重置:运维或控制台可能手动重置 offset
所以在正式使用的时候,要分清消息是否可以提前提交offset,尽量业务成功后再返回成功,不要提前确认,Consumer 必须幂等,监控消费堆积和重试,减少频繁 Rebalance,新 Group 明确消费起点,谨慎重置 offset。
五. 顺序消息
顺序消息指的是:消费者按照消息发送的先后顺序进行消费,RocketMQ 的顺序消息不是靠整个 Topic 全局排队实现的,而是靠 MessageQueue 内有序 实现的,核心原则是:同一个业务 Key 的消息发送到同一个 MessageQueue;同一个 MessageQueue 内的消息按顺序消费 。也就是说,RocketMQ 保证的是:单个 MessageQueue 内有序。
我们要实现顺序消费,需要保证生产端让同一个业务key(就是需要保证顺序的业务消息,比如同一个订单关联的消息)发送到同一个MessageQueue,例如同一个订单的顺序消息,可以使用订单id 对 队列数 取余,根据取余的结果选择对应的队列MessageQueue,这样相同订单id的消息会被路由到同一个队列。
同时还要保证消费端依次消费,关键是:同一个 MessageQueue 同一时刻只由一个消费线程处理,必须前面的消息处理成功,后面的 offset 才能继续推进。如果某条消息消费失败,RocketMQ 不能简单跳过它继续消费后面的消息,而是暂停消费一会,然后继续尝试消费。这意味着:一条异常消息可能阻塞整个 Queue,这就是顺序消息最大的代价。
举例如下:
bash
1. 订单系统产生订单状态变更事件
2. Producer 按 orderId 选择 MessageQueue
3. 同一个 orderId 的消息进入同一个 Queue
4. Broker 按写入顺序保存消息
5. Consumer Group 内某个 Consumer 被分配到这个 Queue
6. Consumer 使用顺序消费监听器
7. 按 Queue offset 顺序处理消息
8. 消费成功后推进 offset
9. 消费失败则暂停当前 Queue 后续消费,稍后重试
关于顺序消费一般都只需要局部顺序即可,就是比如同一个订单内的消息顺序,不同订单之间可以并发。
Queue 数量决定顺序消费的并发上限。如果 Topic 只有 4 个 Queue,那么同一个 Consumer Group 最多 4 个 Consumer 实例能有效分摊消费。建议根据业务吞吐提前规划 Queue 数量。但也不要无限增加 Queue,因为 Queue 过多会增加 Broker、Consumer 和 Rebalance 的管理成本。
顺序消息总结来说:
bash
1. Producer 保证同一业务 Key 的事件按业务顺序发送
2. Producer 通过 MessageQueueSelector 把同一 Key 路由到同一 MessageQueue
3. Broker 为同一 Topic + QueueId 分配递增 QueueOffset
4. ConsumeQueue 按 QueueOffset 建立逻辑有序索引
5. Consumer Group 内同一 MessageQueue 同一时刻只分配给一个 Consumer
6. 顺序消费时 Consumer 对 MessageQueue 加锁,串行执行 Listener
7. 消费成功后 offset 顺序推进
8. 消费失败时暂停当前 Queue,不跳过失败消息
RocketMQ 顺序消息的本质,是把同一业务 Key 的事件收敛到同一个 MessageQueue,再依靠 QueueOffset、ConsumeQueue、队列分配锁和顺序 offset 提交来保证队列内顺序。
顺序消息只保证同一个 MessageQueue 内的顺序,因此当消息被路由到不同队列、消费者未使用顺序消费模式、消费过程中引入异步处理、或者消费失败导致重试和重新分配时,都可能导致顺序语义失效。此外,在 Consumer 扩缩容触发 Rebalance 时也可能出现短暂乱序。生产上通常通过固定 Key 路由到同一队列、使用顺序消费模式、禁止异步处理以及保证业务幂等性来避免顺序被破坏。
六. 事务消息
RocketMQ 事务消息主要解决的是这个问题:本地事务执行成功了,但 MQ 消息发送失败;或者 MQ 消息发送成功了,但本地事务执行失败。它要保证的是:本地事务执行结果 和 消息最终是否可被消费 保持一致。也就是说事务消息说的是生产端事情发生之后保证同步的发送出一条消息到Broker。
RocketMQ 事务消息使用的是类似两阶段提交的思想,但它不是标准 XA。核心流程是:
bash
先发送一条消费者不可见的半消息;
再执行本地事务;
最后根据本地事务结果提交或回滚这条消息。
也就是:
bash
Half Message -> Local Transaction -> Commit / Rollback
其中 Half Message 是"半消息"。半消息已经发送到 Broker,但对 Consumer 不可见。只有当 Producer 确认本地事务成功后,Broker 才会把消息提交为可消费状态。
事务消息的发送流程如下:
bash
1. Producer 发送 Half Message
2. Broker 保存 Half Message,Consumer 不可见
3. Broker 返回 Half Message 发送成功
4. Producer 执行本地事务
5. 本地事务成功 -> Commit 消息
6. 本地事务失败 -> Rollback 消息
7. 状态未知 -> Broker 后续回查
在这个过程中,很关键的一步就是事务回查 ,例如 Producer 执行本地事务后,还没来得及向 Broker 发送 Commit 或 Rollback 就宕机了,Broker 就不知道这条 Half Message 最终应该提交还是回滚。
所以 Broker 会在一段时间后向 Producer Group 发起事务状态回查。Broker 回查 Producer: 这条事务消息对应的本地事务到底成功了吗?Producer 收到回查后,需要根据业务数据判断事务状态,告诉Broker回查的结果,结果分为三种:
bash
COMMIT_MESSAGE:提交消息,Consumer 可见
ROLLBACK_MESSAGE:回滚消息,Consumer 不可见
UNKNOW:暂时未知,后续继续回查
代码大致如下:
java
TransactionMQProducer producer = new TransactionMQProducer("order_tx_producer_group");
producer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
// 执行本地事务,例如创建订单
orderService.createOrder(...);
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// Broker 回查时调用
String orderId = msg.getKeys();
Order order = orderService.findByOrderId(orderId);
if (order != null) {
return LocalTransactionState.COMMIT_MESSAGE;
}
return LocalTransactionState.ROLLBACK_MESSAGE;
}
});
在事务消息的业务设计中可以参考如下方式:
bash
1. 消息中带 eventId / orderId / transactionId 方便回查
2. 发送 Half Message
3. executeLocalTransaction 中执行短本地事务
4. 本地事务成功后返回 COMMIT
5. 本地事务失败返回 ROLLBACK
6. 不确定才返回 UNKNOW
7. checkLocalTransaction 从数据库查询事务状态
8. Consumer 端使用 eventId 做幂等
9. 对回查异常和死信消息做监控
七. 延迟消息与定时消息
RocketMQ的延时消息或者定时消息,对于Broker来说其实本质都是在Broker服务器的某个时间点让消息变成可以被消费。RocketMQ 4.x版本的时候,Producer 不能任意指定延时时间,而是从固定等级中选择。官方 4.x 文档给出的默认等级包括:
bash
1s、5s、10s、30s、1min、2min、3min、4min、5min、6min、7min、8min、9min、10min、20min、30min、1h、2h
在这个版本中定时消息的原理是Broker 会把延时消息先投递到内部延时 Topic,比如SCHEDULE_TOPIC_XXXX,不同延时等级对应不同队列。
Broker 内部有定时调度服务扫描这些队列,判断消息是否到期:未到期:继续等待;已到期:重新投递到真实 Topic。
在RocketMQ 5.x版本之后,支持自定义设置延时时间,RocketMQ 5.x 的延时消息是在 Broker 侧完成调度的,核心组件是 TimerMessageStore、TimerWheel 和 TimerLog。Producer 发送延时消息时,会在消息中携带一个未来的投递时间。Broker 收到消息后,会先把消息内容写入 CommitLog,保证消息本身已经持久化;但是这条消息不会立即进入真实 Topic 对应的 ConsumeQueue,所以 Consumer 在到期前看不到它。随后 Broker 会把这条延时消息交给 TimerMessageStore 处理。TimerMessageStore 可以理解为 5.x 延时消息的调度中心 ,它负责接收延时消息、保存调度信息、维护时间轮、扫描到期消息、读取原始消息,并在到期后把消息重新投递到真实 Topic。
TimerLog 是延时消息的调度日志。CommitLog 保存的是完整消息内容,而 TimerLog 保存的是这条延时消息的调度记录。它通常会记录消息的投递时间、消息在 CommitLog 中的物理位置、消息大小、状态等信息。之所以需要 TimerLog,是因为延时消息不能只放在内存里等待到期,否则 Broker 重启后未到期的消息就会丢失。TimerLog 的作用就是把"这条消息应该在什么时间被投递、原始消息在哪里"这类调度信息持久化下来。Broker 重启后,可以根据 TimerLog 恢复未完成的延时消息调度任务。
TimerWheel 是时间轮,用来按照投递时间高效组织延时消息。可以把它理解成一个按时间切分的环形槽数组,每个槽代表一个时间片,比如某一秒或某个时间范围。Broker 不需要每次扫描所有延时消息来判断哪些到期,而是根据消息的投递时间把它放入对应的时间槽。后台线程随着时间推进不断扫描当前时间对应的槽位,找到已经到期的消息,然后交给 TimerMessageStore 做后续投递处理。时间轮的价值在于避免全量扫描大量延时消息,提升定时调度效率。
当一条延时消息到期后,TimerMessageStore 会根据 TimerWheel 找到到期任务,再根据 TimerLog 中保存的 CommitLog offset 和消息大小,从 CommitLog 读取原始消息内容。然后 Broker 会恢复消息原本的 Topic、Queue、Tag、Key、属性等信息,把它重新写入真实 Topic 的正常消息存储链路中,并生成对应的 ConsumeQueue 索引。到这一步,消息才真正变成 Consumer 可见的普通消息,Consumer 后续就可以按照普通消息的消费流程拉取和处理它。
所以 RocketMQ 5.x 延时消息的完整链路可以概括为:CommitLog 负责保存原始消息内容,TimerLog 负责持久化延时调度记录,TimerWheel 负责按时间槽高效组织和发现到期消息,TimerMessageStore 负责协调整个定时消息的写入、恢复、扫描和到期投递。它不是 Producer 端 sleep,也不是 Consumer 端定时轮询,而是 Broker 在服务端统一管理延时消息的生命周期。到期前,消息只存在于定时调度体系中,对 Consumer 不可见;到期后,Broker 将它恢复为普通消息并投递到真实 Topic,Consumer 才能正常消费。
下面这些是实际使用时比较重要的最佳实践和注意事项。
-
定时消息只负责触发,不负责最终判断。比如订单 30 分钟未支付自动关闭,正确做法不是 Consumer 收到定时消息后直接关闭订单,而是先查询订单当前状态。如果订单仍然是待支付,才执行关闭;如果订单已经支付、取消或关闭,就直接忽略并返回消费成功。因为定时消息到期时,业务状态可能已经发生变化。
-
Consumer 必须做幂等。定时消息到期后会变成普通消息,后续消费流程和普通消息一样,仍然可能因为消费成功但 offset 未提交、Consumer 重启、Rebalance、网络异常等原因被重复投递。所以消费逻辑不能假设"只会执行一次"。常见幂等方式包括业务状态判断、数据库唯一索引、消费记录表、Redis 去重、版本号或乐观锁。
-
不要把大量消息设置成同一个投递时间。如果大量定时消息在同一秒到期,Broker 需要集中扫描和重新投递,Consumer 也会在同一时间收到大量消息,容易造成瞬时流量尖峰、消息堆积和投递延迟。比如营销活动、优惠券提醒、批量任务触发这类场景,最好按业务 ID 或随机秒数打散投递时间。
-
不要把定时消息当成硬实时任务。RocketMQ 的定时消息适合订单超时、延迟重试、定时通知、状态检查这类业务级定时场景,但不适合毫秒级强实时调度。即使 5.x 支持设置时间戳,实际投递仍会受到 Broker 负载、到期消息数量、Consumer 消费能力、网络和重试等因素影响。更合理的理解是:到期后尽快投递,而不是绝对准点执行。
-
长周期定时任务要配合数据库状态。RocketMQ 是消息系统,不是长期任务数据库。如果定时时间很长,比如几天、几周甚至更久,建议在业务库中也保存任务状态、计划执行时间和处理结果。MQ 定时消息负责触发,数据库负责保存可审计、可补偿、可查询的业务状态。这样即使消息异常,也可以通过补偿任务扫描数据库恢复。
-
消息体里必须带业务唯一标识。定时消息到期后,Consumer 通常需要反查业务状态,所以消息中至少要带 orderId、taskId、eventId、userId 等业务 Key。不要发送无法定位业务记录的消息。推荐消息中包含业务 ID、事件类型、计划触发时间、创建时间和事件 ID,便于幂等、排查和补偿。
-
合理规划 Topic 类型和使用方式。RocketMQ 5.x 对消息类型有更明确的约束,定时/延时消息应该发送到支持 Delay 类型的 Topic,不要把普通消息、事务消息、顺序消息、定时消息随意混在一个 Topic 里。这样有利于 Broker 做类型校验,也方便后续治理和监控。
-
失败处理要按普通消息治理。定时消息到期后只是变成普通可消费消息,并不代表业务一定成功。如果 Consumer 处理失败,仍然会进入重试流程;多次失败后可能进入死信队列。因此要监控消费失败次数、重试堆积、死信队列,并准备人工处理或自动补偿逻辑。
最后总结一下:定时消息最推荐的使用方式是"MQ 定时触发 + 业务库状态判断 + Consumer 幂等处理 + 失败重试和补偿"。它非常适合订单超时关闭、延迟重试、状态检查、定时通知等业务场景,但不适合做高精度调度器,也不应该作为长期业务状态的唯一存储。
八. 常见问题与解决方案
8.1 消息丢失怎么排查
排查流程大致如下:
- 生产端有没有 SendStatus=SEND_OK
- 有没有 msgId / key
- 用 queryMsgByKey / queryMsgById 查 Broker 是否存在
- 查 Broker 日志是否有刷盘、主从、磁盘异常
- 查 ConsumerGroup offset 是否已经推进
- 查消费日志是否异常被吞
- 查 %RETRY% 和 %DLQ%
- 查订阅表达式、Tag、ConsumerGroup 是否变更
- 查消息是否过期清理
- 如果是 5.x,再查 Proxy / gRPC / 消息类型约束
RocketMQ 消息丢失要从生产 、存储 、复制 、消费 四个阶段排查。生产端看是否同步发送并确认 SendStatus 为 SEND_OK,是否记录 msgId 和 key。Broker 端看刷盘方式、主从复制方式、磁盘空间、CommitLog 和 ConsumeQueue 是否异常。消费端看是否业务失败却返回 CONSUME_SUCCESS,是否进入重试队列或死信队列,ConsumerGroup offset 是否已经推进。RocketMQ 4.x 重点看 Producer、Broker、Consumer,5.x 还要额外关注 Proxy、gRPC SDK 和 Topic 消息类型约束。和 Kafka 类似,Kafka 也要看 producer ack、broker 副本同步和 consumer offset 提交,但 Kafka 的重试和死信通常需要业务自己实现,而 RocketMQ 内置了消费重试和 DLQ。
在生产端,例如:
bash
SendResult result = producer.send(message);
result.getSendStatus() == SendStatus.SEND_OK
这种写法,要重点查看:
- 异步发送但 callback 失败没处理
- 发送异常被 catch 后吞掉
- 超时后业务误认为成功
这些情况实际都是有可能没发送成功,但是生产端业务侧没有处理,以为是发送成功了。还要注意记录发送消息的msgId / keys,用这两个信息可以去Broker查询是否有对应的消息记录。
如果生产端的消息发送状态正常,那么就要查看Broker端是否正常的接收并存储了消息,可以根据生产端日志信息去RocketMq控制台查询消息信息,比如日志信息如下:
java
SendResult sendResult = producer.send(msg);
log.info("send result: status={}, msgId={}, offsetMsgId={}, queue={}, queueOffset={}",
sendResult.getSendStatus(),
sendResult.getMsgId(),
sendResult.getOffsetMsgId(),
sendResult.getMessageQueue(),
sendResult.getQueueOffset());
// status=SEND_OK,
// msgId=7F0000012A9418B4AAC26A6A0001,
// offsetMsgId=C0A8016500002A9F00000000000123AB,
// queue=MessageQueue [topic=order_topic, brokerName=broker-a, queueId=2],
// queueOffset=1024
那么就可以根据offsetMsgId或者消息key去RocketMq控制台查询消息信息,如果消息存在那么Broker端消息就是正常存储的。
如果Broker端没有查到,但是你已经有offsetMsgId等生产信息,那这个时候就可能是主从复制、异步刷盘等可能造成的存储问题。比如如下流程:
bash
Producer -> Master Broker
Master 写入后生成 offsetMsgId
Master 还没同步到 Slave
Master 宕机
Slave 接管
Consumer 从 Slave / 新 Master 消费
消息不存在
offsetMsgId 只能说明 Broker 曾经为这条消息分配过物理存储位置,它通常和 Broker 地址、CommitLog 物理偏移量有关。但它不等价于消息已经可靠落盘,也不代表消息一定可以被消费者正常消费。
如果 Broker 配置为:flushDiskType=ASYNC_FLUSH异步刷盘,有可能消息只写入了PageCache / mmap,此时宕机就会导致消息丢失。如果对消息可靠要求较高,可以设置:flushDiskType=SYNC_FLUSH同步刷盘。
如果 Broker 配置为:brokerRole=ASYNC_MASTER,表示Broker 作为异步 Master 运行,此时就可能出现Master节点写入成功,消息还未同步到Slave节点,然后Master节点直接宕机,Slave节点接管,这个时候这条未同步的消息就会丢失。如果要求高可用可以考虑:brokerRole=SYNC_MASTER,表示Master写成功后Slave节点也要同步成功。
总结下来可以按照如下流程排查Borker端:
bash
1. SendStatus 是否为 SEND_OK
2. 是否使用 ASYNC_FLUSH
3. 是否使用 ASYNC_MASTER
4. 发送后 Broker 是否宕机或重启
5. 是否发生主从切换
6. CommitLog 是否被清理或截断
7. ConsumeQueue 是否构建成功
8. IndexFile 是否构建成功
9. 磁盘是否满过或 IO 异常
10. 是否是事务消息或延迟消息
11. 是否查错 NameServer / Broker / 集群
12. RocketMQ 5.x 是否经过 Proxy,Proxy 是否转发成功
经过如上步骤发现都没有问题的话,那么就需要到消费端查看是否是消费时造成的消息丢失,消费端导致消息丢失通常是:消息已经被消费者拿到,消费进度已经提交,但业务没有真正处理成功。也就是:业务失败,但 offset / ack 成功了。
比如如下代码:
java
public ConsumeConcurrentlyStatus consumeMessage(
List<MessageExt> msgs,
ConsumeConcurrentlyContext context) {
try {
doBusiness(msgs);
} catch (Exception e) {
log.error("consume error", e);
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
正确写法应该是:
java
try {
doBusiness(msgs);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (Exception e) {
log.error("consume error", e);
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
又或者异步处理消息,提前返回了成功,但是在异步线程或者线程池中任务执行失败了,比如如下代码:
java
public ConsumeConcurrentlyStatus consumeMessage(...) {
executor.submit(() -> doBusiness(msg));
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
这种就要自行取舍到底适不适合异步处理,或者自己设计可靠的本地兜底机制。
消费端还有可能导致消息丢失的是消息进入重试队列或死信队列,但没人处理。RocketMQ 集群消费失败后,会进入重试队列:%RETRY%consumer_group,超过最大重试次数后,会进入死信队列:%DLQ%consumer_group,如果你只看原 Topic,会觉得消息丢了,这个时候就要专门去处理这两个队列。
重试队列一般是不需要额外手动处理的,例如消费者组:order-consumer-group,RocketMQ 自动创建:%RETRY%order-consumer-group,这个 Topic:不需要手动创建、不需要手动发送、不需要手动订阅,Broker 和 Consumer 会自动完成重试流程。
死信队列需要我们专门去处理,比如如果你的消费者组是:order-consumer-group,那么对应的死信队列就是:%DLQ%order-consumer-group,生产环境中一般不会直接自动消费死信队列,而是先通过监控系统发现 DLQ 积压并告警。开发人员根据消息内容和异常日志定位问题,例如第三方服务故障、数据库异常或代码 Bug。问题修复后,通过内部消息管理平台将死信消息重新投递到原 Topic,由 order-consumer-group 重新消费。同时消费逻辑需要保证幂等性,避免重投导致重复下单、重复扣库存等问题。
8.2 消息积压怎么处理
消息积压的本质是消费能力小于生产能力。一般先通过监控确认积压位置,是 Broker 侧堆积还是 Consumer 侧消费变慢。通常优先扩容 Consumer 实例和提升消费线程数来快速缓解压力,同时排查消费逻辑是否存在慢 SQL、外部接口耗时或锁竞争问题。如果是业务突发流量,会临时扩容消费端进行削峰;如果是队列不均衡,会增加 queue 或调整 key 分布。在根因修复后,再逐步恢复正常消费规模。
正常我们可以根据如下步骤处理
java
1. 查 consumerProgress,看积压量
2. 看消费日志,确认是否大量异常重试
3. 看消费者 CPU、线程池、数据库、下游接口
4. 看 Topic 的 MessageQueue 数量
5. 判断能不能扩消费者实例
6. 判断是否需要提高消费线程数
7. 优化慢消费逻辑
8. 监控 %RETRY% 和 %DLQ%
9. 必要时生产端限流
10. 极端情况才考虑跳过 offset 或转储处理
切记增加消费者实例并不一定能解决消息积压问题,因为 RocketMQ 的并发能力受 queue 数量限制,同时消费瓶颈往往在业务逻辑或外部依赖上。如果 queue 不足、单条处理耗时较高或存在外部系统瓶颈,单纯扩容消费者只会增加资源浪费而不会提升整体吞吐量。因此生产环境通常是先定位瓶颈(queue分布、消费耗时、依赖系统),再决定是否扩容消费者。
8.3 消费失败一直重试怎么办
RocketMQ 的设计理念不是「失败立刻丢弃」,而是「尽量重试直到成功」,但是如果一直失败,就必须区分是什么原因导致的。
- 如果是临时性故障,比如数据库连接异常、下游接口超时、业务数据不存在等临时错误,那么就可以等待重试,异常恢复后即可正常重试处理。
- 如果是代码bug,那么就不需要重试了,尽快修复代码bug,记录异常的消息或者如果已进入死信队列那就需要尽快处理死信队列消息
我们应该尽量在Mq消费时根据异常类型做不同的处理,比如:
java
try {
doBusiness();
return CONSUME_SUCCESS;
} catch (TimeoutException e) {
// 临时故障
return RECONSUME_LATER;
} catch (SQLException e) {
// 数据库异常
return RECONSUME_LATER;
} catch (BizException e) {
// 业务数据错误
saveErrorMsg(msg);
return CONSUME_SUCCESS;
}
8.4 Consumer 数量增加后为什么没有提升消费速度
Consumer 数量增加但消费速度没有提升,主要原因是 RocketMQ 的并发能力受 queue 数量限制,一个 queue 同一时刻只能被一个 Consumer 实例消费,因此当 queue 数量不足时扩容不会带来并发提升。另外,消费瓶颈通常不在 MQ,而在业务处理逻辑,例如慢 SQL 、外部 RPC 调用 、锁竞争 或线程池阻塞 等,这些都会限制整体消费速度。此外,如果存在队列数据分布不均,还会导致热点 queue 成为瓶颈。因此提升消费能力需要从 queue 分布、业务耗时和外部依赖三个方向综合优化,而不是单纯增加 Consumer 数量。
8.5 Broker 磁盘写满怎么办
正常处理顺序如下:
java
1. 生产端限流,防止继续打满
2. 确认是 Broker 数据盘、系统盘还是日志盘满
3. 清理无关日志,不先动 CommitLog
4. 查消费积压和重试/死信
5. 如果业务允许,降低 fileReservedTime 触发清理
6. 如果业务不允许丢,扩容磁盘或迁移 store
7. 增加 Broker / Topic 队列分散写入
8. 恢复生产后持续观察磁盘增长速度
九. 实战建议
9.1 Topic 和 Tag 如何设计
Topic 用于业务域或数据流的粗粒度隔离,Tag 用于同一 Topic 下的细粒度分类。通常一个业务域一个 Topic,Tag 用来区分不同业务场景或消息类型,避免 Topic 过多导致管理复杂。
正常情况下可以按照如下模板进行设计:
java
Topic 命名:
{业务域}_{事件类型}_topic
Tag 命名:
{对象}_{动作}
比如:
java
Topic: order_event_topic
Tag:
ORDER_CREATED
ORDER_PAID
ORDER_CANCELLED
Topic: payment_event_topic
Tag:
PAYMENT_SUCCESS
PAYMENT_FAILED
REFUND_SUCCESS
Topic: inventory_event_topic
Tag:
STOCK_DEDUCTED
STOCK_RELEASED
实际设计中通常是"一个业务域一个 Topic,多种事件用 Tag 区分",以达到解耦和可扩展的目的。
9.2 Queue 数量如何设置
Queue 数量的核心作用是:决定 Topic 的存储分片数,决定 ConsumerGroup 的最大并行消费能力,影响消息分布、积压处理、顺序消息并发度。Queue 数量 = 最大消费并发能力上限。Queue 数量正常应该大于等于消费实例数量,并且最好是消费者数量的倍数。
Queue 数量要按未来最大消费并发、Broker 数量、顺序性要求和积压恢复目标来定。普通消息建议预留 1 ~ 2 倍扩容空间;顺序消息要保证 ShardingKey 足够分散,并避免频繁调整 Queue 数量。
可以用这个粗略估算:
java
需要的 Queue 数 >= 峰值生产 TPS / 单 Consumer 实例消费 TPS
再加扩容余量:
java
建议 Queue 数 = 需要的 Queue 数 * 1.5 ~ 2
例如:
java
峰值生产:20000 条/秒
单 Consumer:2000 条/秒
需要 Consumer:10 个
建议 Queue:16 或 20
如果有 4 个 Broker,可以设置:
java
每个 Broker 4 或 5 个 Queue
总 Queue 数 16 或 20
9.3 Producer 发送超时和重试配置
Producer 发送消息时可以通过 sendMsgTimeout 控制发送超时时间,默认一般为 3 秒。如果在超时时间内未收到 Broker 响应,则认为发送失败。对于同步发送模式,可以通过 retryTimesWhenSendFailed 配置重试次数,默认通常为 2 次;异步发送则通过 retryTimesWhenSendAsyncFailed 控制。重试机制仅对网络异常、超时等可恢复错误生效,不会对业务逻辑错误重试。需要注意的是,重试可能导致消息重复,因此业务侧必须保证幂等性。
如下代码:
java
DefaultMQProducer producer = new DefaultMQProducer("order_producer_group");
producer.setNamesrvAddr("127.0.0.1:9876");
// 发送超时时间,默认 3000ms
producer.setSendMsgTimeout(3000);
// 同步发送失败后的内部重试次数,默认 2
// 总尝试次数通常是:1 次首次发送 + 2 次重试 = 3 次
producer.setRetryTimesWhenSendFailed(2);
// 异步发送失败后的内部重试次数,默认 2
producer.setRetryTimesWhenSendAsyncFailed(2);
// 如果发送结果不是 SEND_OK,是否换 Broker 重试,默认 false
producer.setRetryAnotherBrokerWhenNotStoreOK(true);
producer.start();