《Java 消息中间件·事件驱动架构》4.1 分布式事务的三种落地

跨服务之后「事务」这个词只剩下一半含义。本文把 2PC、TCC、最终一致三种落地拆开:各自要求什么前提、把不确定性放在哪里、失败时留下什么残局,并用一张四维对照表(一致性强度、可用性、实现成本、失败模式)说明为什么大多数业务最终选了第三种,以及哪两类场景不该选它。

跨服务之后,「事务」只剩下一半含义

在单体应用里,一次借阅创建只有一件事要保证:借阅记录写进去、库存扣掉,要么都成,要么都不成。本地事务就能做到,因为两个操作共享同一个连接、同一把锁、同一个提交点。

拆成两个服务之后,这个「同一个提交点」消失了。借阅服务有自己的库,库存服务有自己的库,中间隔着一层网络。此时说「分布式事务」,其实是在问三个不同的问题:

  1. 原子性:两个库的修改能不能同时生效、同时失效?
  2. 隔离性:一方改了、另一方还没改的这段时间,第三方读到什么?
  3. 可恢复性:进程崩溃、网络断开、超时之后,谁来把残局收拾干净?

三种主流落地,本质上是把这三个问题的答案放在不同位置:

方案原子性靠什么隔离性靠什么残局谁来收
2PC协调者 + 参与者 prepare资源锁一直持有到提交协调者,或者人工
TCC业务层的 confirm/cancel预留资源,中间态对外可见事务管理器重试 + 人工
最终一致本地事务 + 可靠投递不保证,只保证最终收敛对账 + 补偿

下面用同一个业务场景把三种方案各走一遍:libraryd 里「创建借阅」要写借阅表,同时让库存服务把可借数量减一。这是本书构造的合成场景,不是任何真实系统的复盘。

方案一:两阶段提交

2PC 的流程很短:协调者先让所有参与者 prepare,全部回复「可以提交」之后再发 commit;任何一个回复「不行」,就全体 rollback。

放到 libraryd 上:

协调者(借阅服务)
  ├─ prepare  loan 库:插入 loan 行,加锁,不提交
  ├─ prepare  库存库:inventory.qty -= 1,加锁,不提交
  ├─ 两边都 OK → commit 两边
  └─ 任一失败   → rollback 两边

前提:两个库都得支持 XA 这类两阶段协议,并且有一个可靠的协调者。数据库层面这通常没问题,MySQL、PostgreSQL、Oracle 都有 XA 实现。

代价是所有人都知道的那几条,但值得逐条说清楚它们为什么会咬人:

代价具体表现后果
同步阻塞prepare 之后资源锁一直持有到 commit借阅高峰时库存行被跨网络锁住,其他请求排队
协调者单点协调者崩溃时参与者停在 prepare锁不释放,只能等协调者恢复或人工介入
提交阶段不确定commit 发出后部分参与者没收到一部分提交、一部分没提交,数据不一致
超时语义含糊参与者不知道是该回滚还是该等只能靠启发式决策,可能和协调者相反

第三条是最难受的:2PC 保证的是「正常情况下原子」,不是「任何情况下原子」。 协调者在提交阶段崩溃,参与者会长期停在 prepared 状态;此时人工介入要判断「到底该提交还是回滚」,而这个判断所需的信息恰好随协调者一起丢了。

把一次成功路径和一次协调者崩溃的路径并排摊开,能看清锁到底持有了多久:

时刻正常路径协调者在 T3 崩溃
T1协调者发 prepare同左
T2两个参与者加锁、写日志、回 OK同左
T3协调者收到两个 OK协调者崩溃,未发出 commit
T4发 commit,参与者提交、释放锁参与者停在 prepared,锁不释放
T5协调者记录完成锁一直持有,直到协调者恢复或人工裁决

正常路径里锁的持有时间是「两次网络往返 + 两次本地写日志」。崩溃路径里,这个时间没有上界。生产环境里这个上界通常由 DBA 的「长时间锁等待」告警来兜底,也就是说,2PC 的最终兜底手段是人工。

还有一条常被忽略:2PC 的「强一致」是提交意义上的原子,不是读意义上的隔离。两个库各自提交的瞬间之间,仍然存在一个第三方能观察到「借阅有了、库存没减」的窗口,只是这个窗口比最终一致短得多。

方案二:TCC

TCC 把两阶段从数据库层搬到业务层,要求每个参与者提供三个接口:

  • try:预留资源,不真正扣减;
  • confirm:确认使用预留的资源;
  • cancel:释放预留的资源。

libraryd 里库存服务的三个接口可以是这样:

// 预留:可借数量减一,冻结数量加一;不改变「已借出」
boolean tryReserve(String bookId, String loanId);
// 确认:冻结数量减一,已借出加一
boolean confirm(String bookId, String loanId);
// 取消:冻结数量减一,可借数量加一
boolean cancel(String bookId, String loanId);

协调者(借阅服务)先调所有参与者的 try,全部成功再调 confirm;任何一个 try 失败,就对已经成功的那些调 cancel。

TCC 解决了 2PC 的阻塞问题:try 阶段结束就释放数据库行锁,资源以「冻结」的形式留在业务表里,不再占用数据库锁。但代价从数据库转移到了业务代码,而且是三份:

问题触发条件不处理的后果
空回滚cancel 先于 try 到达(try 超时但实际没执行)把本来没预留的资源又加了一次,库存凭空变多
悬挂cancel 执行完,迟到的 try 才到达资源被永久冻结,再也不会有人来 confirm
幂等confirm/cancel 被重复调用重复扣减或重复释放

这三个问题不是理论风险,是 TCC 实现的必答题。通常的做法是给每个 try 记一条带状态的事务记录(TRYING / CONFIRMED / CANCELLED),cancel 遇到不存在的记录时插入一条「已取消」的墓碑记录来挡住迟到的 try。也就是说,TCC 的每个参与者都要自己实现一个小型事务日志,这部分工作量经常被低估。

前提:资源必须可预留。库存、额度、优惠券名额可以;「发一条通知」「调一次外部风控」这类不可逆、不可预留的动作不行。

代价:每个参与者三个接口,加上防悬挂、幂等、超时补偿,再加一个可靠的事务管理器来驱动重试。参与者数量越多,组合爆炸越严重——三个参与者就是九种中间状态。

TCC 的状态机长什么样

把每个参与者的 try/confirm/cancel 展开,会得到一张状态迁移表。这张表是 TCC 实现的真正规格,写代码之前就应该先画出来:

当前状态收到 try收到 confirm收到 cancel
不存在建记录,置 TRYING,预留资源拒绝(无记录),记一条 CONFIRMED 墓碑建记录,置 CANCELLED(墓碑,挡住迟到的 try)
TRYING幂等返回成功置 CONFIRMED,冻结转已用置 CANCELLED,释放预留
CONFIRMED幂等返回成功(不重复预留)幂等返回成功拒绝(已确认,不能再取消)
CANCELLED拒绝(悬挂,明确回滚)拒绝幂等返回成功

这张表里有三行是「拒绝」,而每一次拒绝都意味着一个异常分支。TCC 框架能帮你生成这些分支,但生成不了「哪种拒绝该报警、哪种拒绝该静默」的判断——那要业务来定。

再补一条实践中的经验:cancel 的墓碑记录不能设过期时间太短。如果协调者在 try 超时后 30 秒才发 cancel,而墓碑 10 秒就被清理,迟到的 try 会重新预留,资源就悬挂了。墓碑的保留期必须大于「最长重试链路的时长」,这个数只能从自己的超时配置里推,不能拍脑袋。

方案三:最终一致

第三种方案承认一件事:跨服务的强一致买不起,那就把窗口说清楚,把收敛机制做扎实。

libraryd 的写法是:借阅服务在本地事务里同时写 loan 表和 outbox 表;中继进程把 outbox 里的记录发到 library.loan.created;库存服务消费事件、扣减库存、写自己的幂等表。整个链路里没有任何跨库锁。

它的保证只有一句:如果借阅创建成功,库存最终会减一;如果中间任何一步失败,要么重试到成功,要么进入对账和补偿。 注意「最终」两个字没有被量化——这正是 4.3 要解决的问题。

前提:

  • 业务能接受一段时间的中间态(借阅已创建、库存还没减);
  • 每个消费者都能幂等处理重复事件;
  • 有一条对账链路能发现「库变了但事件没发」这类静默失败;
  • 有一个补偿出口处理对账发现的差异。

代价:

代价说明
一致性窗口从毫秒到分钟不等,取决于积压与消费能力
幂等成本每个消费者都要有业务幂等键,不能只靠位移
对账成本需要定期比对写侧、事件、读侧三方,见 4.2
补偿成本差异不能自动修复时,要有明确的人工出口,见 4.3
认知成本「现在数据是对的吗」这个问题没有一行 SQL 能回答

最后一条最容易被低估。2PC 里运维问「现在一致吗」,答案是查协调者日志;最终一致里这个问题要变成一个持续运行的对账任务。

最终一致的投递环节有三种做法

「最终一致」不是一个方案,是一族方案。它们的分歧点在同一个地方:怎么让「数据库已提交」和「事件已发出」这两件事不脱节。 主流做法有三条:

做法机制前提代价
本地消息表(outbox)业务写和消息写在同一本地事务;中继轮询并发送业务库与 outbox 同库同事务中继有延迟;需要标记发送状态;至少一次,消费端要幂等
半消息 + 回查先发一条对消费者不可见的消息,本地事务成功后再提交;失败则回查业务状态broker 支持半消息与回查接口强绑定特定 MQ;回查接口要幂等且能查准
变更数据捕获(CDC)订阅数据库日志,把行变更翻译成事件有可订阅的日志(如 binlog);能稳定解析 schema事件语义由表结构决定,容易退化成「表变更」而不是领域事件;运维链路长

三条都能用,但适用场景不同。outbox 最通用,缺点是中继是自研的、要自己保证「至少一次 + 幂等」;半消息最省事,缺点是换 MQ 就得重写;CDC 侵入最小,缺点在 2.1 已经说过——从表变更里反推领域事件,很容易把 UPDATE inventory SET qty = qty - 1 直接暴露成事件,下游拿到的是「一行变了」,不是「一本书被借走了」。

本书统一用 outbox,原因是它不依赖特定 broker,且「表结构即协议」,出问题可以用一条 SQL 查清楚。4.2 会把它的工程细节和兜底对账完整走一遍。

三方案的四维对照

把上面的讨论压成一张表。四个维度是选型时真正会吵架的四个:

方案一致性强度可用性实现成本失败模式
2PC提交原子;读不隔离,提交间隙可见中间态最低:任一参与者不可用即整体阻塞,锁持有到提交中:依赖 XA 驱动;协调者、超时、启发式回滚的运维复杂协调者崩溃导致参与者长期持锁;提交阶段部分失败产生不一致,需人工判定
TCC最终一致,但中间态是「预留」,对外可解释、可查询中:try 阶段要求全体可用;confirm/cancel 可异步重试高:每个参与者三个接口,外加防悬挂、空回滚、幂等与事务管理器try 成功而 confirm 丢失导致资源长期冻结;cancel 失败导致资源永久占用
最终一致最终一致,窗口未被约束前不可承诺最高:异步,单个下游故障不阻塞上游写入中:outbox、幂等消费、对账、补偿四件套;组织成本高于代码成本事件静默丢失(写库未写事件)、重复消费、乱序、长期不一致只能靠对账发现

这张表里没有「正确」的一行,只有「你的业务能承受哪一列的代价」。

为什么大多数业务选第三种

不是因为第三种更先进,而是因为另外两种的代价落在业务最不愿意付的地方。

第一,可用性和一致性强耦合。 2PC 把「所有参与者都活着」写进了提交条件。库存服务做一次发布、数据库做一次主从切换,借阅创建就整体不可用。对一个有真实流量的系统来说,这个耦合面太大了。

第二,失败模式的运维成本不对称。 2PC 和 TCC 的失败是「卡住」:资源锁不释放、预留不确认。这类故障不会自己好,必须有人判断、有人操作。最终一致的失败是「慢了」:事件还在路上,或者已经进了死信。它的大部分情况可以自动收敛,剩下的小部分进对账报告,按批次处理。能自动收敛的失败,运维成本远低于需要人工判定的失败。

第三,业务本身通常不需要强一致。 借阅创建之后,库存晚一点减一,用户感知不到;真正感知得到的是「借阅创建失败」。把强一致换成可用性,用户体验是变好的。

但有两类场景不该跟着选第三种:

场景为什么不能只靠最终一致更合适的做法
账户余额、押金冻结中间态是「钱」,窗口内可能出现超额消费TCC(冻结额度)或账务系统内部的强一致
稀缺资源的名额抢占超卖不可逆,补偿只能退钱不能退体验TCC 预留,或数据库唯一约束 + 乐观锁

判断标准不是「重不重要」,而是中间态能不能被业务解释、能不能被补偿。库存减晚了可以补;名额超卖了,补偿只能道歉。

Kafka 事务不等于分布式事务

这是选型时最常见的误解,值得单独澄清。

Kafka 的事务(transactional.id + beginTransaction/commitTransaction)解决的是**「消费一批消息、处理、再 produce 一批消息、再提交位移」这四步在 Kafka 内部的原子性**。它保证下游 read_committed 的消费者要么看到全部输出,要么一条都看不到。

它不保证你的数据库写入和消息发送原子。下面这段代码里的 jdbcUpdate 和 producer.send 仍然可能一个成功一个失败:

producer.beginTransaction();
try {
    jdbcUpdate(conn, loan);                 // 数据库,不参与 Kafka 事务
    producer.send(new ProducerRecord<>("library.loan.created", key, value));
    producer.commitTransaction();           // 只对 Kafka 这一侧生效
} catch (Exception e) {
    producer.abortTransaction();            // 数据库那一侧不会回滚
}

要让它真正原子,数据库必须自己参与同一个事务协议(XA),或者干脆用 outbox 把「数据库写入」和「消息发送」解耦成两步。这也解释了为什么 4.2 要花一整节讲本地消息表:在没有 XA 的世界里,outbox 是成本最低的「伪两阶段」。

Kafka 事务真正有用的地方在流处理内部:读一个主题、写另一个主题、更新状态存储,这三件事由 Kafka 自己协调。卷二第 3 章从客户端角度讲过它的边界,本卷 8.3 会从 Streams 的 processing.guarantee 角度再讲一次。

一个具体的判断:借阅与押金

抽象的对照表落到两个真实流程上,结论会清楚很多。这两个流程都在 libraryd 里,一个是借阅创建,一个是借阅时的押金冻结。

借阅创建:写 loan 表、库存减一、给预约队列发一条通知。

  • 中间态是什么?「借阅有了、库存还没减」。用户看到的是「借阅成功」,库存页面上少一本书要等一个一致性窗口。
  • 中间态能被业务解释吗?能,就是「系统在同步」。
  • 能补偿吗?能,库存多减了就加回来,事件漏了就重发。
  • 结论:最终一致。用 outbox + 事件,库存与通知各自消费。

押金冻结:从会员账户冻结 50 元,借阅归还时解冻或扣款。

  • 中间态是什么?「钱冻了、借阅没建」或者「借阅建了、钱没冻」。
  • 能解释吗?不能。用户会看到余额少了但没借到书,客服也无法判断这 50 元是该退还是该扣。
  • 能补偿吗?技术上能,但每一次补偿都是一次账务调整,需要凭证、需要审计。
  • 结论:TCC。账户服务提供 tryFreeze/confirmFreeze/cancelFreeze,冻结是一个对外可查的状态,用户能看到「冻结中」,窗口内也有明确语义。

同一个系统里两种方案并存,这不是摇摆,是按中间态的可解释性分流。判断顺序应该是:先看中间态长什么样,再看用什么技术,而不是反过来。

顺带说一个常见的过度设计:把所有跨服务调用都套上 TCC,包括「发通知」「写日志」这类不可预留的动作。这类动作的 try 无事可做,只能空转,cancel 也只能记一笔「本来要发但没发」,平白多出两倍的调用量和三倍的状态。不可预留的动作,本来就应该走事件。

选型清单

把上面的判断做成可以照着走的顺序:

  1. 先问中间态能不能被业务解释。 不能解释的(钱、名额),排除纯最终一致。
  2. 再问资源能不能预留。 能预留的,TCC 是可用性与一致性的折中;不能预留的(外部调用、通知),只能最终一致 + 补偿。
  3. 再问参与者数量。 参与者超过三个,TCC 的中间状态组合会失控,优先考虑把跨服务调用改成事件。
  4. 最后问失败能不能自动收敛。 不能自动收敛的差异,必须先设计好人工出口,再谈上线。
  5. 只有在参与者少、且都支持 XA、且团队有 2PC 运维经验时,才考虑 2PC。 三个条件缺一个,都不划算。

libraryd 的选择是:借阅创建走最终一致(借阅与库存),押金冻结走 TCC(账户服务预留额度),没有任何一条链路用 2PC。这个组合不是最优解,是一个团队能维护得住的解。

小结

  • 跨服务之后,「事务」拆成原子性、隔离性、可恢复性三个独立问题,三种方案是把它们放在不同位置。
  • 2PC 保证提交原子,但同步阻塞、协调者单点,且提交阶段崩溃会留下无法自动判定的残局。
  • TCC 把锁从数据库搬到业务,代价是每个参与者三个接口,外加空回滚、悬挂、幂等三个必答题。
  • 最终一致用窗口换可用性,代价是对账与补偿;它的失败大多能自动收敛,这是它被大多数业务选中的真正原因。
  • Kafka 事务只覆盖 Kafka 内部的读-处理-写,不覆盖数据库;把它当分布式事务用是选型里最常见的错误。
  • 判断该用哪一种,看中间态能否被业务解释、资源能否预留、参与者有几个、失败能否自动收敛。

阅读导航:上一节 3.3 投影重建与重放 | 下一节 4.2 本地消息表与对账

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收