分布式理论03:分布式事务
拆解跨服务提交为什么难,以及 2PC、TCC、Saga、本地消息表这些方案分别适合什么业务后果和补偿边界。
分布式事务难在哪里
单体应用里,一个下单流程可能都在同一个数据库事务里:保存订单、扣库存、使用优惠券,要么一起提交,要么一起回滚。
拆成服务以后,订单服务写订单库,库存服务写库存库,优惠券服务写优惠券库。每个服务都有自己的事务,调用方没有一个天然的总开关,可以让所有数据库同时提交或同时回滚。
于是问题变成:如果订单成功了,库存失败了,系统怎么解释这个中间状态?是立刻回滚订单,还是保留订单等待补偿?用户看到什么?后台怎么修?
分布式事务不是先选框架,而是先判断业务后果。
先按业务后果分层
我通常会先把链路分成三类。分类之后,方案才有意义。
| 链路类型 | 例子 | 特点 | 处理方式 |
|---|---|---|---|
| 强约束链路 | 支付扣款、账户余额、核心库存 | 数据错了直接影响资产或履约 | 强状态校验、对账、幂等、人工兜底 |
| 可补偿链路 | 优惠券占用、积分发放、会员等级 | 失败后可以修回来,但要有明确状态和告警 | 消息重试、补偿任务、状态修复 |
| 旁路链路 | 通知、搜索索引、统计报表 | 不应该阻塞主流程 | 消息异步、最终一致 |
否则把所有链路都做成强一致,系统会很重;把所有链路都异步化,出问题时又没人能解释状态。
四类常见方案对比
| 方案 | 核心思想 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 2PC | 先准备、再提交,协调者统一决策 | 一致性语义清楚 | 协调成本高、阻塞风险大 | 低并发、强一致要求极高的内部系统 |
| TCC | Try 预留、Confirm 确认、Cancel 释放 | 一致性强、业务可控 | 业务侵入强、每个阶段要幂等 | 库存预占、额度冻结等资源可明确冻结的场景 |
| Saga | 拆成多个本地事务,每步有补偿 | 长流程友好、无阻塞 | 补偿不一定等于反向操作 | 订单、物流、履约等长流程 |
| 本地消息表/事务消息 | 业务数据和事件在同一个本地事务里 | 可靠、解耦、落地简单 | 有延迟、需要消费端幂等 | 主库写成功后可靠通知下游 |
没有哪个方案是万能的。真实项目里,同一条链路可能混用多种方案:核心扣减用 TCC,下游通知用本地消息表,长流程编排用 Saga。
一个下单场景怎么选
假设用户下单需要锁库存、使用优惠券、创建支付单。
库存是强约束,可以用预占模型:库存服务先冻结库存,订单支付成功后确认扣减,超时未支付再释放。这更接近 TCC 的思想。
优惠券可以做状态机:未使用、锁定、已使用、已释放。订单创建时锁定,支付成功后确认使用,订单关闭后释放。这里同样要求接口幂等,不能因为重试重复锁定。
支付单通常不适合简单补偿,因为涉及外部支付渠道。更稳的是用支付状态机和回调对账:本地状态只按合法方向推进,定时任务和渠道对账修复异常状态。
通知、积分、搜索索引这些旁路动作,则不要阻塞下单主流程。可以通过本地消息表或 MQ 异步推进。
边界情况比 happy path 更重要
分布式事务最容易漏的是边界情况。
接口超时但下游成功了怎么办?Confirm 重试了两次怎么办?Cancel 比 Try 先到怎么办?消息重复消费怎么办?补偿任务执行一半失败怎么办?
这些问题决定方案是否能上线。比如 TCC 至少要考虑:
Try:预留资源,允许重复请求返回同一结果
Confirm:只确认已经 Try 成功的记录,重复确认不产生副作用
Cancel:Try 不存在时也要安全返回,避免空回滚失败
状态:不能从 Confirmed 回退到 Cancelled
真正可靠的分布式事务,靠的是状态机、幂等、补偿、对账和告警一起工作。
状态表比流程图更重要
分布式事务设计不能只画”订单调用库存再调用优惠券”。真正能落地的是状态表。
比如库存预占可以有这样的状态:
TRYING 已预占,等待订单确认
CONFIRMED 已确认扣减
CANCELLED 已释放
EXPIRED 超时自动释放
对应的更新必须是条件更新:
update stock_reservation
set status = 'CONFIRMED', updated_at = now()
where reservation_id = ?
and status = 'TRYING';
这条 SQL 的关键不是语法,而是它拒绝非法状态流转。重复 Confirm 时,第一次更新成功,后续更新影响行数为 0,业务可以查询当前状态后返回成功。Cancel 也是同理,不能把已经 Confirmed 的记录释放掉。
TCC 里最容易踩的三个坑也都和状态有关:
- 空回滚:Cancel 先于 Try 到达,必须安全返回并记录状态
- 悬挂:Cancel 已经执行,迟到的 Try 不能再预占资源
- 幂等:Try / Confirm / Cancel 都可能重复调用
如果没有状态表,这些坑只能靠代码里的 if-else 硬扛,迟早会漏。
对账不是补充功能,而是事务的一部分
很多分布式事务方案在设计文档里写到”失败后补偿”,但没有说明补偿从哪里发现问题。
真实项目里至少要有三类对账:
| 对账类型 | 例子 | 目的 |
|---|---|---|
| 本地状态对账 | 订单成功但库存预占还在 TRYING | 发现内部状态不一致 |
| 跨服务对账 | 支付成功但订单仍是待支付 | 发现服务间状态不同步 |
| 外部渠道对账 | 本地支付状态和渠道账单不一致 | 发现与外部系统的差异 |
对账任务不能只打印日志。它要能产生待处理记录、重试次数、最后失败原因和人工处理入口。否则补偿失败后,问题只是从在线链路转移到了无人认领的后台任务里。
常见误区
误区一:引入一个分布式事务框架就结束了
框架可以帮你协调流程,但业务状态、幂等键、补偿语义、异常处理仍然要自己设计。
误区二:补偿就是反向 SQL
很多业务动作不可逆,只能通过作废、冲正、追加记录来修复。
误区三:只保证写成功,不设计查询态
用户看到”处理中”多久?后台怎么查卡在哪一步?没有这些,异常会变成客服和运维的黑洞。
项目落地时先问
做分布式事务设计时,可以先问:
- 哪一步失败会产生不可接受后果?
- 哪些资源可以冻结、确认和释放?
- 哪些动作只能补偿,不能回滚?
- 每个接口的幂等键是什么?
- 中间状态用户能不能看到,能看多久?
- 对账和人工处理入口在哪里?
回答完这些,再谈 2PC、TCC、Saga、本地消息表,方案就不会飘。