- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 事务跨越服务边界的那一刻
- 为什么跨服务的 ACID 很难
- 两阶段提交(2PC)— 硬造原子性
- Saga 模式 — 放弃原子性,用补偿来代替
- 编舞 vs 编排
- 双写问题 — 为什么需要发件箱
- 发件箱模式 — 捆进同一个本地事务
- 最终一致性 — 在实务中意味着什么
- 实务指南小结
- 结语
- 参考资料
引言 — 事务跨越服务边界的那一刻
在单一数据库里,事务是个靠得住的朋友。以 BEGIN 开始、以 COMMIT 结束,里面的所有改动要么全部生效,要么全部撤销。可一旦把系统拆成多个服务,这份可靠就动摇了。当订单服务、支付服务、库存服务各自拥有自己的数据库时,"创建订单、处理支付、扣减库存"这一个逻辑操作,就横跨了三个物理上分离的数据库。
于是,那个可怕的问题又回来了:如果支付成功了,但扣减库存失败了呢?没有哪一个 COMMIT 能一次性把三个数据库同时确定下来。这就是分布式事务(distributed transaction)问题,是微服务无法回避的根本性难题。本文会对照两种代表性方案 — 两阶段提交与 Saga 模式,梳理我们在两者之间究竟失去了什么、得到了什么。
如果想亲眼看看与这些概念相连的消息传递,可以在本站的消息队列演练场里可视化异步消息的流动。
为什么跨服务的 ACID 很难
单一数据库的事务之所以强大,是因为一个系统掌控着全部数据。一个事务管理器加锁、写 WAL、原子地确定提交。一切都在同一个屋檐下,"全部成功或全部失败"自然容易保证。
一旦拆到多个服务,这个前提就崩塌了。
- 没有共享的事务管理器。 每个服务的数据库只知道自己的事务。默认情况下,并不存在一个能把服务 A 的提交和服务 B 的提交捆成一体的上位存在。
- 网络插了一脚。 服务间通信要走网络,而网络慢、会丢消息、会延迟、会打乱顺序,最重要的是会断开。"请求发出去了,但没收到响应",发送方根本无法判断这是成功还是失败。
- 部分失败是家常便饭。 单一进程里更接近"要么全活、要么全死",但在分布式环境中,"一部分成功、一部分失败"的中间状态很常见。
正因为这三点,要在多个服务之间重现单一数据库里理所当然的原子性,从根本上就很难。这里有两条分叉的解法。一条是"无论如何也要硬造出原子性"的两阶段提交,另一条是"放弃原子性,转而走向最终一致性"的 Saga。
两阶段提交(2PC)— 硬造原子性
两阶段提交(Two-Phase Commit, 2PC)是把多个参与者(participant)的提交捆成一体的经典协议。中心有一个协调者(coordinator),顾名思义分两个阶段进行。
第一阶段 — 准备(prepare / voting)。 协调者向所有参与者提问:"准备好提交了吗?"每个参与者并不真正提交改动,而是完成一切能够提交的准备工作(获取锁、写日志),投出"是(已准备)"或"否"。一旦回答"是",该参与者此后若被协调者命令提交,就必须能够提交。
第二阶段 — 提交或中止(commit / abort)。 若所有参与者都回答"是",协调者就命令全体"提交"。只要有一个回答"否"或没有响应,就命令全体"中止"。
第一阶段 (准备):
协调者 --"准备好了吗?"--> 参与者们
参与者们 --"是/否"--> 协调者
第二阶段 (提交/中止):
若全部为"是":
协调者 --"提交!"--> 参与者们 (全体提交)
若有一个为"否"/无响应:
协调者 --"中止!"--> 参与者们 (全体回滚)
逻辑上看很干净。因为是确认全体都准备好之后才提交,原子性看起来是守住了。但 2PC 有一个严重的弱点。
阻塞(blocking)问题。 这是 2PC 最大的缺陷。参与者投出"是(已准备)"之后,如果协调者恰好在下达第二阶段命令之前挂掉,参与者就会陷入进退两难的境地。它既没听到"提交",也没听到"中止",却又不能擅自决定(毕竟它已经承诺了"是")。这个参与者会一直握着锁无限期地等待协调者恢复。被这把锁卡住的其他事务也会一起停摆。
其他失败模式。 准备阶段通过了,但提交命令只到达了部分参与者;网络分区把协调者和一部分参与者隔开 — 部分失败的组合情形还有很多。有一些变体(3PC 等)试图应对这些问题,但都不是彻底的解法,反而只会增加复杂度。
所以 2PC 给了原子性,代价却是牺牲了可用性和性能。协调者成了单点故障,而在准备-提交之间长时间握锁又拉低了吞吐量。这就是为什么在服务众多、延迟较大的微服务环境里 2PC 不太常用。(反过来,在受到良好控制的单一数据中心内部或 XA 事务这类特定场景中,它依然在用。)
Saga 模式 — 放弃原子性,用补偿来代替
如果说 2PC 是"为了守住原子性而甘愿承受阻塞",那么Saga 模式把思路整个反过来:放弃原子性,把每一步作为独立的本地事务分别提交,一旦出问题,就用补偿事务把已经做过的事情撤销,以此收场。
核心概念是补偿事务(compensating transaction)。如果某一步提交之后,后面的步骤失败了,就执行一个单独的事务,把之前已提交的内容"在语义上取消"。如果说回滚是"把还没提交的东西当作没发生过",那么补偿就是"把已经提交、已经反映到现实中的东西撤回来"。
用一个订单的例子来看:
正常流程 (每一步都独立提交):
1. 创建订单 (提交)
2. 扣款支付 (提交)
3. 扣减库存 (提交)
4. 预约配送 (提交)
第 3 步因库存不足而失败 -> 按逆序补偿:
补偿第 2 步: 支付退款
补偿第 1 步: 取消订单
这里有几个重要的性质需要留意。
- 补偿未必是原操作的完美逆运算。"扣款支付"的补偿是"退款",但退款和"从未发生扣款"这个状态并不完全等价(交易记录会留下,也可能产生手续费)。补偿是语义上的撤销,不是物理上的撤销。
- 中间状态会暴露给外部。 在 Saga 进行的过程中,其他观察者可能会看到"支付已完成,但配送还没有"这样的中间状态。与 2PC 的隔离性不同,Saga 并不保证隔离。所以应用程序必须被设计成能够容忍这类中间状态。
- 补偿本身也可能失败。 因此补偿必须是可重试且幂等(idempotent)的,最坏情况下还需要有人工介入的路径。
Saga 就这样以牺牲原子性和隔离性为代价,换来了无阻塞的高可用性以及服务之间的松耦合。这正是 Saga 在微服务中事实上成为标准做法的原因。
编舞 vs 编排
实际实现 Saga 大体上有两种方式。分歧点在于:谁来决定"进入下一步"。
编舞(choreography)— 没有中央指挥的舞蹈。 每个服务完成自己的工作后就发布一个事件,其他服务订阅这个事件,继续各自的步骤。没有中央协调者。就像舞者们没有指挥,仅凭对彼此动作的反应把舞跳下去。
订单服务: 发布"订单已创建"事件
|
v (订阅)
支付服务: 处理支付 -> 发布"支付完成"事件
|
v (订阅)
库存服务: 扣减库存 -> 发布"库存已确认"事件
|
v (订阅)
配送服务: 预约配送
优点是服务之间耦合松散,新服务只需订阅事件就能轻松接入。缺点是整个流程分散在多个服务里,难以一眼看清全貌。要知道"这个订单现在在哪一步",得翻查好几个服务,而一旦事件出现循环或意外纠缠,调试就会变得棘手。
编排(orchestration)— 有指挥的演奏。 中央的编排器(orchestrator)指挥整个 Saga。编排器按顺序调用各个步骤 — "现在扣款"、收到响应后"现在扣减库存" — 一旦失败,就指示执行补偿步骤。
┌───────────────── 编排器 ─────────────────┐
│ 1. 向支付服务请求"扣款" -> 成功 │
│ 2. 向库存服务请求"扣减" -> 失败! │
│ 3. 补偿: 向支付服务请求"退款" │
│ 4. 补偿: 向订单服务请求"取消" │
└────────────────────────────────────────────┘
优点是整个流程集中在一处,易于理解和追踪。因为 Saga 的状态由编排器持有,"现在在哪一步"一目了然。缺点是编排器成了逻辑的中心,会变得复杂,一旦处理不当,又可能重新变成单一瓶颈或单点故障。
两者之间的取舍取决于规模和复杂度。步骤少、逻辑简单时,编舞更轻量;步骤多、流程复杂、可见性又很重要时,编排更便于管理。
双写问题 — 为什么需要发件箱
无论是 Saga 还是任何事件驱动架构,实务中都必然会撞上一个陷阱:双写(dual write)问题。
情况是这样的。服务做某项工作时,往往需要做两件事:(1) 更新自己的数据库,(2) 发布一个宣告此事的事件/消息。问题在于,这两者是不同的系统(本地数据库和消息代理),无法被捆进同一个事务里。
危险的顺序:
1. 把订单存入数据库 (提交成功)
2. 向 Kafka 发布"订单已创建" <- 如果进程在这里挂掉呢?
结果: 数据库里有订单,但事件从未发布。
下游服务永远不知道这个订单存在。(不一致!)
调换顺序也一样。如果先发布事件,数据库保存却失败了,那么世界上就飘着一个"订单已创建"的事件,而数据库里实际并不存在这个订单。无论先做哪一件,只要中间挂掉,数据库状态和已发布的事件就会错位。
这个问题的标准解法就是发件箱模式(outbox pattern)。
发件箱模式 — 捆进同一个本地事务
发件箱模式的核心思路是这样的:把要发布的事件,和业务数据一起,存进同一个数据库里的"发件箱"表,并且放在同一个事务里。这样一来,"数据变更"和"事件记录"就落在了同一个本地事务内,两者要么一起原子地提交,要么一起失败。双写就变成了单写。
一个本地事务:
BEGIN
INSERT INTO orders (...) -- 业务数据
INSERT INTO outbox (event, ...) -- 待发布事件
COMMIT -- 两者都提交,或两者都回滚
之后由一个独立的进程:
轮询发件箱表(或追踪数据库日志)
-> 把新事件发布到消息代理
-> 发布成功后,在发件箱中标记已处理/删除
把事件真正推送到代理的方式,常见的有两种。一种是轮询发布者(polling publisher),由一个独立的进程定期读取发件箱表,发布尚未发送的事件。另一种是事务日志追踪(transaction log tailing / CDC),读取数据库的变更日志(例如 PostgreSQL 的 WAL),检测插入到发件箱中的行并发布(Debezium 之类的工具就是这种方式)。
这里有一点很重要:发件箱保证的是,事件的发布是至少一次(at-least-once),而不是恰好一次。如果发布者在发布之后、标记"已处理"之前挂掉,同一个事件就可能被重复发布。所以接收方(消费者)必须是幂等的 — 即便收到同一个事件两次,结果也要一致。这种幂等性与去重,正是分布式系统可靠性的核心,也是下一篇文章的主题。
最终一致性 — 在实务中意味着什么
放弃 2PC、转向 Saga 与发件箱,意味着我们接受的是最终一致性(eventual consistency),而非强一致性。让我们不带误解地把这个词的含义梳理清楚。
最终一致性所保证的是"终将所有副本和服务的状态归于一致"。但在此之前,不一致是可以被观察到的。支付已经处理完成,而订单状态还没变成"已支付"的这段短暂窗口,是可能存在的 — 因为 Saga 可能还在进行中,或者事件还在传播的路上。
这在实务中具体意味着什么呢?
- UI 和 API 必须能容忍中间状态。向用户展示"处理中"这样的状态,是诚实且安全的做法。不要立刻承诺一个最终状态。
- 刚读取时,可能看不到自己刚写入的内容。刚创建的东西马上去查询,可能还查不到("read-your-writes"默认并不被保证)。如果确实需要,就要为这部分单独确保强一致性。
- 业务必须自己定义中间状态。在"支付已完成,但库存预留失败"这样的情况下,该向用户展示什么、该如何补偿,是业务决策,而不是技术决策。
核心在于,最终一致性不是"没有一致性",而是"被延迟的一致性"。承认这份延迟,并把这个窗口反映进设计里,才是处理分布式事务的实战直觉。
实务指南小结
把到目前为止的内容压缩一下。
能避免就尽量避免分布式事务本身。如果有数据必须捆在同一个事务里,最好的做法是把它们放进同一个服务、同一个数据库的边界设计。拆分服务的界线,应当尊重事务边界。
如果真的必须跨服务,优先考虑 Saga 而非 2PC。2PC 的阻塞和单点故障,与微服务的可用性目标相冲突。Saga 的最终一致性和补偿机制,大多数时候更贴近现实。
流程简单就用编舞,复杂且重视可见性就用编排。步骤越多、失败处理越复杂,中央编排器的可追踪性就越有价值。
要发布事件,就默认使用发件箱模式。双写不一致是那种悄悄让数据错位的典型 bug。用发件箱把"数据变更和事件记录"捆进同一个本地事务里。
让消费者保持幂等。无论是发件箱还是任何消息代理,"至少一次"都是现实。如果不假设会收到重复消息去设计,就一定会出现重复处理的 bug。
结语
单一数据库的事务之所以强大,是因为一个系统掌控着一切。系统一旦被拆成多个服务,这份掌控就会分散,网络和部分失败随之插手,原样重现"全部成功或全部失败"从根本上变得困难。
对此的两条分叉解法就是 2PC 和 Saga。2PC 为了守住原子性,甘愿承受阻塞和单点故障;Saga 放弃原子性,却通过补偿事务和最终一致性换来了高可用性。而事件驱动世界里的双写陷阱,则由发件箱模式来驯服。这一切的底层,始终铺着幂等性这个前提。
归根结底,分布式事务是在"完美原子性的理想"与"可用系统的现实"之间做选择。多数情况下,我们会接受一点延迟的一致性,并用设计把那个窗口包裹起来。当我们有意识地处理这种权衡时,分布式系统中的事务就不再是一种恐惧,而是一门可以驾驭的工程学。
参考资料
- Chris Richardson, "Pattern: Saga": https://microservices.io/patterns/data/saga.html
- Chris Richardson, "Pattern: Transactional outbox": https://microservices.io/patterns/data/transactional-outbox.html
- Hector Garcia-Molina & Kenneth Salem, "Sagas" (1987): https://www.cs.cornell.edu/andru/cs711/2002fa/reading/sagas.pdf
- Martin Kleppmann, "Designing Data-Intensive Applications" (第 9 章, Consistency and Consensus)
- Debezium, "Outbox Event Router": https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html