高可用中的2PC,3PC以及幂等性概念

2025年9月5日 作者 ScotI_Blog

在分布式系统中,协调跨多个资源的交易可能很复杂。想象一下,你有多套系统或数据库需要同步工作,确保在失败的情况下所有操作都提交或回滚。实现这一目标的两种著名协议是两阶段提交(2PC)和三阶段提交(3PC)。这两种协议在确保数据一致性方面都起着至关重要的作用,特别是在分布式环境中,但它们在方法、功能和系统故障的恢复能力上有所不同

本文参考:基于RabbitMQ的高可用分布式队列服务  https://time.geekbang.org/column/article/321346

CAP理论

首先我们先来回顾一下再分布式系统中,至关重要的CAP理论

在一个分布式的系统中,当涉及到共享数据问题时,以下三个特性最多只能满足其中两个:

  • 一致性(Consistency):代表在任何时刻、任何分布式节点中,我们所看到的数据都是没有矛盾的。这与第 11 讲所提到的 ACID 中的 C 是相同的单词,但它们又有不同的定义(分别指 Replication 的一致性和数据库状态的一致性)。在分布式事务中,ACID 的 C 要以满足 CAP 中的 C 为前提。
  • 可用性(Availability):代表系统不间断地提供服务的能力。
  • 分区容忍性(Partition Tolerance):代表分布式环境中,当部分节点因网络原因而彼此失联(即与其他节点形成“网络分区”)时,系统仍能正确地提供服务的能力。

举个例子来说明CAP,

事例场景:Fenix’s Bookstore 是一个在线书店。一份商品成功售出,需要确保以下三件事情被正确地处理:

        1用户的账号扣减相应的商品款项;

        2商品仓库中扣减库存,将商品标识为待配送状态;

        3商家的账号增加相应的商品款项

假设某次交易请求分别由“账号节点 1”“商家节点 2”“仓库节点 N”来进行响应,当用户购买一件价值 100 元的商品后,账号节点 1 首先应该给用户账号扣减 100 元货款。
账号节点 1 在自己的数据库扣减 100 元是很容易的,但它还要把这次交易变动告知账号节点 2 到 N,以及确保能正确变更商家和仓库集群其他账号节点中的关联数据。那么此时,我们可能会面临以下几种情况:

  • 如果该变动信息没有及时同步给其他账号节点,那么当用户购买其他商品时,会被分配给另一个节点处理,因为没有及时同步,此时系统会看到用户账户上有不正确的余额,从而错误地发生了原本无法进行的交易。此为一致性问题。
  • 如果因为要把该变动信息同步给其他账号节点,就必须暂停对该用户的交易服务,直到数据同步一致后再重新恢复,那么当用户在下一次购买商品时,可能会因为系统暂时无法提供服务而被拒绝交易。此为可用性问题
  • 如果由于账号服务集群中某一部分节点,因出现网络问题,无法正常与另一部分节点交换账号变动信息,那么此时的服务集群中,无论哪一部分节点对外提供的服务,都可能是不正确的,我们需要考虑能否接受由于部分节点之间的连接中断,而影响整个集群的正确性的情况。此为分区容忍性问题。

以上还只是涉及到了账号服务集群自身的 CAP 问题,而对于整个 Bookstore 站点来说,它更是面临着来自于账号、商家和仓库服务集群带来的 CAP 问题。

因此,分布式系统、分布式事务的各个细节,大多数都能归结为CAP中的某个方面问题,但由于CAP不可得兼,因此在进行系统设计时,很多时候也必须做出取舍

如果放弃分区容错性(CA without P)


        这意味着,我们将假设节点之间的通讯永远是可靠的。可是永远可靠的通讯在分布式系统中必定是不成立的,这不是你想不想的问题,而是网络分区现象始终会存在。
        在现实场景中,主流的 RDBMS(关系数据库管理系统)集群通常就是采用放弃分区容错性的工作模式。以 Oracle 的 RAC 集群为例,它的每一个节点都有自己的 SGA(系统全局区)、重做日志、回滚日志等,但各个节点是共享磁盘中的同一份数据文件和控制文件的,也就是说,RAC 集群是通过共享磁盘的方式来避免网络分区的出现。


如果放弃可用性(CP without A)


       这意味着,我们将假设一旦发生分区,节点之间的信息同步时间可以无限制地延长,那么这个问题就相当于退化到了上一讲所讨论的全局事务的场景之中,即一个系统可以使用多个数据源。我们可以通过 2PC/3PC 等手段,同时获得分区容错性和一致性。
       在现实中,除了 DTP 模型的分布式数据库事务外,著名的 HBase 也是属于 CP 系统。以它的集群为例,假如某个 RegionServer 宕机了,这个 RegionServer 持有的所有键值范围都将离线,直到数据恢复过程完成为止,这个时间通常会是很长的。


如果放弃一致性(AP without C)


       这意味着,我们将假设一旦发生分区,节点之间所提供的数据可能不一致。
        AP 系统目前是分布式系统设计的主流选择,大多数的 NoSQL 库和支持分布式的缓存都是 AP 系统。因为 P 是分布式网络的天然属性,你不想要也无法丢弃;而 A 通常是建设分布式的目的,如果可用性随着节点数量增加反而降低的话,很多分布式系统可能就没有存在的价值了(除非银行这些涉及到金钱交易的服务,宁可中断也不能出错)。

因此,分布式事务无法做到尽善尽美,因此出现了这样的说法:

在“分布式事务”中,我们的设计目标同样也不得不从获得强一致性,降低为获得“最终一致性”,在这个意义上,其实“事务”一词的含义也已经被拓宽了。

最终一致性这个概念,来自于丹·普利切特提出的可靠事件队列

下面,我们继续以 Fenix’s Bookstore 的事例场景,来解释下丹 · 普利切特提出的“可靠事件队列”的具体做法,下图为操作时序:

按照步骤先来拆分一下这些步骤

第一步,最终用户向 Fenix’s Bookstore 发送交易请求:购买一本价值 100 元的《深入理解 Java 虚拟机》。
第二步,Fenix’s Bookstore 应该对用户账户扣款、商家账户收款、库存商品出库这三个操作有一个出错概率的先验评估,根据出错概率的大小来安排它们的操作顺序(这个一般体现在程序代码中,有一些大型系统也可能动态排序)。比如,最有可能出错的地方,是用户购买了,但是系统不同意扣款,或者是账户余额不足;其次是商品库存不足;最后是商家收款,一般收款不会遇到什么意外。那么这个顺序就应该是最容易出错的最先进行,即:账户扣款 → 仓库出库 → 商家收款。
第三步,账户服务进行扣款业务,如果扣款成功,就在自己的数据库建立一张消息表,里面存入一条消息:“事务 ID:UUID;扣款:100 元(状态:已完成);仓库出库《深入理解 Java 虚拟机》:1 本(状态:进行中);某商家收款:100 元(状态:进行中)”。注意,这个步骤中“扣款业务”和“写入消息”是依靠同一个本地事务写入自身数据库的。
第四步,系统建立一个消息服务,定时轮询消息表,将状态是“进行中”的消息同时发送到库存和商家服务节点中去。

可能会产生以下几种情况:

1商家和仓库服务成功完成了收款和出库工作,向用户账户服务器返回执行结果,用户账户服务把消息状态从“进行中”更新为“已完成”。整个事务宣告顺利结束,达到最终一致性的状态。

2商家或仓库服务有某些或全部因网络原因,未能收到来自用户账户服务的消息。此时,由于用户账户服务器中存储的消息状态,一直处于“进行中”,所以消息服务器将在每次轮询的时候,持续地向对应的服务重复发送消息。这个步骤的可重复性,就决定了所有被消息服务器发送的消息都必须具备幂等性。通常我们的设计是让消息带上一个唯一的事务 ID,以保证一个事务中的出库、收款动作只会被处理一次

3商家或仓库服务有某个或全部无法完成工作。比如仓库发现《深入理解 Java 虚拟机》没有库存了,此时,仍然是持续自动重发消息,直至操作成功(比如补充了库存),或者被人工介入为止。

4商家和仓库服务成功完成了收款和出库工作,但回复的应答消息因网络原因丢失。此时,用户账户服务仍会重新发出下一条消息,但因消息幂等,所以不会导致重复出库和收款,只会导致商家、仓库服务器重新发送一条应答消息。此过程会一直重复,直至双方网络恢复。

5也有一些支持分布式事务的消息框架,如 RocketMQ,原生就支持分布式事务操作,这时候前面提到的情况 2、4 也可以交给消息框架来保障。

这种靠着持续重试来保证可靠性的操作,在计算机中就非常常见,它有个专门的名字,叫做“最大努力交付”(Best-Effort Delivery),比如 TCP 协议中的可靠性保障,就属于最大努力交付。

而“可靠事件队列”有一种更普通的形式,被称为“最大努力一次提交”(Best-Effort 1PC),意思就是系统会把最有可能出错的业务,以本地事务的方式完成后,通过不断重试的方式(不限于消息系统)来促使同个事务的其他关联业务完成。

XA 协议

为了解决分布式事务的一致性问题,1991 年的时候X/Open组织(后来并入了The Open Group)提出了一套叫做X/Open XA(XA 是 eXtended Architecture 的缩写)的事务处理框架。这个框架的核心内容是,定义了全局的事务管理器(Transaction Manager,用于协调全局事务)和局部的资源管理器(Resource Manager,用于驱动本地事务)之间的通讯接口。

如果我们以声明式事务来编码的话,那与本地事务看起来可能没什么区别,都是标个 @Transactional 注解而已,但如果是以编程式事务来实现的话,在写法上就有差异了。我们具体看看:

public void buyBook(PaymentBill bill) {
  userTransaction.begin();
    warehouseTransaction.begin();
    businessTransaction.begin();
  try {
      userAccountService.pay(bill.getMoney());
      warehouseService.deliver(bill.getItems());
      businessAccountService.receipt(bill.getMoney());
        userTransaction.commit();
      warehouseTransaction.commit();
      businessTransaction.commit();
  } catch(Exception e) {
        userTransaction.rollback();
      warehouseTransaction.rollback();
      businessTransaction.rollback();
  }
}

代码上能看出程序的目的是要做三次事务提交,但实际代码并不能这样写。为什么呢?
我们可以试想一下:如果程序运行到 businessTransaction.commit() 中出现错误,会跳转到 catch 块中继续执行,这时候 userTransaction 和 warehouseTransaction 已经提交了,再去调用 rollback() 方法已经无济于事。因为这会导致一部分数据被提交,另一部分被回滚,无法保证整个事务的一致性

为了解决这个问题,XA 将事务提交拆分成了两阶段过程,也就是准备阶段和提交阶段。

准备阶段,又叫做投票阶段。在这一阶段,协调者询问事务的所有参与者是否准备好提交,如果已经准备好提交回复 Prepared,否则回复 Non-Prepared。

这里的“准备”操作,其实和我们通常理解的“准备”不太一样:对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record。这意味着在做完数据持久化后并不会立即释放隔离性,也就是仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。

提交阶段,又叫做执行阶段,协调者如果在准备阶段收到所有事务参与者回复的 Prepared 消息,就会首先在本地持久化事务状态为 Commit,然后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者都会将自己的事务状态持久化为“Abort”之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。

这就是大名鼎鼎的2PC,不过要通过2PC实现事务一致性,还需要满足一些条件:

第一,必须假设网络在提交阶段这个短时间内是可靠的,即提交阶段不会丢失消息。同时也假设网络通讯在全过程都不会出现误差,即可以丢失后消息,但不会传递错误的消息,XA 的设计目标并不是解决诸如拜占庭将军一类的问题。

第二,必须假设因为网络分区、机器崩溃或者其他原因而导致失联的节点最终能够恢复,不会永久性地处于失联状态。由于在准备阶段已经写入了完整的重做日志,所以当失联机器一旦恢复,就能够从日志中找出已准备妥当但并未提交的事务数据,再向协调者查询该事务的状态,确定下一步应该进行提交还是回滚操作。

两段式提交(2PC)

两阶段提交协议(2PC)是一种广泛使用的方法,用于确保分布式系统中的原子事务。在 2PC 中,协调者监督多个参与者,并确保所有参与者都同意提交事务,或者如果任何参与者未能提交,所有参与者都会回滚。

两段式提交的原理很简单,也不难实现,但有三个非常明显的缺点。

  • 单点问题协调者在两段提交中具有举足轻重的作用,协调者等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。一旦协调者宕机,所有参与者都会受到影响。如果协调者一直没有恢复,没有正常发送 Commit 或者 Rollback 的指令,那所有参与者都必须一直等待。
  • 性能问题两段提交过程中,所有参与者相当于被绑定成为一个统一调度的整体,期间要经过两次远程服务调用、三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record),整个过程将持续到参与者集群中最慢的那一个处理操作结束为止。这就决定了两段式提交的性能通常都比较差。
  • 一致性风险当网络稳定性和宕机恢复能力的假设不成立时,两段式提交可能会出现一致性问题。

三段式提交(3PC)


为了解决两段式提交的单点问题、性能问题和数据一致性问题,“三段式提交”(3 Phase Commit,3PC)协议出现了。但是三段式提交,也并没有解决一致性问题。
这是为什么呢?别着急,接下来我就具体和你分析下其中的缘由,以及了解三段式提交是否真正解决了单点问题和性能问题。
三段式提交把原本的两段式提交的准备阶段再细分为两个阶段,分别称为 CanCommit、PreCommit,把提交阶段改为 DoCommit 阶段。其中,新增的 CanCommit 是一个询问阶段,协调者让每个参与的数据库根据自身状态,评估该事务是否有可能顺利完成。
将准备阶段一分为二的理由是,这个阶段是重负载的操作,一旦协调者发出开始准备的消息,每个参与者都将马上开始写重做日志,这时候涉及的数据资源都会被锁住。如果此时某一个参与者无法完成提交,相当于所有的参与者都做了一轮无用功。
所以,增加一轮询问阶段,如果都得到了正面的响应,那事务能够成功提交的把握就比较大了,也意味着因某个参与者提交时发生崩溃而导致全部回滚的风险相对变小了。
因此,在事务需要回滚的场景中,三段式的性能通常要比两段式好很多,但在事务能够正常提交的场景中,两段式和三段式提交的性能都很差,三段式因为多了一次询问,性能还要更差一些。

Print Friendly, PDF & Email