24.7 事务消息 (半消息 / 回查)
一句话
事务消息把 “发消息” 拆成两段:先投一条消费者看不见的半消息,本地事务成功后再把它转正;如果转正的确认丢了,Broker 就回查生产者的本地事务状态,替它做决定。
和本地消息表的分工
两者解决的是同一个洞——“本地事务 + 发消息” 不原子,但把可靠性责任放在了不同地方:
| 维度 | 本地消息表(24.6) | 事务消息 |
|---|---|---|
| 可靠性责任 | 业务方:自己写 relay、重试、清理 | MQ:Broker 存半消息、定时回查 |
| 消息存放在哪 | 业务库的一张表 | MQ 的 half topic |
| 投递延迟 | 轮询间隔(100ms–1s) | 转正后立即投递,毫秒级 |
| 依赖 | 任意 MQ | 必须支持事务消息的 MQ |
| 业务侵入 | 建表 + relay + 归档 | 实现两个回调 + 本地事务表 |
| 主要运维风险 | 表膨胀、relay 积压 | 半消息堆积、回查失败 |
🔑 事务消息没有让问题消失,只是把 “消息表” 从你的库搬到了 MQ,把 “relay” 换成了 Broker 的回查线程。业务侧仍然要一张本地事务表——否则回查无法回答。
完整流程:两次投递 + 一次兜底回查
Producer Broker Consumer
│ ① 发半消息(Prepared) │
├──────────────────────────────────▶│ 写入 half topic,对消费组不可见
│ ② 返回「存储成功」 │
│◀──────────────────────────────────┤
│ ③ 执行本地事务(订单落库 + 状态表) │
│ ④ Commit / Rollback │
├──────────────────────────────────▶│ Commit → 写入真实 topic ──▶ 可见
│ │ Rollback → 删除半消息
│ (④ 丢失,或返回 UNKNOWN) │
│ ⑤ 回查 CheckLocalTransaction ◀────┤ 定时扫描超时的半消息
│ ⑥ 返回 Commit / Rollback / UNKNOWN │
| 步骤 | 动作 | 失败后果 |
|---|---|---|
| ① | 发半消息 | 发送失败,本地事务根本不执行 |
| ③ | 执行本地事务 | 失败则回滚,并回 ④ ROLLBACK |
| ④ | 二次确认 | 丢失 → 半消息留在 half topic,等回查 |
| ⑤ | 回查 | 生产者查本地事务表回答;查不出就 UNKNOWN |
| ⑥ | 转正 / 丢弃 | 转正后消费可见;超过上限丢弃并告警 |
半消息为什么必须对消费者不可见
半消息 (half message / prepared message) 已经写进 Broker 的存储,但不在真实 topic 里,消费组读不到。原因有三:
- 时序:消息先到、本地事务后提交。若此时可见,消费者可能先读到 “订单已创建”,回头查库却发现订单不存在。
- 回滚:本地事务可能失败。可见的消息无法撤回,只能再发一条 “作废” 消息,把一次失败放大成两条消息。
- 职责:半消息只是一张 “待定信封”,它的唯一用途是等生产者来盖章。盖章之前,它不构成业务事实。
💡 RocketMQ 4.x 把半消息存在
RMQ_SYS_TRANS_HALF_TOPIC,转正时把消息的 topic / queueId 恢复成真实值再写入真实 topic。5.x 服务端换成了事务 API,语义不变。
回查:把 “不确定” 收敛成 “确定”
Broker 不知道生产者那笔本地事务到底提交没有,于是定时来问:
参数(RocketMQ,实际以 broker.conf 为准) | 默认值 | 含义 |
|---|---|---|
transactionTimeOut | 6s | 半消息超过这么久没收到二次确认,就开始回查 |
transactionCheckInterval | 60s | 两次回查之间的间隔 |
transactionCheckMax | 15 | 最多回查多少次 |
按默认值算收敛时间:首次回查最早在第 6 秒,之后每 60 秒一次,第 15 次回查发生在 6 + 60 × 14 = 846s ≈ 14 分钟。超过上限仍无结论,半消息被丢弃 (部分版本转入 TRANS_CHECK_MAX_TIME_TOPIC 供人工排查),并打日志告警。
⚠️ **回查不是重试业务。**回查只回答 “已经发生的那笔本地事务是什么结果”,绝不能借机再执行一次下单。否则一次回查就是一次重复下单——这是事务消息最致命的一类误用。
回查必须查库,不能查内存
生产者的回调长这样:
func (l *listener) CheckLocalTransaction(msg *primitive.MessageExt) primitive.LocalTransactionState {
switch queryTxState(l.db, string(msg.Body)) { // 查本地事务表,不是查内存
case "COMMITTED":
return primitive.CommitMessageState
case "ROLLED_BACK":
return primitive.RollbackMessageState
default:
return primitive.UnkonwnState
}
}
queryTxState 读的是数据库,不是进程内 map——因为回查可能发生在另一个实例上 (生产者重启、扩缩容、宕机迁移)。内存里的状态在崩溃那一刻就没了。
⚠️ 回查读主库。读到延迟的从库会返回 “查不到”,进而答
UNKNOWN;如果积攒到回查上限,一条已经提交的本地事务对应的消息会被丢弃,造成永久不一致。
本地事务表:回查的唯一依据
CREATE TABLE tx_log (
order_id VARCHAR(64) PRIMARY KEY,
state VARCHAR(16) NOT NULL, -- PREPARING / COMMITTED / ROLLED_BACK
updated_at DATETIME NOT NULL DEFAULT NOW()
) ENGINE=InnoDB;
-- 业务提交:订单与状态在同一个本地事务里
BEGIN;
INSERT INTO orders (order_id, user_id, amount, status) VALUES (?, ?, ?, 'PAID');
UPDATE tx_log SET state = 'COMMITTED', updated_at = NOW() WHERE order_id = ?;
COMMIT;
PREPARING 这一档不是多余的:它让回查能区分 “还没开始” 和 “已经回滚”。
- 只写 “提交 / 回滚” 两态:本地事务失败时表里根本没有行,回查只能一直答
UNKNOWN,白等 14 分钟。 - 先写
PREPARING(独立小事务提交),再执行本地事务改状态:回查看到PREPARING且已超过 10 分钟,就可以判定为 “生产者崩了”,安全地回ROLLBACK。
三种返回值,三种命运
| 返回值 | 含义 | Broker 动作 | 什么时候用 |
|---|---|---|---|
COMMIT | 本地事务已提交 | 半消息转正,投递给消费组 | 查到 COMMITTED |
ROLLBACK | 本地事务已回滚 | 删除半消息,永不投递 | 查到 ROLLED_BACK,或业务明确失败 |
UNKNOWN | 还判断不了 | 等下一轮回查,超限丢弃 | 本地事务在途、主从延迟、记录缺失 |
🔑
UNKNOWN是合法返回值,但每次返回它都在消耗回查预算。15 次预算用完,Broker 会替你做决定——丢弃。所以UNKNOWN只能用于 “马上就会有结果” 的场景。
可运行的半消息 / 回查模拟
用内存结构模拟 Broker 与生产者,跑通四种结局 (Go 1.21+,go run 直接跑):
package main
import (
"fmt"
"sync"
)
const (
statePrepared = "PREPARED"
stateCommitted = "COMMITTED"
stateRolledBack = "ROLLED_BACK"
stateDiscarded = "DISCARDED"
)
// localTx 是生产者侧的本地事务表:回查时唯一能回答「到底提交没有」的依据。
type localTx struct {
mu sync.Mutex
states map[string]string // orderID -> COMMITTED / ROLLED_BACK
}
func (l *localTx) set(id, state string) {
l.mu.Lock()
defer l.mu.Unlock()
l.states[id] = state
}
func (l *localTx) get(id string) string {
l.mu.Lock()
defer l.mu.Unlock()
if s, ok := l.states[id]; ok {
return s
}
return "UNKNOWN"
}
type halfMsg struct {
id string
body string
state string
checks int
}
// broker 模拟 MQ:半消息先落盘,转正后才进真实 topic。
type broker struct {
mu sync.Mutex
halves []*halfMsg
topic []string // 消费者可见的消息
maxChecks int
}
func (b *broker) find(id string) *halfMsg {
for _, h := range b.halves {
if h.id == id {
return h
}
}
return nil
}
func (b *broker) sendHalf(id, body string) {
b.mu.Lock()
defer b.mu.Unlock()
b.halves = append(b.halves, &halfMsg{id: id, body: body, state: statePrepared})
fmt.Printf("① 半消息 %s 落盘,消费者不可见\n", id)
}
func (b *broker) commit(id string) {
b.mu.Lock()
defer b.mu.Unlock()
h := b.find(id)
if h == nil || h.state != statePrepared {
return
}
h.state = stateCommitted
b.topic = append(b.topic, h.body)
fmt.Printf(" ✓ %s 转正,消费者可见\n", id)
}
func (b *broker) rollback(id string) {
b.mu.Lock()
defer b.mu.Unlock()
h := b.find(id)
if h == nil || h.state != statePrepared {
return
}
h.state = stateRolledBack
fmt.Printf(" ✗ %s 丢弃(本地事务回滚)\n", id)
}
// checkBack 模拟 Broker 的定时回查:问生产者「这笔本地事务到底成没成」。
func (b *broker) checkBack(id string, query func(string) string) {
b.mu.Lock()
defer b.mu.Unlock()
h := b.find(id)
if h == nil || h.state != statePrepared {
return
}
h.checks++
fmt.Printf("? 第 %d 次回查 %s:", h.checks, id)
switch query(id) {
case stateCommitted:
h.state = stateCommitted
b.topic = append(b.topic, h.body)
fmt.Println("本地已提交 → 转正")
case stateRolledBack:
h.state = stateRolledBack
fmt.Println("本地已回滚 → 丢弃")
default:
if h.checks >= b.maxChecks {
h.state = stateDiscarded
fmt.Println("仍 UNKNOWN,达到上限 → 丢弃并告警")
} else {
fmt.Println("仍 UNKNOWN → 等下一轮")
}
}
}
func main() {
tx := &localTx{states: make(map[string]string)}
b := &broker{maxChecks: 3}
fmt.Println("场景 1:正常两阶段")
b.sendHalf("m1", "order-1 已创建")
tx.set("m1", stateCommitted)
b.commit("m1")
fmt.Println("\n场景 2:本地事务成功,但提交确认丢了")
b.sendHalf("m2", "order-2 已创建")
tx.set("m2", stateCommitted)
calls := 0
b.checkBack("m2", func(id string) string {
calls++
if calls == 1 {
return "UNKNOWN" // 模拟本地事务还在执行
}
return tx.get(id)
})
b.checkBack("m2", tx.get)
fmt.Println("\n场景 3:本地事务失败,回查发现已回滚")
b.sendHalf("m3", "order-3 已创建")
tx.set("m3", stateRolledBack)
b.checkBack("m3", tx.get)
fmt.Println("\n场景 4:回查始终 UNKNOWN,超过上限")
b.sendHalf("m4", "order-4 已创建")
for i := 0; i < 3; i++ {
b.checkBack("m4", tx.get)
}
fmt.Println("\n消费者可见的消息:", b.topic)
}
输出:
场景 1:正常两阶段
① 半消息 m1 落盘,消费者不可见
✓ m1 转正,消费者可见
场景 2:本地事务成功,但提交确认丢了
① 半消息 m2 落盘,消费者不可见
? 第 1 次回查 m2:仍 UNKNOWN → 等下一轮
? 第 2 次回查 m2:本地已提交 → 转正
场景 3:本地事务失败,回查发现已回滚
① 半消息 m3 落盘,消费者不可见
? 第 1 次回查 m3:本地已回滚 → 丢弃
场景 4:回查始终 UNKNOWN,超过上限
① 半消息 m4 落盘,消费者不可见
? 第 1 次回查 m4:仍 UNKNOWN → 等下一轮
? 第 2 次回查 m4:仍 UNKNOWN → 等下一轮
? 第 3 次回查 m4:仍 UNKNOWN,达到上限 → 丢弃并告警
消费者可见的消息: [order-1 已创建 order-2 已创建]
场景 2 是回查的价值所在:确认丢了,消息没丢。场景 4 是它的边界:回查也不是万能的,答不出结论的消息最终会被丢弃。
真实客户端骨架 (RocketMQ)
下面的骨架需要真实 Broker,不能单机跑,但 API 是按上游实现写的 (rocketmq-client-go/v2:监听器在创建生产者时绑定,回查入参是 MessageExt):
package main
import (
"context"
"database/sql"
"log"
"github.com/apache/rocketmq-client-go/v2"
"github.com/apache/rocketmq-client-go/v2/primitive"
"github.com/apache/rocketmq-client-go/v2/producer"
_ "github.com/go-sql-driver/mysql"
)
type orderTxListener struct {
db *sql.DB
}
// 本地事务:订单落库 + 状态表置 COMMITTED,同一个事务。
// 生产实现应先独立提交一行 PREPARING,再在这里改成 COMMITTED,
// 这样回查才能区分「还没开始」和「已经回滚」。
func (l *orderTxListener) ExecuteLocalTransaction(msg *primitive.Message) primitive.LocalTransactionState {
orderID := string(msg.Body)
tx, err := l.db.Begin()
if err != nil {
return primitive.RollbackMessageState // 事务都开不了,业务必然没做
}
defer tx.Rollback()
if _, err := tx.Exec("INSERT INTO orders(order_id, status) VALUES(?, 'PAID')", orderID); err != nil {
return primitive.RollbackMessageState
}
_, err = tx.Exec(`INSERT INTO tx_log(order_id, state) VALUES(?, 'COMMITTED')
ON DUPLICATE KEY UPDATE state='COMMITTED'`, orderID)
if err != nil {
return primitive.RollbackMessageState
}
if err := tx.Commit(); err != nil {
return primitive.UnkonwnState // 提交结果未知,交给回查裁决,绝不在这里重试业务
}
return primitive.CommitMessageState
}
// 回查:只读本地事务表,绝不重复执行业务。注意入参是 MessageExt。
func (l *orderTxListener) CheckLocalTransaction(msg *primitive.MessageExt) primitive.LocalTransactionState {
var state string
err := l.db.QueryRow("SELECT state FROM tx_log WHERE order_id=?", string(msg.Body)).Scan(&state)
if err != nil {
return primitive.UnkonwnState
}
switch state {
case "COMMITTED":
return primitive.CommitMessageState
case "ROLLED_BACK":
return primitive.RollbackMessageState
default:
return primitive.UnkonwnState
}
}
func main() {
db, err := sql.Open("mysql", "dsn")
if err != nil {
log.Fatal(err)
}
// 监听器在创建生产者时绑定,没有单独的 Register 步骤。
p, err := rocketmq.NewTransactionProducer(
&orderTxListener{db: db},
producer.WithNameServer([]string{"127.0.0.1:9876"}),
producer.WithGroupName("order-tx-group"),
producer.WithRetry(2),
)
if err != nil {
log.Fatal(err)
}
if err := p.Start(); err != nil {
log.Fatal(err)
}
defer p.Shutdown()
msg := primitive.NewMessage("order-topic", []byte("order-123"))
res, err := p.SendMessageInTransaction(context.Background(), msg)
if err != nil {
log.Printf("发送事务消息失败:%v", err)
return
}
log.Printf("事务消息结果:%v", res.State)
}
💡 库里的常量名确实拼作
primitive.UnkonwnState(上游笔误,不是排版错误);另外两个是CommitMessageState、RollbackMessageState。
生产端的五条约束
- **本地事务要短。**半消息在等,事务跑得越久,6 秒后的回查越可能撞上 “还没提交”,只能答
UNKNOWN。 - **本地事务表与业务同库同事务。**分开写,就会出现 “业务成功、状态没记” → 回查答不出 → 消息被丢弃。
- **回查回调只读、无副作用、可并发。**Broker 会用多线程回查同一批消息,回调里加锁写库会把回查拖慢。
- **失败时优先返回
ROLLBACK,而不是UNKNOWN。**业务已知失败就明确回滚,别浪费 15 次回查预算。 - **
UNKNOWN只留给 “马上有结果” 的情况。**长期UNKNOWN等于把决定权交给 Broker 的丢弃策略。
消费端仍然要幂等
事务消息只保证生产侧的 “本地事务 ⇔ 消息投递” 原子性,不保证消费一次:
- 转正后投递失败 → MQ 重投;
- 消费成功、ack 丢失 → MQ 重投;
- 消费组再均衡 → 部分消息重放。
所以消费端必须按 msg_id 去重 (幂等表 / 业务唯一键 / 状态机 CAS),规则与本地消息表完全一致 (见 24.6)。
破除误区:Kafka 事务 ≠ 事务消息
| 误区 | 现实 |
|---|---|
| 「Kafka 事务能保证数据库与消息的原子性」 | 不能。Kafka 事务只覆盖 Kafka 内部的多个 topic-partition 写入 + consumer offset 提交;数据库写入不在事务边界内 |
| 「用了事务消息,消费端就不用幂等」 | 事务消息只保证生产侧不丢、转正原子;消费侧仍是 at-least-once |
| 「用了事务消息,不需要本地事务表」 | 回查必须查状态,没有表就只能答 UNKNOWN |
| 「一条事务消息能覆盖多个服务的写入」 | 只覆盖单个 producer 的本地事务 + 一条消息;多服务多写要用 Saga / TCC |
Kafka 事务的正确用途是流处理 exactly-once:transactional.id + 幂等 producer + isolation.level=read_committed,让 “读—处理—写—提交位点” 成为一个原子单元。它不解决 “MySQL 写成功、Kafka 没发出去”。
🔑 需要 DB 与 Kafka 的原子性,仍然只有两条路:本地消息表 (outbox)+ CDC,或者业务侧接受最终一致 + 对账。事务消息是 MQ 厂商提供的 “outbox 托管版”,不是通用协议。
反模式
| 反模式 | 后果 |
|---|---|
本地事务没有状态表,回查永远 UNKNOWN | 回查超限后半消息被丢弃,业务成功但下游永远收不到 |
| 在回查里重新执行下单 / 扣款 | 每次回查都是一次重复业务,直接资损 |
| 本地事务里做远程调用、批量操作 | 超过 6 秒触发回查,回查又查到未提交,UNKNOWN 循环 |
| 回查读从库 | 主从延迟导致「查不到」,已提交的消息被误丢弃 |
| 消费端不幂等 | 重投导致重复发货、重复加积分 |
以为 ROLLBACK 能撤回已投递消息 | 转正后的消息收不回,只能补偿 |
| 用一条事务消息做多服务多写 | 只覆盖单 producer 单消息,跨服务仍需 Saga / TCC |
| 不监控半消息堆积 | 磁盘被未决半消息占满,Broker 拒绝写入,全站消息不可用 |
自检
Q1 半消息已经写进 Broker 了,为什么还要专门设计成 “消费者不可见”?如果可见,会出现哪两个具体故障?
▸ 答案:半消息写盘时,本地事务还没执行完,消息代表的业务事实尚未成立。若可见:① 消费者读到 “订单已创建”,回查业务库却没有这笔订单,只能报错或写入脏数据;② 本地事务失败时消息已经投出,无法撤回,只能再发一条 “作废” 消息,把一次失败放大成两条消息,且中间存在错误的业务窗口。半消息本质是 “待生产者盖章的信封”,盖章前不构成事实。
Q2 回查解决的是哪个时间窗口的问题?生产者如果没有本地事务表,会发生什么?
▸ 答案:解决的是 “Broker 保存了半消息、但没收到生产者 Commit/Rollback 确认” 的窗口——确认包在网络上丢了,或生产者执行完本地事务就崩了。Broker 不知道结果,只能来问。没有本地事务表,回查只能答 UNKNOWN,15 次 (约 14 分钟) 后消息被丢弃:若本地事务其实已提交,下游永远收不到,形成永久不一致;若已回滚,则白等 14 分钟并产生告警噪音。
Q3 团队说 “我们用了 Kafka 事务,所以数据库和消息是原子的,消费端也不用幂等”。指出两处错误。
▸ 答案:① Kafka 事务的边界只在 Kafka 内部 (多个分区写入 + offset 提交),不包含数据库写入。DB 提交成功、Kafka 事务失败 (或反之) 依然不一致。② 事务消息/事务生产者都不改变消费侧语义,消费仍是 at-least-once;重投、再均衡都会导致重复消费,必须幂等。要 DB 与 Kafka 原子,用 outbox + CDC,或接受最终一致 + 对账。