分布式事务和Spanner分布式数据库
一、分布式事务
首先事务可以这么理解:程序员有一些不同的操作,或许针对数据库不同的记录,他们希望所有这些操作作为一个整体,不会因为失败而被分割,也不会被其他活动看到中间状态。事务处理系统要求程序员对这些读操作、写操作标明起始和结束,这样才能知道事务的起始和结束。事务处理系统可以保证在事务的开始和结束之间的行为是可预期的。
如果单机的数据库事务,那么使用锁来保证事务的原子性和并发控制就行了,对于崩溃恢复使用log日志来进行回滚或者重新commit即可。但是对于分布式事务来说,也就是当一个拥有大量数据的系统来说,他们通常会将数据进行分割或者分片到许多不同的服务器上。假设你运行了一个银行,你一半用户的账户在一个服务器,另一半用户的账户在另一个服务器,这样的话可以同时满足负载分担和存储空间的要求。对于其他的场景也有类似的分片,比如说对网站上文章的投票,或许有上亿篇文章,那么可以在一个服务器上对一半的文章进行投票,在另一个服务器对另一半进行投票。
对于复杂的分布式系统上的事务来说,每个服务器所执行的都是总体事务的一部分任务,那么怎么让这多机器打成共识,并且共同决定 commit (提交) 还是 abort(退回),并且能够处理 FT 容错,在系统崩溃重启后依然能完成这个事务返回预期的接口,就是需要精心设计了。
分布式事务主要涉及两个部分:
- 并发控制(Concurrency Control)
- 原子提交(Atomic Commit)
并发控制其实就是序列化的别名,通过与其他尝试使用相同数据的并发事务进行隔离,可以实现可序列化。我们说可序列化是指,并行的执行一些事物得到的结果,与按照某种串行的顺序来执行这些事务,可以得到相同的结果。实际的执行过程或许会有大量的并行处理,但是这里要求得到的结果与按照某种顺序一次一个事务的串行执行结果是一样的。所以,如果你要检查一个并发事务执行是否是可序列化的,你查看结果,并看看是否可以找到对于同一些事务,存在一次只执行一个事务的顺序,按照这个顺序执行可以生成相同的结果。
如下有两个事务:T1 和 T2
#初始值 x=10, y=10 T1: BEGIN_X add(x,1) add(y,-1) END_X T2: BEGIN_X v1=get(x) t2=get(y) print( t1, t2 ) END_X
第一个事务称为T1,程序员会标记它的开始,我们称之为BEGIN_X,之后是对于两个账户的操作,我们会对账户X加1,对账户Y加-1。之后我们需要标记事务的结束,我们称之为END_X。
同时,我们还有一个事务,会检查所有的账户,对所有账户进行审计,确保尽管可能存在转账,但是所有账户的金额加起来总数是不变的。所以,第二个事务是审计事务,我们称为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”。
所以,这是两种串行执行的合法结果。如果我们同时执行这两个事务,看到了这两种结果之外的结果,那么我们运行的数据库不能提供序列化执行的能力。
那么怎么保证系统只能出现这两种结果就是设计分布式事务的目标。
而对于原子性,也就是 All-Or-Nothing ,要么能看到过程结果都提交,要么失败后所有过程都回滚。在数据库中这种正确性称之为正确性,也就是 ACID
- Atomic,原子性。它意味着,事务可能有多个步骤,比如说写多个数据记录,尽管可能存在故障,但是要么所有的写数据都完成了,要么没有写数据能完成。不应该发生类似这种情况:在一个特定的时间发生了故障,导致事务中一半的写数据完成并可见,另一半的写数据没有完成,这里要么全有,要么全没有(All or Nothing)。
- Consistent,一致性。一致性并不是分布式事务需要考虑的点,它通常指的是强制某些程序定义的数据不变。
- Isolated,隔离性。这一点还比较重要。这是一个属性,它表明两个同时运行的事务,在事务结束前,能不能看到彼此的更新,能不能看到另一个事务中间的临时的更新。目标是不能。
- Durable,持久化的。这意味着,在事务提交之后,在客户端或者程序提交事务之后,并从数据库得到了回复说,yes,我们执行了你的事务,那么这时,在数据库中的修改是持久化的,它们不会因为一些错误而被擦除。在实际中,这意味着数据需要被写入到一些非易失的存储(Non-Volatile Storage),持久化的存储,例如磁盘。
二、并发控制
对于并发控制,一般有两种策略,第一种主要策略是悲观并发控制(Pessimistic Concurrency Control)。
那就是在事务使用任何数据之前,它需要获得数据的锁。如果一些其他的事务已经在使用这里的数据,锁会被它们持有,当前事务必须等待这些事务结束,之后当前事务才能获取到锁。在悲观系统中,如果有锁冲突,比如其他事务持有了锁,就会造成延时等待。所以这里需要为正确性而牺牲性能。
第二种主要策略是乐观并发控制(Optimistic Concurrency Control)。
这里的基本思想是,你不用担心其他的事务是否正在读写你要使用的数据,你直接继续执行你的读写操作,通常来说这些执行会在一些临时区域,只有在事务最后的时候,你再检查是不是有一些其他的事务干扰了你。如果没有这样的其他事务,那么你的事务就完成了,并且你也不需要承受锁带来的性能损耗,因为操作锁的代价一般都比较高;但是如果有一些其他的事务在同一时间修改了你关心的数据,并造成了冲突,那么你必须要Abort当前事务,并重试。这就是乐观并发控制。
如果冲突非常频繁,你或许会想要使用悲观并发控制,因为如果冲突非常频繁的话,在乐观并发控制中你会有大量的Abort操作。如果冲突非常少,那么乐观并发控制可以更快,因为它完全避免了锁带来的性能损耗。
今天这里需要谈论的锁是两阶段锁(Two-Phase Locking),这是一种最常见的锁。
两阶段锁只需要牢牢记住两个点:
- 在事务进行前,先申请所有数据相关需要的锁
- 在事务提交结束后才释放所有的锁
三、两阶段提交
在一个分布式环境中,数据被分割在多台机器上,如何构建数据库或存储系统以支持事务。
如下场景,有两个服务器,服务器S1保存了X的记录,服务器S2保存了Y的记录,它们的初始值都是10。
事务T1同时修改了X和Y,相应的我们需要向数据库发送消息说对X加1,对Y减1。但是如果我们不够小心,我们很容易就会陷入到这个场景中:我们告诉服务器S1去对X加1,但是,之后出现了一些故障,或许持有Y记录的服务器S2故障了,使得我们没有没法完成更新的第二步。所以,这是一个问题:某个局部的故障会导致事务被分割成两半。如果我们不够小心,我们会导致一个事务中只有一半指令会生效。
不管怎样,服务器2不能完成它在事务中应该做的那部分工作。但是服务器1又完成了它在事务中的那部分工作。所以这也是一种需要处理的问题。
这里我们想要的特性,我之前也提到过,就是,要么系统中的每一部分都完成它们在事务中的工作,要么系统中的所有部分都不完成它们在事务中的工作。在前面,我们违反的规则是,在故障时没有保证原子性。
原子性是指,事务的每一个部分都执行,或者任何一个部分都不执行。很多时候,我们看到的解决方案是原子提交协议(Atomic Commit Protocols)。通常来说,原子提交协议的风格是:假设你有一批计算机,每一台都执行一个大任务的不同部分,原子提交协议将会帮助计算机来决定,它是否能够执行它对应的工作,它是否执行了对应的工作,又或者,某些事情出错了,所有计算机都要同意,没有一个会执行自己的任务。
两阶段提交不仅被分布式数据库所使用,同时也被各种看起来不像是传统数据库的分布式系统所使用。通常情况下,我们需要执行的任务会以某种方式分包在多个服务器上,每个服务器需要完成任务的不同部分。所以,在前一个例子中,实际上是数据被分割在不同的服务器上,所以相应的任务(为X加1,为Y减1)也被分包在不同的服务器上。我们将会假设,有一个计算机会用来管理事务,它被称为事务协调者(Transaction Coordinator)。事务协调者有很多种方法用来管理事务,我们这里就假设它是一个实际运行事务的计算机。在一个计算机上,事务协调者以某种形式运行事务的代码,例如Put/Get/Add,它向持有了不同数据的其他计算机发送消息,其他计算机再执行事务的不同部分。
在一个完整的系统中,或许会有很多不同的并发运行事务,也会有许多个事务协调者在执行它们各自的事务。在这个架构里的各个组成部分,都需要知道消息对应的是哪个事务。它们都会记录状态。每个持有数据的服务器会维护一个锁的表单,用来记录锁被哪个事务所持有。所以对于事务,需要有事务ID(Transaction ID),简称为TID。
这里的ID在事务开始的时候,由事务协调器来分配。这样事务协调器会发出消息说:这个消息是事务95的。同时事务协调器会在本地记录事务95的状态,对事务的参与者(例如服务器S1,S2)打上事务ID的标记。
两阶段提交的过程如下:
步骤如下:
- TC 事务协调者运行了整个事务,它会向A,B发送Put和Get,告诉它们读取X,Y的数值,对X加1等等。所以,在事务的最开始,TC会向参与者A发送Get请求并得到回复,之后再向参与者B发送一个Put请求并得到回复。这里需要数据库准备锁,避免其他的数据竞态。
- 参与者也就是服务 A 和 B 回复,数据已经获取锁,并且可以进行操作相应的事务
- 在开始执行事务时,TC需要确保,所有的事务参与者能够完成它们在事务中的那部分工作。更具体的,如果在事务中有任何Put请求,我们需要确保,执行Put的参与者仍然能执行Put。TC为了确保这一点,会向所有的参与者发送Prepare消息。
- 同样的 Prepare 消息
- 当A或者B收到了Prepare消息,它们就知道事务要执行但是还没执行的内容,它们会查看自身的状态并决定它们实际上能不能完成事务。或许它们需要Abort这个事务因为这个事务会引起死锁,或许它们在故障重启过程中并完全忘记了这个事务因此不能完成事务。所以,A和B会检查自己的状态并说,我有能力或者我没能力完成这个事务,它们会向TC回复Yes或者No。
- 事务协调者会等待来自于每一个参与者的这些Yes/No投票。如果所有的参与者都回复Yes,那么事务可以提交,不会发生错误。之后事务协调者会发出一个Commit消息,给每一个事务的参与者,当事务协调者发出Prepare消息时,如果所有的参与者都回复Yes,那么事务可以commit。如果任何一个参与者回复了No,表明自己不能完成这个事务,或许是因为错误,或许有不一致性,或许丢失了记录,那么事务协调者不会发送commit消息。如果任何一个参与者回复了No,表明自己不能完成这个事务,或许是因为错误,或许有不一致性,或许丢失了记录,那么事务协调者不会发送commit消息,它会发送一轮Abort消息给所有的参与者说,请撤回这个事务。
- 在事务Commit之后,会发生两件事情。首先,事务协调者会向客户端发送代表了事务输出的内容,表明事务结束了,事务没有被Abort并且被持久化保存起来了。另一个有意思的事情是,为了遵守前面的锁规则(两阶段锁),事务参与者会释放锁(这里不论Commit还是Abort都会释放锁)。
四、故障恢复
现在,我们需要在脑中设想各种可能发生的错误,并确认这里的两阶段提交协议是否仍然可以提供All-or-Noting的原子特性。如果不能的话,我们该如何调整或者扩展协议?
第一个我想考虑的错误是故障重启。我的意思是类似于断电,服务器会突然中断执行,当电力恢复之后,作为事务处理系统的一部分,服务器会运行一些恢复软件。这里实际上有两个场景需要考虑。
第一个场景,参与者B可能在回复事务协调者的Prepare消息之前的崩溃了
因为B知道自己没有发送Yes,那么它也知道事务协调者不可能Commit事务。这里有很多种方法可以实现,其中一种方法是,因为B故障重启了,内存中的数据都会清除,所以B中所有有关事务的信息都不能活过故障,所以,故障之后B不知道任何有关事务的信息,也不知道给谁回复过Yes。之后,如果事务协调者发送了一个Prepare消息过来,因为B不知道事务,B会回复No,并要求Abort事务。
第二个场景,参与者B可能在回复Yes之后崩溃了。
现在B承诺可以commit,因为它回复了Yes。接下来极有可能发生的事情是,事务协调者从所有的参与者获得了Yes的回复,并将Commit消息发送给了A,所以A实际上会执行事务分包给它的那一部分,持久化存储结果,并释放锁。这样的话,为了确保All-or-Nothing原子性,我们需要确保B在故障恢复之后,仍然能完成事务分包给它的那一部分。在B故障的时候,不知道事务是否能Commit,因为它还没有收到Commit消息。但是B还是需要做好Commit的准备。这意味着,在故障重启的时候,B不能丢失对于事务的状态记录。
在B回复Prepare之前,它必须确保记住当前事务的中间状态,记住所有要做的修改,记住事务持有的所有的锁,这些信息必须在磁盘上持久化存储。通常来说,这些信息以Log的形式在磁盘上存储。所以在B回复Yes给Prepare消息之前,它首先要将相应的Log写入磁盘,并在Log中记录所有有关提交事务必须的信息。这包括了所有由Put创建的新的数值,和锁的完整列表。之后,B才会回复Yes。
之后,如果B在发送完Yes之后崩溃了,当它重启恢复时,通过查看自己的Log,它可以发现自己正在一个事务的中间,并且对一个事务的Prepare消息回复了Yes。Log里有Commit需要做的所有的修改,和事务持有的所有的锁。之后,当B最终收到了Commit而不是Abort,通过读取Log,B就知道如何完成它在事务中的那部分工作。
第三个场景,最后一个可能崩溃的地方是,B可能在收到Commit之后崩溃了。
B有可能在处理完Commit之后就崩溃了。但是这样的话,B就完成了修改,并将数据持久化存储在磁盘上了。这样的话,故障重启就不需要做任何事情,因为事务已经完成了。
因为没有收到ACK,事务协调者会再次发送Commit消息。当B重启之后,收到了Commit消息时,它可能已经将Log中的修改写入到自己的持久化存储中、释放了锁、并删除了有关事务的Log。所以我们需要关心,如果B收到了同一个Commit消息两次,该怎么办?这里B可以记住事务的信息,但是这会消耗内存,所以实际上B会完全忘记已经在磁盘上持久化存储的事务的信息。对于一个它不知道事务的Commit消息,B会简单的ACK这条消息。
上面是事务的参与者在各种奇怪的时间点崩溃的场景。那对于事务协调者呢?它只是一个计算机,如果它出现故障,也会是个问题。
第四个场景,事务协调者TC在commit之前崩溃:
如果事务协调者在发送Commit消息之前就崩溃了,那就无所谓了,因为没有一个参与者会Commit事务。也就是说,如果事务协调者在崩溃前没有发送Commit消息,它可以直接Abort事务。因为参与者可以在自己的Log中看到事务,但是又从来没有收到Commit消息,事务的参与者会向事务协调者查询事务,事务协调者会发现自己不认识这个事务,它必然是之前崩溃的时候Abort的事务。所以这就是事务协调者在Commit之前就崩溃了的场景。
第五个场景,事务协调者TC在提交了一个commit之后或者部分commit之后崩溃:
那么就不允许它忘记相关的事务。这意味着,在崩溃的时间点,也就是事务协调者决定要Commit而不是Abort事务,并且在发送任何Commit消息之前,它必须先将事务的信息写入到自己的Log,并存放在例如磁盘的持久化存储中,这样计算故障重启了,信息还会存在。
所以,事务协调者在收到所有对于Prepare消息的Yes/No投票后,会将结果和事务ID写入存在磁盘中的Log,之后才会开始发送Commit消息。之后,可能在发送完第一个Commit消息就崩溃了,也可能发送了所有的Commit消息才崩溃,不管在哪,当事务协调者故障重启时,恢复软件查看Log可以发现哪些事务执行了一半,哪些事务已经Commit了,哪些事务已经Abort了。作为恢复流程的一部分,对于执行了一半的事务,事务协调者会向所有的参与者重发Commit消息或者Abort消息,以防在崩溃前没有向参与者发送这些消息。这就是为什么参与者需要准备好接收重复的Commit消息的一个原因。
二阶段最大的麻烦就是会面临各种丢包的情况,特别当协议是由大量交互构成的。
举个例子,事务协调者发送了Prepare消息,但是并没有收到所有的Yes/No消息,事务协调者这时该怎么做呢?
其中一个选择是,事务协调者重新发送一轮Prepare消息,表明自己没有收到全部的Yes/No回复。事务协调者可以持续不断的重发Prepare消息。但是如果其中一个参与者要关机很长时间,我们将会在持有锁的状态下一直等待。
另外一个选择是,在事务协调者没有收到Yes/No回复一段时间之后,它可以单方面的Abort事务。因为它知道它没有得到完整的Yes/No消息,当然它也不可能发送Commit消息,所以没有一个参与者会Commit事务,所以总是可以Abort事务。
所以这里需要注意的是,只有 TC 事务协调者才能决定是否能 commit 或者 abort。参与者是不能自己决定的。
假设B收到了Prepare消息,并回复了Yes。大概在下图的位置中:
这里的原因是,因为B对Prepare消息回复了Yes,这意味着事务协调者可能收到了来自于所有参与者的Yes,并且可能已经向部分参与者发送Commit消息。这意味着A可能已经看到了Commit消息,Commit事务,持久化存储事务的结果并释放锁。所以在上面的区间里,B不能单方面的决定Abort事务,它必须无限等待事务协调者的Commit消息。如果事务协调者故障了,最终会有人来修复它,它在恢复过程中会读取Log,并重发Commit消息。
这里的Block行为是两阶段提交里非常重要的一个特性,并且它不是一个好的属性。因为它意味着,在特定的故障中,你会很容易的陷入到一个需要等待很长时间的场景中,在等待过程中,你会一直持有锁,并阻塞其他的事务。所以,人们总是尝试在两阶段提交中,将这个区间尽可能快的完成,这样可能造成Block的时间窗口也会尽可能的小。所以人们尽量会确保协议中这部分尽可能轻量化,甚至对于一些变种的协议,对于一些特定的场景都不用等待。
二阶段提交确实是能够提供原子性的事务,但是却有一些缺点:
- 同步阻塞问题。执行过程中,所有参与节点都是事务阻塞型的。当参与者占有公共资源时,其他第三方节点访问公共资源不得不处于阻塞状态。也就是说从投票阶段到提交阶段完成这段时间,资源是被锁住的。
- 单点故障。由于协调者的重要性,一旦协调者发生故障。参与者会一直阻塞下去。尤其在第二阶段,协调者发生故障,那么所有的参与者还都处于锁定事务资源的状态中,而无法继续完成事务操作。【协调者发出Commmit消息之前宕机的情况】(如果是协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)
- 数据不一致。在二阶段提交的阶段二中,当协调者向参与者发送commit请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,这回导致只有一部分参与者接受到了commit请求。而在这部分参与者接到commit请求之后就会执行commit操作。但是其他部分未接到commit请求的机器则无法执行事务提交。于是整个分布式系统便出现了数据不一致性的现象。
- 二阶段无法解决的问题:------ 极限情况下,对某一事务的不确定性!【协调者发出Commmit消息之后宕机的情况】协调者再发出commit消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。由于二阶段提交存在着诸如同步阻塞、单点问题、脑裂等缺陷,所以,研究者们在二阶段提交的基础上做了改进,提出了三阶段提交。
两阶段提交有着极差的名声。其中一个原因是,因为有多轮消息的存在,它非常的慢。在上面的图中,各个组成部分之间着大量的交互。另一个原因是,这里有大量的写磁盘操作,比如说B在回复Yes给Prepare消息之后不仅要向磁盘写入数据,还需要等待磁盘写入结束,如果你使用一个机械硬盘,这会花费10毫秒来完成Log数据的写入,这决定了事务的参与者能够以多快的速度处理事务。10毫秒完成Log写磁盘,那么最快就是每秒处理100个事务,这是一个非常慢的结果。同时,事务协调者也需要写磁盘,在收到所有Prepare消息的Yes回复之后,它也需要将Log写入磁盘,并等待磁盘写入结束。之后它才能发送Commit消息,这里又有了10毫秒。在这两个10毫秒内,锁都被参与者持有者,其他使用相关数据的事务都会被阻塞。
因此,你只会在一个小的环境中看到两阶段提交,比如说在一个组织的一个机房里面。你不会在不同的银行之间转账看到它,你或许可以在银行内部的系统中看见两阶段提交,但是你永远也不会在物理分隔的不同组织之间看见两阶段提交,因为它可能会陷入到Block区间中。
使用Raft可以通过将数据复制到多个参与者得到高可用。Raft的意义在于,即使部分参与的服务器故障了或者不可达,系统仍然能工作。Raft能做到这一点是因为所有的服务器都在做相同的事情,所以我们不需要所有的服务器都参与,我们只需要过半服务器参与。然而两阶段提交,参与者完全没有在做相同的事情,每个参与者都在做事务中的不同部分,比如A可能在对X加1,B可能在对Y减1。所以在两阶段提交中,所有的参与者都在做不同的事情。所有的参与者都必须完成自己那部分工作,这样事务才能结束,所以这里需要等待所有的参与者。
所以,Raft通过复制可以不用每一个参与者都在线,而两阶段提交每个参与者都做了不同的工作,并且每个参与者的工作都必须完成,所以两阶段提交对于可用性没有任何帮助。Raft完全就是可用性,而两阶段提交完全不是高可用的,系统中的任何一个部分出错了,系统都有可能等待直到这个部分修复。
五、三阶段提交
三阶段提交(Three-phase commit),也叫三阶段提交协议(Three-phase commit protocol),是二阶段提交(2PC)的改进版本。
三阶段的修改是在二阶段提交上新增了两个改动点:
- 引入超时机制。同时在协调者和参与者中都引入超时机制。
- 在第一阶段和第二阶段中插入一个准备阶段,保证了在最后提交阶段之前各参与节点状态的一致。
其实刚开始的大概会有个疑问,三阶段提交为什么要在二阶段的基础上新增一个 CanCommit 阶段??
我们仔细看看上面的二阶段中当一开始 TC 发送相关的 get/set 指令和需要的数据给参与者时,返回的响应是 ack。并没有返回的是 yes/no 的投票选择,而是把能否执行的分片事务投票放在了 prepare 阶段,这里会导致后面 block 区域的产生,也就是参与者无法决定是否能 commit/abort,而只能一直阻塞下去。
假设有1个协调者,9个参与者。其中有一个参与者不具备执行该事务的能力。协调者发出prepare消息之后,其余参与者都将资源锁住,执行事务,写入undo和redo日志。协调者收到相应之后,发现有一个参与者不能参与。所以,又出一个roolback消息。其余8个参与者,又对消息进行回滚。这样子,是不是做了很多无用功?所以,引入can-Commit阶段,主要是为了在预执行之前,保证所有参与者都具备可执行条件,从而减少资源浪费。
CanCommit 阶段:
3PC的CanCommit阶段其实和2PC的准备阶段很像。协调者向参与者发送commit请求,参与者如果可以提交就返回Yes响应,否则返回No响应。
- 事务询问 协调者向参与者发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待参与者的响应。
- 响应反馈 参与者接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No。
PreCommit 阶段:
本阶段协调者会根据第一阶段的询盘结果采取相应操作,询问响应结果主要有两种:
第一种情况:假如协调者从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行
- 发送预提交请求 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
- 事务预提交 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中。
- 响应反馈 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。
第二种情况:假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断。具体步骤如下:
- 发送中断请求 协调者向所有参与者发送abort请求。
- 中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。
DoCommit 阶段:
该阶段进行真正的事务提交,也可以分为以下两种情况。
针对第一种情况,协调者向各个参与者发起事务提交请求,具体步骤如下:
- 协调者向所有参与者发送事务commit通知
- 所有参与者在收到通知之后执行commit操作,并释放占有的资源
- 参与者向协调者反馈事务提交结果
第二种情况:中断事务 协调者没有接收到参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。具体步骤如下:
- 发送中断请求 协调者向所有参与者发送事务rollback通知。
- 事务回滚 所有参与者在收到通知之后执行rollback操作,并释放占有的资源。
- 反馈结果 参与者向协调者反馈事务提交结果。
- 中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。
二阶段和三阶段提交的区别:
相对于2PC,3PC主要解决的单点故障问题,并减少阻塞,因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。
解决了二阶段的参与者无法自己决定 commit/abort 的缺陷。在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时,会在等待超时之后,会继续进行事务的提交。其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么Coordinator产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了。所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。
缺点和二阶段提交也是一致,没有改变,也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
如果进入PreCommit后,协调者发出的是abort请求,如果只有一个参与者收到并进行了abort操作,而其他对于系统状态未知的参与者会根据3PC选择继续Commit,那么系统的不一致性就存在了。所以无论是2PC还是3PC都存在问题,后面会继续了解传说中唯一的一致性算法Paxos。
有关于分布式事务的项目,我看了
https://assets.amazon.science/a6/34/41496f64421faafa1cbe301c007c/a-cloud-optimized-transport-protocol-for-elastic-and-scalable-hpc.pdf
这篇论文简单讲了 spanner 分布式数据库的相关实现。
六、Spnner 分布式数据库
在上面提到的,二阶段提交因为为了保持一致性,系统的可用性和性能并不是它考虑解决的点。并且三阶段提交也只是让参与者出现错误后,能自己快速 abort 而不至于阻塞非常久。但是在 FT 容错性上,并没有给出非常好的方案,只是保障了故障恢复,但是并不高效。所以 spanner 利用 paxos 和二阶段提交来完成了对外一致性(externally-consisten)的分布式数据库。因为二阶段中无论事务协调者还是参与者,如果发生 crash,都势必需要时间进行故障恢复,所以还是有单点故障的风险,使用 paxos 是可以解决这个问题的。
举个例子
Begin x=x-1 y=y+1 END
这里是个非常简单的事务,spanner 会把这整个事务分为:x=x-1 和 y=y+1 这两个分事务来进行处理,假设 server-x 和 server-y 是专门保存这两个变量的服务器。在 spanner 中的存储结构如下:
datacenter 之间在物理区域上互相隔离。并且使用的是 paxos 共识算法来进行统一的集群管理,保证了数据的可用性的同时,还保持了一致性。客户端会选择离自己最近的 leader 当做本次分布式事务的协调者,并在第一步发送给各个leader 参与者时,会带上此消息,并且带上需要准备修改的数据。
leader 作为参与者会向自己的 follow 发送 prepare 请求,决定是否能够完成此事务
当 server-y 自己表示能通完成事务同时,并收到了其他参与者发送给它的 yes 结果,那么就可以将这个事务进行提交了,并且释放相关的锁资源。
以上就是 spanner 中分布式事务对二阶段提交所作的优化。除此之外我觉得在布式事务中 spanner 用了一套关于 TureTime 的时间api接口,来实现相关的 MVVC,这是比较有用的多版本控制方法。MVCC 在 spanner 这里通过数据时间戳来实现,同一行数据内的原子性。并且读写事务不加锁(性能考虑)。
注意看,在 TT.now() 这个方法中返回的时间是一个时间片段,[ealiest, latest]。这是由于物理跨区域的情况下,请求时间的耗时必须将其考虑进去。下面是调用 TT.Now() 时服务器所返回的时间。
spanner 利用此时间戳来进行多版本控制,来实现数据库的隔离性。
在 Read-only 和 Read-write 看来其中的时间是不一样的,想想一下,在每个事务中所分配的时间戳是不一样的,比如:
- read-write time 是 commit time
- read-only 是 start time
当事务结束之后,这个时间戳会被当成版本号来添加到数据上,来进行多版本控制。假设下面有三个同时并发的事务:
@后面的数字,代表的是以时间戳作为数据的版本号,T1和T2都是 R/W 的事务,所以他们的版本都是在 commit 之后确定的,T3 是 R/O 事务,所以版本号是事务开始的时间戳。
那么在 commit 这个时间点的时候,它是怎么根据 api 的 TT.now() 返回当前时间戳,来确定版本号的呢?
在 R/O 同时并发过程,这里的时间戳版本号是这样确定的。也就是 R/W 事务要在 earlier 之前,R/O 事务要在 lastest 之后。