深入 RocketMQ 内核:事务消息——分布式事务的终极解法(四)

系列第二篇:核心概念与架构篇(四)

你好,我们又见面了。

在之前的文章里,我们把 RocketMQ 的存储、发送、消费全链路都拆解了一遍。但有一个重量级特性,我们一直没聊------事务消息(Transactional Message)

为什么把事务消息放在这里才讲?因为理解事务消息需要两个前提:一是你要知道消息是怎么存储的(CommitLog + ConsumeQueue),二是你要知道消息是怎么发送的(同步/异步/单向)。有了这些基础,我们才能深入理解事务消息的精妙之处。

说实话,事务消息是 RocketMQ 最复杂、也最令人惊叹的特性之一。它解决的是一道难题------在分布式系统中,如何保证"发送消息"和"执行本地事务"要么一起成功,要么一起失败?

今天这篇文章,我们就来彻底搞懂事务消息。我会从它要解决的问题开始,一步步深入到半消息、事务回查、状态机等核心机制。老规矩,配合流程图,一步一图。

八、事务消息

事务消息诞生的背景与要解决的问题

我们先来看一个经典的分布式事务困境

你在电商系统里做一个"下单扣库存"的业务。订单服务和库存服务是两个独立的服务,通过消息来解耦:
flowchart LR subgraph Problem分布式事务困境 Step1订单服务创建订单 --> Step2发送扣库存消息 Step2 --> Step3{消息发送成功<br>但订单插入失败?} Step3 --> Issue1"⚠️ 消息已经发出去了\库存被扣了,但订单不存在\→ 数据不一致" Step2 -.-> Step4{订单插入成功<br>但消息发送失败?} Step4 -.-> Issue2"⚠️ 订单创建了\但库存没扣\→ 超卖风险" end style Issue1 fill:#ffcdd2 style Issue2 fill:#ffcdd2

问题可以归结为一句话:本地数据库操作(订单插入)和消息发送(扣库存通知)这两个操作,不在同一个事务里。它们要么都成功,要么都失败------但在分布式环境下,这很难做到。

常见的解决方案及其局限

方案 怎么做 问题
先发消息,再执行本地事务 消息发出去后再插入订单 消息发成功但订单插入失败 → 库存被误扣
先执行本地事务,再发消息 插入订单后再发消息 订单插入成功但消息发送失败 → 超卖
本地事务 + 本地消息表 在同一个数据库里存消息表,定时扫描发送 依赖数据库,轮询效率低,有延迟

RocketMQ 事务消息的解法

RocketMQ 事务消息的核心思想是:让消息的发送和本地事务的执行,通过"半消息 + 事务回查"机制,达成最终一致性

它不依赖本地消息表,不需要轮询,不需要额外的存储,全部在 Broker 和 Producer 之间协调完成。

事务消息的核心流程:半消息(Half Message)

什么是半消息(Half Message)?

半消息是事务消息的核心概念。它是一种暂不可见 的消息------Producer 发送了这条消息,但 Consumer 暂时还消费不到
flowchart LR subgraph HalfMsg半消息 H1"✅ 消息已写入 CommitLog\(有存储)" H2"❌ 消息在 ConsumeQueue 中\被标记为不可见" H3"⏳ 等待本地事务\执行结果" end NoteHalf"半消息 = 消息已存储,但不可消费\像个"待确认"的订单" style HalfMsg fill:#fff3e0

半消息的设计意图

半消息的存在,解决了"消息发送成功了,但本地事务还没执行"这个中间状态的问题。

  • 如果半消息直接可见,Consumer 就能消费到------但此时本地事务可能还没执行完,数据状态不确定
  • 如果半消息不可见,Consumer 就消费不到------等本地事务执行完,再决定是提交还是回滚

这样,消息的可见性和本地事务的执行结果绑定在了一起

本地事务与半消息的协调机制

事务消息的完整流程,核心就是一个两阶段协调的过程:
flowchart TD Start(业务发起事务消息) --> Step1阶段一:发送半消息 Step1 --> Step1aProducer 发送半消息到 Broker Step1a --> Step1bBroker 存储半消息\标记为不可消费 Step1b --> Step1cBroker 返回半消息发送成功 Step1c --> Step2阶段二:执行本地事务 Step2 --> Step2aProducer 执行本地业务逻辑\(如插入订单) Step2a --> Step2b{本地事务结果} Step2b -->|成功| Step3阶段三:提交/回滚 Step2b -->|失败| Step3 Step3 --> Step3aProducer 向 Broker\发送事务状态 Step3a --> Step3b{状态} Step3b -->|COMMIT| Step3cBroker 将半消息\标记为可见 Step3c --> Step3dConsumer 可以消费到消息 Step3b -->|ROLLBACK| Step3eBroker 删除半消息\(标记删除) Step3e --> Step3f消息被丢弃,不消费 style Step1a fill:#e3f2fd style Step2 fill:#c8e6c9 style Step3c fill:#c8e6c9 style Step3e fill:#ffcdd2

关键点

  1. 半消息是第一步:先发半消息,再执行本地事务。如果半消息发送失败,直接结束,不用执行本地事务。
  2. 本地事务决定最终状态 :本地事务成功 → COMMIT;本地事务失败 → ROLLBACK
  3. Broker 最终执行COMMIT 使消息可见,ROLLBACK 使消息被丢弃。

事务回查(Check)机制的实现原理

上面我们讨论的是正常情况------本地事务执行完,Producer 能正常告诉 Broker 是 COMMIT 还是 ROLLBACK

但如果本地事务执行完后,Producer 宕机了,或者网络断了呢?

Broker 怎么知道这条半消息最终应该被提交还是回滚?

答案就是事务回查(Transaction Check)机制
flowchart TD Start(Broker 扫描半消息) --> Step1{半消息状态<br>是否为 UNKNOWN?} Step1 -->|否| Skip跳过 Step1 -->|是| Step2Broker 向 Producer\发起事务回查请求 Step2 --> Step3{Producer 是否<br>能正常响应?} Step3 -->|否| Step4标记为需要再次回查\等待下一轮 Step4 --> Step5{回查次数<br>是否超限?} Step5 -->|是| Step6默认回滚\防止消息堆积 Step5 -->|否| Start Step3 -->|是| Step7Producer 执行\事务状态检查逻辑 Step7 --> Step8返回事务状态\COMMIT / ROLLBACK / UNKNOWN Step8 --> Step9Broker 根据状态\执行提交或回滚 style Step2 fill:#ffcdd2 style Step7 fill:#e3f2fd style Step9 fill:#c8e6c9

回查机制的核心设计

  1. Broker 主动发起 :Broker 会定期扫描处于 UNKNOWN 状态的半消息(默认每 60 秒一次)
  2. Producer 被动响应 :Producer 需要实现 TransactionListenercheckLocalTransaction() 方法,根据业务状态返回最终决定
  3. 可配置重试:回查失败会重试,但如果超过配置的重试次数(默认 15 次),Broker 会默认回滚

为什么需要回查?

回查解决了分布式系统中的不确定性问题------当 Producer 不可达时,Broker 不能无限等待,必须有最终决策。回查机制确保了半消息不会永远处于"悬而未决"的状态。

事务消息的状态:COMMIT、ROLLBACK、UNKNOWN

RocketMQ 事务消息有三种状态,由 Producer 的 TransactionListener 返回:

状态 含义 Broker 操作 Consumer 是否可见
COMMIT 本地事务成功,提交消息 将半消息标记为可见 ✅ 可见,可消费
ROLLBACK 本地事务失败,回滚消息 删除半消息 ❌ 不可见,被丢弃
UNKNOWN 本地事务状态未知 暂不操作,等待回查 ❌ 暂不可见,等待决策
java 复制代码
public class OrderTransactionListener implements TransactionListener {
    
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            // 执行本地事务:插入订单
            orderService.createOrder(arg);
            return LocalTransactionState.COMMIT_MESSAGE;
        } catch (Exception e) {
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
    }
    
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // 回查时,根据业务数据判断事务是否成功
        String orderId = msg.getProperty("orderId");
        if (orderService.orderExists(orderId)) {
            return LocalTransactionState.COMMIT_MESSAGE;
        }
        return LocalTransactionState.ROLLBACK_MESSAGE;
    }
}

UNKNOWN 的使用场景

  • 本地事务执行过程中发生了异常,无法确定是否成功
  • 本地事务执行时间很长,需要异步确认
  • 依赖外部系统的状态,需要等待外部回调

UNKNOWN 状态触发的回查机制,给了系统足够的容错空间。

事务消息的完整时序图与执行流程

把前面所有的内容串起来,我们来看一张完整的时序图:
sequenceDiagram participant App as 业务应用 participant P as Producer participant B as Broker participant C as Consumer participant DB as 本地数据库 App->>P: 1. 发送事务消息 P->>B: 2. 发送半消息<br>(状态:PREPARED) B->>B: 3. 存储半消息<br>(不可消费,记录事务状态) B-->>P: 4. 半消息发送成功 P->>App: 5. 回调执行本地事务 App->>DB: 6. 执行本地事务<br>(插入订单等) alt 本地事务成功 DB-->>App: 7a. 事务提交成功 App-->>P: 8a. 返回 COMMIT P->>B: 9a. 提交事务消息 B->>B: 10a. 标记消息可见 B-->>C: 11a. Consumer 消费到消息 else 本地事务失败 DB-->>App: 7b. 事务回滚 App-->>P: 8b. 返回 ROLLBACK P->>B: 9b. 回滚事务消息 B->>B: 10b. 删除半消息 Note over C: 消息不可见,不被消费 else 本地事务执行异常/超时 App-->>P: 8c. 返回 UNKNOWN Note over P: 此时不向 Broker 发送最终决定 loop 事务回查(默认每 60 秒) B->>P: 9c. 发起回查请求 P->>App: 10c. 检查本地事务状态 App->>DB: 11c. 查询业务数据 DB-->>App: 12c. 返回查询结果 App-->>P: 13c. 返回 COMMIT/ROLLBACK/UNKNOWN P->>B: 14c. 提交最终事务状态 alt 多次回查仍返回 UNKNOWN B->>B: 15c. 超过回查次数限制,默认回滚 end end end

流程概括

  1. 发送半消息 → 2. 执行本地事务 → 3. 根据结果决定状态 → 4. COMMIT/ROLLBACK/UNKNOWN → 5. UNKNOWN 触发回查 → 6. 最终确定状态

这个流程保证了:要么本地事务成功且消息被消费,要么本地事务失败且消息被丢弃

事务回查的时间参数配置

事务回查涉及多个时间参数,合理配置这些参数是事务消息稳定运行的关键:

参数 默认值 说明 配置位置
transactionTimeout 6000ms 本地事务执行超时时间。超过这个时间,Broker 认为事务状态未知,触发回查 Producer 端
transactionCheckInterval 60000ms Broker 回查半消息的间隔 Broker 端
transactionCheckMax 15 次 最大回查次数。超过后默认回滚 Broker 端
yaml 复制代码
# Broker 端配置
transactionCheckInterval=60000          # 回查间隔 60 秒
transactionCheckMax=15                  # 最大回查 15 次
transactionCheckTimeout=6000            # 回查请求超时时间 6 秒

# Producer 端配置
DefaultMQProducer producer = new DefaultMQProducer();
producer.setTransactionTimeout(6000);   // 事务超时 6 秒

配置建议

  1. transactionTimeout 要根据业务调整:如果本地事务涉及多个数据库操作或远程调用,需要设置足够的超时时间
  2. transactionCheckInterval 不宜过短:频繁回查会增加 Broker 和 Producer 的负载
  3. transactionCheckMax 要结合业务容忍度:核心业务可以设置更大的值(如 30 次),给系统更长的恢复时间

事务消息的使用限制与注意事项

事务消息虽强,但并非万能。在使用时,有几条重要的限制需要牢记:

限制一:不支持延迟消息和批量消息

事务消息不能和延迟消息、批量消息组合使用。事务消息本身就是"先存后发"的机制,和延迟消息的"定时投递"在逻辑上冲突。

限制二:Consumer 必须处理重复消费

事务消息虽然保证了"事务提交后消息可见",但在异常情况下(如网络重传、Rebalance),Consumer 仍然可能重复消费消息。业务方必须做好幂等处理。

限制三:事务消息的发送是同步的

事务消息只支持同步发送,不支持异步或单向发送。因为需要等待本地事务执行结果,同步是最自然的模式。

限制四:事务消息不支持广播消费

广播消费模式下,每个 Consumer 独立消费,事务消息的状态管理会变得复杂,所以 RocketMQ 的事务消息不支持广播模式

限制五:半消息对 Broker 有存储开销

每条半消息在 COMMITROLLBACK 之前,都会占用 Broker 的存储空间(CommitLog 和 ConsumeQueue)。如果有大量半消息长时间 UNKNOWN,会导致存储膨胀。
flowchart LR subgraph Limitations使用限制 L1"❌ 不支持延迟消息" L2"❌ 不支持批量消息" L3"❌ 不支持异步/单向发送" L4"❌ 不支持广播消费" L5"⚠️ 半消息占用存储空间" end style Limitations fill:#fff3e0

事务消息回查失败的处理策略

回查不是万能的,它也可能失败。我们需要理解各种失败场景及应对策略:

场景一:Producer 节点宕机

如果所有 Producer 节点都宕机了,Broker 的回查请求无法送达。

策略 :配置 transactionCheckMaxtransactionCheckInterval,在 Producer 恢复前,Broker 会持续尝试回查。如果超过最大次数仍无响应,Broker 默认回滚。运维层面需要监控"超时未确认"的半消息数量。

场景二:回查请求超时

Producer 收到了回查请求,但因为网络或负载原因,响应超时。

策略 :Broker 会重试回查(下一次扫描时再次发起)。Producer 端应确保 checkLocalTransaction() 方法的执行时间远小于 transactionCheckTimeout

场景三:checkLocalTransaction 返回 UNKNOWN

Producer 仍然无法确定事务状态(比如依赖的数据库暂时不可用)。

策略 :返回 UNKNOWN 后,Broker 会在下一个回查周期再次询问。建议在业务层实现重试机制,直到能确定状态为止。

场景四:超过最大回查次数

15 次回查都失败了,消息仍然 UNKNOWN

策略 :Broker 默认回滚消息(从 5.x 开始,可以通过配置修改默认行为)。运维需要监控这类情况,手动介入处理。
flowchart TD Start回查失败 --> Check{失败类型} Check -->|Producer 宕机| Fix1等待 Producer 恢复\Broker 持续重试\超限后默认回滚 Check -->|响应超时| Fix2优化网络\或缩短 checkLocalTransaction 耗时 Check -->|返回 UNKNOWN| Fix3优化业务检查逻辑\确保能快速确定状态 Check -->|超过最大次数| Fix4监控告警\人工介入排查 Fix1 --> Done Fix2 --> Done Fix3 --> Done Fix4 --> Done style Start fill:#ffcdd2 style Done fill:#c8e6c9

事务消息与分布式事务的对比(TCC、Saga)

事务消息只是分布式事务的一种解决方案。我们来对比一下主流的几种方案:

TCC(Try-Confirm-Cancel)

维度 TCC 事务消息
核心思想 预留资源 → 确认/取消 半消息 → 提交/回滚
业务侵入 高(需要实现 Try/Confirm/Cancel) 中(只需实现本地事务 + 回查)
数据一致性 强一致性 最终一致性
适用场景 跨服务调用、资源预留 消息驱动的异步场景
性能 较低(多次 RPC) 较高(一次半消息 + 本地事务)

Saga

维度 Saga 事务消息
核心思想 长事务拆分为多个本地事务 + 补偿 本地事务 + 消息最终一致性
业务侵入 高(需要实现正向操作 + 补偿操作)
数据一致性 最终一致性 最终一致性
适用场景 长事务、跨多个服务的复杂流程 消息驱动的异步解耦场景

对比总结
flowchart TB subgraph Compare分布式事务方案对比 ATCC\强一致性\业务侵入高 BSaga\最终一致性\需要补偿 C事务消息\最终一致性\通过回查自愈 end subgraph Selection选型建议 S1"🔵 事务消息:\消息驱动、异步解耦、\对一致性要求不是强同步" S2"🟠 TCC:\跨服务调用、需要强一致性、\资源可预留" S3"🟢 Saga:\长事务、复杂流程、\需要有补偿逻辑" end C --> S1 A --> S2 B --> S3 style A fill:#fff3e0 style B fill:#e3f2fd style C fill:#c8e6c9

选型建议

  • 事务消息适合:消息驱动的异步场景,对性能要求高,能接受最终一致性
  • TCC 适合:跨服务调用的强一致性场景,资源可以预留(如扣减库存、冻结资金)
  • Saga 适合:复杂的长事务流程,每个步骤都有明确的补偿操作

事务消息与普通消息的性能对比

事务消息因为多了"半消息 + 本地事务 + 可能的回查"这些步骤,性能自然不如普通消息。我们来量化对比一下:

对比维度 普通消息 事务消息 性能差距
网络往返 1 次(发送) 2-3 次(发送半消息 + 提交/回滚 + 可能的回查) 约 2 倍延迟
Broker 写入 1 次 CommitLog 写入 2 次 CommitLog 写入(半消息 + 提交标记) 约 2 倍存储 IO
存储空间 消息体 消息体 + 事务状态记录 约额外 30%-50%
CPU 开销 中(需要事务状态管理和扫描) 约 20%-30% 额外
TPS 上限 ~10 万级 ~2-3 万级 约为普通消息的 1/3 - 1/5

flowchart LR subgraph Normal普通消息发送 N1发送请求 --> N2写入 CommitLog --> N3返回 ACK NoteNormal"1 次网络往返 + 1 次写入" end subgraph Transactional事务消息发送 T1发送半消息 --> T2写入半消息 --> T3返回 ACK T3 --> T4执行本地事务 T4 --> T5发送 COMMIT/ROLLBACK --> T6标记消息状态 T6 --> T7返回 ACK NoteTran"2 次网络往返 + 2 次写入\+ 本地事务 + 可能的回查" end style Normal fill:#c8e6c9 style Transactional fill:#fff3e0

性能损耗的核心来源

  1. 半消息额外写入:每条事务消息至少多一次 CommitLog 写入
  2. 事务状态维护:Broker 需要维护和扫描半消息的状态
  3. 网络往返增加:从 1 次变成 2 次(半消息 + 提交/回滚)
  4. 回查开销 :如果返回 UNKNOWN,还有额外的回查负载

💡 小贴士:如果你的业务不需要强一致性保障,普通消息 + 幂等消费可能是更好的选择。事务消息虽好,但不要滥用------它只应该在"本地事务和消息发送必须保持一致"的场景下使用。

事务消息的内部存储结构

了解事务消息的存储细节,能帮你更深入地理解它的工作原理:
flowchart TB subgraph Storage事务消息存储结构 subgraph Half半消息存储 H1CommitLog 中写入\消息体 + 事务标记 H2ConsumeQueue 中写入\但标记为不可见 H3TransactionStateService\记录事务状态 UNKNOWN end subgraph Committed提交后 C1在原 ConsumeQueue 位置\标记为可见 C2可能增加 Op 消息\记录提交操作 end subgraph Rollback回滚后 R1标记为已删除\不影响 CommitLog R2消息不可消费 end end style Half fill:#fff3e0 style Committed fill:#c8e6c9 style Rollback fill:#ffcdd2

关键存储组件

组件 作用
RMQ_SYS_TRANS_HALF_TOPIC 半消息的存储 Topic,所有事务消息的半消息都暂存于此
TransactionStateService 管理事务状态的服务,扫描半消息并触发回查
Op 消息 记录 COMMIT/ROLLBACK 操作,用于快速判断半消息的最终状态

小结

这篇文章我们彻底吃透了 RocketMQ 的事务消息机制,通过 7 张流程图,搞清楚了:

  • 事务消息要解决的问题------分布式环境下本地事务和消息发送的一致性问题
  • 半消息的概念------暂不可见的中间状态,解决了"先发消息还是先执行事务"的困境
  • 本地事务与半消息的协调------两阶段流程:发送半消息 → 执行本地事务 → 提交/回滚
  • 事务回查机制------当 Producer 不可达时,Broker 主动回查,确保消息不会永远悬而未决
  • 三种事务状态------COMMIT、ROLLBACK、UNKNOWN 的含义和流转
  • 时间参数配置------transactionTimeout、transactionCheckInterval 等参数的作用
  • 使用限制------不支持延迟/批量/异步/广播等
  • 回查失败的处理策略------各种异常场景的应对方案
  • 与其他分布式事务方案的对比------TCC、Saga 的差异和选型建议
  • 性能对比------事务消息约为普通消息吞吐量的 1/3 到 1/5

事务消息是 RocketMQ 最复杂的特性之一,但它的设计极其精妙------通过"半消息 + 回查"的方式,在保证最终一致性的同时,把业务侵入降到了最低。

现在,RocketMQ 的核心知识体系你已经完整掌握了。从架构到存储,从发送到消费,再到事务消息这个重量级特性,你已经成为了一名真正的 RocketMQ 内行人。如果还有进阶话题想深入(如 RocketMQ 5.x 的 Pop 消费模式、gRPC 协议等),欢迎继续探讨!


系列文章:

  1. 入门认知篇 ✅
  2. 核心概念与架构篇 ✅(含 Topic、路由、存储)
  3. 存储与原理篇(上)✅
  4. 存储与原理篇(中)✅
  5. 存储与原理篇(下)✅
  6. 事务消息 ✅(本文)
  7. 进阶应用篇(待续)