24.9 事务模型选型

一句话

选型先做减法:用约束排除 “参与方改不了、隔离做不到、网络扛不住” 的方案,再在剩下的里选总成本最低的——一致性强度、改造量、运维负担三者之和,而不是一致性单项最大。

先破除六个误区

误区 现实
「一致性越强越好,直接上 2PC」 2PC 从 prepare 锁到 commit;跨服务长事务会把热点行吞吐锁到个位数,协调者故障还留 in-doubt
「最终一致 = 不可靠」 TCC、Saga、消息表、事务消息全是最终一致。可靠性来自幂等 + 重试 + 对账,不来自模型名字
「TCC 最强,所有场景都该用」 TCC 要求资源可预留、每个分支写 3 个接口 + 状态表。通知类业务用 TCC,改造量是收益的十倍
「Saga 能替代 TCC」 Saga 没有隔离性,中间态会被并发请求读到;资金扣减会因此重复扣款
「上了 MQ 就自动可靠」 MQ 只保证投递;业务与消息的原子性仍要靠本地消息表或事务消息
「3PC 解决了 2PC 的阻塞」 多一轮 RTT,仍然挡不住网络分区;工程上几乎没有可用的实现与生态

🔑 端到端 exactly-once 不存在。所有模型最终都落在 at-least-once + 幂等上。选型选的不是 “有没有 exactly-once”,而是 “不一致窗口多长、窗口里谁来兜底”。

选型只回答三个问题

顺序不能反:先约束,再隔离,最后才谈成本。

① 约束:谁不可控?

约束 触发条件 直接排除的方案
参与方不可改造 第三方支付、银行接口、SaaS、老系统 TCC(没有 Try/Confirm/Cancel)、2PC(没有 XA)
跨组织 / 跨公网 供应商、渠道、外部清算 2PC / XA(长事务 + 协调者单点)
资源不可预留 已放款、已开票、已发短信 TCC(Try 无处预留)
数据必须留痕 资金流水、审计要求 任何「物理删除式回滚」
网络不稳定 弱网、跨地域 2PC(阻塞放大)

**先把参与方清单列出来,标注每个是否可控、是否有补偿手段。**这张表决定了候选集,比任何模型对比都重要。

② 隔离:中间态能不能被看见?

同一个 “已扣款、未发货” 的中间态:

  • 只是展示问题 → Saga / 消息方案可以接受,前端标 “处理中”。
  • 会被别的请求当成事实使用 (库存判定、余额校验、重复下单) → 必须有隔离:单库事务、2PC,或 TCC 的预留。

⚠️ 很多 “Saga 上线后超卖” 的事故,根因不是补偿写错了,而是选型时没问这一句:中间态会被谁读到?

③ 成本:为 “更强” 付多少?

成本项 怎么估 谁最容易低估
改造成本 接口数 × 参与方数 + 状态表 + 补偿逻辑 TCC:每个分支 3 个接口,N 个分支就是 3N
运维成本 表增长、relay、半消息堆积、对账人力 本地消息表:写入量 = 业务 TPS
故障成本 单次不一致的赔付 × 发生概率 消息方案:没有对账就永远发现不了
性能成本 锁持有时间 × TPS = 并发阻塞量 2PC:热点行吞吐 ≈ 1 / 持锁时长

一致性不是开关,是一条光谱

强一致            预留式            可补偿            最终一致          尽力而为
本地事务 ── 2PC ── TCC ── Saga ── 本地消息表 ── 事务消息 ── 最大努力通知
   ↑                  ↑                                    ↑
 不跨库            中间态可见但可控                      只保证「尽力送到」
模型 不一致窗口 隔离性 回滚方式 额外延迟
本地事务 0 强 数据库回滚 0
2PC / XA prepare → commit 强(行锁) 数据库回滚 2 轮 RTT + 全程持锁
TCC Try → Confirm 中(预留量可见) Cancel 释放 / 冲正 2 轮 RTT + 预留窗口
Saga 整个流程 无 逆序补偿 各步耗时之和
本地消息表 提交 → relay → 消费 无 不回头,靠补偿 / 对账 轮询间隔
事务消息 提交 → 转正 → 消费 无 不回头 转正毫秒级
最大努力通知 回调 → 重试 → 对账 无 不回头 重试间隔

🔑 光谱上每向右一步,可用性与吞吐上升,隔离性与实现约束下降。选型就是在这条线上找一个 “业务能接受、团队维护得起” 的落点。

八个模型的总表

模型 一致性 隔离性 业务改造 参与方要求 失败处理 典型场景
本地事务 强 强 无 同一个库 自动回滚 同库多表、单聚合
2PC / XA 强 强 低(换数据源) 全部支持 XA、短事务、低并发 自动回滚,in-doubt 需人工 同机房两库、老系统过渡
3PC 强 强 低 支持 XA(更少) 超时预提交,仍可能不一致 几乎不用
TCC 最终 中(预留) 高(每分支 3 接口) 可改造、资源可预留 Cancel 释放 + 重试 资金、库存、优惠券
Saga 最终 无 中(每步一个补偿) 有语义逆操作 逆序补偿 + 重试 + 死信 长流程、含第三方
本地消息表 最终 无 中(表 + relay) 有业务库 relay 重试 + 消费幂等 本地写 + 通知下游
事务消息 最终 无 中(2 回调 + 状态表) MQ 支持事务消息 回查 + 转正 / 丢弃 已有 RocketMQ
最大努力通知 弱 无 低 对方只接受回调 重试 + 主动查单 + 对账 支付回调、开放平台

成本对照 (相对值,1 最低、5 最高)

模型 改造成本 运维成本 吞吐影响 故障处理成本
本地事务 1 1 1 1
2PC / XA 1 2 4 2
3PC 3 3 4 3
TCC 4 3 2 2
Saga 3 3 1 3
本地消息表 2 2 1 2
事务消息 2 3 1 2
最大努力通知 1 1 1 4

💡 这张表里没有 “总分”。成本可以相加,一致性不能相乘——先用前面的决策树筛掉不可行的,再在这张表里比剩下的。

决策树

需要跨服务 / 跨库吗?
│
├─ 否 → 本地事务(24.1)
│
└─ 是
   │
   ├─ 只是「本地写 + 通知下游」?
   │   ├─ 下游可控   → 本地消息表([24.6](24.6-本地消息表.md))或事务消息([24.7](24.7-事务消息.md))
   │   └─ 下游不可控 → 最大努力通知 + 主动查单 + 对账(24.8)
   │
   ├─ 含不可改造的第三方?
   │   ├─ 是 → Saga,第三方步骤排最后([24.5](24.5-Saga.md))
   │   └─ 否 ↓
   │
   ├─ 中间态被读到会出错(超卖 / 重复扣 / 余额判定)?
   │   ├─ 是 → TCC 预留([24.4 TCC](24.4-TCC.md));同聚合内先考虑拆回本地事务
   │   └─ 否 ↓
   │
   ├─ 全部支持 XA、步骤 ≤ 2、并发低?
   │   ├─ 是 → 2PC / XA(24.2)
   │   └─ 否 → Saga([24.5](24.5-Saga.md))

三个说明:

  1. 先问 “能不能不分布式”:拆服务前先看这些写入能不能落回一个聚合、一个库。能落回去,上面整棵树都不用走。
  2. 决策树里没有 3PC:多一轮 RTT、挡不住分区、实现支持少,没有工程理由选它。
  3. 第三方永远排最后:Saga 把不可补偿的步骤放末尾,前面的步骤失败时不需要撤销它。

场景决策表

场景 关键约束 选型 兜底
同库两表转账 单库 本地事务 无
下单:订单 + 库存 + 优惠券 库存防超卖、券防超发 编排 Saga + 库存/券 TCC 日终对账
订单成功后发积分 / 短信 只需通知、可延迟 本地消息表 / 事务消息 消费幂等 + 积压告警
支付结果通知商户 商户系统不可控 最大努力通知 + 商户查单 T+1 对账
出行:机票 + 酒店 + 租车 三方供应商、可退订 Saga 补偿重试 + 人工
跨行转账 跨组织清算 不走分布式事务:清算 + 对账 差错处理
拆分后的余额扣减 不能多扣、不能少扣 TCC 日终对账 + 差错池
搜索索引 / 数仓同步 可延迟、可重放 CDC / 消息 全量重建
同机房两库短事务 都支持 XA、并发低 2PC / XA 协调者高可用
营销发券(总量约束) 券不能超发 TCC 或数据库条件更新 券库存对账
第三方开票 不可补偿、异步 Saga 最后一步 / 消息 人工 + 对账
老系统只能回调 无法改造 最大努力通知 主动查询 + 对账

一次完整的选型走查

场景:电商下单,跨订单、库存、优惠券三个服务;支付成功后由第三方开票,再发积分通知;峰值 1000 TPS,热门 SKU 库存 1000 件。

第一步,填参与方约束表——这张表才是选型的输入:

参与方 可控 可补偿 需要隔离 备注
订单服务 是 是(取消) 否 主聚合
库存服务 是 是(回补) 是 防超卖
优惠券服务 是 是(退券) 是 防超发
支付 是 部分(退款) 否 退款有手续费
第三方开票 否 否 否 只能重试 / 人工
积分 是 是(扣回) 否 可延迟

第二步,逐个淘汰候选:

候选 否决 / 保留理由
全链路 2PC 库存热点行持锁 66ms → 单 SKU 上限约 15 TPS,否决
全链路 TCC 第三方开票没有 Try/Confirm/Cancel,否决
全链路 Saga 库存中间态被并发下单读到 → 超卖,否决
Saga + 库存/券 TCC + 积分消息 每个约束都有人接住,保留
单库条件扣减 + 消息 库存与订单同库时更简单;拆库后不成立,备选

第三步,定最终组合:

  • 编排式 Saga 做主干:订单为聚合根,状态机与 step 日志持久化;
  • 库存、优惠券用 TCC 预留:Try 冻结、Confirm 落账、Cancel 释放;
  • 第三方开票放在 Saga 最后一步:失败进异步重试 + 人工,不影响前面步骤;
  • 积分走本地消息表:只要求通知,接受秒级到分钟级延迟;
  • 日终对账兜底:差错进差错池,人工处理有 SLA。

把决定写下来:ADR 骨架

选型结论如果不落文档,三个月后没人记得为什么否决了 2PC:

# ADR-024 下单链路的事务模型

- 状态:已接受
- 日期:2026-10-24
- 决策人:交易架构组

## 背景
下单跨订单、库存、券三个服务;库存与券存在并发争抢;
开票由第三方提供,无法改造;峰值 1000 TPS,热门 SKU 库存 1000 件。

## 决策
编排式 Saga + 库存/券 TCC 预留 + 积分本地消息表 + 日终对账。

## 备选与否决理由
- 全链路 2PC:热点行持锁导致单 SKU 约 15 TPS。
- 全链路 TCC:第三方开票无法实现 Try/Confirm/Cancel。
- 全链路 Saga:库存中间态可见,存在超卖。

## 后果
- 新增:Saga 状态机与 step 日志、TCC 分支表、outbox 表、对账任务。
- 接受:「已支付、未出票」窗口,前端展示「处理中」。
- 补偿失败:进死信 + 人工,SLA 4 小时。

ADR 里最重要的不是 “选了什么”,而是否决理由与后果——它们决定了半年后能不能安全地改。

同一场景的三条路线,用数字比

假设:下单峰值 1000 TPS,单个热门 SKU 库存 1000 件,链路 3 个服务,每个服务处理 20ms,跨机房 RTT 2ms。

路线 库存何时被占 峰值下的占用 超卖风险 结论
2PC prepare 起锁行 1000 TPS × 66ms ≈ 66 个事务在排队等同一行锁;单行串行化 → 该行吞吐上限 ≈ 15 TPS 无 热点行排队,P99 爆炸
TCC Try 冻结 预留窗口 200ms × 1000 TPS = 200 件冻结(占库存 20%) 无 Try 只在毫秒级本地事务里改冻结量,不跨服务持锁;代价是 20% 库存在窗口内被冻结
本地消息表 异步扣减 0 冻结 有:异步扣减必然存在「已下单未扣库存」窗口 想消窗口就要同步扣,又回到锁或预留

结论:

  • 如果库存和订单在同一个库:UPDATE stock SET n=n-1 WHERE sku=? AND n>=1 的行锁只有毫秒级,比 TCC 的 200ms 预留窗口短两个数量级——先别急着分布式。
  • 如果库存必须是独立服务:TCC 的预留是跨服务场景下能同时挡住超卖与长锁的手段。
  • 用消息做异步扣减,必须接受超卖并准备赔付方案,否则就别选。

🔑 选型的第一性原理不是 “哪个模型更先进”,而是 “把冲突放在哪一层解决”:数据库行锁 (毫秒)、业务预留 (200ms)、事后补偿 (小时级)。冲突放得越晚,中间态窗口越长,兜底成本越高。

组合,而不是单选

现实里没有一条资金链路只用一个模型:

组合 用在哪 谁兜底
Saga 编排 + 各步 TCC 交易主链路:订单、库存、券 状态机 + 对账
TCC + 日终对账 余额、库存扣减 差错池 + 人工
本地消息表 + 消费幂等 积分、通知、搜索同步 积压告警 + 重放
最大努力通知 + 主动查单 第三方回调 定时对账
2PC + 本地消息表 同库强一致 + 异步下游 relay + 对账

组合的分工原则:**主链路选强一点的,通知链路选弱一点的,最后统一用对账兜底。**对账不是 “可选优化”,是任何最终一致方案的组成部分。

跨地域与多活,会改掉选型结论

约束 对选型的影响
跨地域 RTT ≥ 30ms 2PC 持锁时间随参与方线性增长,直接排除
单元化部署 事务在单元内闭环;跨单元只走消息 + 对账
多地多活 TCC 的冻结量必须单元归属清晰,否则同一份库存被两个单元各冻一次
灾备切换 未决事务要有恢复预案:in-doubt 事务、半消息、Saga 断点

🔑 距离越远,越只能选 “不持锁” 的模型。跨地域的正确做法是把一次事务尽量收进一个单元,而不是给 2PC 加超时。

一条常见的翻车路径

一个假设但常见的决策过程:

  1. 听到 “资金类必须 TCC”,给 5 个参与方各写 Try / Confirm / Cancel。
  2. 写到第 4 个发现是外部供应商,没有 Try,只能把它挪出事务,整体改回 Saga。
  3. Saga 上线后用户读到 “已扣款、未出票”,客诉;补语义锁。
  4. 语义锁导致重复请求被拒,又补幂等键、重试、死信。
  5. 最后仍然上线日终对账——因为补偿失败总会发生。

根因只有一句:**先选了模型,后问约束。**如果第一步先列参与方清单,第 2 步的返工不会发生。

评审时的十个问题

  1. 参与方清单是什么?哪些不可改造、哪些不可补偿?
  2. 不一致窗口能容忍多久?超时造成的损失怎么赔?
  3. 中间态会被谁读到?读到之后会做什么错误决策?
  4. 幂等键是什么?唯一约束落在数据库还是应用层?
  5. 谁负责重试?上限几次?退避策略?死信去哪?
  6. 补偿失败怎么办?人工入口、责任人、SLA 是什么?
  7. 对账怎么做?T+1 还是准实时?差错池谁处理?
  8. 容量:消息表 / 半消息 / 日志每天增长多少?保留多久?
  9. 可观测:一个 tx_id / saga_id 能否串起全部参与方?
  10. 降级:MQ 挂了、协调者挂了、数据库主从切换,业务怎么走?

十个问题里只要有三个答不上来,说明还没到选型阶段,应该先补业务约束。

演进:不是一步到位

阶段 模型 什么时候走到这一步 什么时候退回去
单体单库 本地事务 起步 拆服务后不再适用
刚拆服务,只需通知 本地消息表 有「本地写 + 异步通知」,不想依赖 MQ 特性 relay 维护压不住 → 事务消息
已有 RocketMQ 事务消息 要低延迟、不想自己维护 relay MQ 不支持事务消息、半消息堆积严重 → 本地消息表
长流程、多参与方 Saga 含第三方、步骤 ≥ 3、每步可补偿 补偿逻辑失控 → 收窄事务边界
资金 / 库存强约束 TCC 中间态不能被读到、资源可预留 参与方不可改造 → Saga + 对账

💡 反向演进也是对的:TCC 的维护成本压不住时,把 “防超卖” 收回到单库条件更新,用消息把下游改成异步,往往比继续加补偿逻辑更省。

可运行的选型判定器

把上面的决策树写成一个函数,用 9 个典型场景做表驱动验证 (Go 1.21+,go run 直接跑):

package main

import "fmt"

// Requirement 是选型前必须先问清的约束,顺序就是决策顺序。
type Requirement struct {
	CrossService  bool // 是否跨服务 / 跨库
	ThirdParty    bool // 是否包含不可改造的第三方
	OnlyNotify    bool // 是否只是「本地写 + 通知下游」
	NeedIsolation bool // 中间态是否不能被并发请求读到
	XAReady       bool // 参与方是否都支持 XA,且事务短、并发低
	Steps         int  // 业务步骤数
	Compensable   bool // 每一步是否都有语义逆操作
}

// choose 返回该约束组合下成本最低的可行方案。
func choose(r Requirement) string {
	switch {
	case !r.CrossService:
		return "本地事务"
	case r.ThirdParty && r.OnlyNotify:
		return "最大努力通知 + 对账"
	case r.ThirdParty:
		return "Saga(第三方步骤排最后)"
	case r.OnlyNotify:
		return "本地消息表 / 事务消息"
	case r.XAReady && r.Steps <= 2:
		return "2PC / XA"
	case r.NeedIsolation:
		return "TCC"
	case r.Steps >= 3 && r.Compensable:
		return "Saga"
	default:
		return "本地消息表 + 对账"
	}
}

func main() {
	cases := []struct {
		name string
		r    Requirement
		want string
	}{
		{"同库两表转账", Requirement{Steps: 2}, "本地事务"},
		{"支付回调通知商户", Requirement{CrossService: true, ThirdParty: true, OnlyNotify: true, Steps: 1},
			"最大努力通知 + 对账"},
		{"机票 + 酒店 + 租车", Requirement{CrossService: true, Steps: 3, Compensable: true}, "Saga"},
		{"下单扣库存(防超卖)", Requirement{CrossService: true, NeedIsolation: true, Steps: 2, Compensable: true}, "TCC"},
		{"两个库短事务且都支持 XA", Requirement{CrossService: true, XAReady: true, Steps: 2}, "2PC / XA"},
		{"订单成功后发积分", Requirement{CrossService: true, OnlyNotify: true, Steps: 2}, "本地消息表 / 事务消息"},
		{"第三方开票(不可补偿)", Requirement{CrossService: true, ThirdParty: true, Steps: 4, Compensable: false},
			"Saga(第三方步骤排最后)"},
		{"无隔离要求的三步协同", Requirement{CrossService: true, Steps: 3, Compensable: true}, "Saga"},
		{"不可补偿且无需隔离的多步", Requirement{CrossService: true, Steps: 3}, "本地消息表 + 对账"},
	}

	failed := 0
	for _, c := range cases {
		got := choose(c.r)
		mark := "✓"
		if got != c.want {
			mark = "✗"
			failed++
		}
		fmt.Printf("%s %-22s → %s\n", mark, c.name, got)
	}
	fmt.Printf("\n%d/%d 通过\n", len(cases)-failed, len(cases))
}

输出:

✓ 同库两表转账                 → 本地事务
✓ 支付回调通知商户               → 最大努力通知 + 对账
✓ 机票 + 酒店 + 租车           → Saga
✓ 下单扣库存(防超卖)             → TCC
✓ 两个库短事务且都支持 XA          → 2PC / XA
✓ 订单成功后发积分               → 本地消息表 / 事务消息
✓ 第三方开票(不可补偿)            → Saga(第三方步骤排最后)
✓ 无隔离要求的三步协同             → Saga
✓ 不可补偿且无需隔离的多步           → 本地消息表 + 对账

9/9 通过

这个判定器只编码了 “可行性” 和 “默认最优”,真实选型还要把前面的成本对照表填进去——同一个可行解,在不同团队里的改造成本可能差 3 倍。

反模式

反模式 后果
先选模型,再问约束 第三方接不进来,架构返工,TCC 接口白写
为「技术先进性」上 TCC 3 倍接口 + 状态表,团队维护不动,最后靠人工补账
用 Saga 做余额 / 库存扣减 中间态被读到,重复扣款、超卖
用消息最终一致做防超卖 超卖在对账时才暴露,赔付成本高于改造
跨公网用 2PC 长事务持锁 + 协调者单点,超时即 in-doubt
只做补偿、不做对账 补偿失败无人知,账目长期漂移
忽略消息表增长 单表过亿,relay 扫描拖垮业务库
把 3PC 当 2PC 的升级 多一轮 RTT,仍挡不住分区,几乎没有实现支持

自检

Q1 “资金类必须用 TCC” 这句话只对一半。另一半是什么?

▸ 答案:TCC 只适用于 “资源可预留 + 参与方可改造 + 中间态不能被读到” 的资金场景。跨行清算、第三方支付、渠道分账的参与方根本改不出 Try/Confirm/Cancel,只能走 Saga 或 “通知 + 对账”。资金类的本质要求是不丢、不重、可对账,不是 “必须 TCC”;很多资金系统的主模型是本地事务,跨机构那一段靠清算和对账。

Q2 库存防超卖,什么情况下 Saga 一定不够?给出两种正确解法。

▸ 答案:当库存判定依赖 “当前余量” 且存在并发写入时,Saga 的中间态 (已下单、未扣库存) 会被其他请求当成可售库存读到,必然超卖;补偿发生在事后,补不回已经卖出的货。正确解法:① 库存与订单同库时,用原子条件更新 UPDATE ... WHERE n >= 1 (行锁毫秒级);② 库存独立成服务时,用 TCC 预扣/冻结;③ 也可以加唯一键 + 预扣记录,但本质仍是 “在写之前占住量”。

Q3 2PC 与 TCC 都是两阶段,为什么 2PC 的热点行吞吐会被锁死,TCC 不会?

▸ 答案:2PC 的 prepare 持有数据库行锁直到 commit,热点行同一时刻只能有一个事务持有,吞吐上限 ≈ 1 / 持锁时长 (示例里 66ms → 约 15 TPS)。TCC 的 Try 只是一次短本地事务 (毫秒级) 更新冻结字段,不跨服务持锁;冲突被转移到 “冻结量够不够” 的业务判断上,粒度更细、时间更短。代价是中间态可见、每个分支 3 个接口、需要处理空回滚/悬挂。

Q4 “下单 → 扣库存 → 扣券 → 支付 → 第三方开票 → 发积分” 这条链路,怎么组合模型?

▸ 答案:主干用编排式 Saga (一个 saga_id 串全链路,状态机持久化);库存和券用 TCC 预留 (防超卖、防超发);支付作为 Saga 的一步,失败走退款冲正;第三方开票排在最后并标记 “不可补偿”,失败进异步重试 + 人工;发积分只走本地消息表 / 事务消息 (只要通知);全链路用日终对账兜底。不要试图用一个模型覆盖所有步骤。