RocketMQ 事务消息(半消息)介绍

9 阅读8分钟

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)
)
枚举值数值(按上述定义)含义
CommitMessageState1半消息 提交:之后对订阅该 Topic 的消费组而言,本条消息按 普通已提交消息 参与投递与消费。
RollbackMessageState2半消息 回滚/丢弃:消费组 不会 收到本条消息(在该事务语义下)。
UnknowState3未决:Broker 侧不结束该条事务消息;由 回查 再次询问,直到变为 Commit/Rollback 或触达业务侧「回查次数上限」等策略。

说明:UnknowState 不会作为消息体字段传给下游消费者;它只存在于 Producer 与 Broker 之间的事务收尾协议 中。


3. 两条核心回调:各自干什么、入参出参是什么

实现方在进程内注册 TransactionListener(接口名以所用客户端为准),由 一个监听器类型 承载两个方法即可。

3.1 ExecuteLocalTransaction — 半消息阶段的「本地事务」

  • 谁调用:RocketMQ 事务 Producer 所在进程内的客户端,在半消息就绪后 同步(或按客户端实现) 回调。

  • 入参

    • context.Context:超时、取消等。
    • *primitive.Message:半消息。业务上主要使用 msg.Body(常见为 JSON/Protobuf 等)、msg.Topicmsg.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.TopicORDER_DOMAIN_TOPIC以实际配置为准
msg.GetTags()PAID按发送端约定
msg.TransactionId7F0000012345ABC@...(示意)事务消息 ID不等于 消费侧 MsgId
msg.BodyUTF-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.MsgId0A1B2C3D4E5F6789ABCDEF0000(示意)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 讨论。