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

📅 2026/7/22 10:41:54 👁️ 阅读次数 📝 编程学习
事务消息诞生的背景与要解决的问题

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

你在电商系统里做一个“下单扣库存”的业务。订单服务和库存服务是两个独立的服务,通过消息来解耦:

flowchart LR
subgraph Problem[分布式事务困境]
Step1[订单服务创建订单] --> Step2[发送扣库存消息]
Step2 --> Step3{消息发送成功
但订单插入失败?}
Step3 --> Issue1[“⚠️ 消息已经发出去了
库存被扣了,但订单不存在
→ 数据不一致”]

Step2 -.-> Step4{订单插入成功<br>但消息发送失败?} Step4 -.-> Issue2["⚠️ 订单创建了<br>但库存没扣<br>→ 超卖风险"] 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["半消息 = 消息已存储,但不可消费<br>像个“待确认”的订单"] style HalfMsg fill:#fff3e0

半消息的设计意图:

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

如果半消息直接可见,Consumer 就能消费到——但此时本地事务可能还没执行完,数据状态不确定
如果半消息不可见,Consumer 就消费不到——等本地事务执行完,再决定是提交还是回滚
这样,消息的可见性和本地事务的执行结果绑定在了一起。

本地事务与半消息的协调机制
事务消息的完整流程,核心就是一个两阶段协调的过程:

flowchart TD
Start([业务发起事务消息]) --> Step1[阶段一:发送半消息]

Step1 --> Step1a[Producer 发送半消息到 Broker] Step1a --> Step1b[Broker 存储半消息<br>标记为不可消费] Step1b --> Step1c[Broker 返回半消息发送成功] Step1c --> Step2[阶段二:执行本地事务] Step2 --> Step2a[Producer 执行本地业务逻辑<br>(如插入订单)] Step2a --> Step2b{本地事务结果} Step2b -->|成功| Step3[阶段三:提交/回滚] Step2b -->|失败| Step3 Step3 --> Step3a[Producer 向 Broker<br>发送事务状态] Step3a --> Step3b{状态} Step3b -->|COMMIT| Step3c[Broker 将半消息<br>标记为可见] Step3c --> Step3d[Consumer 可以消费到消息] Step3b -->|ROLLBACK| Step3e[Broker 删除半消息<br>(标记删除)] 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{半消息状态
是否为 UNKNOWN?}
Step1 -->|否| Skip[跳过]
Step1 -->|是| Step2[Broker 向 Producer
发起事务回查请求]

Step2 --> Step3{Producer 是否<br>能正常响应?} Step3 -->|否| Step4[标记为需要再次回查<br>等待下一轮] Step4 --> Step5{回查次数<br>是否超限?} Step5 -->|是| Step6[默认回滚<br>防止消息堆积] Step5 -->|否| Start Step3 -->|是| Step7[Producer 执行<br>事务状态检查逻辑] Step7 --> Step8[返回事务状态<br>COMMIT / ROLLBACK / UNKNOWN] Step8 --> Step9[Broker 根据状态<br>执行提交或回滚] 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 本地事务状态未知 暂不操作,等待回查 ❌ 暂不可见,等待决策
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 端

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 恢复<br>Broker 持续重试<br>超限后默认回滚] Check -->|响应超时| Fix2[优化网络<br>或缩短 checkLocalTransaction 耗时] Check -->|返回 UNKNOWN| Fix3[优化业务检查逻辑<br>确保能快速确定状态] Check -->|超过最大次数| Fix4[监控告警<br>人工介入排查] 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