图 1:本文封面,取舍做完,才轮到工程上场。
「讲讲 CAP」是道经典面试题,那道题的主轴是取舍框架——一致性、可用性、分区容忍三者不可兼得,分区不可回避,于是真正要选的是 C 还是 A。但面试答完取舍,工程上的问题才刚开始:选了 AP 之后,不一致的窗口怎么收口?跨系统的转账、下单、审批,到底用什么机制保证「最终」会一致?本文讲取舍之后的落地:BASE 的工程承诺,以及 2PC、TCC、Saga 三条路线各自适合什么场景。
BASE:AP 路线的工程承诺。
BASE 是 Basically Available(基本可用)、Soft state(软状态)、Eventually consistent(最终一致)的缩写,可以读作 CAP 选 AP 之后的施工图纸:基本可用——故障时允许降级(返回旧值、排队、部分可用)而不是整体拒绝;软状态——允许副本之间存在中间态,状态可以随时间推移而变化;最终一致——只要不再写入,系统保证所有访问终将看到最后的更新。Werner Vogels 在 2008 年的《Eventually Consistent》里把这条链路讲透了:放松一致性换取分区下的可用性,代价是开发者必须直面「不一致窗口」,用会话一致性、单调读、读写己之写等变体把窗口管住。BASE 不是「放弃一致性」,而是把一致性从「立即」改签到「终将」。
路线一:2PC,强一致的同步阻塞方案。
两阶段提交由协调者主导:第一阶段 prepare,协调者问所有参与者「能不能提交」,参与者校验、锁定资源、答 Yes 或 No;第二阶段,全部 Yes 则统一 commit,任一 No 则全员 rollback。一致性最强——要么都提交要么都回滚——但代价同样硬:同步阻塞,prepare 期间资源锁一直被持有;协调者单点,它在 prepare 之后宕机,参与者只能持锁干等;锁持有时间长,吞吐受限。适合短事务、同机房、参与者少的场景,比如数据库内部的 XA 事务。
路线二:TCC,冻结资源换隔离性。
TCC 把每个业务动作拆成三步:Try(预留资源,比如冻结 100 元而不是直接扣)、Confirm(确认执行,把冻结变实扣)、Cancel(取消,解冻退回)。Try 全过才进 Confirm,任何一步失败就逆向调 Cancel。冻结换来接近强一致的隔离性——中间态不会泄露成「钱少了」——代价是业务侵入最大:每个动作要写三套接口,每套都要幂等、要防空转(Cancel 先于 Try 到达)、防悬挂(Try 在 Cancel 之后又到达)。适合金额敏感的账务核心。
路线三:Saga,本地事务串加补偿。
Saga 出自 1987 年 Garcia-Molina 与 Salem 的论文,原始动机是长事务(LLT)占锁太久:把一个长事务拆成一串本地事务 T1…Tn,每步直接提交释放锁;每步配一个补偿事务 Ci,第 j 步失败时逆向执行 Cj-1…C1 回撤。论文里那个关键句子今天依然刺眼:补偿事务从语义上撤销已提交的动作,但不必把数据库恢复到原初状态——退款的流水不是「从未扣款」,而是一笔反向记录。吞吐最好、天然适合长流程与跨系统编排;代价是中间态对外可见——T1 提交后、补偿完成前,别人真能看到「钱少了」。
三条路线在同一笔转账上各跑一遍。
下面这份模拟让「A 扣 100、B 加 100」且 B 失败的场景分别走三条路线,用 Node.js 22.15.0 直接运行(node dtx.js):
'use strict';
const log = [];
// 2PC:prepare 全员持锁答 Yes/No,统一落锤;协调者宕机则参与者持锁阻塞
function twoPC() {
log.push('== 2PC ==');
log.push('prepare:A、B 各自校验并锁定资源');
log.push('B 答 No -> 协调者发 rollback,A、B 各自回滚释放锁');
log.push('一致性:强;代价:prepare 期间锁一直被持有,协调者宕机参与者干等');
}
// TCC:Try 冻结 -> Confirm 实扣 / Cancel 解冻
function tcc() {
log.push('== TCC ==');
let aAvailable = 1000, aFrozen = 0;
aAvailable -= 100; aFrozen += 100; // Try:冻结而不是直接扣
log.push(`Try:A 冻结 100(可用 ${aAvailable},冻结 ${aFrozen})`);
aAvailable += 100; aFrozen -= 100; // Cancel:逆向解冻
log.push(`Confirm 阶段 B 失败 -> Cancel:A 解冻(可用 ${aAvailable},冻结 ${aFrozen})`);
log.push('一致性:接近强(冻结换隔离);代价:每个动作写 Try/Confirm/Cancel 三套接口');
}
// Saga:本地事务逐步提交,失败逆向补偿,中间态对外可见
function saga() {
log.push('== Saga ==');
let a = 1000;
a -= 100;
log.push(`T1 提交:A 扣 100(余额 ${a})<- 此刻外部已可见中间态`);
log.push('T2 失败 -> 补偿 C1:A 退回 100');
a += 100;
log.push(`补偿完成:A 余额 ${a}(最终一致,但中间曾「钱少了」)`);
log.push('一致性:最终一致;代价:每步写补偿、每步幂等');
}
twoPC();
tcc();
saga();
console.log(log.join('\n'));
实测输出:
== 2PC ==
prepare:A、B 各自校验并锁定资源
B 答 No -> 协调者发 rollback,A、B 各自回滚释放锁
一致性:强;代价:prepare 期间锁一直被持有,协调者宕机参与者干等
== TCC ==
Try:A 冻结 100(可用 900,冻结 100)
Confirm 阶段 B 失败 -> Cancel:A 解冻(可用 1000,冻结 0)
一致性:接近强(冻结换隔离);代价:每个动作写 Try/Confirm/Cancel 三套接口
== Saga ==
T1 提交:A 扣 100(余额 900)<- 此刻外部已可见中间态
T2 失败 -> 补偿 C1:A 退回 100
补偿完成:A 余额 1000(最终一致,但中间曾「钱少了」)
一致性:最终一致;代价:每步写补偿、每步幂等
同一个失败场景,三种失败姿势:2PC 把资源锁到最后一刻,任何外部观察者都看不到中间态,但协调者一倒全员卡死;TCC 用冻结把中间态隔离起来,业务代码量翻三倍;Saga 让中间态裸奔,用补偿事务收尾,换最长的流程也能跑。
图 2:三条路线,三种「中间态怎么藏」的答案。
选型先问两个问题。
第一,业务能容忍多久、多可见的不一致?账务流水不能容忍「钱少了」被看见,选 TCC;订单履约的长流程每一步都真实发生(发货不能「冻结」),选 Saga;同机房短事务图省心,2PC 也够用。第二,能拿出多少改造成本?无论哪条路线,补偿与幂等都会成为业务代码的一等公民,可观测性要追踪到每一步——分布式事务框架解决的是协议,语义正确性永远在业务手里。AI 平台里训练任务编排、计费与资源开通的跨系统流转,正是 Saga 与 TCC 的天然战场。
你的系统里跨服务的一致性是怎么收口的?补偿事务的幂等键,当初是怎么设计的?欢迎聊聊。