12.3 两阶段提交(Two-Phase Commit)

我们下一个话题更具体一点:在一个分布式环境中,数据被分割在多台机器上,如何构建数据库或存储系统以支持事务。所以这个话题是,如何构建分布式事务(Distributed Transaction)。具体来说,如何应付错误,甚至是由多台机器中的一台引起的部分错误。这种部分错误在分布式系统中很常见。所以,在分布式事务之外,我们也要确保出现错误时,数据库仍然具有可序列化和某种程度的All-or-Nothing原子性。

一个场景是,我们或许有两个服务器,服务器S1保存了X的记录,服务器S2保存了Y的记录,它们的初始值都是10。

接下来我们要运行之前的两个事务。事务T1同时修改了X和Y,相应的我们需要向数据库发送消息说对X加1,对Y减1。但是如果我们不够小心,我们很容易就会陷入到这个场景中:我们告诉服务器S1去对X加1,

但是,之后出现了一些故障,或许持有Y记录的服务器S2故障了,使得我们没有办法完成更新的第二步。所以,这是一个问题:某个局部的故障会导致事务被分割成两半。如果我们不够小心,我们会导致一个事务中只有一半指令会生效。

甚至服务器没有崩溃都可能触发这里的场景。如果X完成了在事务中的工作,并且在服务器S2上,收到了对Y减1的请求,但是服务器S2发现Y记录并不存在。

或者存在,但是账户余额等于0。这时,不能对Y减1。

不管怎样,服务器2不能完成它在事务中应该做的那部分工作。但是服务器1又完成了它在事务中的那部分工作。所以这也是一种需要处理的问题。

这里我们想要的特性,我之前也提到过,就是,要么系统中的每一部分都完成它们在事务中的工作,要么系统中的所有部分都不完成它们在事务中的工作。在前面,我们违反的规则是,在故障时没有保证原子性。

原子性是指,事务的每一个部分都执行,或者任何一个部分都不执行。很多时候,我们看到的解决方案是原子提交协议(Atomic Commit Protocols)。通常来说,原子提交协议的风格是:假设你有一批计算机,每一台都执行一个大任务的不同部分,原子提交协议将会帮助计算机来决定,它是否能够执行它对应的工作,它是否执行了对应的工作,又或者,某些事情出错了,所有计算机都要同意,没有一个会执行自己的任务。

这里的挑战是,如何应对各种各样的故障,机器故障,消息缺失。同时,还要考虑性能。原子提交协议在今天的阅读内容中有介绍,其中一种是两阶段提交(Two-Phase Commit)。

两阶段提交不仅被分布式数据库所使用,同时也被各种看起来不像是传统数据库的分布式系统所使用。通常情况下,我们需要执行的任务会以某种方式分包在多个服务器上,每个服务器需要完成任务的不同部分。所以,在前一个例子中,实际上是数据被分割在不同的服务器上,所以相应的任务(为X加1,为Y减1)也被分包在不同的服务器上。我们将会假设,有一个计算机会用来管理事务,它被称为事务协调者(Transaction Coordinator)。事务协调者有很多种方法用来管理事务,我们这里就假设它是一个实际运行事务的计算机。在一个计算机上,事务协调者以某种形式运行事务的代码,例如Put/Get/Add,它向持有了不同数据的其他计算机发送消息,其他计算机再执行事务的不同部分。

所以,在我们的配置中,我们有一个计算机作为事务协调者(TC),然后还有服务器S1,S2,分别持有X,Y的记录。

事务协调者会向服务器S1发消息说,请对X加1,向服务器S2发消息说,请对Y减1。

之后会有更多消息来确认,要么两个服务器都执行了操作,要么两个服务器都没有执行操作。这就是两阶段提交的实现框架。

有些事情你需要记住,在一个完整的系统中,或许会有很多不同的并发运行事务,也会有许多个事务协调者在执行它们各自的事务。在这个架构里的各个组成部分,都需要知道消息对应的是哪个事务。它们都会记录状态。每个持有数据的服务器会维护一个锁的表单,用来记录锁被哪个事务所持有。所以对于事务,需要有事务ID(Transaction ID),简称为TID。

虽然不是很确定,这里假设系统中的每一个消息都被打上唯一的事务ID作为标记。这里的ID在事务开始的时候,由事务协调器来分配。这样事务协调器会发出消息说:这个消息是事务95的。同时事务协调器会在本地记录事务95的状态,对事务的参与者(例如服务器S1,S2)打上事务ID的标记。

这就是一些相关的术语,我们有事务协调者,我们还有其他的服务器执行部分的事务,这些服务器被称为参与者(Participants)。

接下来,让我画出两阶段提交协议的一个参考执行过程。我们将Two-Phase Commit简称为2PC。参与者有:事务协调者(TC),我们假设只有两个参与者(A,B),两个参与者就是持有数据的两个不同的服务器。

事务协调者运行了整个事务,它会向A,B发送Put和Get,告诉它们读取X,Y的数值,对X加1等等。所以,在事务的最开始,TC会向参与者A发送Get请求并得到回复,之后再向参与者B发送一个Put请求并得到回复。

这里只是举个例子,如果有一个复杂的事务,可能会有一个更长的请求序列。

之后,当事务协调者到达了事务的结束并想要提交事务,这样才能:

  • 释放所有的锁,

  • 并使得事务的结果对于外部是可见的,

  • 再向客户端回复。

我们假设有一个外部的客户端C,它在最最开始的时候会向TC发请求说,请运行这个事务。并且之后这个客户端会等待回复。

在开始执行事务时,TC需要确保,所有的事务参与者能够完成它们在事务中的那部分工作。更具体的,如果在事务中有任何Put请求,我们需要确保,执行Put的参与者仍然能执行Put。TC为了确保这一点,会向所有的参与者发送Prepare消息。

当A或者B收到了Prepare消息,它们就知道事务要执行但是还没执行的内容,它们会查看自身的状态并决定它们实际上能不能完成事务。或许它们需要Abort这个事务因为这个事务会引起死锁,或许它们在故障重启过程中并完全忘记了这个事务因此不能完成事务。所以,A和B会检查自己的状态并说,我有能力或者我没能力完成这个事务,它们会向TC回复Yes或者No。

事务协调者会等待来自于每一个参与者的这些Yes/No投票。如果所有的参与者都回复Yes,那么事务可以提交,不会发生错误。之后事务协调者会发出一个Commit消息,给每一个事务的参与者,

之后,事务参与者通常会回复ACK说,我们知道了要commit。

当事务协调者发出Prepare消息时,如果所有的参与者都回复Yes,那么事务可以commit。如果任何一个参与者回复了No,表明自己不能完成这个事务,或许是因为错误,或许有不一致性,或许丢失了记录,那么事务协调者不会发送commit消息,

它会发送一轮Abort消息给所有的参与者说,请撤回这个事务。

在事务Commit之后,会发生两件事情。首先,事务协调者会向客户端发送代表了事务输出的内容,表明事务结束了,事务没有被Abort并且被持久化保存起来了。另一个有意思的事情是,为了遵守前面的锁规则(两阶段锁),事务参与者会释放锁(这里不论Commit还是Abort都会释放锁)。

实际上,为了遵循两阶段锁规则,每个事务参与者在参与事务时,会对任何涉及到的数据加锁。所以我们可以想象,在每个参与者中都会有个表单,表单会记录数据当前是为哪个事务加的锁。当收到Commit或者Abort消息时,事务参与者会对数据解锁,之后其他的事务才可以使用相应的数据。这里的解锁操作会解除对于其他事务的阻塞。这实际上是可序列化机制的一部分。

目前来说,还没有问题,因为架构中的每一个成员都遵循了协议,没有错误,两个参与者只会一起Commit,如果其中一个需要Abort,那么它们两个都会Abort。所以,基于刚刚描述的协议,如果没有错误的话,我们得到了这种All-or-Noting的原子特性。

最后更新于