深蓝黑背景封面:右侧大标题「CAP 取舍之后,工程怎么落地」,副标题「BASE 与 2PC、TCC、Saga 三条最终一致性路线」;左侧原创示意图——一座 CAP 三角形天平落定之后,地面分出三条路径石板路:2PC 是两人同步跨栏的紧绷路线,TCC 是预留再确认的谨慎路线,Saga 是走错了回撤一步的补偿路线;左上标注「知识专栏 · 分布式事务」,右上标注「Day 10 · 11-07」 图 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 让中间态裸奔,用补偿事务收尾,换最长的流程也能跑。

方案对照图:顶部一条 BASE 横幅——基本可用、软状态、最终一致,说明这是 CAP 选 AP 之后的工程承诺。中部三栏对照三条路线:2PC 由协调者先问所有参与者「能不能提交」再统一落锤,一致性强但同步阻塞、协调者单点、锁持有时间长,适合短事务与同机房;TCC 把业务动作拆成预留、确认、取消三步,冻结资源换隔离性,一致性接近强但业务侵入最大,适合账务等金额敏感场景;Saga 把大事务拆成一串本地事务,每步配一个补偿动作,失败时逆向回撤,吞吐最好但中间态对外可见,适合长流程与跨系统。右下讲共同代价:补偿与幂等成为业务代码的一等公民,可观测性要追踪到每一步。底部提示:选型先问业务能容忍多久的不一致、能拿出多少改造成本 图 2:三条路线,三种「中间态怎么藏」的答案。

选型先问两个问题。

第一,业务能容忍多久、多可见的不一致?账务流水不能容忍「钱少了」被看见,选 TCC;订单履约的长流程每一步都真实发生(发货不能「冻结」),选 Saga;同机房短事务图省心,2PC 也够用。第二,能拿出多少改造成本?无论哪条路线,补偿与幂等都会成为业务代码的一等公民,可观测性要追踪到每一步——分布式事务框架解决的是协议,语义正确性永远在业务手里。AI 平台里训练任务编排、计费与资源开通的跨系统流转,正是 Saga 与 TCC 的天然战场。

你的系统里跨服务的一致性是怎么收口的?补偿事务的幂等键,当初是怎么设计的?欢迎聊聊。