12.1 分布式事务初探(Distributed Transaction)

今天讨论的内容是分布式事务。

分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit)。

之所以提及分布式事务,是因为对于拥有大量数据的人来说,他们通常会将数据进行分割或者分片到许多不同的服务器上。假设你运行了一个银行,你一半用户的账户在一个服务器,另一半用户的账户在另一个服务器,这样的话可以同时满足负载分担和存储空间的要求。对于其他的场景也有类似的分片,比如说对网站上文章的投票,或许有上亿篇文章,那么可以在一个服务器上对一半的文章进行投票,在另一个服务器对另一半进行投票。

对于一些操作,可能会要求从多个服务器上修改或者读取数据。比如说我们从一个账户到另一个账户完成银行转账,这两个账户可能在不同的服务器上。因此,为了完成转账,我们必须要读取并修改两个服务器的数据。

一种构建系统的方式,我们在后面的课程也会看到,就是尝试向应用程序的开发人员,隐藏将数据分割在多个服务器上带来的复杂度。在过去的几十年间,这都是设计数据库需要考虑的问题,所以很多现在的材料的介绍都是基于数据库。但是这种方式(隐藏数据分片在多个服务器),现在在一些与传统数据库不相关的分布式系统也在广泛应用。

人们通常将并发控制和原子提交放在一起,当做事务。有关事务,我们之前介绍过。

可以这么理解事务:程序员有一些不同的操作,或许针对数据库不同的记录,他们希望所有这些操作作为一个整体,不会因为失败而被分割,也不会被其他活动看到中间状态。事务处理系统要求程序员对这些读操作、写操作标明起始和结束,这样才能知道事务的起始和结束。事务处理系统可以保证在事务的开始和结束之间的行为是可预期的。

例如,假设我们运行了一个银行,我们想从用户Y转账到用户X,这两个账户最开始都有10块钱,这里的X,Y都是数据库的记录。

这里有两个交易,第一个是从Y转账1块钱到X,另一个是对于所有的银行账户做审计,确保总的钱数不会改变,因为毕竟在账户间转钱不会改变所有账户的总钱数。我们假设这两个交易同时发生。为了用事务来描述这里的交易,我们需要有两个事务,第一个事务称为T1,程序员会标记它的开始,我们称之为BEGIN_X,之后是对于两个账户的操作,我们会对账户X加1,对账户Y加-1。之后我们需要标记事务的结束,我们称之为END_X。

同时,我们还有一个事务,会检查所有的账户,对所有账户进行审计,确保尽管可能存在转账,但是所有账户的金额加起来总数是不变的。所以,第二个事务是审计事务,我们称为T2。我们也需要为事务标记开始和结束。这一次我们只是读数据,所以这是一个只读事务。我们需要获取所有账户的当前余额,因为现在我们只有两个账户,所以我们使用两个临时的变量,第一个是用来读取并存放账户X的余额,第二个用来读取并存放账户Y的余额,之后我们将它们都打印出来,最后是事务的结束。

这里的问题是,这两个事务的合法结果是什么?这是我们首先想要确定的事情。最初的状态是,两个账户都是10块钱,但是在同时运行完两个事务之后,最终结果可能是什么?我们需要一个概念来定义什么是正确的结果。一旦我们知道了这个概念,我们需要构建能执行这些事务的机制,在可能存在并发和失败的前提下,仍然得到正确的结果。

所以,首先,什么是正确性?数据库通常对于正确性有一个概念称为ACID。分别代表:

  • Atomic,原子性。它意味着,事务可能有多个步骤,比如说写多个数据记录,尽管可能存在故障,但是要么所有的写数据都完成了,要么没有写数据能完成。不应该发生类似这种情况:在一个特定的时间发生了故障,导致事务中一半的写数据完成并可见,另一半的写数据没有完成,这里要么全有,要么全没有(All or Nothing)。

  • Consistent,一致性。我们实际上不会担心这一条,它通常是指数据库会强制某些应用程序定义的数据不变,这不是我们今天要考虑的点。

  • Isolated,隔离性。这一点还比较重要。这是一个属性,它表明两个同时运行的事务,在事务结束前,能不能看到彼此的更新,能不能看到另一个事务中间的临时的更新。目标是不能。隔离在技术上的具体体现是,事务需要串行执行,我之后会再解释这一条。但是总结起来,事务不能看到彼此之间的中间状态,只能看到完成的事务结果。

  • Durable,持久化的。这意味着,在事务提交之后,在客户端或者程序提交事务之后,并从数据库得到了回复说,yes,我们执行了你的事务,那么这时,在数据库中的修改是持久化的,它们不会因为一些错误而被擦除。在实际中,这意味着数据需要被写入到一些非易失的存储(Non-Volatile Storage),持久化的存储,例如磁盘。

今天的课程会讨论,在考虑到错误,考虑到多个并发行为的前提下,什么才是正确的行为,并确保数据在出现故障之后,仍然存在。这里对我们来说最有意思的部分是有关隔离性或者串行的具体定义。我会首先介绍这一点,之后再介绍如何执行上面例子中的两个事务。

通常来说,隔离性(Isolated)意味着可序列化(Serializable)。它的定义是如果在同一时间并行的执行一系列的事务,那么可以生成一系列的结果。这里的结果包括两个方面:由任何事务中的修改行为产生的数据库记录的修改;和任何事务生成的输出。所以前面例子中的两个事务,T1的结果是修改数据库记录,T2的结果是打印出数据。

我们说可序列化是指,并行的执行一些事物得到的结果,与按照某种串行的顺序来执行这些事务,可以得到相同的结果。实际的执行过程或许会有大量的并行处理,但是这里要求得到的结果与按照某种顺序一次一个事务的串行执行结果是一样的。所以,如果你要检查一个并发事务执行是否是可序列化的,你查看结果,并看看是否可以找到对于同一些事务,存在一次只执行一个事务的顺序,按照这个顺序执行可以生成相同的结果。

所以,我们刚刚例子中的事务,只有两种一次一个的串行顺序,要么是T1,T2,要么是T2,T1。我们可以看一下这两种串行执行生成的结果。

我们先执行T1,再执行T2,我们得到X=11,Y=9,因为T1先执行,T2中的打印,可以看到这两个更新过后的数据,所以这里会打印字符串“11,9”。

另一种可能的顺序是,先执行T2,再执行T1,这种情况下,T2可以看到更新之前的数据,但是更新仍然会在T1中发生,所以最后的结果是X=11,Y=9。但是这一次,T2打印的是字符串“10,10”。

所以,这是两种串行执行的合法结果。如果我们同时执行这两个事务,看到了这两种结果之外的结果,那么我们运行的数据库不能提供序列化执行的能力(也就是不具备隔离性 Isolated)。所以,实际上,我们在考虑问题的时候,可以认为这是唯二可能的结果,我们最好设计我们的系统,并让系统只输出两个结果中的一个。

如果你同时提交两个事务,你不知道是T1,T2的顺序,还是T2,T1的顺序,所以你需要预期可能会有超过一个合法的结果。当你同时运行了更多的事务,结果也会更加复杂,可能会有很多不同的正确的结果,这些结果都是可序列化的,因为这里对于事务存在许多顺序,可以被用来满足序列化的要求。

现在我们对于正确性有了一个定义,我们甚至知道了可能的结果是什么。我们可以提出几个有关执行顺序的假设。

例如,假设系统实际上这么执行,开始执行T2,并执行到读X,之后执行了T1。在T1结束之后,T2再继续执行。

如果不是T2这样的事务,最后的结果可能也是合法的。但是现在,我们想知道如果按照这种方式执行,我们得到的结果是否是之前的两种结果之一。在这里,T2事务中的变量t1可以看到10,t2会看到减Y之后的结果所以是9,最后的打印将会是字符串“10,9”。这不符合之前的两种结果,所以这里描述的执行方式不是可序列化的,它不合法。

另一个有趣的问题是,如果我们一开始执行事务T1,然后在执行完第一个add时,执行了整个事务T2。

这意味着,在T2执行的点,T2可以读到X为11,Y为10,之后打印字符串“11,10”。这也不是之前的两种合法结果之一。所以对于这两个事务,这里的执行过程也不合法。

可序列化是一个应用广泛且实用的定义,背后的原因是,它定义了事务执行过程的正确性。它是一个对于程序员来说是非常简单的编程模型,作为程序员你可以写非常复杂的事务而不用担心系统同时在运行什么,或许有许多其他的事务想要在相同的时间读写相同的数据,或许会发生错误,这些你都不需要关心。可序列化特性确保你可以安全的写你的事务,就像没有其他事情发生一样。因为系统最终的结果必须表现的就像,你的事务在这种一次一个的顺序中是独占运行的。这是一个非常简单,非常好的编程模型。

可序列化的另一方面优势是,只要事务不使用相同的数据,它可以允许真正的并行执行事务。我们之前的例子之所以有问题,是因为T1和T2都读取了数据X和Y。但是如果它们使用完全没有交集的数据库记录,那么这两个事务可以完全并行的执行。在一个分片的系统中,不同的数据在不同的机器上,你可以获得真正的并行速度提升,因为可能一个事务只会在第一个机器的第一个分片上执行,而另一个事务并行的在第二个机器上执行。所以,这里有可能可以获得更高的并发性能。

在我详细介绍可序列化的事务之前,我还想提出一个小点。有一件场景我们需要能够应付,事务可能会因为这样或那样的原因在执行的过程中失败或者决定失败,通常这被称为Abort。对于大部分的事务系统,我们需要能够处理,例如当一个事务尝试访问一个不存在的记录,或者除以0,又或者是,某些事务的实现中使用了锁,一些事务触发了死锁,而解除死锁的唯一方式就是干掉一个或者多个参与死锁的事务,类似这样的场景。所以在事务执行的过程中,如果事务突然决定不能继续执行,这时事务可能已经修改了部分数据库记录,我们需要能够回退这些事务,并撤回任何已经做了的修改。

实现事务的策略,我会划分成两块,在这门课程中我都会介绍它们,先来简单的看一下这两块。

第一个大的有关实现的话题是并发控制(Concurrency Control)。这是我们用来提供可序列化的主要工具。所以并发控制就是可序列化的别名。通过与其他尝试使用相同数据的并发事务进行隔离,可以实现可序列化。

另一个有关实现的大的话题是原子提交(Atomic Commit)。它帮助我们处理类似这样的可能场景:前面例子中的事务T1在执行过程中可能已经修改了X的值,突然事务涉及的一台服务器出现错误了,我们需要能从这种场景恢复。所以,哪怕事务涉及的机器只有部分还在运行,我们需要具备能够从部分故障中恢复的能力。这里我们使用的工具就是原子提交。我们后面会介绍。

最后更新于