【橘子微服务】是时候丢掉2PC模式了

本文译自是时候离开2PC了

我们从这个系列开始探索分布式事务,当然我们会从saga开始,在此之前我想先说明在分布式事务之前我们更多的使用2PC来解决事务问题。但是他显然存在一些问题,本文就是说明这个问题的。

背景

两阶段提交协议 (2PC) 已在企业软件系统中使用了三十多年。它是一种极具影响力的协议,可确保访问多个分区或分片中数据的事务的原子性和持久性。它无处不在,—在较旧的“古老”分布式系统、数据库系统和文件系统(如 Oracle、IBM DB2、PostgreSQL 和 Microsoft TxF(事务性 NTFS))中,以及在较年轻的“千禧一代”系统中(如 MariaDB、TokuDB、VoltDB、Cloud Spanner、Apache Flink、Apache Kafka 和 Azure SQL 数据库)中。如果您的系统支持跨分片/分区/数据库的 ACID 事务,则它很有可能在后台运行 2PC(或其某些变体)。有时甚至“掩盖”—旧版本的 MongoDB 要求用户在应用程序代码中为多文档事务实现 2PC(https://www.mongodb.com/zh-cn/docs/manual/core/transactions/)。

在这篇文章中,我们将首先描述 2PC:它是如何工作的以及它解决了什么问题。然后,我们将展示 2PC 的一些主要问题以及现代系统如何尝试解决这些问题。不幸的是,这些尝试的解决方案会导致其他问题的出现。最后,我将说明下一代分布式系统应该避免 2PC,以及如何做到这一点。

2PC 协议概述

2PC 有很多变体,但基本协议的工作原理如下:
背景假设:事务所需的工作已经被划分到存储该事务访问的数据的所有分片/分区中。我们将在每个分片上执行的工作称为由该分片的 “worker” 执行。每个 worker 都能够彼此独立地开始处理其对给定事务的职责。2PC 协议在事务处理结束时开始,此时事务已准备好“提交”。它由单个协调器机器(可能是该事务中涉及的 worker 之一)启动(译者注:他的意思是这个协调者可以是独立于各个服务之外的,也可以是其中的某个服务)。

2PC 协议的基本流程如下图所示。[该协议从图的顶部开始,然后向下进行]。
在这里插入图片描述
阶段 1:协调器询问每个工作人员是否已经成功完成了该事务的职责并准备好提交。每个工作人员都回答 是/否。
第 2 阶段:协调器对所有响应进行计数。如果每个 worker 都回答 ‘是’,则事务将提交。否则,它将中止。协调器向每个 worker 发送一条消息,其中包含最终的提交决定,并收到回信。

这种机制确保了事务的原子性:要么整个事务将反映在系统的最终状态中,要么一个都不反映。如果即使只有一个 worker 无法提交,那么整个事务将被中止。换句话说:每个 worker 都有交易的 “否决权”。

它还确保了事务的持久性。每个工作程序确保在阶段 1 中响应 "是"之前,事务的所有写入都已持久写入存储。这让协调者可以自由地对交易做出最终决定,而不必担心 worker 在投 “是” 后可能会失败。在本文中,我们在使用术语“持久写入”时故意含糊其辞—该术语可以指写入本地非易失性存储,也可以指将写入复制到足够多的位置,使其被视为“持久”

除了持久写入事务直接需要的写入之外,协议本身还需要额外的写入,这些写入必须持久才能继续。例如,worker在第 1 阶段响应“是”之前拥有否决权。在此之后,它将无法更改其投票。但是,如果它在响应“是”后立即崩溃怎么办?当它恢复时,它可能不知道它响应了"是",并且仍然认为它有否决权并继续中止交易。为了防止这种情况,它必须在将响应“是” 投票发回给协调器之前持久地写入投票。[除了此示例之外,在标准 2PC 中,还有另外两个写入在发送作为协议一部分的消息之前是持久的]。

2PC 的问题

2PC 有两个主要问题。第一个是众所周知的,并且在每本介绍 2PC 的著名教科书中都有讨论。第二个问题鲜为人知,但仍然是一个主要问题。

第一个众所周知的问题称为 阻塞问题。当每个 worker 都投响应了"是" ,但 协调者 在向至少一个 worker 发送包含最终决定的消息之前失败时,就会发生这种情况。这是一个问题的原因是,通过响应"是",每个 worker 都取消了否决交易的权力。但是,协调器仍然拥有决定交易最终状态的绝对权力。如果协调器在向至少一个 worker 发送带有最终决定的消息之前失败,则 worker 无法聚在一起在他们之间做出决定—他们不能中止,因为可能是协调器在失败之前决定提交,而他们不能提交,因为也许协调器决定在失败之前中止。因此,他们必须阻止—等待,直到协调器恢复—才能找出最终决定。同时,它们无法处理与停止的事务冲突的事务,因为该事务的写入的最终结果尚未确定(译者注:worker在投票之后就失去了参与角色的权利,此时只能等待协调者的响应,协调者出问题了,那这个事务将一直阻塞)

阻塞问题有两类解决方法。第一类解决方法修改了核心协议,以消除阻塞问题。不幸的是,这些修改通常会通过添加额外的一轮通信—来降低性能—因此在实践中很少使用。第二类保持协议完好无损,但降低了协调器失败类型的可能性,而不是导致阻塞程序—例如,通过在副本共识协议上运行 2PC 并确保协议的重要状态始终被复制。不幸的是,这些解决方法再次降低了性能,因为协议要求这些副本共识轮次按顺序进行,因此它们可能会大大增加协议的延迟。

第二个不为人知的问题是我所说的“堵塞问题”。2PC 发生在事务处理之后,因此必然会增加事务的延迟,其数量等于运行协议所需的时间。对于许多应用程序来说,这种延迟增加本身就已经是一个问题,但一个可能更大的问题是, worker节点直到第二阶段的中途才知道事务的最终结果。在他们知道最终结果之前,他们必须为它可能中止的可能性做好准备,因此他们通常会阻止冲突的事务取得进展,直到他们确定事务将提交。这些被阻止的事务反过来会阻止其他事务运行,依此类推,直到 2PC 完成并且所有被阻止的事务都可以恢复。这种阻塞 进一步增加了平均事务延迟,并且还降低了事务吞吐量。

总结一下我们上面讨论的问题:2PC 在四个维度上损害了系统:延迟(协议的时间加上冲突交易的停顿时间)、吞吐量(因为它阻止了冲突交易在协议期间运行)、可扩展性(系统越大,交易就越有可能成为多分区,并且必须支付 2PC 的吞吐量和延迟成本), 和 可用性 (我们上面讨论的阻塞问题)。 没有人喜欢 2PC,但几十年来,人们一直认为它是一种不能替代的坏家伙。

是时候进一步了

过去三十多年来,我们一直受困于分片系统中的两阶段提交。人们已经意识到它带来的性能、可伸缩性和可用性问题,但尽管如此,他们仍在继续,没有明显的更好的选择。

事实是,如果我们以不同的方式构建我们的系统,对 2PC 的需求就会消失。学术界(例如这篇 SIGMOD 2016 论文)和工业界都在实现这一目标。但是,这些尝试通常首先避免多分片事务,例如在事务之前重新分区数据,使其不再是多分片的。不幸的是,这种重新分区以其他方式降低了系统的性能。

我呼吁的是我们构建分布式系统的方式进行更深层次的变革。我坚持认为,系统仍然应该能够处理多分片事务,—具有所有 ACID 保证以及这— — 原子性和持久性等内容,但使用更简单、更快速的提交协议。
这一切都归结为一个几十年来一直存在于我们系统中的基本假设:事务可能随时因任何原因中止。 即使我在相同的初始系统状态上运行相同的事务…如果我在下午 2:00 运行它,它可能会成功提交,但在 3:00 时它可能会异常中止。

大多数架构师认为我们需要这种假设有几个原因。首先,机器可能随时出现故障,—包括在事务处理过程中。恢复时,通常不可能重新创建故障之前在 内存中的该事务的所有状态(译者注:内存丢失)。因此,似乎不可能从失败之前交易中断的地方继续。因此,系统会中止失败时正在进行的所有事务。由于故障可能随时发生,这意味着事务可能随时中止。

其次,大多数并发控制协议都需要能够随时中止事务。乐观的协议在处理事务后执行 “验证” 阶段。如果验证失败,则事务将中止。悲观协议通常使用锁来防止并发异常。这种对锁的使用可能会导致死锁,这可以通过中止(至少)一个死锁事务来解决。由于可能随时发现死锁,因此事务需要保留随时中止的能力(译者注:mysql解决死锁就是释放最长时间的一个操作?)。

如果你仔细查看两阶段提交协议,你会发现这种任意中止事务的可能性是协议中复杂性和延迟的主要来源。worker 无法轻易地告诉彼此是否会提交,因为它们在此之后(在提交事务之前)可能仍然会失败,并且希望在恢复期间中止此事务。因此,他们必须等到事务处理结束(当所有重要状态都变得持久时)并进行必要的两个阶段:在第一阶段,每个 worker 公开放弃其控制权以中止事务,只有这样才能发生第二个阶段,在这个阶段中做出最终决定并传播。
在我看来,我们需要消除 worker 的否决权,并构建系统,让系统在执行过程中放弃可以随时中止事务这种自由。只允许 事务 中的 逻辑导致 事务 中止。如果理论上可以在给定数据库的当前状态的情况下提交事务,则无论发生何种类型的失败,该事务都必须提交。此外,相对于其他并发运行的事务,不得存在可能影响事务的最终提交/中止状态的争用条件。

放弃灵活性这听起来很困难。我们很快将讨论如何实现此目的。但首先让我们观察一下如果事务不能灵活终止,提交协议是如何变化的。

当事务不能任意中止时,提交协议是什么样子的

让我们看两个例子:
在第一个示例中,假设为存储变量 X 的值的分片的工作程序分配了事务的单个任务:将 X 的值更改为 42。假设(现在)没有在 X 上定义完整性约束或触发器(这可能会阻止系统将 X 设置为 42)。在这种情况下,该 worker 永远不会被赋予能够中止事务的能力。无论发生什么,该 worker 都必须将 X 更改为 42。如果该 worker 失败,则必须在恢复后将 X 更改为 42。由于它从来没有任何中止的权力,因此无需在 commit 协议期间与该 worker 检查以查看它是否会提交。
在第二个示例中,假设为存储变量 Y 和 Z 的值的分片的工作程序为事务分配了两个任务:从上一个 Y 值中减去 1,并将 Z 设置为 Y 的新值。此外,假设 Y 上有一个完整性约束,它规定 Y 永远不会低于 0(例如,如果它表示零售应用程序中某个商品的库存)。因此,此 worker 必须运行以下代码的等效项:

IF (Y > 0)
   y-1
ELSE
    结束事务
Z = Y

必须授予此 worker 中止事务的权限,因为这是应用程序的逻辑所必需的。但是,这种能力是有限的。只有当 Y 的初始值为 0 时,此 worker 才能中止事务。否则,它别无选择,只能承诺。因此,它不必等到完成交易代码后才知道它是否会提交。相反:一旦它完成执行事务中的第一行代码,它就已经知道它的最终提交/中止决定。这意味着相对于 2PC 来说,提交协议将能够更早地开始。
现在让我们将这两个示例合并为一个示例,其中事务由两个 worker 执行—其中一个正在执行第一个示例中描述的工作,另一个正在执行第二个示例中描述的工作。由于我们保证了原子性,因此第一个 worker 不能简单地盲目地将 X 设置为 42。相反,它自己的工作也必须依赖于 Y 的值。实际上,它的交易代码变为:

temp = Do_Remote_Read(Y)
if (temp > 0)
   X = 42

请注意,如果第一个 worker 的代码是以这种方式编写的,那么另一个 worker 的代码可以简化为:

IF (Y > 0)
Subtract 1 from Y
Z = Y

通过以这种方式编写事务代码,我们从两个 worker 中删除了显式的终止逻辑。相反,两个工作程序都有 if 语句,用于检查可能导致原始事务中止的约束。如果原始事务已中止,则两个 worker 最终都不会执行任何操作。否则,两个 worker 都会根据事务逻辑的要求更改其本地 state 的值。
此时需要注意的重要一点是,在上面的代码中已经完全消除了对提交协议的需求。不允许系统出于应用程序代码对给定数据状态定义的条件逻辑以外的任何原因中止事务。所有 worker 都在相同的条件逻辑上调节他们的写入,这样他们就可以独立决定在事务由于当前系统状态而无法完成的情况下 “什么都不做”。因此,事务中止的所有可能性都已被消除,并且在事务处理结束时不需要任何类型的分布式协议来对事务做出组合的最终决定。2PC 的所有问题都已消除。没有阻塞问题,因为没有协调器。并且不存在 阻塞 问题,因为所有必要的检查都与事务处理重叠,而不是在事务处理完成后重叠。

此外,只要系统不允许出于基于输入数据状态的条件应用程序逻辑以外的任何原因中止事务,就始终可以像我们上面所做的那样重写任何事务,以便将代码中的中止逻辑替换为有条件地检查中止条件的 if 语句。此外,无需实际重写应用程序代码即可完成此操作。[如何执行此操作的细节不在本文的讨论范围之内,但概括一下:分片在完成任何可能导致中止的条件逻辑时,可以设置特殊的系统拥有的布尔标志,并且正是这些布尔标志从其他分片远程读取。
本质上:在事务处理系统中,有两种类型的中止是可能的:(1) 由数据状态引起的中止,以及 (2) 由系统本身引起的中止(例如故障或死锁)。
类别 (1) 总是可以根据数据的条件逻辑来写,就像我们上面所做的那样。因此,如果可以消除类别 (2) 中止,则可以消除 commit 协议。

所以现在,我们所要做的就是解释如何消除第 (2) 类 中止。

删除系统引起的中止

我花了将近整整十年的时间设计不允许系统诱导的中止的系统。此类系统的示例包括 Calvin、CalvinFS、Orthrus、PVW 和延迟处理交易的系统。此功能的推动力来自 Calvin — — 的第一个项目,因为它是一个确定性数据库系统。确定性数据库保证给定一组定义的输入请求,数据库中数据只有一种可能的最终状态。因此,可以将相同的 input 发送到系统的两个不同的副本,并确保这些 Replica 将独立处理此 Importing,并最终处于相同的最终状态,没有任何发散的可能性。
系统引起的中止(例如系统故障或并发控制争用条件)从根本上说是不确定的事件。一个副本很可能会失败或进入争用条件,而另一个副本则不会。如果允许这些非确定性事件导致事务中止,则一个副本可以中止事务,而另一个副本将提交—根本违反确定性保证。因此,我们必须以一种失败和竞争条件不会导致事务中止的方式设计 Calvin。对于并发控制,Calvin 使用悲观锁定和死锁避免技术,确保系统永远不会陷入由于死锁而不得不中止事务的情况。面对系统故障,Calvin 没有从中断的地方开始处理事务(因为在故障期间丢失了易失性内存)。尽管如此,它还是能够完成该交易的处理,而不必中止它。它通过从相同的原始 input 重新启动事务来实现这一点。
这些解决方案都—既不避免死锁,也不—在发生故障时重新启动事务,也不限于在确定性数据库系统中使用。[在非确定性系统中,如果其他未失败的机器观察到与在故障期间丢失的事务相关的某些易失性状态,则事务重启会变得有点棘手。但是有一些简单的方法可以解决这个问题,这超出了本文的范围。事实上,我上面链接的其他一些系统是非确定性系统。一旦我们意识到消除系统级中止所带来的力量,我们就在 Calvin 项目之后构建了这个功能,甚至—非确定性系统。

结论

我看到系统架构师在分片系统中继续使用 2PC 的好处很小。我相信删除系统诱导的中止和重写状态诱导的中止是更好的前进方式。确定性数据库系统(如 Calvin 或 FaunaDB)总是会删除系统诱导的中止,因此通常可以避免如上所述的 2PC。但是,将这种优势仅限于确定性数据库是一种巨大的浪费。从非确定性系统中删除系统诱导的 aborts 并不难。最近的项目表明,甚至可以在使用悲观并发控制以外的并发控制技术的系统中消除系统诱导的中止。例如,我们上面链接的 PVW 和惰性事务处理系统都使用多版本并发控制的变体。FaunaDB 使用了乐观并发控制的变体。

在我看来,几乎没有借口继续关于系统中系统诱导流产的需要的过时假设。在过去,当系统在单台机器上运行时,这样的假设是合理的。然而,在现代,许多系统需要扩展到多台可能彼此独立发生故障的机器,这些假设需要昂贵的协调和提交协议,例如 2PC。2PC 的性能问题是导致不符合 ACID 的系统兴起的主要力量,这些系统为了实现更好的可伸缩性、可用性和性能而放弃了重要的保证。2PC 太慢了—它增加了所有事务的延迟,—不仅因为协议本身的长度,还因为阻止了访问同一组数据的交易并发运行。2PC 还限制了可伸缩性(通过降低并发性)和可用性(我们上面讨论的阻塞问题)。前进的道路很明确:在设计系统时,我们需要重新考虑过时的假设,并 “告别” 两阶段提交!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值