1. 要解决什么问题
在分布式场景里,「先写数据库再发 MQ 」或「先发 MQ 再写库」单独做,都会在故障时出现只有一种成功、另一种失败的裂缝,例如:
- 库已更新,但 MQ 没发出去 → 下游永远收不到通知。
- MQ 已发出,但库回滚或失败 → 下游已动,与本地状态不一致。
事务消息(半消息) 的思路是:先发一条 对消费者不可见(或等价语义)的半消息 ,在 本地事务有明确结论 后,再由 Producer 与 Broker 协作决定 提交(Commit)或丢弃(Rollback) ;若第一次无法定论,则通过 回查(Check) 多次对齐 数据库或其它持久化状态中的事实,再给出终态。
2. RocketMQ Go 客户端:本地事务状态枚举
事务监听器的两个回调方法均返回:
(github.com/apache/rocketmq-client-go/v2/primitive.LocalTransactionState, error)
其中 LocalTransactionState 在依赖库中的定义(节选)为:
go
const (
CommitMessageState LocalTransactionState = iota + 1 // 1
RollbackMessageState // 2
UnknowState // 3(库内拼写为 Unknow)
)
| 枚举值 | 数值(按上述定义) | 含义 |
|---|---|---|
| CommitMessageState | 1 | 半消息 提交 :之后对订阅该 Topic 的消费组而言,本条消息按 普通已提交消息 参与投递与消费。 |
| RollbackMessageState | 2 | 半消息 回滚/丢弃 :消费组 不会 收到本条消息(在该事务语义下)。 |
| UnknowState | 3 | 未决 :Broker 侧不结束该条事务消息;由 回查 再次询问,直到变为 Commit/Rollback 或触达业务侧「回查次数上限」等策略。 |
说明:UnknowState 不会作为消息体字段传给下游消费者 ;它只存在于 Producer 与 Broker 之间的事务收尾协议 中。
3. 两条核心回调:各自干什么、入参出参是什么
实现方在进程内注册 TransactionListener (接口名以所用客户端为准),由 一个监听器类型 承载两个方法即可。
3.1 ExecuteLocalTransaction --- 半消息阶段的「本地事务」
-
谁调用 :RocketMQ 事务 Producer 所在进程内的客户端,在半消息就绪后 同步(或按客户端实现) 回调。
-
入参
context.Context:超时、取消等。*primitive.Message:半消息。业务上主要使用msg.Body(常见为 JSON/Protobuf 等)、msg.Topic、msg.GetTags()、msg.TransactionId等。
注意:TransactionId是 事务消息 ID ,与 Broker 分配的消费侧MsgId不是同一概念;半消息阶段Message上通常 没有 与回查阶段MessageExt.MsgId对等的完整信息。
-
出参
primitive.LocalTransactionState:三选一 Commit / Rollback / Unknow。error:错误信息;最终是否以 error 改变 Broker 决策以客户端实现为准 。常见实践是:需要回查时返回UnknowState, nil,把「未决」明确体现在第一个返回值上。
-
实现上通常做什么 :解析
msg.Body→ 执行业务约定的 本地副作用 (写库、更新状态机、写发件箱等)→ 返回三态之一。具体是否按 路由字段 (如process_mode、消息类型枚举等)分支,由项目自行约定。
示例(半消息阶段 --- 通用示意)
示例 A:msg 上与排查相关的字段(示意值)
| 字段 / 方法 | 示例值 | 说明 |
|---|---|---|
msg.Topic |
ORDER_DOMAIN_TOPIC |
以实际配置为准 |
msg.GetTags() |
PAID |
按发送端约定 |
msg.TransactionId |
7F0000012345ABC@...(示意) |
事务消息 ID ;不等于 消费侧 MsgId |
msg.Body |
UTF-8 JSON(或其它序列化)字节 | 由业务定义反序列化结构 |
示例 B:msg.Body 的 JSON 骨架(与具体行业无关)
json
{
"meta": { "source": "billing", "occurred_at": 1710000000 },
"route": { "handler": "default" },
"payload": {
"biz_id": "BIZ-2025-0001",
"amount": 100,
"currency": "USD"
}
}
route.handler(或等价字段)可为空,表示 默认处理链 ;非空时由实现方switch/策略表 分发到不同本地逻辑(宣讲时强调「可插拔路由」即可,不必展开各分支业务)。
示例 C:典型返回值(通用语义,不绑定具体函数名)
| 场景 | 返回 (LocalTransactionState, error) |
对外一句话 |
|---|---|---|
| 本地处理成功且允许投递 | (CommitMessageState, nil) |
半消息提交,下游将可消费 |
| 消息体非法、鉴权失败等 确定不应投递 | (RollbackMessageState, err) |
半消息丢弃 |
| 本地处理失败且 无法确定是否已部分落库 | (UnknowState, nil) |
未决,交给回查读持久化状态再定 |
| 基础设施瞬时故障、策略上选择未决 | (UnknowState, err 或 nil) |
依项目约定;宣讲点:未决 ≠ 下游可见 |
| 业务规则明确 禁止再投递(如终态冲突) | (RollbackMessageState, nil) |
不提交半消息 |
3.2 CheckLocalTransaction --- 事务回查
-
谁调用 :当
ExecuteLocalTransaction返回UnknowState,或 Broker/客户端认为需要确认时,之后、可多次 调用。 -
入参
context.Context。*primitive.MessageExt:在Message基础上扩展,包含MsgId、回查次数 (通过GetProperty(primitive.PropertyTranscationCheckTimes)解析)等,便于日志与限次策略。
-
出参 :与上相同,仍为
(LocalTransactionState, error)。 -
实现上通常做什么 :根据
msgExt.Body中的业务主键 查询数据库或缓存 → 若仍未完成可 幂等补跑 → 返回 Commit/Rollback/Unknow;并对 回查次数上限 做保护(超限 Rollback + 告警),避免无限悬挂。
示例(回查阶段 --- 与半消息阶段对照)
示例 A:msgExt 比半消息阶段多了什么(示意)
| 字段 / 方法 | 示例值 | 说明 |
|---|---|---|
msgExt.Body |
与半消息 同一条业务载荷 | 路由规则与第一次一致 |
msgExt.MsgId |
0A1B2C3D4E5F6789ABCDEF0000(示意) |
Broker 侧消息标识 |
msgExt.TransactionId |
与半消息阶段 相同事务 ID(示意) | 串联同一条事务消息生命周期 |
msgExt.GetProperty(PropertyTranscationCheckTimes) |
"1"、"2"、... |
第几次回查;与配置 maxCheckTimes 比较 |
说明:依赖库中属性名为 PropertyTranscationCheckTimes (拼写为 Transcation,与 RocketMQ 历史命名一致)。
示例 B:典型返回值(通用语义)
| 场景 | 返回 (LocalTransactionState, error) |
对外一句话 |
|---|---|---|
| 持久化状态已表明 可投递 | (CommitMessageState, nil) |
提交半消息 |
| 幂等补跑后成功 | (CommitMessageState, nil) |
回查阶段收口为可投递 |
| 持久化状态或策略表明 不可投递 | (RollbackMessageState, nil) |
丢弃半消息 |
| 回查次数 超过上限 | (RollbackMessageState, nil) |
防止无限 Unknown,并应 告警 |
| 仍无法读库或仍 transient | (UnknowState, nil) |
继续未决,等待下次回查 |
4. 代码如何拆(职责分层 --- 宣讲用表)
下表描述 常见拆分方式 ,便于听众理解「不是两个巨型函数写完一切」;具体文件名、函数名为实现细节,各团队可不同。
| 分层 / 模块 | 对外一句话 | 对内说明(不含具体业务算法) |
|---|---|---|
| 监听器入口 | 实现 TransactionListener,注册到 MQ 客户端。 |
对外暴露 ExecuteLocalTransaction / CheckLocalTransaction;内部可 薄封装,转发到 execute/check 两个模块。 |
| 半消息执行(execute) | 半消息 第一次到达 时跑本地逻辑。 | 解析 msg.Body、路由 、调用各领域 用例/服务;返回 Commit/Rollback/Unknow。 |
| 事务回查(check) | 未决 时反复询问,直到可判定。 | 解析回查次数、限次 、读持久化状态、可选 幂等重试;返回三态。 |
| 领域分支(可选) | 按路由走不同本地事务。 | 独立文件/包均可,只要从 execute/check 统一入口 分发,避免回调内无限膨胀。 |
| 消息契约(可选) | 信封、路由常量、序列化辅助。 | 与 MQ 契约版本 对齐;路由字段建议 显式、可测。 |
5. 「HTTP 入站」与「事务监听器」不要混为一谈(通用对照)
| 通道 | 典型形态 | 说明 |
|---|---|---|
| 外部系统 → 本服务 HTTP | 渠道/合作方回调、开放平台通知 | 入参为 HTTP;与 MQ 事务 无直接同一调用栈。 |
| 本服务 → RocketMQ 事务发送 | 应用内异步可靠扩散 | 将 已整理的业务载荷 写入 msg.Body,再走 半消息 → Execute →(必要时)Check → Commit/Rollback。 |
| 消费组 | Push/Pull 消费者 | 仅在 Commit 之后 收到消息;须 幂等 处理。 |
宣讲可强调:事务监听器处理的是「已进入本进程、并放在 msg.Body 里的那条载荷」,而不是 HTTP 连接本身。
6. 死信队列(DLQ)在整条链路中的位置
- 半消息 +
ExecuteLocalTransaction+CheckLocalTransaction:解决的是 「这条消息要不要提交给消费方」 ,不属于 「消息已被消费但一直失败」的场景,因此 通常不称为 DLQ 问题。 RollbackMessageState:半消息丢弃,无下游消费 ,无消费侧 DLQ。UnknowState+ 回查 :仍是 事务未决收尾 ;回查次数超限时常见策略为 Rollback + 告警/运维介入 ,仍 不是 消费 DLQ。- Commit 之后 :消息进入与普通消息相同的投递路径;若 Push 消费者 处理失败,会按 重试队列 / 最大重消费次数 等配置重试;超过上限 时,RocketMQ 常见行为是进入 死信队列(Topic 名常带
%DLQ%+ ConsumerGroup) 或等价隔离,需运维/补偿程序处理。
7. 端到端流程图(宣讲用)
7.1 事务生产者侧(半消息 + 执行 + 回查)
解析 Body、本地处理"] C["CheckLocalTransaction
读状态、限次、补判"] E -->|Commit| B2 E -->|Rollback| B2 E -->|Unknow| C C -->|再判| B2 end U3 --> B1 B1 --> E
说明:图中 Unknow 与 Go 依赖库常量拼写一致(UnknowState)。
7.2 枚举与消费侧、DLQ 的关系(简图)
说明:DLQ 节点表示「超过最大重试等策略后可能进入死信」;标签内避免未闭合的 :、/、" 混用,以免部分渲染器解析失败。
8. 一句话总结
先发半消息占位;ExecuteLocalTransaction 完成与消息绑定的本地处理并返回 Commit(1) / Rollback(2) / Unknow(3);若为 Unknow,则由 CheckLocalTransaction 结合持久化状态多次补判;只有 Commit 后下游消费者才收到同一条载荷;消费反复失败才进入与事务无关的 DLQ 讨论。