Skip to content

필사 모드: 分布式事务:2PC vs Saga 模式

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 事务跨越服务边界的那一刻

在单一数据库里,事务是个靠得住的朋友。以 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 放弃原子性,却通过补偿事务和最终一致性换来了高可用性。而事件驱动世界里的双写陷阱,则由发件箱模式来驯服。这一切的底层,始终铺着幂等性这个前提。

归根结底,分布式事务是在"完美原子性的理想"与"可用系统的现实"之间做选择。多数情况下,我们会接受一点延迟的一致性,并用设计把那个窗口包裹起来。当我们有意识地处理这种权衡时,分布式系统中的事务就不再是一种恐惧,而是一门可以驾驭的工程学。

参考资料

현재 단락 (1/104)

在单一数据库里,事务是个靠得住的朋友。以 `BEGIN` 开始、以 `COMMIT` 结束,里面的所有改动要么全部生效,要么全部撤销。可一旦把系统拆成多个服务,这份可靠就动摇了。当订单服务、支付服务、...

작성 글자: 0원문 글자: 6,101작성 단락: 0/104