Chapter 06

分布式事务

上一章把"单份数据在多台机器上"能拿到的保证排成了一把梯子——线性一致到最终一致,并指出多数派 quorum 的交集是共识的安全基石。本章把镜头从"一份数据"拉到"一个操作横跨多个节点或多个服务":当一次下单要同时改库存、改余额、发消息,而它们分散在不同的库、不同的服务里,怎样让这组改动整体成立或整体失败。

本章建立的认知

  • 2PC 把原子性建立在一个协调者上,因此是按构造阻塞的协议——参与者投了 YES 之后只能等。
  • Saga 用一串本地事务加补偿换来无全局锁,代价是放弃隔离:中间状态对外可见,补偿是语义抵消而非回滚。
  • TCC 用 Try 阶段把资源预留成 pending 状态,把中间值藏起来,隔离强于 Saga,代价是每个服务要实现三个幂等接口。
  • 跨"数据库 + 消息"的双写无法原子完成;发件箱(outbox)把业务行和事件写进同一个本地事务,再异步投递,用 at-least-once 换确定性。

事务这个词在单机关系库里指 ACID:一组改动要么全成、要么全废(原子性),不会读到别人写到一半的状态(隔离性)。把它搬到分布式环境,难点不在"事务"这个概念,而在第 01 章那条主线——状态被迫跨机器存在。一旦库存表在 A 服务的库、余额表在 B 服务的库,一次业务操作就要让两个独立的存储节点对"成"或"败"达成一致。第 05 章已经证明:让多个节点对一件事达成一致需要付出代价(共识要多数派往返,或者牺牲可用性)。分布式事务就是这笔代价在"业务操作"这一层的具体账单。

本章拆四种机制,每一种都先讲它怎么提供原子性,再讲它因此付出什么、在什么条件下失效。读完应该能在一次架构评审里,对"这个跨服务操作该怎么保证正确"给出有依据的选型,而不是条件反射地说"上个分布式事务框架"。

6.1两阶段提交:原子性押在一个协调者身上

两阶段提交(two-phase commit,简称 2PC)是把单机事务的"提交"动作拆成两步,套到多个参与者身上。一个协调者(coordinator)指挥若干参与者(participant,通常是各自的数据库节点):

  • 阶段一 · prepare(准备):协调者问每个参与者"你能提交吗"。参与者把改动写进 redo/undo 日志、锁住涉及的行,确认自己之后无论崩溃重启都能提交,然后回投 YES。投了 YES 就等于立下承诺:之后协调者一旦下令提交,该参与者必须能提交。
  • 阶段二 · commit/abort(提交或中止):只要有一个参与者投 NO,协调者广播 abort;全员 YES,协调者把"提交"这个决定先持久化,再广播 commit。参与者收到后落实改动、释放锁。

这套设计的原子性来自一个关键点:所有参与者把"自己决定提交"的权力交给了协调者。没有任何参与者能单方面决定,于是不会出现"A 提交了 B 中止了"的分叉。这也正是它致命弱点的来源。

机制深一层 · in-doubt 窗口为何无解

考虑这个时刻:所有参与者都投了 YES、都进入持锁等待,而协调者在广播阶段二之前崩溃。此刻一个参与者醒来,发现自己处于 in-doubt(悬而未决)状态——它已经承诺能提交,但它不知道最终决定是 commit 还是 abort。

它能自己决定吗?不能。若它擅自 commit,而别的参与者其实收到过 abort,原子性就破了;若它擅自 abort,而协调者其实已经把 commit 决定持久化并告诉了别人,原子性同样破了。唯一安全的动作是继续持锁等待协调者恢复。锁就这样一直拿着,挡住所有想碰这些行的事务。

很多人的第一反应是:那把协调者做成高可用、复制几份不就行了?这能让协调者更不容易整体消失,但救不了 in-doubt 窗口本身。问题不在协调者"会挂",而在阶段一和阶段二之间存在一个时间窗口,参与者在这个窗口里持锁且无法自决。复制让这个窗口更难触发,却没有消除"参与者必须等一个外部决定"这个结构。2PC 是按构造(by design)阻塞的协议——这是 Gilbert-Lynch 那条铁律在事务层的回声:要强原子性,就别指望同时拿到"协调者故障时仍不阻塞"。

2PC 时序:协调者在阶段二前崩溃,参与者持锁卡死 协调者 Coordinator 参与者 A(库存库) 参与者 B(余额库) 阶段一 PREPARE prepare? prepare? 锁住行 锁住行 YES(已承诺) YES(已承诺) 协调者崩溃 (阶段二未发出) in-doubt 持锁等待 无法自决 in-doubt 持锁等待 无法自决 阶段二缺席 → 锁无限期挂住,挡住所有后续事务
图 6.12PC 的阻塞不是偶发故障,而是协议结构:阶段一与阶段二之间存在一个参与者持锁且无法自决的窗口。注意:协调者崩溃后,参与者既不能擅自提交也不能擅自中止,只能持锁干等——复制协调者降低崩溃概率,但消不掉这个 in-doubt 窗口。
代价与何时失效

代价:阶段一到阶段二全程持锁;锁的持有时间被网络往返和最慢参与者拉长。何时失效:协调者在阶段二前崩溃、或某参与者 GC 暂停 / 网络抖动,都会把锁拖到秒级甚至更久,在跨微服务调用里直接打满调用方的线程池或连接池,引发级联过载。结论:2PC 适合同一个事务管理器内、参与者少且都在内网低延迟的场景(典型是单数据库内部跨分片,或同一个 XA 资源管理器下的若干库);不适合跨独立部署的微服务——这也是 09 章会讲到的"跨服务 XA/2PC 已被取代"。

预测一下

电商下单要扣库存(服务 A)+ 扣余额(服务 B),用 2PC 把两个服务的库绑成一个事务。在运行期最可能出什么问题?换成下一节的 Saga,又要牺牲什么?

展开参考答案

2PC 的运行期风险:只要协调者重启、或 A/B 任一参与者发生 GC 暂停 / 短暂网络分区,投了 YES 的参与者就持锁卡在 in-doubt,库存行和余额行被锁住,期间所有想读写这些行的请求排队,调用方线程池迅速被占满,一个下单的卡顿放大成全站下单变慢——故障被原子性协议放大成了可用性事件。

换 Saga 牺牲的:Saga 没有全局锁、不阻塞,但放弃了隔离性——"已扣库存、还没扣余额"这个中间状态对外可见(别的请求可能读到偏少的库存),而且失败时靠补偿(把库存加回去)而不是回滚,补偿动作本身可能因业务副作用不可逆(详见下一节与本章挑战)。

6.2Saga:用补偿换掉全局锁

既然跨服务持锁是 2PC 的病根,Saga 的思路就是根本不持全局锁。一个长流程被拆成一串本地事务 T₁、T₂、…、Tₙ,每一步只在自己的库里做一个普通的本地事务并立即提交。如果走到第 k 步失败了,就反向执行已完成步骤的补偿事务 Cₖ₋₁、…、C₁,把已经造成的影响一个个抵消掉。

这里的关键词是补偿(compensation),不是回滚(rollback)。回滚是数据库还没提交、直接丢弃;补偿是前一步已经真实提交、已经生效,只能再做一个语义上相反的操作去抵消它。"扣了 10 件库存"的补偿是"加回 10 件库存";"创建了订单"的补偿是"把订单标记为已取消"。

Saga 正向链与失败后的反向补偿链 T1 创建订单 本地事务·已提交 T2 扣库存 本地事务·已提交 T3 扣余额 本地事务·已提交 T4 失败 余额不足 触发反向补偿 C1 取消订单 语义抵消 C2 库存加回 语义抵消 C3 余额退回 语义抵消 反向顺序 C3 → C2 → C1:每步补偿单独提交,期间中间状态对外可见
图 6.2Saga 把一个大事务换成"一串小事务 + 一串补偿",从而免去全局锁。注意:补偿是反向语义抵消已提交的影响,不是回滚;在 T2、T3 提交到补偿完成之间,"库存已扣、订单将被取消"的中间状态对其他请求可见——这就是 Saga 放弃的隔离性。

Saga 有两种编排方式,值得在评审里分清:

  • 编排式(orchestration):一个中央 saga 编排器显式调用每一步、记录进度、决定何时补偿。逻辑集中、易追踪、易处理超时,代价是编排器成为一个需要自己保证高可用的组件。
  • 协同式(choreography):没有中央大脑,每个服务完成自己那步后发一个事件,下游服务订阅事件触发下一步。无单点、耦合低,但整条流程的状态散落在各服务里,出问题时难以看清"现在走到哪、该不该补偿"。
代价与何时失效

代价:放弃隔离性。中间状态对外可见,等于允许了脏读——回扣第 05 章那把一致性梯子:Saga 期间系统对外只提供接近"最终一致"的保证,而非事务隔离。何时失效:当某一步的副作用不可逆时,补偿无法真正抵消——"已发出的发货短信""已打到第三方账户的钱"收不回来,补偿只能退化成"再发一条更正""走人工退款流程"。结论:Saga 适合步骤多、跨多个服务、能为每一步设计出合理补偿、且业务能容忍短暂中间态的长流程;不适合要求严格隔离、或关键步骤不可逆又无人工兜底的场景。

6.3TCC:用 pending 状态把中间值藏起来

Saga 的痛点是中间状态裸露。TCC(Try-Confirm-Cancel)针对的正是这一点——它把每个参与服务的操作拆成三个必须由业务自己实现的接口:

  • Try:尝试预留资源,但不正式生效。比如扣库存的 Try 不是真扣,而是把 10 件库存从"可用"挪到"冻结(pending)";扣余额的 Try 把金额从"可用余额"挪到"冻结金额"。
  • Confirm:所有参与者 Try 都成功后,逐个 Confirm,把冻结的资源正式落实(冻结的库存真正扣减、冻结的金额真正划走)。Confirm 只操作自己 Try 阶段预留的那份,不再做业务校验。
  • Cancel:任一 Try 失败,对已 Try 成功的参与者逐个 Cancel,把冻结释放回去。

关键在于:对外可见的口径里,pending 的资源既不算"还在"也不算"已用"。别的请求看到的"可用库存"已经把冻结部分排除,于是不会读到 Saga 那种"看起来还在、其实即将被扣"的中间值。隔离性因此强于 Saga——这是 TCC 相对 Saga 的核心买点:补偿(Cancel)发生在正式提交(Confirm)之前,而 Saga 的补偿发生在每步都已正式提交之后。

机制深一层 · 为什么三个接口都必须幂等

分布式环境里任何一次 Confirm / Cancel 调用都可能因网络超时被重试。若 Confirm 不幂等,重复 Confirm 会把同一笔冻结金额划走两次。所以 TCC 要求:Try / Confirm / Cancel 三个接口都幂等,且要能处理"空回滚"(还没 Try 就收到 Cancel,因为 Try 的请求在网络上丢了而协调端已超时触发 Cancel)和"悬挂"(Cancel 已先到、迟到的 Try 不能再预留成功)。这套 pending 簿记和幂等边界,就是 TCC 比 Saga 多出来的开发成本。

表 6.A · 三种跨服务事务机制对比
机制 隔离性 是否持全局锁 开发成本 何时选它
2PC / XA 强(等同本地事务) 是,prepare 到 commit 全程持锁 低(资源管理器代劳) 同一事务管理器内、参与者少且低延迟(如单库跨分片)
Saga 无(中间状态可见,脏读) 否,每步本地事务即时提交 中(要为每步设计补偿) 跨多服务的长流程,能容忍中间态、补偿可设计
TCC 中强(pending 藏住中间值) 否,但 Try 阶段冻结资源 高(每服务 3 个幂等接口 + 簿记) 需要较强隔离的资金 / 库存类核心交易

三者之间没有"最好",只有"在隔离性、锁、开发成本三者间各落一个点"。2PC 用锁换隔离,Saga 用放弃隔离换不阻塞,TCC 用 pending 簿记和三倍接口换回一部分隔离。Apache Seata 这类框架把三种模式都做成可选项(它的 AT 模式本质是自动化的 2PC 变体,另有 TCC、Saga 模式),正说明业界承认没有银弹——选型取决于这一笔操作能容忍什么。

6.4双写问题与发件箱:当一边是数据库、另一边是消息队列

前三节假设各参与者都是数据库。但现实里最常见的跨系统操作是另一种形态:一次操作既要写数据库、又要发一条消息。下单成功要落订单行,同时要往消息队列发一条"订单已创建"事件,让物流、积分、推荐等下游异步消费。这两个动作落在两个完全不同的系统(数据库 + broker)上,没有一个共同的事务能把它们包住——这就是双写问题(dual-write problem)。

朴素写法是顺序执行:先写库,再发消息。两种崩法:

  • 写库成功、发消息前进程崩 → 订单存在,但下游永远收不到事件,消息丢失。
  • 调换顺序:先发消息、再写库失败 → 下游收到了"订单已创建",但库里根本没有这个订单,幽灵事件。

常见的"补救"是 try/catch 加重试:发消息失败就重试几次。但这解决不了根本问题,只是把"丢事件"换成了"重复事件"——重试可能在消息其实已经发出去、只是 ack 丢了的情况下再发一次。要点:单纯的应用层重试无法让"写库"和"发消息"变成一个原子动作,因为这两个系统之间没有共享的提交协议。

为什么不能直接上 2PC

理论上可以用支持 XA 的 broker 把数据库和消息队列拉进一个 2PC 事务,但这就把 6.1 节的全部代价(协调者阻塞、in-doubt 持锁、跨独立系统的运维复杂度)原样搬了进来,且多数现代 broker(如 Kafka)对外部 XA 支持有限。工程上几乎没人这么做——主流答案是发件箱。

发件箱(Transactional Outbox)模式

发件箱的核心洞察是:把"要发的消息"也变成一次数据库写,这样它就能和业务数据共享同一个本地事务。具体做法:

  • 在业务库里加一张 outbox 表。下单时,在同一个本地事务里既插入订单行、又往 outbox 插入一条事件记录。本地事务的原子性保证:要么订单和事件都落库,要么都不落——双写被收敛成了一次单库事务。
  • 另有一个 relay(投递器)进程,异步地把 outbox 表里还没发出的记录读出来、投递到 broker、然后标记为已发送。relay 有两种实现:轮询 outbox 表,或用 CDC(change data capture,变更数据捕获,如 Debezium 订阅数据库的事务日志/binlog)来感知新行。
下单:业务行 + 事件写进同一个本地事务 sql
BEGIN;
  INSERT INTO orders (id, user_id, amount, status)
  VALUES ('ord_8801', 'u_42', 199.00, 'CREATED');

  -- 同一事务里写入待发事件;原子性由本地事务保证
  INSERT INTO outbox (id, aggregate, event_type, payload, sent)
  VALUES ('evt_5512', 'order', 'OrderCreated',
          '{"orderId":"ord_8801","userId":"u_42","amount":199.00}',
          FALSE);
COMMIT;
-- relay 进程稍后轮询 / 经 CDC 读到 sent=FALSE 的行,投递后置为 TRUE
发件箱模式:同事务双写 + relay/CDC 异步投递 订单服务 同一个本地事务(原子) orders 表 业务数据 outbox 表 待发事件 sent=F INSERT ×2 relay / CDC 轮询或 Debezium 读 sent=F broker at-least-once 消费者必须幂等 用幂等键去重 去重表与副作用同事务 原子性来自左侧单库事务;投递可重试 → broker 端至少一次,重复由消费者幂等吸收
图 6.3发件箱把"写库 + 发消息"的跨系统双写,收敛成"一次单库事务 + 一次异步投递"。注意:原子性只由左边那个本地事务提供;relay 的投递必然是 at-least-once(投了没收到 ack 就会重投),所以消费者一侧必须用幂等键去重,且去重记录要和业务副作用写进同一个事务,否则重复事件会变成重复扣款。

发件箱用一次本地事务换来了确定性,但它不提供 exactly-once。relay 投递成功、还没来得及把 sent 标记为 true 就崩溃,重启后会把同一条事件再投一次。因此发件箱给到 broker 的语义是 at-least-once(至少一次)——这正好呼应第 07 章的核心结论:传输层做不到恰好一次,工程上的"有效一次"等于至少一次加上消费者幂等。消费者必须基于事件里的幂等键(如 evt_5512)去重,并且把去重记录和业务副作用写进同一个事务,否则"先扣款、再写去重表"之间崩溃仍会重复扣款。

表 6.B · 解决"写库 + 发消息"双写的三种做法
做法 原子性来源 失败模式 评价
直接双写 + 重试 无(两个独立系统) 崩在两步之间 → 丢事件或幽灵事件;重试 → 重复 不安全,只是把丢消息换成重复消息
XA / 2PC 把库和 broker 绑一起 2PC 协调者 协调者阻塞、in-doubt 持锁;broker 支持有限 代价高、生态差,几乎不用
发件箱 outbox + relay/CDC 同一个本地事务 relay 重投 → at-least-once(消费者幂等吸收) 主流答案:把双写收敛成单库事务
常见错误

把 Saga 的补偿当成干净回滚:补偿抵消的是已经发生的副作用,发出去的邮件、打出去的款收不回来——设计 Saga 时必须为每一步问"它的副作用可逆吗"。以为应用层重试能解双写:重试只把丢事件换成重复事件,原子性必须来自同一个本地事务(outbox)。忘了消费者幂等:outbox 是 at-least-once,下游不做幂等去重就会重复处理;且去重表要和副作用同事务,否则"扣了款、还没记去重"之间崩溃照样重复扣款。

自测

先合上教程,把答案写下来或讲出来,再展开对照。能讲清"为什么"比记住名词重要。

  1. 2PC 的协调者为什么是一个"阻塞的单点"?把协调者复制成高可用,能不能消除这种阻塞?为什么?
  2. Saga 和 TCC 都不持全局锁,但隔离性不同。差别具体落在哪一步上?为什么 TCC 能藏住中间状态而 Saga 不能?
  3. "先写库、发消息失败就 try/catch 重试"为什么解决不了双写问题?发件箱凭什么能解决,它给到下游的是哪种投递语义?
展开参考答案

1. 阻塞来自协议结构而非协调者会挂:阶段一所有参与者投 YES 后进入 in-doubt 并持锁,在收到阶段二决定前无法单方面决定提交还是中止(擅自任一方向都可能破坏原子性),只能持锁等待。复制协调者降低了它整体消失的概率,但消不掉"阶段一与阶段二之间参与者必须等一个外部决定"这个窗口——所以阻塞仍在,2PC 是按构造阻塞的协议。

2. 差在补偿相对于"正式提交"的时机。Saga 每一步是本地事务立即提交,中间状态(如已扣的库存)对外可见,补偿发生在已提交之后,所以会脏读。TCC 的 Try 只把资源预留为 pending(冻结),对外可见口径里冻结部分既不算"在"也不算"已用",正式落实(Confirm)发生在所有 Try 成功之后,Cancel 发生在 Confirm 之前——pending 把中间值藏住了,所以隔离强于 Saga。代价是每个服务要实现 Try/Confirm/Cancel 三个幂等接口加 pending 簿记。

3. 因为"写库"和"发消息"是两个独立系统,没有共享的提交协议,应用层重试无法把它们变成一个原子动作;重试只是在"消息可能已发但 ack 丢了"时再发一次,把丢事件换成重复事件。发件箱把事件作为一行写进业务库,与业务数据共享同一个本地事务,原子性由这个单库事务提供;relay/CDC 再异步投递。它给下游的是 at-least-once(relay 可能重投),所以消费者必须幂等去重,去重记录与副作用同事务。

进阶挑战

给"下单 → 扣库存 → 发优惠券"设计一条 Saga

三步分别在订单服务、库存服务、营销服务。写出每一步对应的补偿动作。然后指出:哪一个补偿是不可逆的(即补偿无法真正抵消已发生的副作用)?面对这个不可逆补偿,工程上该怎么兜底?

展开思路(不是唯一解)

正向:T1 创建订单 → T2 扣库存 → T3 发优惠券到用户账户。补偿:C1 取消订单(标记 CANCELLED);C2 库存加回;C3 作废/回收优惠券。

难点在 C3:如果优惠券已被用户立即使用(已抵扣下一单),单纯"回收优惠券"就抵消不了——副作用已经外溢。兜底思路:① 调整顺序,把"发优惠券"放到整条 Saga 的最后一步且做成不可补偿的终态,前面都成功才发,从根上避免补偿它;② 若顺序无法调整,给优惠券设"未使用才可回收",已使用则转入人工对账 / 记账冲正流程(按金额做反向记账),承认这一步只能最终一致而非即时抵消。关键认知:当补偿不可逆,正确做法是重排步骤把不可逆动作后置,或显式接受人工兜底,而不是假装补偿干净。