系列第二篇:核心概念与架构篇(四)
你好,我们又见面了。
在之前的文章里,我们把 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
关键点:
- 半消息是第一步:先发半消息,再执行本地事务。如果半消息发送失败,直接结束,不用执行本地事务。
- 本地事务决定最终状态 :本地事务成功 →
COMMIT;本地事务失败 →ROLLBACK。 - 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
回查机制的核心设计:
- Broker 主动发起 :Broker 会定期扫描处于
UNKNOWN状态的半消息(默认每 60 秒一次) - Producer 被动响应 :Producer 需要实现
TransactionListener的checkLocalTransaction()方法,根据业务状态返回最终决定 - 可配置重试:回查失败会重试,但如果超过配置的重试次数(默认 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
流程概括:
- 发送半消息 → 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 秒
配置建议:
transactionTimeout要根据业务调整:如果本地事务涉及多个数据库操作或远程调用,需要设置足够的超时时间transactionCheckInterval不宜过短:频繁回查会增加 Broker 和 Producer 的负载transactionCheckMax要结合业务容忍度:核心业务可以设置更大的值(如 30 次),给系统更长的恢复时间
事务消息的使用限制与注意事项
事务消息虽强,但并非万能。在使用时,有几条重要的限制需要牢记:
限制一:不支持延迟消息和批量消息
事务消息不能和延迟消息、批量消息组合使用。事务消息本身就是"先存后发"的机制,和延迟消息的"定时投递"在逻辑上冲突。
限制二:Consumer 必须处理重复消费
事务消息虽然保证了"事务提交后消息可见",但在异常情况下(如网络重传、Rebalance),Consumer 仍然可能重复消费消息。业务方必须做好幂等处理。
限制三:事务消息的发送是同步的
事务消息只支持同步发送,不支持异步或单向发送。因为需要等待本地事务执行结果,同步是最自然的模式。
限制四:事务消息不支持广播消费
广播消费模式下,每个 Consumer 独立消费,事务消息的状态管理会变得复杂,所以 RocketMQ 的事务消息不支持广播模式。
限制五:半消息对 Broker 有存储开销
每条半消息在 COMMIT 或 ROLLBACK 之前,都会占用 Broker 的存储空间(CommitLog 和 ConsumeQueue)。如果有大量半消息长时间 UNKNOWN,会导致存储膨胀。
flowchart LR subgraph Limitations使用限制 L1"❌ 不支持延迟消息" L2"❌ 不支持批量消息" L3"❌ 不支持异步/单向发送" L4"❌ 不支持广播消费" L5"⚠️ 半消息占用存储空间" end style Limitations fill:#fff3e0
事务消息回查失败的处理策略
回查不是万能的,它也可能失败。我们需要理解各种失败场景及应对策略:
场景一:Producer 节点宕机
如果所有 Producer 节点都宕机了,Broker 的回查请求无法送达。
→ 策略 :配置 transactionCheckMax 和 transactionCheckInterval,在 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
性能损耗的核心来源:
- 半消息额外写入:每条事务消息至少多一次 CommitLog 写入
- 事务状态维护:Broker 需要维护和扫描半消息的状态
- 网络往返增加:从 1 次变成 2 次(半消息 + 提交/回滚)
- 回查开销 :如果返回
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 协议等),欢迎继续探讨!
系列文章:
- 入门认知篇 ✅
- 核心概念与架构篇 ✅(含 Topic、路由、存储)
- 存储与原理篇(上)✅
- 存储与原理篇(中)✅
- 存储与原理篇(下)✅
- 事务消息 ✅(本文)
- 进阶应用篇(待续)