1. 要解决什么问题
在分布式场景里,「先写数据库再发 MQ」或「先发 MQ 再写库」单独做,都会在故障时出现只有一种成功、另一种失败的裂缝,例如:
- 库已更新,但 MQ 没发出去 → 下游永远收不到通知。
- MQ 已发出,但库回滚或失败 → 下游已动,与本地状态不一致。
事务消息(半消息) 的思路是:先发一条 对消费者不可见(或等价语义)的半消息,在 本地事务有明确结论 后,再由 Producer 与 Broker 协作决定 提交(Commit)或丢弃(Rollback);若第一次无法定论,则通过 回查(Check) 多次对齐 数据库或其它持久化状态中的事实,再给出终态。
2. RocketMQ Go 客户端:本地事务状态枚举
事务监听器的两个回调方法均返回:
(github.com/apache/rocketmq-client-go/v2/primitive.LocalTransactionState, error)
其中 LocalTransactionState 在依赖库中的定义(节选)为:
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 骨架(与具体行业无关)
{
"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 事务生产者侧(半消息 + 执行 + 回查)
flowchart TD
subgraph Up["上游应用内"]
U1["业务完成"]
U2["构造 msg.Body"]
U3["事务发送 API"]
U1 --> U2 --> U3
end
subgraph Brk["Broker"]
B1["半消息 prepared"]
B2{"终态"}
B3["Commit 后可投递"]
B4["Rollback 丢弃"]
B1 --> B2
B2 -->|Commit| B3
B2 -->|Rollback| B4
end
subgraph Lst["TransactionListener"]
E["ExecuteLocalTransaction<br/>解析 Body、本地处理"]
C["CheckLocalTransaction<br/>读状态、限次、补判"]
E -->|Commit| B2
E -->|Rollback| B2
E -->|Unknow| C
C -->|再判| B2
end
U3 --> B1
B1 --> E
说明:图中 Unknow 与 Go 依赖库常量拼写一致(UnknowState)。
7.2 枚举与消费侧、DLQ 的关系(简图)
flowchart LR
subgraph Tx["LocalTransactionState"]
S1["1 Commit"]
S2["2 Rollback"]
S3["3 Unknow"]
end
subgraph Post["Commit 之后"]
M["消费"]
R{"成功?"}
RT["重试"]
DLQ["DLQ"]
M --> R
R -->|是| OK["ACK"]
R -->|否| RT --> M
RT -->|超次| DLQ
end
S1 --> M
S2 --> X["无消费"]
S3 --> CK["Check"]
CK --> S1
CK --> S2
说明:DLQ 节点表示「超过最大重试等策略后可能进入死信」;标签内避免未闭合的 :、/、" 混用,以免部分渲染器解析失败。
8. 一句话总结
先发半消息占位;ExecuteLocalTransaction 完成与消息绑定的本地处理并返回 Commit(1) / Rollback(2) / Unknow(3);若为 Unknow,则由 CheckLocalTransaction 结合持久化状态多次补判;只有 Commit 后下游消费者才收到同一条载荷;消费反复失败才进入与事务无关的 DLQ 讨论。