Skip to content

分布式一致性与事务 ​

单体时代的 @Transactional 在分布式里不管用了:一次下单要跨订单库、库存库、支付服务、积分服务。 这篇把六种方案放在一张表里比清楚,再给一棵能直接用的决策树。

一、先搞清楚「要多强的一致」 ​

级别含义例子
强一致任何时刻所有节点看到同一份数据银行转账、库存扣减
顺序一致所有节点看到的操作顺序一致消息队列、日志复制
最终一致允许短暂不一致,最终会一致订单搜索、积分、通知

工程现实:跨服务几乎都用最终一致 + 业务补偿,强一致只在极少量核心链路(如账户余额)上追求。

CAP 的结论大家都背过:网络分区发生时,只能在一致性和可用性里选一个。真实系统基本都是 AP + 最终一致。

二、六种方案对比 ​

方案一致性侵入性性能适用
2PC / XA强一致低(数据库层)差(全程锁资源)单库多表、少量跨库
TCC最终一致(偏强)高(写三套接口)好资金、库存等核心链路
Saga最终一致中(写补偿)好长流程、多步骤业务
本地消息表最终一致中(多一张表)好大多数跨服务场景
事务消息最终一致中好已用 RocketMQ
最大努力通知弱(靠重试)低好对接第三方(支付回调)

1. 2PC(两阶段提交) ​

阶段一 Prepare:协调者问所有参与者「能提交吗」,参与者锁住资源回答 yes/no
阶段二 Commit:全 yes 则提交,有 no 则全部回滚
阶段一 Prepare:协调者问所有参与者「能提交吗」,参与者锁住资源回答 yes/no
阶段二 Commit:全 yes 则提交,有 no 则全部回滚

问题:

  • 同步阻塞:Prepare 到 Commit 之间资源一直被锁;
  • 协调者单点:协调者挂了,参与者一直等;
  • 数据不一致:第二阶段部分参与者收到提交、部分没收到。

XA 是 2PC 的工业实现(MySQL 支持),但性能损耗大,互联网场景基本不用。

2. TCC(Try-Confirm-Cancel) ​

Try:     检查并预留资源(冻结 100 元,不扣)
Confirm: 真正扣减(扣掉冻结的 100 元)
Cancel:  释放预留(解冻)
Try:     检查并预留资源(冻结 100 元,不扣)
Confirm: 真正扣减(扣掉冻结的 100 元)
Cancel:  释放预留(解冻)

关键设计:

  • 三个接口都必须幂等;
  • 防悬挂:Cancel 比 Try 先到(网络乱序),要拒绝后到的 Try;
  • 空回滚:Try 没执行成功,Cancel 也要能正常返回。

代价:业务要写三套逻辑,侵入性最强。但它是资金类场景的标配。

3. Saga ​

正向:T1 → T2 → T3 → T4
失败:执行补偿 C3 → C2 → C1(反向)
正向:T1 → T2 → T3 → T4
失败:执行补偿 C3 → C2 → C1(反向)

两种实现:编排式(中央协调器,好维护)和协同式(各服务发事件,耦合低但难追)。

注意:Saga 没有隔离性 —— 中间状态对外可见。所以要配语义锁(状态机只允许特定流转)或预留资源。

4. 本地消息表 ​

见 异步与消息架构 · 第五节。它是最通用的方案:业务和消息在同一事务里落地,再异步投递 + 补偿。

5. 事务消息 ​

RocketMQ 提供 half message + 本地事务 + 回查,不需要自己扫表。绑中间件。

6. 最大努力通知 ​

1. 业务完成后,主动回调第三方(失败则按 1s/5s/30s/... 重试 N 次)
2. 同时提供「查询接口」让对方主动来查
3. 对账兜底
1. 业务完成后,主动回调第三方(失败则按 1s/5s/30s/... 重试 N 次)
2. 同时提供「查询接口」让对方主动来查
3. 对账兜底

典型场景:支付结果通知。不保证一定送到,只保证尽力 + 可查询 + 可对账。

三、选型决策树 ​

跨服务的数据一致性需求
├─ 能不能改成「异步 + 最终一致」?
│   ├─ 能 → 本地消息表(通用)或事务消息(已有 RocketMQ)
│   └─ 不能 → 继续
├─ 是不是资金/库存这类「不能超卖、不能多扣」的?
│   ├─ 是 → TCC(配防悬挂、空回滚、幂等)
│   └─ 否 → 继续
├─ 流程是不是很长(>3 步)、要人工介入?
│   ├─ 是 → Saga(配补偿 + 状态机)
│   └─ 否 → 2PC/XA(只在同库多表、量小的时候用)
└─ 是否对接外部系统?
    └─ 是 → 最大努力通知 + 对账
跨服务的数据一致性需求
├─ 能不能改成「异步 + 最终一致」?
│   ├─ 能 → 本地消息表(通用)或事务消息(已有 RocketMQ)
│   └─ 不能 → 继续
├─ 是不是资金/库存这类「不能超卖、不能多扣」的?
│   ├─ 是 → TCC(配防悬挂、空回滚、幂等)
│   └─ 否 → 继续
├─ 流程是不是很长(>3 步)、要人工介入?
│   ├─ 是 → Saga(配补偿 + 状态机)
│   └─ 否 → 2PC/XA(只在同库多表、量小的时候用)
└─ 是否对接外部系统?
    └─ 是 → 最大努力通知 + 对账

四、兜底:幂等 + 对账 ​

任何方案都会漏,所以最后一定要有两道防线:

幂等 ​

所有参与方(尤其是消费方和 TCC 的 Try/Cancel)都必须幂等:用业务唯一键 + 唯一索引,或状态机(只允许 待支付 → 已支付 单向流转)。

对账 ​

类型做什么频率
内部对账订单金额 vs 支付流水 vs 库存扣减准实时(分钟级)
外部对账我方流水 vs 第三方账单每日 T+1
差异处理自动补偿 / 转人工—

对账是分布式系统的最后一块遮羞布:只要对账能发现差异,就不会出现「钱丢了没人知道」。

五、两个真实案例 ​

案例 1:下单扣库存 ​

❌ 反例:订单服务写库 → 调用库存服务扣减(若扣减失败,订单已生成)

✅ 正例:
1. 订单服务:创建订单(status=待确认) + 写本地消息表(事务内)
2. 消息投递 → 库存服务消费:扣减库存(幂等键 = 订单号)
3. 库存成功 → 回消息「已扣减」→ 订单改「已确认」
4. 库存失败 → 订单改「已取消」并通知用户
5. 定时任务:扫描「待确认」超 5 分钟的订单 → 主动查库存服务状态
❌ 反例:订单服务写库 → 调用库存服务扣减(若扣减失败,订单已生成)

✅ 正例:
1. 订单服务:创建订单(status=待确认) + 写本地消息表(事务内)
2. 消息投递 → 库存服务消费:扣减库存(幂等键 = 订单号)
3. 库存成功 → 回消息「已扣减」→ 订单改「已确认」
4. 库存失败 → 订单改「已取消」并通知用户
5. 定时任务:扫描「待确认」超 5 分钟的订单 → 主动查库存服务状态

案例 2:跨行转账 ​

TCC 三段:
Try     :A 账户冻结 100(可用余额 -100,冻结 +100)
Confirm :A 扣掉冻结的 100;B 增加 100
Cancel  :A 解冻 100

为什么不用本地消息表?因为转账要求「中间状态对外不可见」,
用户不能看到「钱扣了但没到账」—— 这是 TCC 的典型场景。
TCC 三段:
Try     :A 账户冻结 100(可用余额 -100,冻结 +100)
Confirm :A 扣掉冻结的 100;B 增加 100
Cancel  :A 解冻 100

为什么不用本地消息表?因为转账要求「中间状态对外不可见」,
用户不能看到「钱扣了但没到账」—— 这是 TCC 的典型场景。

六、速查 ​

问题答案
跨服务能用 @Transactional 吗不能,它只管单库本地事务
2PC 为什么不用同步阻塞、协调者单点、性能差
TCC 三个坑防悬挂、空回滚、幂等
Saga 最大问题没有隔离性,中间状态可见
最通用的方案本地消息表 + 幂等消费 + 定时补偿
对第三方怎么保证最大努力通知 + 查询接口 + 日终对账
最终兜底靠什么幂等 + 对账,别的都不够