Principles of Computer System Design An Introduction

Chapter 9 Atomicity: All-or-Nothing and Before-or-After

Read part of this book according to the course mit6.824 2020 requirements, specifically 9.1.5, 9.1.6, 9.5.2, 9.5.3, 9.6.3。This is the course’s web page.6.824 Schedule: Spring 2020 (mit.edu)

9.1.5 Before-or-After Atomicity: Coordinating Concurrent Threads

在第五章中,我们学习了如何通过创建线程来表达并发机会,而并发的目的是通过同时执行多个任务来提高性能。此外,上面的第9.1.2节指出,中断也可以创建并发。并发线程在它们的路径交叉之前并不表示任何特殊问题。路径交叉的方式总是可以用可共享、可写数据的术语来描述:并发线程恰好在大约相同的时间对同一块可写数据感兴趣。甚至并不必要使并发线程同时运行;如果一个线程被阻塞(可能是因为中断)在执行一个操作的中间,一个正在运行的不同线程可以对该线程使用的数据感兴趣,并且该被阻塞的线程将在某个时候再次使用该数据。

从应用程序程序员的角度来看,第5章介绍了两种非常不同的并发协调要求:sequence coordination and before-or-after atomicity(序列协调和先前或后续的原子性)。序列协调是一种“动作W必须在动作X之前发生”的约束条件。为了正确性,第一个动作必须在第二个动作开始之前完成。例如,从键盘读取键入的字符必须发生在运行将这些字符呈现在显示器上的程序之前。一般来说,当编写程序时,可以预见到序列协调约束,并且程序员知道并发动作的身份。因此,序列协调通常是使用特殊的语言构造或共享变量(例如第5章的事件计数)进行显式编程的。

相比之下,before-or-after atomicity (先前或后续的原子性)是一个更普遍的约束条件,多个同时操作同一数据的操作不应该相互干扰。我们将before-or-after atomicity定义如下:

如果从调用者的角度看,同时发生的操作的效果与这些操作完全在彼此之前或彼此之后发生的效果相同,那么这些操作具有先前或后续的属性。

在第5章中,我们看到如何使用显式锁和实现ACQUIRE和RELEASE过程的线程管理器创建before-or-after atomicity 操作。第5章展示了使用锁的一些before-or-after atomicity 操作的示例,并强调编写正确的before-or-after atomicity 操作(例如协调具有多个生产者或多个消费者的有界缓冲区)可能是一个棘手的问题。为了确保正确性,需要建立一个令人信服的论据,证明每个涉及共享变量的操作都遵循锁协议。

before-or-after atomicitysequence coordination不同的一点是,必须具有before-or-after atomicity 属性的操作的程序员不一定知道可能触及共享变量的所有其他操作的身份。这种缺乏知识可能会使通过显式编程步骤协调操作成为问题(即在这种背景下使用sequence coordination的编写方式是行不通的)。相反,程序员需要一种自动、隐式的机制,以确保正确处理每个共享变量。本章将描述几种这样的机制。换句话说,正确的协调需要在并发线程读写共享数据的方式上有纪律性(即并发线程读写共享数据时需要遵守一定的约束)。

计算机系统中有很多需要before-or-after atomicity 的应用。在操作系统中,多个并发线程可能决定在大约相同的时间使用共享打印机。如果不希望不同线程的打印行交错显示在打印输出中,那么这样做就没有意义。此外,真正重要的并不是哪个线程首先使用打印机;主要考虑因素是在下一个任务开始之前必须完全完成一次打印,因此要为每个打印作业赋予before-or-after atomicity 属性。

为了提供更详细的示例,让我们回到银行应用程序和TRANSFER程序。这次,账户余额存储在共享内存变量中(请记住,声明关键字reference表示参数是按引用传递的,因此TRANSFER可以更改这些参数的值):

procedure TRANSFER (reference debit_account, reference credit_account, amount) {debit_account ← debit_account - amount credit_account ← credit_account + amount
}

尽管X ← X + Y这样的程序语句看起来是单元的,但实际上它是复合的:它涉及读取X和Y的值,执行加法,然后将结果写回X。如果在此语句执行读取和写入之间,另一个并发线程读取并更改了X的值,那么该线程可能会感到惊讶,因为此语句会覆盖其更改。

假设在以下情况下将此过程应用于帐户A(最初包含300美元)和B(最初包含100美元):

TRANSFER (A, B, $10)

我们期望借方账户A最终拥有290美元,贷方账户B最终拥有110美元。然而,假设第二个并发线程正在执行以下语句:

TRANSFER (B, C, $25)

其中账户C初始为175美元。当两个线程完成转账后,我们期望B最终拥有85美元,C最终拥有200美元。此外,无论哪个转账先发生,这个期望都应该得到满足。但是第一个线程中的credit_account变量与第二个线程中的debit_account变量绑定到同一个对象(账户B)。如果两个转账几乎同时发生,就会出现破坏正确性的风险。为了理解这种风险,请考虑图9.2,该图说明了两个线程的READ和WRITE步骤相对于变量B的几个可能的时间序列。

对于每个时间序列,该图显示了包含账户B余额的单元格的值的历史记录。如果步骤1-1和1-2都在步骤2-1和2-2之前(或反之亦然),则两个转账将按照预期工作,B最终拥有85美元。然而,如果步骤2-1在步骤1-1之后但在步骤1-2之前发生,就会出现错误:其中一个转账不会影响账户B,尽管它应该这样做。前两种情况说明了共享变量B的历史记录,其中答案是正确的结果;其余四种情况说明了四种不同的序列,导致B的两个错误值。

上图是两个线程两步读写操作的序号标记。

如上图所示,如果共享变量B的两个线程不协调它们的并发活动,可能会出现六种变量B的可能历史记录。这其中只有两种正确情况,剩下的情况得到的值都是错误的。不难看出,得到错误结果都是因为有某个写操作失效了。(图9.2)

因此,我们的目标是确保实际发生第一种或第二种时间序列。实现这个目标的一种方法是使步骤1-1和1-2是原子的,并且步骤2-1和2-2也应该是类似的原子操作。

在原始程序中,这两个步骤:

debit_account ← debit_account - amount

credit_account ← credit_account + amount

每个步骤都应该是原子的。不应该有并发线程打算在此语句的读取和写入步骤之间读取共享变量debit_account的值的可能性。

9.1.6 Correctness and Serialization

在图9.2的前两个序列正确而其他四个序列不正确的概念是基于我们对银行应用程序的理解。更好的做法是具有与应用程序无关的更一般的正确性概念。应用程序独立性是模块化的目标:我们希望能够为提供before-or-after atomicity的机制的正确性提供论据,而不涉及使用该机制的应用程序是否正确的问题。

确实存在这样的正确性概念:如果每个结果都保证是通过某些纯串行应用相同操作而获得的,那么并发操作之间的协调可以被认为是正确的。

这个正确性概念的推理过程包括几个步骤。考虑图9.3,该图抽象地显示了将某些操作(无论是原子的还是非原子的)应用于系统的效果:操作改变了系统的状态。现在,如果我们确信:

此图表示了:单个操作将系统从一个状态转移到另一个状态。

  1. 系统的旧状态从应用程序的角度来看是正确的;
  2. 操作本身可以正确地将任何正确的旧状态转换为正确的新状态,

因此,我们可以推断出新状态也必须是正确的。这种推理方法适用于任何应用程序相关的“正确”和“正确转换”的定义,因此我们的推理方法独立于这些定义,也独立于应用程序。

当多个操作同时作用于系统时,如图9.4所示,相应的要求是,产生的新状态应该是那些将多个操作串行化后产生的状态之一,如图9.5所示。这个正确性标准意味着,如果并发操作的结果保证是通过某些纯串行应用相同操作而获得的,则它们是正确协调的

当多个操作同时作用于系统时,它们一起产生一个新状态。如果这些操作是before-or-after atomicity的,并且旧状态是正确的,则新状态将是正确的。

只要协调要求是 before-or-after atomicity,任何串行化都可以胜任。

**此外,我们甚至不需要坚持系统实际沿着图9.5中任何特定路径遍历中间状态——它可以沿着虚线轨迹穿过通过应用程序定义不正确的中间状态。**只要中间状态在实现层以上不可见,且系统保证最终到达可接受的最终状态之一,我们可以声明协调是正确的,因为存在一条导致该状态的轨迹,每一步都可以应用正确性论证。

由于我们对 before-or-after atomicity的定义是每个before-or-after的操作都像是在每个其他before-or-after的操作之前或之后完全运行一样,因此before-or-after atomicity直接导致了这个正确性概念。换句话说,前后原子性的效果是将这些操作串行化,因此可以得出前后原子性保证正确协调性的结论。另一种表达这个想法的方法是说,**当并发操作具有before-or-after属性时,它们是可串行化的:存在某些并发事务的串行顺序,如果按照该顺序进行,则会导致相同的结束状态。**因此,在图9.2中,情况1和情况2的序列可能是由一个串行顺序产生的,但情况3到6的操作则不可能。

我们坚持最终状态必须是可以通过某些原子操作的串行化达到的状态,但我们不关心具体的串行化顺序。此外,我们不需要坚持中间状态实际上存在。实际的状态轨迹可以是虚线所示的那样,但前提是从外部无法观察到中间状态。

即只要最终状态等价于某种串行化执行的结果即可,即使存在一些不符合要求的中间状态,但只要从外部无法观察到这些中间状态,那么就是可以接受的,因为此时我们只能看到开始状态和结束状态。

在图9.2的例子中,只有两个并发操作,每个并发操作只有两个步骤。随着并发操作的数量和每个操作中步骤的数量增加,可能发生的单个步骤发生顺序的数量会迅速增长,但只有其中一些顺序才能确保正确的结果。由于并发的目的是为了提高性能,人们希望能够从正确顺序的集合中选择具有最高性能的正确顺序。可以想象,通常很难做出这个选择。在本章的第9.4节和第9.5节中,我们将遇到几种编程技术,这些技术可以确保从可能顺序的子集中进行选择,其中所有成员都保证是正确的,但不幸的是,可能不包括具有最高性能的正确顺序。

在某些应用程序中,使用比串行化更强的正确性要求是适当的。例如,银行系统的设计人员可能希望通过要求所谓的外部时间一致性来避免年代错误:如果存在任何外部证据(如打印的收据),表明T1结束于T2开始之前或之后,T1和T2在系统内的串行化顺序应该是T1在T2之前。另一个更强的正确性要求的例子是,处理器架构师可能需要顺序一致性:当处理器同时执行同一指令流中的多个指令时,结果应该就像按照程序员指定的原始顺序执行指令一样。

这里是针对于CPU内部执行指令的顺序一致性,为什么是更严格呢,因为这里只规定了一种serializable的顺序是合理的,所以更加的严格了。

回到我们的例子,一个真正的资金转账应用程序通常有几个不同的before-or-after atomicity要求。考虑以下审计程序;其目的是验证所有账户余额的总和为零(在双重簿记中,属于银行的账户,如金库中的现金金额,具有负余额):

procedure AUDIT()sum ← 0for each W ← in bank.accountssum ← sum + W.balanceif (sum ≠ 0) call for investigation

假设AUDIT正在一个线程中运行,同时另一个线程正在将资金从账户A转移到账户B。如果AUDIT在转账之前检查账户A,之后检查账户B,则会将转移的金额计算两次,因此将计算出一个不正确的答案。因此,整个审计过程应在任何单个转账之前或之后发生:我们希望它是一个before-or-after动作。

还有另一个前后原子性要求:如果AUDIT应在TRANSFER语句中的debit_account ← debit_account - amount语句之后但在credit_account ← credit_account + amount语句之前运行,这将计算不包括amount的余额总和;因此我们得出结论,两个余额更新应在任何AUDIT操作之前或之后完全发生;换句话说,TRANSFER应该是一个前后动作。

9.5.2 Simple Locking

第二个锁定方式称为简单锁定,它与标记点规则在精神上类似,但并非完全相同。简单锁定方式有两个规则。首先,每个事务在进行任何实际的读写操作之前,必须为其打算读取或写入的每个共享数据对象获取锁其次,只有在事务安装其最后一个更新并提交或完全恢复数据并中止之后,它才能释放其锁。类似于标记点,事务具有所谓的锁点:它获取所有锁的第一时刻。当它达到其锁点时获取的锁集合称为其锁集合。锁管理器可以通过要求每个事务将其打算获取的锁集合作为begin_transaction操作的参数来实施简单锁定,如果需要等待它们变得可用,则获取锁集合的所有锁。锁管理器还可以在所有读取数据和记录更改的调用上拦截自己,以验证它们是否引用了锁集合中的变量。锁管理器还会截取提交或中止的调用(或者,如果应用程序使用向前恢复,则记录END记录),在此时它自动释放锁集合的所有锁。

在简单锁定规则中,事务必须等到所有的更新操作都完成并成功提交或完全恢复数据后,才能释放它所持有的所有锁。这样可以确保在事务执行期间,其他事务无法访问相同的共享数据对象,从而避免了数据竞争和并发访问冲突。当事务完成后,锁管理器会自动释放所有的锁,以便其他事务可以继续访问这些共享数据对象。

注意上面的加粗字体,Simple Locking规则是在进行任何实际的读写操作之前,必须为其打算读取或写入的每个共享数据对象获取锁,注意是共享数据对象,非共享的则可以在后续直接获取,因为不存在冲突。

简单锁定原理可以正确地协调并发事务。我们可以使用与标记点纪律正确性证明类似的论证方式来证明这一点。假设一个全知的外部观察者维护一个有序列表,当事务达到其锁点时,它会立即将事务标识符添加到列表中,并在事务开始释放其锁时将其从列表中删除。在简单锁定纪律下,每个事务都同意在其添加到观察者的列表之前不读取或写入任何内容。我们也知道,在列表中先于该事务的所有事务都必须已经通过了其锁点。由于没有数据对象可以出现在两个事务的锁集合中,任何事务的锁集合中的数据对象都不会出现在列表中其前面的(preceding—前继)事务的锁集合中,而且也不会出现在列表中任何早于它的事务的锁集合中。因此,该事务的所有输入值与在列表中前面的事务提交或中止时的输入值相同。同样的论证方式也适用于前面的事务,因此任何事务的所有输入都与在列表中所有前面的事务按照列表顺序串行运行时所使用的输入相同。因此,简单锁定纪律确保该事务在前面的事务完全运行后和下一个事务之前完全运行。并发事务将产生结果,就好像它们按照它们到达锁定点的顺序串行化一样。

与标记点纪律一样,简单锁定也可能会错过一些并发机会。此外,简单锁定纪律在某些应用中可能会产生一个显著的问题。因为它要求事务获取每个它要读取或写入的共享对象的锁(回想一下,标记点纪律只要求标记事务将写入的共享对象),那些通过读取其他共享数据对象来发现需要读取哪些对象的应用程序别无选择,只能锁定它们可能需要读取的每个对象。在应用程序可能需要读取的对象集合大于它最终实际读取的集合的程度上,简单锁定纪律可能会干扰并发机会。另一方面,当事务很简单(例如图9.16中的TRANSFER事务,它只需要锁定两个记录,这两个记录在开始时就已知)时,简单锁定可以是有效的。

最后一段说明了Simple Locking的应用场景,也说明了此锁定方式在复杂的场景下是有问题的,会在一定程度上限制系统的性能。

9.5.3 Two-Phase Locking

第三种锁定纪律称为两阶段锁定,与读取捕获纪律类似,避免了事务必须预先知道要获取哪些锁的要求。两阶段锁定被广泛使用,但更难以证明它的正确性。两阶段锁定纪律允许事务在进行过程中获取锁,并且事务一旦在该对象上获得锁,就可以立即读取或写入数据对象。主要的约束是事务在通过其锁点之前不能释放任何锁。此外,如果事务永远不需要再次读取某个仅读取的对象,即使是在中止时,它也可以在到达锁点后的任何时间释放对该对象的锁。该纪律的名称是由于事务所获取的锁数量在锁点之前单调递增(第一阶段),之后单调递减(第二阶段)。与简单锁定一样,两阶段锁定使并发事务按照它们到达锁定点的顺序产生结果,就好像它们按照序列化的顺序运行一样。锁管理器可以通过拦截所有读取和写入数据的调用来实现两阶段锁定。它在每个共享变量的第一次使用时获取锁(可能需要等待)。与简单锁定一样,它然后保持锁定状态,直到拦截到提交、中止或记录事务的结束记录时,才会一次性释放所有锁定。

Simple Locking在进行任何实际的读写操作之前,必须获取到所有的共享对象的锁才能继续。

2PL则可以允许事务在进行的过程中不断地获取锁,一旦获取就可以马上进行读写。

大体上,2PL在Simple Locking的基础上放松了获取锁的要求,允许事务边进行边获取。

两阶段锁定的额外灵活性使得它更难证明它保证了before-or-after atomicity。非正式地说,一旦事务在数据对象上获取了锁,该对象的值就与事务到达锁点时的值相同,因此现在读取该值必须产生与等待到那时读取它相同的结果。此外,如果事务永远不会再次查看它没有修改的对象,即使是在中止时,释放该对象的锁也必须是无害的。关于两阶段锁定如何实现正确的before-or-after atomicity的正式论证可以在大多数高级并发控制和事务的文本书中找到,例如 Gray 和 Reuter 的《Transaction Processing》[进一步阅读建议1.1.5]。

两阶段锁定纪律可以潜在地允许比简单锁定纪律更多的并发操作,但它仍会不必要地阻止某些可串行化,因此是正确的操作顺序。例如,假设事务T1读取X并写入Y,而事务T2只对Y进行(盲目的)写入。由于T1和T2的锁集在变量Y处相交,两阶段锁定纪律将强制事务T2在T1之前完全运行或之后完全运行。但是,如下面的操作序列中所示:

T1: READ X
T2: WRITE Y
T1: WRITE Y

写入T2发生在T1的两个步骤之间,与在T1之前完全运行T2的结果相同,因此结果始终是正确的,即使这个顺序会被两阶段锁定所防止。同时允许所有可能的并发性并确保 before-or-after atomicity的纪律是相当困难的。(理论家将这个问题认定为NP完全问题。)

锁和日志之间有两种交互需要一些思考:(1)个别事务中止,(2)系统恢复。中止是最容易处理的。由于我们要求中止的事务在释放任何锁之前将其更改的数据对象恢复到其原始值,因此不需要对中止的事务进行特殊处理。为了实现before-or-after atomicity,它们看起来就像没有更改任何内容的已提交事务。在事务结束之前不释放任何修改数据上的锁的规则对于执行中止操作是必不可少的。如果在某个修改的对象上的锁被释放,然后事务决定中止,它可能会发现某些其他事务现在已经获取了该锁并再次更改了该对象。除非保持修改对象上的锁定,否则回滚中止的更改可能是不可能的。

日志恢复和锁之间的交互作用不太明显。问题是锁本身是否是需要记录更改的数据对象。为了分析这个问题,假设系统崩溃了。在完成崩溃恢复时,不应该有未完成的事务,因为在崩溃时处于未完成状态的任何事务都应该被恢复过程回滚,并且恢复不允许任何新事务开始,直到它完成为止。由于锁仅存在于协调未完成事务的目的,如果在崩溃恢复完成时仍有锁定,则显然会出现错误。这一观察表明,锁应该在易失性存储器中,这样它们会在崩溃时自动消失,而不是在非易失性存储器中,在那里恢复过程将不得不搜索它们以释放它们。然而,更大的问题是,基于日志的恢复算法是否会构造出一个正确的系统状态——在已提交的事务崩溃之前,这个状态是可以通过某些串行顺序的事务来产生的。

继续假设锁在易失性存储器中,并且在崩溃的瞬间,所有关于锁的记录都丢失了。一些事务集——那些记录了BEGIN记录但尚未记录END记录的事务——可能尚未完成。但我们知道,在崩溃瞬间,没有完成的事务在那时的锁值消失时具有非重叠锁集。图9.23的恢复算法将系统性地对不完整的事务执行UNDO或REDO安装,但每个这样的UNDO或REDO必须修改一个在某些事务的锁集中的变量。因为这些锁集必须是非重叠的,这些特定的操作可以在恢复期间安全地重做或撤消,而不必担心before-or-after atomicity。换句话说,锁创建了事务的特定序列化,而日志捕获了该序列化。由于RECOVER按照日志中指定的顺序以相反的顺序执行UNDO操作,并按照日志中指定的顺序以正向顺序执行REDO操作,因此RECOVER重建了完全相同的序列化。因此,即使是一个从日志中重建整个数据库的恢复算法,也保证会产生与原始执行事务时相同的序列化。只要没有新事务开始,直到恢复完成,就不会出现协调错误的危险,尽管在恢复期间没有锁。

由于锁已经消失,不存在需要使用锁协调并发访问的情况。但是,这并不代表在恢复过程中不需要使用锁。恢复过程中确实需要对数据库进行修改操作,而这些操作可能会与其他事务的并发访问产生冲突,因此需要使用锁来协调并发访问,以保证恢复过程的正确性。所以,在恢复过程中需要使用锁来协调并发访问,以保证恢复的正确性。

所以这里说的“恢复期间没有锁”,指的应该是锁在易失性存储存放,在故障后锁的相关信息都没有了。特指的这些,而不是说在恢复过程中真的一点都不需要锁。

9.6.3 Multiple-Site Atomicity: Distributed Two-Phase Commit

如果一个事务需要在几个由最好努力网络隔离的站点执行这个事务的几个不同部分(即将这个事务进行拆分为不同的几部分,放到几个不同的隔离站点进行执行,只有不同的隔离站点都执行成功后,该事务才可被视为成功执行),那么获取原子性就更加困难,因为用于协调各站点事务的任何消息都可能会丢失、延迟或重复。在第4章中,我们学习了一种称为远程过程调用(RPC)的方法,用于在另一个站点执行操作。在第7章[在线]中,我们学习了如何使用持久发送器设计诸如RPC的协议,以确保至少一次执行和重复抑制以确保至多一次执行。

不幸的是,这两种保证都不完全符合确保多站点事务原子性所需的条件。但是,通过将两阶段提交协议与持久发送器、重复抑制和单站点事务正确组合,我们可以创建一个正确的多站点事务。我们假设每个站点都可以在其本地实现本地事务,使用版本历史记录或日志和锁等技术实现all-or-nothing atomicitybefore-or-after atomicity如果所有站点都提交或所有站点都中止,多站点原子性协议的正确性将得到保证;如果某些站点提交其多站点交易的一部分,而其他站点中止了该同一交易的一部分,则我们将失败。

假设多站点事务由协调器Alice请求工作站点Bob、Charles和Dawn的组件事务X、Y和Z。简单地发出三个远程过程调用显然不能为Alice生成一个事务,因为Bob可能会执行X,而Charles可能会报告他不能执行Y。在概念上,协调器希望向三个工作者发送三条消息,例如向Bob发送以下消息:

From: Alice
To: Bob
Re: my transaction 91if (Charles does Y and Dawn does Z) then do X, please.

让三个工作者处理细节。我们需要一些线索,了解Bob如何完成这个奇怪的请求。

这个线索源于认识到协调器创建了一个更高层次的事务,每个工作者都要执行一个嵌套在更高层次事务中的事务。因此,我们需要的是一个分布式的两阶段提交协议。复杂之处在于协调器和工作者之间无法可靠地通信。因此,问题归结为构建一个可靠的分布式两阶段提交协议。我们可以通过应用持久发送器重复抑制来实现这一点。

协议的第一阶段始于协调器Alice为整个事务创建一个顶层结果记录。然后,Alice开始向Bob持久发送类似RPC的消息:

From:Alice
To: Bob
Re: my transaction 271Please do X as part of my transaction.

类似的消息也会从Alice发送到Charles和Dawn,同样涉及到事务271,并分别请求他们执行Y和Z。与普通的远程过程调用一样,如果Alice在合理的时间内没有从一个或多个工作者那里收到响应,她会将消息重新发送给未响应的工作者,直到获得响应为止。

一个工作者站点在收到这种形式的请求后,会检查重复,并创建自己的事务,但将事务嵌套在Alice的原始事务中。然后,它开始执行所请求的操作的预提交部分,并向Alice报告已经完成了这部分工作:

From:Bob
To: Alice
Re: your transaction 271My part X is ready to commit.

当Alice收集到完整的响应集后,就会转入事务的两阶段提交部分,向Bob、Charles和Dawn的每个人发送消息,例如:

Two-phase-commit message #1: From:AliceTo: BobRe: my transaction 271PREPARE to commit X.

Bob在接收到这个消息后,会进行暂时提交(但不是最终提交),或者中止。Bob创建了耐久的暂时版本(或记录了计划更新到日志存储中)并记录了一个结果记录,表示它已准备好进行提交或中止。然后,Bob持久地向Alice发送响应,报告他的状态:

Two-phase-commit message #2: From:Bob To:Alice Re: your transaction 271 I am PREPARED to commit my part. Have you decided to commit yet? Regards.

另外,如果Bob收到来自Alice的重复请求,他的持久发送器将会发送一个重复的“PREPARED”或“ABORTED”响应。即不会重复执行,防止资源浪费。

此时,处于“PREPARED”状态的Bob处于不确定状态。正如在本地层次嵌套中一样,Bob必须能够要么运行到最后、要么中止,以无限期地保持准备状态,并等待其他人(Alice)发出指示。此外,协调器可能会独立崩溃或失去通信联系,增加了Bob的不确定性。如果协调器崩溃,所有工作者站点都必须等待协调器恢复;在这个协议中,协调器是单点故障

单点故障:当一个系统中某个组件的故障会导致整个系统无法正常工作时,这个组件就被称为单点故障。也就是说,单点故障是指系统中某个组件或节点出现故障后,会引发整个系统的崩溃,使得整个系统无法正常运行。在分布式系统中,单点故障通常指的是某个关键组件或节点(例如协调器、数据中心、网络连接等)的故障,因为这些组件或节点的故障会对整个系统的可用性产生重大影响。为了避免单点故障,通常会采取冗余设计和备份策略,以确保系统的高可用性和容错性。

作为协调器,Alice从她的几个工作者站点收集响应消息(可能会多次重新请求某些工作者站点的“PREPARED”响应)。如果所有工作者站点都发送了“PREPARED”消息,则两阶段提交的第一阶段完成。如果有任何一个工作者站点发送了“ABORT”消息或没有发送任何响应,则Alice可以中止整个事务,或者尝试使用其他工作者站点来执行该组件事务。当Alice通过标记自己的结果记录为“COMMITTED”来提交整个事务时,第二阶段开始。

一旦更高层次的结果记录被标记为“COMMITTED”或“ABORTED”,Alice就会向Bob、Charles和Dawn的每个人发送一个完成消息:

Two-phase-commit message #3 From:Alice To:Bob Re: my transaction 271 My transaction committed. Thanks for your help.

每个工作者站点在收到这样的消息后,将其状态从“PREPARED”更改为“COMMITTED”,执行任何必要的后提交操作,并退出。与此同时,Alice可以进行其他业务,但对于未来有一个重要的要求:她(协调器)必须可靠地并且无限期地记住这个事务的结果。原因是她的一个或多个完成消息可能已经丢失了。任何处于“PREPARED”状态的工作者站点都在等待完成消息告诉他们该如何继续。如果在合理的时间内没有收到完成消息,工作者站点的持久发送器将重新发送其“PREPARED”消息。每当Alice收到重复的“PREPARED”消息时,她只需返回所命名事务的结果记录的当前状态即可。

如果使用日志和锁的工作者站点崩溃,那么该站点的恢复过程必须进行三个额外的步骤。首先,它必须将任何“PREPARED”事务分类为暂时的赢家,并将其恢复到“PREPARED”状态。其次,其次,如果工作者站点在进行before-or-after atomicity类型操作时使用锁,则恢复过程必须重新获取在故障发生时“PREPARED”事务持有的任何锁。最后,恢复过程必须重新启动持久发送器,以了解更高层次事务的当前状态。如果工作者站点使用版本历史记录,则只需要最后一步,重新启动持久发送器即可。

即工作者站点崩溃后通过一系列机制重建崩前的状态,并通过重新启动持久发送器来尝试重新了解更高层次事务的当前状态。

由于工作者站点充当其“PREPARED”消息的持久发送器,Alice可以确信每个工作者站点最终会得知她的事务已提交。但是,由于工作者站点的持久发送器是独立的,Alice无法确保它们会同时执行。相反,Alice只能确信她的事务最终会完成。这种同时行动和最终行动之间的区别非常重要,接下来将很快看到。

如果一切顺利,N个工作者站点的两阶段提交将在3N个消息中完成,如图9.37所示:对于每个工作者站点,需要一个“PREPARE”消息、一个“PREPARED”响应消息和一个“COMMIT”消息。这个3N消息协议是完整且足够的,尽管有几种不同的变体可以提出。

下面提出了分布式两阶段提交的几种变体:

一个简化变体的例子是,初始的RPC请求和响应也可以分别携带“PREPARE”和“PREPARED”消息。但是,一旦工作者站点发送了“PREPARED”消息,它就失去了单方面中止的能力,并且必须保持在等待来自协调器的指令的边缘。为了最小化这种等待,通常最好将“PREPARE”/“PREPARED”消息对的发送延迟到协调器知道其他工作者站点似乎已准备好完成它们的部分时。

实现上述划线机制,可以使用适当的超时机制,当已经执行完毕的工作站等待的时间超过这个时间后,就可以发送自己的PREPARED响应消息。

分布式两阶段提交协议的某些版本包括来自工作者站点向协调器发送的第四个确认消息。其目的是收集完整的确认消息集合——协调器持续发送完成消息,直到每个站点都确认。一旦所有确认都收到,协调器就可以安全地丢弃它的结果记录,因为已知每个工作者站点都已收到消息。

一个既关注结果记录存储空间又关注额外消息成本的系统可以使用一个称为“假设提交”的进一步改进。由于大多数事务都会提交,我们可以使用一个稍微奇怪但非常节省空间的表示方式来表示“COMMITTED”结果记录的值:不存在。**如果有人查询一个不存在的结果记录,协调器会发送一个“COMMITTED”响应。**如果协调器使用这种表示方式,它将通过销毁结果记录来提交,因此不需要每个工作者站点的第四个确认消息。作为回报,这种明显的魔术般的减少消息数量和空间的方式,我们注意到中止事务的结果记录不能轻易地丢弃,因为如果在丢弃后查询到一个查询,查询将接收到“COMMITTED”响应。然而,协调器可以持续地要求中止事务的确认,并在所有这些确认都到达后丢弃结果记录。导致丢弃结果记录的协议与第7章中描述的关闭流和丢弃该流记录的协议完全相同。

在假设提交的协议中,协调器将已提交的事务的结果记录标记为“不存在”,并通过销毁该结果记录来提交。这意味着结果记录已经被从系统中删除,而不仅仅是标记为“不存在”。

因此,这种协议可以通过牺牲一些可用性来换取更高的空间效率和更少的消息成本。客户端无法从协调器上获得已提交事务的结果,但是可以从数据库或其他数据存储中获取已提交事务的结果。

以上是分布式两阶段提交的时序图,使用3N个消息。 (**初始的RPC请求和响应消息未显示。**即工作站在收到RPC请求后已经开始执行自己负责部分的事务了,在执行一段时间后,用分布式2PL进行提交)每个参与者都维护自己的版本历史或恢复日志。 该图显示了协调器和一个工作者站点所做的日志条目记录的步骤:

协调器将事务的不同操作发送给这几个工作站,待他们执行一段时间后,协调器想询问是否可以提交:

  1. 协调器向每个工作者站点发送一个PREPARE消息,请求它们准备提交事务。

  2. 每个工作者站点在收到PREPARE消息后,检查自己是否可以提交事务。如果可以,它们会记录一个PREPARED条目,并向协调器发送一个PREPARED消息。

  3. 协调器收到所有工作者站点的PREPARED消息后,向每个工作者站点发送一个COMMIT消息,请求它们提交事务。

  4. 每个工作者站点在收到COMMIT消息后,记录一个COMMITTED条目,并提交事务。一旦事务提交成功,工作者站点向协调器发送一个ACK消息,以确认它已成功提交事务。

  5. 协调器收到所有工作者站点的ACK消息后,记录一个ACKED条目,并通知客户端事务已成功提交。

  6. 如果有任何工作者站点在步骤2或步骤4中遇到错误,它会记录一个ABORTED条目,并向协调器发送一个ABORT消息,以通知协调器事务已中止。

  7. 协调器收到任何工作者站点的ABORT消息后,记录一个ABORTED条目,并通知客户端事务已中止。

每个参与者都维护自己的版本历史或恢复日志,时序图显示了协调器和一个工作者站点所做的日志条目记录。

分布式两阶段提交并不能解决所有多站点原子性问题。例如,如果协调器站点(在本例中为Alice)在发送PREPARE消息后但在发送COMMITABORT消息之前沉没,工作者站点将处于PREPARED状态,无法继续进行。

即协调器的单点故障问题。

即使没有这种担忧,Alice和她的同事也面临着一个相当棘手的多站点原子性问题,至少在原则上是无法解决的。唯一能拯救他们的是我们的观察,即几个工作者最终都会完成他们的部分,但不一定是同时进行的。如果她要求同时行动,Alice就会陷入麻烦。

即任何工作站的执行完成时间都是不定的,想让这些工作站同时完成是一件十分有难度的事情。

是的,这个无法解决的问题被称为“两个将军的困境”(the dilemma of the two generals)。在这个问题中,两个将军需要通过信使进行通信,以协调攻击计划。但是,由于通信可能会受到干扰或被敌人拦截,将军们无法确定他们的消息是否已经到达对方,并且无法确定对方是否会执行计划。

这个问题是一个经典的计算机科学问题,被广泛用于解释分布式系统中的困境和限制。在分布式系统中,由于通信的不确定性和不可靠性,很难保证所有参与者都能够达成共识或协调。

这个困境可以在本书Online Textbook | Principles of Computer System Design: An Introduction | Supplemental Resources | MIT OpenCourseWare的第9章中的9.6.4部分进行具体了解,这里就不再阅读了,仅仅读完要求的部分。

课堂补充内容

本堂课主题是分布式事务(distributed transaction),首先会讲concurrency control,之后讲atomic commit,这二者通常是在一起的,他们可以由事务(transaction)实现。 这里的事务与数据库中的事务要求是完全相同的(ACID)。 在分布式事务中重点关注隔离性中的serializable级别。

只有当事务并发执行结果符合事务完全序列化执行的某一种结果时,该并发执行的结果才是正确的。

有点像Push query到对应的机器的机制:即将一个事务拆分为几个更小的事务给不同的机器执行。

分布式事务也可以理解为是由分片行为引起的,不同的数据在不同的机器上,因此我们需要push query到存在对应分片的服务器上,当所有的数据更新完时,分片服务器们给coordinator发送prepared消息。因此分布式事务中的atomicity就是要么执行所有的更新、要么就一个都不执行,只不过这里的数据分布不再是单机,而是分布在不同的分片服务器上。

1. concurrency control

一共有两种方案,乐观锁方案和悲观锁方案。本堂课·主要讲pessimistic concurrency control,在接下来的课程中,会有一节课专门讲OCC。

1.1 pessimistic concurrency control

适合并发事务之间冲突较多的场景。

在事务使用任何数据前,他需要提前获取该数据对应的锁。如果在获取过程中发现有别的事务持有该数据的锁,则该事务需要停下来等待,直到目标锁变得可获取。即等待其他持有该数据锁的事务结束。

1.1.1 Two-phase Locking

事务的执行顺序被强制规定为获取锁的顺序。

  1. 在使用任何记录之前要获取它的锁。(注意与simple locking的区别)
  2. 只有事务中止或提交后(事务结束后),事务才能释放自己拥有的锁。即在执行期间一直持有所有的锁。

之所以没有在事务执行过程中用完某锁后就马上释放它,是因为这样可能会造成执行结果的不正确(不属于任何serializable执行结果之一);此外还会让别的事务看到执行中事务的中间状态。

在执行中事务abort的情况下,这个中间状态是不该存在的,因为他会被回滚。

2PL可能会遇到死锁的情况,方法就是abort并回滚其中一个事务,打破死锁的僵局。

1.2 optimistic concurrency control(OCC)

适合并发事务之间冲突较少的场景。

我们不需要去担⼼是否有其他事务和我们同时对数据进⾏读取,你只管继续对数据进⾏读写,并将结果写⼊临时区域

这个临时区域对外界(其他事务)是不可见的,只有我们自己才能看到这个临时区域。

等执行到最后,我们再检查是否有其他并发的事务对当前的事务造成影响:如果没有其他执行冲突修改的事务,那么说明不必使用锁,可以直接提交;若其他事务同时也对该事务修改的数据进行了修改(发生了冲突), 那么我们就得中止当前执行的 事务,并尝试重新执行它。

2. Two phase commit(2PC)-原始版本

Coordinator在将某个事务分割为不同部分后,会把涉及到该事务的所有消息都打上该事务的TxdID:不同的分片服务器(参与者)在接收到与被分配到的操作相关的消息时,这些消息上会有该事务的TxnID,以和其他事务的消息区分。(这里默认每个参与者都采用2PL锁协议)。

  1. Coordinator将事务中的不同操作发送到存在对应数据的分片服务器上,首先参与者们要先获取锁,获取成功后,待这些服务器执行一段时间,Coordinator向这些服务器发送Prepare消息询问他们是否已经执行完自己那部分任务了。
  2. 在收到CoordinatorPrepare消息后:当分片服务器在执行进度接近完成时,会去检查自己是否能真的完成自己负责的这部分操作(如OCC中的最后检查、如突然发生崩溃等等的情况)。
  3. 若分片服务器们确定可以提交,会向协调器回复prepared
  4. 协调器接着向这些服务器们发送Commit消息。分片服务器(参与者)们收到后,记录一个COMMITED条目,并开始提交,若提交成功,则回复协调器一个ACK
  5. 协调器收到所有的ACK,确认该事务执行完毕,并通知客户端该事务执行成功。
  6. 若在步骤2或4中有分片服务器回复了aborted或其他的无法提交的消息,那么协调器会记录一个aborted消息,并告诉所有的参与者rollback他们已经做的修改。(遵守All-Or-Nothing规则)
  7. 在参与者们收到协调器的commitabort消息后,在完成对应操作后就可以释放锁了。

这里使用的2PC是最原始的两阶段提交协议,还有一些修改版本,上面的阅读文献部分有提及到。此外,在原始方式中,协调器的故障会成为单点故障,因此需要某种机制给协调器实现容错机制。

2.1 2PC的崩溃处理机制

参与者B在不同的时间点崩溃,也有不同的处理机制;协调器在不同的时间点崩溃,有不同的处理机制;如下:

2.1.1 Prepare与prepared之间崩溃-参与者

协调器向参与者A、B发送了Prepare消息,但在B发送prepared之前,他崩溃并重启了;协调者没有收到所有参与者的prepared消息,因此别的参与者也不会进行实际的提交操作;这个阶段B可能还没有把该事务相关的状态信息持久化到磁盘上,只有内存上有,因此重启后再次收到协调器重复发送的prepare消息时,他就会回复no,因为B对该事务一无所知。所以结果是该事务被中止。

若B已经持久化事务信息到了磁盘上,那就可以继续执行了,因为他可以从磁盘上读取事务状态并继续执行。

2.1.2 prepared与commit之间崩溃-参与者

协调器向参与者A、B发送了Prepare消息,并都回复了prepared(代表负责部分已经执行完了,并且日志已经持久化到磁盘,但是具体的更改内容还未持久化),在B收到commit消息前,他崩溃了;A收到commit后,会将更改持久化到磁盘上并释放锁;为了符合原子性要求,B在回复prepared消息前必然已经把事务相关的状态信息(锁列表、相关的修改信息)存到磁盘上了(日志的方式);B重启后读取这些信息以恢复出崩溃前事务的状态,然后再次等待协调器的消息。

**当参与者回复prepared之前,它必须先把日志持久化到磁盘上,来防止突然的崩溃。**这也是2PC协议提交速度有点慢的原因所在。

2.1.3 commit与ack之间崩溃-参与者

B收到commit消息,提交完事务后崩溃。参与者B在提交完事务并持久化更改后,会删除这部分事务的log,若此时崩溃,那么B同样对该事务一无所知,所以B需要某种机制记住他已经完成的事务,这样当再次收到协调者的commit消息后,可以正确的回复协调者。

2.1.4 协调器崩溃

根据崩溃的时机不同,要做的处理也不同,关键点在于协调器不能忘记做过的修改。因此关键就在于日志是否被持久到磁盘上了,规则:在发送第一条commit/abort前,协调器已经把事务相关的日志持久到磁盘上了。这样之后若出现崩溃,他就不会忘记做过的修改。若是事务日志不完整,那么就直接中止。

**当协调器发送abort/commit消息前,他也必须持久化事务相关日志到磁盘,以防止突然地崩溃。**重启后查看日志可以知道,自己要发送abort还是commit。

**规则:发送commit之前,可以中止;发送之后,不可以中止,必须继续执行完毕。**这条规则适用于协调器与参与者;在符合时机的情况下,参与者也可以单方面中止事务(触发参与者超时机制,规定时间内没等到prepare消息)。此外,参与者们要能处理重复收到的信息,如多次的prepare、commit消息。

2.2 超时处理机制

不管是任何的超时情况,只要有参与者收到commit消息的可能性,那么就不能中止该事务。协调器只能反复发送commit,参与者只能等待协调器的commit,因为已经有参与者持久化了自己负责的事务更改到磁盘上。

在参与者都确认自己可以提交或不可以提交事务后(即回复协调器的prepare消息后),即使等待协调器下一条消息(abort或commit)已经过了很长的时间(超时了),他们也不能单方面提交或者中止,只能阻塞等待

这会导致某参与者长时间的持有锁,导致别的需要这些锁的事务只能等待。因此衍生了2PC协议的很多变体,他们都尝试让这部分阻塞等待时间尽可能的短。

即最终的决定权仍然在协调器手上,协调器根据参与者的状态决定事务是commit还是abort。参与者只需要和协调器进行信息交换,参与者之间不需要进行信息交换。代价就是上述的阻塞等待,还有协调器成为单点故障。

上面只是原始的2PC协议,还有很多的优化点,如当参与者将事务更改落地后,他们理论上可以删除这部分日志,但是必须以某种方式知道该事务已经被提交了,这是为了由于网络问题协调器没有收到ACK消息,而会反复的发送commit消息。如何处理重复消息,不同变体有不同的方案。

2.3 总结

在分片场景下,2PC协议是为了确保对分布在不同服务器上的多条记录进行操作的事务的ACID特性,若系统不支持同时对多条记录进行操作的事务,那么就不需要用到2PC。

但是2PC很慢,如上面提到的预写式日志(收到消息、阶段性操作完成,都要先把日志持久化到磁盘后才能进入下一阶段)、阻塞等待(其他事务会等待当前参与者持有的锁,极大地限制了并发),还有2PC中的多轮消息发送(等待消息到来)…,所有的一切都会使2PC变得很慢,但是相对的为分布式事务提供了很好的ACID特性。

因此2PL仅仅用于某些小团体、小组织之间,但是对于银行之间,如跨行转账这种,就一般不会使用2PL,因为当一个银行(参与者A或扮演协调器的角色)出现了故障,另一个银行(参与者B)就不得不一直持有锁,他要等待协调器安排的下一步行动。一个银行不能把自己的数据库的命运交到另一个银行手里。

因此人们为了让2PC变得更快,针对不同的具体场景,想出了很多的优化手段,在接下来的课程中会有很多这样的内容出现。

3. 与Raft对比,与Raft结合(Lab4)

2PC与Raft有类似之处,都是一个leader(2PC中是coordinator)多个参与者(raft的参与者的细分类型会多一些)。但是也有不同之处:

  1. Raft所做的就是复制,每个参与者做的都是同样的事情,因此是“复制状态机”,leader只要收到大多数参与者的确认消息就可以继续了,其余的可以慢慢地接收、应用日志。
  2. 2PC做的不是复制,每个参与者做的是不同的事情,协调器必须收到所有的参与者的回复才能进入下一个阶段的计划。

因此我们可以把Raft协议与2PC提交协议结合,构建一个具有高可用性的、且拥有分布式事务功能的系统。直观上看,就是协调器、参与者A、参与者B,三者都有属于自己的容错服务状态机集群,实际上参与2PL中工作的是每个集群中的leader,当leader产生日志时,及时的同步到自己的复制状态机上即可。

这也是我们在lab4(2020年)中要做的东西,做一个类似于这样的分片数据库,具有分布式事务、高可用性的集群服务器。

第一张图是分布式事务的描述图,其中Txn Coordinator、A、B分别对应到第二张图中的一个集群,对外提供服务的是leader,leader将产生的日志实时的发送给复制状态机实现容错,以便在leader故障时有服务器可以快速的接管工作。因此是高可用+分布式事务功能+分片功能。

Google的Spanner就是使用了上述这种构造实现的事务写操作,这也是下堂课的论文。

6.824-Distributed Transactions相关推荐

  1. mit 6.824 Distributed System

    文章目录 LEC1 Introduction LEC2 RPC and Threads LEC3 GFS LEC4 Primary-Backup Replication LEC5 Go, Thread ...

  2. Distributed transactions with multiple databases, Spring Boot, Spring Data JPA and Atomikos

    2019独角兽企业重金招聘Python工程师标准>>> A couple of weeks ago I was evaluating the possibility to use S ...

  3. GRIT: Consistent Distributed Transactions across Polyglot Microservices with Multiple Databases

    GRIT 基于多个数据库的聚合微服务的一致性分布式事务 GRIT: Consistent Distributed Transactions across Polyglot Microservices ...

  4. 分布式事务(Distributed Transactions)概述

    分布式事务是分布式领域必须要面对的问题,同时也是衡量一个分布式系统成熟度的重要指标.那么什么是分布式事务,哪些场景会涉及到分布式事务,如何实现分布式事务?本文将重点讨论以上问题. 分布式事务定义 分布 ...

  5. 6.824: Distributed Systems Spring 2020

    课程链接:http://nil.csail.mit.edu/6.824/2020 GitHub链接:https://github.com/fkuner/6.824 文章目录 Lecture Lectu ...

  6. MIT 6.824涉及的部分论文翻译

    引言 这篇文章用于记录在学习6.824过程中所涉及到的论文的翻译,以帮助像我一样的英语蒻蒻愉快的享受6.824.因为很多论文并不是很常见,导致很多连论文阅读笔记都没有,所以希望看到这篇文章的朋友找到或 ...

  7. 分布式系统(Distributed System)资料

    分布式系统(Distributed System)资料 <Reconfigurable Distributed Storage for Dynamic Networks> 介绍:这是一篇介 ...

  8. Spring JTA multiple resource transactions in Tomcat with Atomikos example--转载

    原文地址:http://www.javacodegeeks.com/2013/07/spring-jta-multiple-resource-transactions-in-tomcat-with-a ...

  9. MIT 6.824 l01 Introduction

    6.824 2020 Lecture 1: Introduction 6.824: Distributed Systems Engineering What is a distributed syst ...

  10. 想从事分布式系统,计算,hadoop等方面,需要哪些基础,推荐哪些书籍?--转自知乎...

    作者:廖君 链接:https://www.zhihu.com/question/19868791/answer/88873783 来源:知乎 分布式系统(Distributed System)资料 & ...

最新文章

  1. Redis-stat的安装与使用
  2. oracle捕捉所有异常,如何捕获和处理特定的Oracle异常?
  3. Flutter、ReactNative、uniapp对比
  4. 【操作系统】中断和异常的比较
  5. android 底部加载更多,android:ScrollView滑动到底部显示加载更多(示例代码)
  6. 深度学习2.08.tensorflow的高阶操作之张量排序
  7. php开启ziparchive类,php压缩解压文件ZipArchive类的方法使用教程
  8. ros重置后地址_RouterOS中BGP软重新配置 - ROS软路由论坛 - ROS教程 - RouterOS - ROS之家 - ROS脚本生成器 - Powered by Discuz!...
  9. (产品求职)阿里巴巴价值观和业务图
  10. 数据库 数据仓库 数据集市的区别
  11. CASCAN拍照式三维扫描仪精密测量叶轮和逆向设计综合技术解决方案
  12. Android开发之监听手机来电
  13. CLIPS 的简单认识
  14. ZYNQ7010教程(一)PL部分
  15. C语言字符表示c的,c语言中的“且”用什么符号表示?
  16. 实数二分(模板及例题)
  17. 海康威视人脸识别门禁系统对接
  18. 各大协作机器人厂商人机安全协作方式(HRC)简介
  19. 上床前告诉设计师的15句真心话
  20. 微信授权-官方案例 ios版

热门文章

  1. kerberos : Failed to find any Kerberos tgt
  2. mysql索引创建规则、联合与一般索引、执行计划、索引选择,索引重建与下推
  3. 十年前的经典日剧,悠长假期
  4. 服务器内存2666显示2400,内存科普:为什么我的2666内存变成2400了?
  5. Java -- 面向对象(三)
  6. OERu使大学教育负担得起
  7. 提高css开发效率的代码片段模板
  8. 14时28分全国默哀3分钟
  9. 如果你有无穷多的水,一个3公升的提捅,一个5公升的提捅,两只提捅形状上下都不均匀,问你如何才能准确称出4公升的水?
  10. fastadmin学习笔记 03 数据查询关联搜索