如果你在 Spring Boot 项目里配置过 RocketMQ 事务消息,一定会遇到一个让人纠结的问题:prepare 阶段返回的半消息到底有没有落盘?是刷到 Page Cache 就算完事,还是必须等本地事务提交后才真正写磁盘?
这个问题我不止一次被人问过。它不该被当成一道死记硬背的面试题,它直接决定了事务消息的可靠性边界。本地业务事务执行完了,Broker 突然宕机,消息能不能靠事务回查找回来?生产者收到半消息的 SendResult 后到底敢不敢执行本地事务?代码里一个毫秒级的先后顺序,背后就是一套完整的存储与一致性设计。
很多人对“半消息何时落盘”有误解,以为 Half 消息只是个内存中的临时状态,等服务端收到 Commit 消息后再落盘。我刚开始看 RocketMQ 源码时也有过这个错觉。后来翻到 CommitLog 和 ConsumeQueue 的构建流程,才意识到方向完全反了。
Half 消息不是“半成品”,它是一条有持久化要求的准备日志。它在进入本地事务之前,就已经按普通消息的完整路径走进了 Broker 存储层。下面我把这条链路拆开讲。
1. 事务消息的“半消息”到底在扮演什么角色
1.1 一个容易被顺序坑死的场景
假设你在做一个订单系统:本地数据库要插入一条订单记录,同时给下游库存服务发一条 MQ 消息。如果你的做法是先插数据库、再发消息,那么消息发送失败时数据库已经提交,你得自己补偿。反过来先发消息、再插库,又可能造成下游收到消息但本地事务失败。
RocketMQ 事务消息的解法是把“发消息”拆成两个阶段:先发一条不让人看到的半消息,再在本地事务执行完成后决定这个半消息是转正还是废弃。这个模式在思路上很像数据库的两阶段提交,但 RocketMQ 并没有把所有资源纳入真正的 XA 协议,而是用一种更轻量的“预写日志 + 定时回查”方式,让业务方在最终一致性的前提下自己控制本地事务结果。
半消息在这里的定位,本质上就是一个“预备记录”。它告诉 Broker:我打算往某个业务主题发一条消息,但我还没确定是提交还是回滚。既然还没确定,业务方消费者当然不能消费它。半消息必须在本地事务开始前落到 Broker,否则后面所有回查和终态推进都无从谈起。
1.2 Half 消息是“准备日志”,不是业务消息
先纠正一个概念:Half 消息不是消费者能读到的业务消息。它的完整生命周期是:
- 生产者把业务消息标记为事务预提交消息,发送给 Broker;
- Broker 识别标记后,将消息持久化到一个内部事务主题中;
- 这条消息此时对任何业务消费者不可见;
- 本地事务执行完成后,生产者或 Broker 推进事务状态,最终把消息恢复到真实的业务主题队列中,消费者才能看到。
所以你从业务 Topic 上去查这条消息,大概率是查不到的。它真正落地的位置,是 RocketMQ 在内部维护的一个专门区域。这个设计的直接好处是:半消息不会污染业务消费,也在最终提交前留下来了可靠的“契约凭证”。
1.3 为什么本地事务执行前就必须先把半消息送到 Broker
有人会问:能不能先执行本地事务,成功后再发送半消息?不行的。如果先执行本地事务再发送半消息,发送失败时本地事务已经提交,你仍然要自己做反向补偿;更麻烦的是,Broker 不知道你本地事务到底成功没有,它没有机会通过回查机制去推动最终状态。
反过来,先把半消息推到 Broker,让它持久化,然后执行本地事务:
- 本地事务成功了,就告诉 Broker 提交半消息;
- 本地事务失败了,就告诉 Broker 回滚半消息;
- 如果半消息发送后客户端宕机,或者 Commit/Rollback 请求丢了,Broker 会拿那条已存在的半消息去回查生产者,问业务方本地事务最终结果。
整个流程里,半消息是否被可靠持久化,决定了回查机制是否存在数据基础。没有落盘的半消息,Broker 宕机一次,这条事务记录就彻底消失了,后面的所有承诺都没有意义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从存储结构拆解:Half 消息落盘和普通消息走的通道一样吗
2.1 CommitLog 是唯一物理落地点
RocketMQ 的存储模型很多人容易搞混。它对消息的物理存储不像 Kafka 那样每个 Topic 独立一套 Log,而是所有 Topic 的消息都顺序追加到同一个 CommitLog 文件里。CommitLog 默认单个文件 1GB,每个 Broker 节点在 store/commitlog 目录下按物理偏移量切分文件。
也就是说,不管消息是普通消息,还是事务半消息,最终都要写到这个统一的大文件里。CommitLog 里没有“某某 Topic 的目录”这种概念,Topic 只是消息中的一个属性字段。消息写入 CommitLog 后,RocketMQ 的后台线程会异步解析新写入的消息,为它构建 ConsumeQueue 和索引文件。ConsumeQueue 才是按 Topic/Queue 组织消息的逻辑队列,真正的消息体始终留在 CommitLog 中。
因此,回答“Half 消息落到哪里”,最底层的答案就是:落到该 Broker 节点对应的 CommitLog 文件里。它没有独立于普通消息的专用物理存储路径。
2.2 Broker 识别事务标记后,会把消息改写到内部半消息主题
普通消息发送时,生产者规定消息属于哪个 Topic 和 Queue。事务半消息则不同。真实业务消息是要发给“order_tx_topic”的,但半消息阶段还不想让消费者看到,所以 Broker 在持久化前会把这条消息的归属做一次改变。
我在实际看源码和排查问题时理解了一个关键点:RocketMQ 并不是在内存里维护一个半消息缓存,而是在消息属性中加上一个事务预提交标记,Broker 检测到这个标记后,会把消息的 Topic 改成一个内部半消息主题,Queue 也固定到特定编号上。同时,它会把真正的业务 Topic 和 QueueId 保存到消息的属性里,留着后面恢复路由用。
消费者即便是订阅了真实业务 Topic,也读不到内部半消息主题里的内容。内部主题不会出现在普通业务订阅中,所以半消息不会提前被消费。
这种做法也解释了为什么你在 Dashboard 按业务消息 Key 查询时,可能搜不到未提交的半消息——你要知道它改用了内部主题存储。
2.3 先写 CommitLog,后建 ConsumeQueue,再谈可见
这里要强调一个顺序:半消息在 CommitLog 中的写入,和它在业务消费者视角中的可见,是不同阶段的事。
正常流程中,消息先追加到 CommitLog,然后 ReputMessageService 这个后台服务把新追加的 CommitLog 内容解析出来,写入 ConsumeQueue。ConsumeQueue 不是一个必须和消息写入同步完成的东西。如果 Broker 在 CommitLog 追加后、ConsumeQueue 构建前宕机,重启后 RocketMQ 会重新扫描 CommitLog,把未构建或未完整构建的队列补出来。
所以半消息的“落盘”成功,指的是 CommitLog 这条记录具备了持久性,不是指 ConsumeQueue 已经指向了它。很多人在排查时误把 ConsumeQueue 当成了消息是否在磁盘上的依据,其实不对。判断物理落盘,要看 CommitLog 的写入和刷盘结果。
3. 一条 Half 消息从发送到刷盘的完整链路
3.1 客户端发送与 SendResult 的真实语义
我们直接看代码。事务消息的生产者通常是这样用的:
java复制TransactionMQProducer producer = new TransactionMQProducer("order_tx_group");
producer.setNamesrvAddr("127.0.0.1:9876");
producer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
// 这里才会执行本地事务,例如插入订单和本地事务标记表
try {
orderService.createOrder((String) arg);
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Throwable t) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
// 如果长时间没收到终态,Broker 会回调这里查询本地事务结果
return orderService.checkOrderByMessage(msg);
}
});
producer.start();
Message msg = new Message("order_tx_topic", orderJson.getBytes(StandardCharsets.UTF_8));
TransactionSendResult result = producer.sendMessageInTransaction(msg, orderId);
注意 executeLocalTransaction 的执行时机:客户端只有在半消息发送成功并获得 Broker 的 SendResult 之后,才会回调这个方法。
如果你在本地事务里需要依赖数据库事务结果,又想先发半消息,那半消息发送失败时就不会进入本地业务处理,这个顺序是安全的。换言之,本地事务是否执行,取决于半消息的第一步发送有没有返回成功。
但“客户端拿到 SendResult”并不一定等于“消息已经刷到物理磁盘”。这要取决于 Broker 端刷盘配置。
3.2 Broker 追加到 CommitLog 后如何返回结果
半消息请求到达 Broker 后,处理路径和普通消息几乎一样。Broker 的消息存储模块会执行以下动作:
先获取写锁,把消息按二进制协议格式追加到当前正在写的 CommitLog MappedFile 中。这一层实际上是写入操作系统 Page Cache,而不是直接写物理磁盘块。追加成功后,消息就有了自己的物理偏移量。
接下来要看 flushDiskType 配置:
- 如果配置
SYNC_FLUSH,追加到 Page Cache 后不会马上返回。Broker 会通过 GroupCommit 机制,把该文件从 Page Cache 强制刷到物理磁盘,或者等待后台刷盘线程完成一次强制刷盘,才给客户端返回写入成功。 - 如果配置
ASYNC_FLUSH,也就是默认常见配置,那么消息只要追加到 Page Cache 并正常落进 MappedFile,就开始走后续异步构建队列的流程。真正把 Page Cache 刷到物理磁盘,由专门的实时刷盘线程在后台周期执行。
所以同样叫“SendResult 成功”,在异步刷盘模式下,得到的成功可能只是“Page Cache 写入成功”,并不是“磁盘落盘成功”。这个语义差异,在高可靠性要求的事务场景里必须清楚。
我平时调事务消息时,会把 flushDiskType 和业务对消息丢失的容忍度一起考虑。如果业务允许极端情况下丢少量消息,用异步刷盘换取性能和吞吐,问题不大;如果业务要求消息丢一条都不行,那事务消息环境就要显式改成同步刷盘,不能想当然认为 RocketMQ 对事务消息默认更严格。
3.3 “真正落盘”的时间点差在哪里
可以把落盘分成三个层次来理解:
- 第一层:消息进入 MappedFile,经过文件系统缓存,可被消费者/索引读取。通常我们把这一步叫“写入成功”。
- 第二层:操作系统把 Page Cache 里的数据刷到磁盘设备。这一步由 Broker 的刷盘线程或 GroupCommit 触发。
- 第三层:如果是主从部署,Master 刷盘后可能还要等从节点同步完成才返回,这属于复制维度的高可用语义。
事务半消息关心的至少是前两层。如果配置了 SYNC_FLUSH,Half 消息在 sendMessageInTransaction 返回前就已经完成第二层刷盘,本地事务开始执行时,这条半消息已经具备跨进程、跨节点恢复的基础。如果配置 ASYNC_FLUSH,Half 消息可能只到了 Page Cache,Broker 在这段时间内掉电,消息就可能丢。
主从复制方面,brokerRole=SYNC_MASTER 会让 Master 在返回给生产者前先等从节点确认复制成功。这能避免一台 Broker 整个宕机后半消息在新主上不存在的问题。但它和本地磁盘刷盘是两回事,不能互相替代。
我用一张表把这几个模式区分开:
| 配置组合 | 返回 SendResult 时的语义 | 适合场景 |
|---|---|---|
| ASYNC_FLUSH + ASYNC_MASTER | 只写入 Page Cache,不等待磁盘,也不等待从节点 | 普通消息、允许极端丢消息的日志 |
| SYNC_FLUSH + ASYNC_MASTER | 已刷本地磁盘,但未等从节点复制 | 事务消息基础保障,可防 Broker 进程崩溃/掉电 |
| SYNC_FLUSH + SYNC_MASTER | 已刷本地磁盘,且从节点确认复制完成 | 金融、订单等强一致场景,防单点故障 |
4. Commit/Rollback 阶段:第二次落盘与最终可见
4.1 事务回查机制,依赖的是早已落盘的 Half
半消息发送后,如果客户端执行本地事务很慢,或者执行完本地事务后,客户端还没来得及向 Broker 发送 Commit/Rollback 就宕机了,Broker 怎么知道这条半消息最终要提交还是要回滚?
RocketMQ 的做法是定时回查。Broker 有一个专门的事务消息检查服务,会周期性扫描半消息队列里长时间没有终态的消息,然后找到这条半消息所属的生产者 Group,向对应的生产者客户端发起事务状态查询。
生产者收到查询请求后,会调用你注册的 checkLocalTransaction 方法。你在那边通过消息中的业务 Key 去查本地数据库中的事务记录,返回最终状态。Broker 再根据返回结果推进消息。
这个机制成立的前提,就是半消息已经在 CommitLog 中持久化。如果半消息没有落盘,Broker 重启后连“曾经有过这条消息”都不知道,更不可能发起回查。所以不要把回查机制看成一个独立功能,它是构建在 Half 消息落盘之上的闭环。
4.2 COMMIT 后,真实业务消息也要再走一次 CommitLog
很多人的下一个疑问是:半消息提交后,是不是只把半消息的几个字节改成“已提交”状态就行?
不行。CommitLog 是追加写的,不允许把已经写入的文件内容原地址改写。半消息进入 CommitLog 时,它的 Topic 是内部半消息主题,消费者并不能路由到它。要让它变成真实业务消息,必须再写一条“没有事务预提交标记”的正常消息,恢复到原始业务 Topic 和原始 QueueId,然后这条新消息的物理记录会被构建到真实业务 Topic 的 ConsumeQueue 中。
所以从 CommitLog 视角看,一次成功的事务消息至少包含两次实质性的写入:
- 第一次写入:半消息落到 CommitLog 的内部半消息主题;
- 第二次写入:Commit 终态确认后,转正后的业务消息落到真实业务 Topic 的路由下。
这个过程会在 Commit 阶段带来一次额外开销。用事务消息时吞吐往往比普通消息低,这不是客户端慢,而是 CommitLog 要承载多一次业务消息写入。批量、小消息体的场景同样要关注这个成本。
当然,为了标记半消息已被处理,RocketMQ 还会有一个内部操作记录,通常被大家叫作 OP 消息或操作日志。它的作用就是告诉检查服务:这条半消息已经有人提交或回滚过,不要再重复回查了。这个操作记录同样要追加到 CommitLog 中。
4.3 ROLLBACK 后,Half 消息并不会从磁盘上立即消失
再讲一个很容易误会的点:回滚事务消息后,那条半消息其实还留在 CommitLog 里。
CommitLog 是顺序追加的,已经写入的数据不会因为逻辑回滚而马上删除。所谓的回滚,只是追加了一条“该半消息已回滚”的操作记录,并在内部主题上标记这条消息不可再被回查或转正。半消息本身的磁盘空间不可能立即回收,要等 CommitLog 文件整体过期被清理时才会随着文件被删除。
所以你在日志或 Dashboard 的旧文件中偶尔还能看到被回滚的半消息,甚至重启后某些内部队列里还能扫到它,这都不代表业务逻辑错乱。判断半消息最终状态的依据不是“CommitLog 中有没有这条记录”,而是“操作记录里是否标记了终态”。
5. 常见问题、验证方法和调优建议
5.1 常见问题速查表
| 问题 | 原因/结论 | 处理方式 |
|---|---|---|
| 半消息发送成功,但业务 Topic 查不到消息 | 半消息在内部半消息主题,不对业务消费者可见,需要等 Commit 后转正 | 用内部事务主题查,或先看执行器是否走到了 COMMIT |
| 配置了异步刷盘,Broker 掉电后半消息丢了 | ASYNC_FLUSH 只 |
