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))
三个说明:
- 先问 “能不能不分布式”:拆服务前先看这些写入能不能落回一个聚合、一个库。能落回去,上面整棵树都不用走。
- 决策树里没有 3PC:多一轮 RTT、挡不住分区、实现支持少,没有工程理由选它。
- 第三方永远排最后: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 加超时。
一条常见的翻车路径
一个假设但常见的决策过程:
- 听到 “资金类必须 TCC”,给 5 个参与方各写 Try / Confirm / Cancel。
- 写到第 4 个发现是外部供应商,没有 Try,只能把它挪出事务,整体改回 Saga。
- Saga 上线后用户读到 “已扣款、未出票”,客诉;补语义锁。
- 语义锁导致重复请求被拒,又补幂等键、重试、死信。
- 最后仍然上线日终对账——因为补偿失败总会发生。
根因只有一句:**先选了模型,后问约束。**如果第一步先列参与方清单,第 2 步的返工不会发生。
评审时的十个问题
- 参与方清单是什么?哪些不可改造、哪些不可补偿?
- 不一致窗口能容忍多久?超时造成的损失怎么赔?
- 中间态会被谁读到?读到之后会做什么错误决策?
- 幂等键是什么?唯一约束落在数据库还是应用层?
- 谁负责重试?上限几次?退避策略?死信去哪?
- 补偿失败怎么办?人工入口、责任人、SLA 是什么?
- 对账怎么做?T+1 还是准实时?差错池谁处理?
- 容量:消息表 / 半消息 / 日志每天增长多少?保留多久?
- 可观测:一个
tx_id/saga_id能否串起全部参与方? - 降级: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 的一步,失败走退款冲正;第三方开票排在最后并标记 “不可补偿”,失败进异步重试 + 人工;发积分只走本地消息表 / 事务消息 (只要通知);全链路用日终对账兜底。不要试图用一个模型覆盖所有步骤。