分布式一致性与事务
单体时代的
@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 最大问题 | 没有隔离性,中间状态可见 |
| 最通用的方案 | 本地消息表 + 幂等消费 + 定时补偿 |
| 对第三方怎么保证 | 最大努力通知 + 查询接口 + 日终对账 |
| 最终兜底靠什么 | 幂等 + 对账,别的都不够 |
Cao Note