你的订单在 A 库,库存卡在 B 库:分布式事务的四份解药,没有一个是银弹
我见过最安静的线上事故,是一张订单表里躺着一笔「已支付」的订单,而库存表里对应的 SKU 还写着 9。用户没投诉,客服没工单,监控全绿。只是每过几天,就有人手工去数据库里改一行数据。
这就是分布式事务的日常打开方式:不报警,不崩溃,只是慢慢地把系统的可信度腐蚀成「这东西得人工看一眼」。今天把它掀开:为什么单机事务救不了你、四种主流解法各自骗了谁、以及我怎么给它们排座次。
一、先说清楚:为什么你不能直接用 ACID
单机数据库的 BEGIN / COMMIT 之所以可靠,是因为事务边界和锁边界在同一个进程、同一个存储引擎里。数据库能拍着胸脯保证:要么全成,要么全不成。
一旦写操作跨越了进程边界——下单要写订单库,扣库存要调仓储服务,扣款要调支付网关——这个承诺就碎了。三个独立系统,谁也不知道对方到底成没成。
关键在于「不知道」比「失败」可怕得多。失败了你重试就行;不知道,你就只能干瞪眼:支付网关超时了,钱扣了没扣?订单写库成功但响应包丢在路上,客户端重试会不会下两单?
这就是为什么处理分布式事务的核心不是「怎么保证不出错」,而是「出错了怎么发现、怎么收敛、怎么让用户感觉不到」。
把分布式事务当成「更强的 ACID」去追求,是很多人掉坑的起点。它不是更强,它是换了一套游戏规则。
二、2PC:教科书里的完美答案,和它的尴尬
两阶段提交(Two-Phase Commit)是最正统的方案:协调者先让所有参与者「准备」(prepare,写 WAL、锁资源),收到全部同意后才统一「提交」(commit)。
协调者 → 参与者A: prepare?
参与者A → 协调者: yes (已写日志, 持锁)
协调者 → 参与者B: prepare?
参与者B → 协调者: yes
协调者 → 参与者A: commit
协调者 → 参与者B: commit
理论上无懈可击,实践中是很多分布式数据库的底座。但作为业务代码里的手写方案,有三条硬伤:
1. 阻塞。prepare 之后、commit 之前参与者必须持锁等着。协调者这时挂了(网络分区、进程被 kill),资源就锁死到超时恢复,用户看到的就是转圈圈。
2. 协调者是单点。你以为写了 RPC 调用,实际上写了个要处理崩溃一致性的分布式系统。
3. 跨异构系统走不通。支付网关是别人家的 API,你没法让它 participate,也没法给它写 WAL。
结论:2PC 是基础设施层面的工具,不是业务代码天天该用的锤子。
三、TCC:把「资源锁定」换成「业务补偿」
TCC(Try / Confirm / Cancel)是业务侵入式方案:不追求数据库层面的原子性,而是让每个业务动作都能反向撤销。
Try: 冻结 1 件库存(status=FROZEN, frozen=1)
Confirm: 冻结转实扣(status=DEDUCTED)
Cancel: 解冻(status=FROZEN → status=AVAILABLE)
优点非常实在:性能好(无全局锁,Try 阶段只改一行状态)、可跨异构系统(对方支持「冻结/确认/解冻」语义即可,外部 HTTP 接口也能包装成 TCC)、隔离性做到业务级(冻结库存不计入可用量)。
但它有两个非常容易被低估的坑:
坑一:幂等是及格线,不是加分项。Cancel 可能被网络重试触发三次,你的解冻逻辑必须扛住三次。而且 Confirm 和 Cancel 可能同时到达(下游超时后两边都重试),你需要一个状态机去仲裁,而不是一个 if。
典型实现是给每笔操作一个全局唯一的分支事务 ID,维护一张「操作日志表」:
BranchTxLog{
XID string // 全局事务 ID
BranchID string // 分支 ID
Status int // TRYED / CONFIRMED / CANCELED
CreatedAt time
}
// 唯一索引:(XID, BranchID)
// 重复请求 → 读日志 → 直接返回上次结果
坑二:空回滚与悬挂。
- 空回滚:Cancel 先于 Try 到达(网络乱序),Cancel 必须能识别「我还没 Try 过」并直接记账返回成功,否则你会去解冻一个不存在的冻结。
- 悬挂:Cancel 已空回滚成功,迟到的 Try 又到,它把货冻结住,再没人来解冻——这笔库存永久卡住。
解法不复杂但必须做:给分支操作加状态机 + 悬挂检测。偷懒的实现,线上一定会以「库存莫名少了一堆」的形式送你上热榜。
四、Saga:把长事务拆成一串可补偿的本地事务
Saga 适合流程长、步骤多、参与方多的场景:下单 → 支付 → 发货 → 结算。它把大事务拆成 n 个小事务,任何一个失败,就从失败点开始反向补偿已完成的那几步。
正向: T1 建单 → T2 扣库存 → T3 支付 → T4 发货
补偿: ← C2 还库存 ← C3 退款 ← C4 取消发货
Saga 最大的优点是不需要参与方支持任何事务协议,每个服务只需提供「做」和「撤」两个接口。你不可能去改支付网关的实现。
但 Saga 有两个必须知道的性质:
1. 它只能提供最终一致性,中间态是可见的。下单到支付之间可能有好几秒,订单在这段时间是「处理中」。如果业务要求「绝不允许用户看到未支付订单」,得在业务层加状态屏蔽,别指望 Saga。
2. 补偿不是撤销。这是最反直觉的一点。补偿动作本身也可能在业务上无法完全回退:
- 优惠券已经发给用户了,补偿时你无法「收回」用户心里对这个券的认知。
- 短信已经发出去了,撤不回来。
- 已经通知了第三方系统,撤回可能引发对方的问题。
所以设计 Saga 时,第一步不是设计正流程,而是逐条列出每个正向动作的补偿动作,明确哪些是「真补偿」哪些是「尽力而为」。无法真补偿的步骤,正向动作本身就要做成幂等 + 人工兜底。
另外别忘了:补偿也可能失败。每个补偿步骤同样需要重试和持久化,需要一个「补偿队列」盯着卡在半路的补偿,而不是打个日志就完事。
五、可靠消息 / 本地消息表:最土但最常用的那招
很多场景下你不需要「跨库原子性」,只需要「操作和消息都不丢」。这时候本地消息表(Transactional Outbox)是最优解,粗暴但极稳:
BEGIN;
INSERT INTO orders ...; -- 业务数据
INSERT INTO outbox (msg, status) VALUES (..., 'PENDING'); -- 事件
COMMIT;
-- 独立线程轮询 outbox → 投递 MQ → 标记 SENT
要点在于:业务数据和待发消息写在同一个本地事务里,「写成功但消息丢了」这个经典问题从根上消失。投递失败就重试,MQ 天然给你 at-least-once。
这个方案的代价是最终一致有个延迟窗口:从本地事务提交到消息被消费,中间有个几百毫秒到几秒的窗口。如果业务能接受这个窗口(绝大多数异步场景都能),它简单、可靠、无侵入,比 TCC 少写一半代码。
消费端必须假设「消息会重复」:用业务唯一键(订单号)做去重表,或用乐观锁做幂等。重复投递不是理论风险,是常态。
还有一个坑:消费者拿到消息时,业务事务可能早已完成很久了。分布式系统没有「当前状态」,只有某个时刻的一致视图。
六、怎么选:一张务实的选择表
- 强一致是硬需求,参与方全是自己的数据库 → XA / Seata AT,或者干脆别拆库。
- 步骤少(2-4 步)、性能敏感 → TCC。库存、优惠券这类资源操作的标准解。
- 流程长、跨组织、补偿动作清晰 → Saga。订单履约、跨系统结算。
- 只需要最终一致 + 消息驱动 → 本地消息表 + 幂等消费。
如果你读完这张表还在纠结,那大概率说明第一步就不该是分布式事务,而是:能不能不拆?
我见过最优雅的「分布式事务解决方案」,是客户把两个库合成了一个,然后发现整个需求瞬间变成了一个本地事务加一个事务消息。设计降级能力,比设计容错能力省下的力气多得多。
七、写在最后:别把它当银弹,当成纪律
不管选哪种方案,下面几条纪律永远成立:
1. 每个步骤都必须幂等。不是因为某个框架帮你做了,是因为分布式世界里「重试」是默认行为而非异常。幂等键唯一索引 + 状态机,是最省事的组合。
2. 每一步都必须可观测。全局事务 ID 贯穿所有日志,一个 grep 就能捞出整条链路。没有这个,排查卡住的事务等于在黑暗里摸象。
3. 补偿逻辑要单独测试,而且要故意让它失败。混沌演练(在 Cancel 阶段注入超时、重复、乱序)才是真考纲。补偿路径是那种「平时没人跑、出事时全靠它」的代码。
4. 必须有人工兜底入口。再完善的方案也会有「卡住」的状态。要有一个后台页面能看到所有悬挂事务,能手动重试或强制取消。做得好不好,最后看的是「异常时你能不能五分钟内搞明白并处理掉」。
说到底,分布式事务不是有标准答案的题,是一道关于「你愿意接受多大不一致、多快收敛」的工程题。选一个你能解释清楚的方案并监控起来,比选一个理论最优但没人懂的高级方案强一百倍。
毕竟,架构图可以很精妙,但凌晨三点能把系统捞回来的人只有一个。
如果这篇讲得还算清楚,别吝啬,转给那个正在做分布式事务改造的同事。他会感谢你——或者至少,会在半夜少打你一个电话。