1.分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit)。
人们通常将并发控制和原子提交放在一起,当做事务。
2.我们说可序列化是指,并行的执行一些事物得到的结果,与按照某种串行的顺序来执行这些事务,可以得到相同的结果。
实际的执行过程或许会有大量的并行处理,但是这里要求得到的结果与按照某种顺序一次一个事务的串行执行结果是一样的。
3.并发控制(Concurrency Control)。这是我们用来提供可序列化的主要工具。所以并发控制就是可序列化的别名。
1.在并发控制中,主要有两种策略:悲观并发控制(Pessimistic Concurrency Control)、乐观并发控制(Optimistic Concurrency Control)。
2.悲观并发控制通常涉及到锁,这里需要为正确性而牺牲性能。
3.乐观并发控制:基本思想是,你不用担心其他的事务是否正在读写你要使用的数据,你直接继续执行你的读写操作,通常来说这些执行会在一些临时区域,只有在事务最后的时候,你再检查是不是有一些其他的事务干扰了你。
如果没有这样的其他事务,那么你的事务就完成了,并且你也不需要承受锁带来的性能损耗,因为操作锁的代价一般都比较高;但是如果有一些其他的事务在同一时间修改了你关心的数据,并造成了冲突,那么你必须要Abort当前事务,并重试。这就是乐观并发控制。
4.悲观并发控制——两阶段锁(Two-Phase Locking),这是一种最常见的锁。
两个规则:
(1)使用任何数据之前,在执行任何数据的读写之前,先获取锁。
(2)持有锁直到事务结束。
所以,这就是两阶段锁的两个阶段,第一个阶段获取锁,第二个阶段是在事务结束前一直持有锁。
5.两阶段锁必然对于性能来说很糟糕,但它对于正确性来说是必要的。
6.对于这些规则,还有些问题:这里非常容易产生死锁。
数据库会有各种措施解决死锁。
7.所以这就是使用两阶段锁的并发控制。这是一个完全标准的数据库行为,在一个单主机的数据库中是这样,在一个分布式数据库也是这样,不过会更加的有趣。
1.在一个分布式环境中,数据被分割在多台机器上,如何构建数据库或存储系统以支持事务。所以这个话题是,如何构建分布式事务(Distributed Transaction)。
2.要么系统中的每一部分都完成它们在事务中的工作,要么系统中的所有部分都不完成它们在事务中的工作。
3.原子提交协议的风格是:假设你有一批计算机,每一台都执行一个大任务的不同部分,原子提交协议将会帮助计算机来决定,这些计算机全部提交或者没有一个提交任务。(分布式的all-or-none)
原子提交协议在今天的阅读内容中有介绍,其中一种是两阶段提交(Two-Phase Commit)。
4.两阶段提交, 我们将会假设,有一个计算机会用来管理事务,它被称为事务协调者(Transaction Coordinator)。
事务协调者有很多种方法用来管理事务,我们这里就假设它是一个实际运行事务的计算机。在一个计算机上,事务协调者以某种形式运行事务的代码,例如Put/Get/Add,它向持有了不同数据的其他计算机发送消息,其他计算机再执行事务的不同部分。
5.事务协调者TC会向服务器S1发消息说,请对X加1,向服务器S2发消息说,请对Y减1。
之后会有更多消息来确认,要么两个服务器都执行了操作,要么两个服务器都没有执行操作。这就是两阶段提交的实现框架。
6.每个持有数据的服务器会维护一个锁的表单,用来记录锁被哪个事务所持有。所以对于事务,需要有事务ID(Transaction ID),简称为TID。
7.我们还有其他的服务器执行部分的事务,这些服务器被称为参与者(Participants)。
8.两阶段提交协议的一个参考执行过程。我们将Two-Phase Commit简称为2PC。
参与的有:事务协调者(TC),我们假设只有两个参与者(A,B),两个参与者就是持有数据的两个不同的服务器。
(1)我们假设有一个外部的客户端C,它在最最开始的时候会向TC发请求说,请运行这个事务。并且之后这个客户端会等待回复。
(2)在开始执行事务时,TC需要确保,所有的事务参与者能够完成它们在事务中的那部分工作。更具体的,如果在事务中有任何Put请求,我们需要确保,执行Put的参与者仍然能执行Put。TC为了确保这一点,会向所有的参与者发送Prepare消息。
(3)当A或者B收到了Prepare消息,它们就知道事务要执行但是还没执行的内容,它们会查看自身的状态并决定它们实际上能不能完成事务。A和B会检查自己的状态并说,我有能力或者我没能力完成这个事务,它们会向TC回复Yes或者No。
(4)事务协调者会等待来自于每一个参与者的这些Yes/No投票。如果所有的参与者都回复Yes,那么事务可以提交,不会发生错误。之后事务协调者会发出一个Commit消息,给每一个事务的参与者.
如果任何一个参与者回复了No,表明自己不能完成这个事务,那么事务协调者不会发送commit消息,它会发送一轮Abort消息给所有的参与者说,请撤回这个事务。
(5)之后,事务参与者通常会回复ACK说,我们知道了要commit。
9.在事务Commit之后,会发生两件事情:
(1) 事务协调者会向客户端发送代表了事务输出的内容,表明事务结束了,事务没有被Abort并且被持久化保存起来了。
(2) 事务参与者会释放锁(这里不论Commit还是Abort都会释放锁)。
我们需要在脑中设想各种可能发生的错误,并确认这里的两阶段提交协议是否仍然可以提供All-or-Noting的原子特性。如果不能的话,我们该如何调整或者扩展协议?
第一个我想考虑的错误是故障重启。这里实际上有两个场景需要考虑:
a. 参与者B可能在回复事务协调者的Prepare消息之前的崩溃了,B被授权可以单方面的Abort事务。
b. B也可能在回复了Yes给事务协调者的Prepare消息之后崩溃的。
A实际上会执行事务分包给它的那一部分,持久化存储结果,并释放锁。
为了确保All-or-Nothing原子性,我们需要确保B在故障恢复之后,仍然能完成事务分包给它的那一部分。在故障重启的时候,B不能丢失对于事务的状态记录。
措施:在B回复Yes给Prepare消息之前,它首先要将相应的Log写入磁盘,并在Log中记录所有有关提交事务必须的信息。
B在这个时间点(回复Yes给TC的Prepare消息之前),必须将Log写入到自己的磁盘中。这里会使得两阶段提交稍微有点慢,因为这里要持久化存储数据。
事务协调者在哪个时间点崩溃了非常重要。
a. 如果事务协调者在崩溃前没有发送Commit消息,它可以直接Abort事务。
b. 如果事务协调者在发送完一个或者多个Commit消息之后崩溃,那么就不允许它忘记相关的事务。
措施:事务协调者决定要Commit而不是Abort事务,并且在发送任何Commit消息之前,它必须先将事务的信息写入到自己的Log,并存放在例如磁盘的持久化存储中,这样计算故障重启了,信息还会存在。
作为恢复流程的一部分,对于执行了一半的事务,事务协调者会向所有的参与者重发Commit消息或者Abort消息,以防在崩溃前没有向参与者发送这些消息。这就是为什么参与者需要准备好接收重复的Commit消息的一个原因。
以上就是发送崩溃的两种主要情况(参与者崩溃和协调者崩溃)
总结:故障恢复的难点在于原子性,如果有一部分参与者已经收到了协调者发送的commit/abort信息,此时不管是协调者还是参与者出现故障,为保障原子性都必须能够提交或回退。解决方法都是在回复之前(参与者是回复prepared信息,协调者是发送commit/abort信息)持久化log到硬盘,保证机器崩溃了仍可获得事务信息。
即,只要发出过commit或abort,就一定要记得并且完成执行。避免故障就持久化log。
以上是主要服务器崩溃的场景,网络故障是怎么办?
(1)事务协调者发送了Prepare消息,但是并没有收到所有的Yes/No消息,事务协调者这时该怎么做呢?
事务协调者可以持续不断的重发Prepare消息。
在事务协调者没有收到Yes/No回复一段时间之后,它可以单方面的Abort事务。因为它还没有发送commit消息。
(2)类似的,如果参与者等待Prepare消息超时了,那意味着它必然还没有回复Yes消息,进而意味着事务协调者必然还没有发送Commit消息。所以此时,它也可以决定Abort事务。
总结:网络故障时,只要参与者还没有回复yes消息,协调者就可以Abort事务。
(3)但是,假设B收到了Prepare消息,并回复了Yes。大概在下图的位置中,
B等了很长时间都没有收到Commit消息。这段时间里,B一直持有事务涉及到数据的锁,此时B不能单方面的决定Abort事务。
在回复Yes给Prepare消息之后,并在收到Commit消息之前这个时间区间内,参与者会等待Commit消息。如果等待Commit消息超时了,参与者不允许Abort/commit事务,它必须无限的等待Commit消息,这里通常称为Block。
这里的Block行为是两阶段提交里非常重要的一个特性,并且它不是一个好的属性。——持有锁等待+堵塞其他事务。
所以人们尽量会确保协议中这部分尽可能轻量化,甚至对于一些变种的协议,对于一些特定的场景都不用等待。
为什么这里的两阶段提交协议能构建一个A和B要么全Commit,要么全Abort的系统?其中一个原因是,决策是在一个单一的实例,也就是事务协调者完成的。
但是,使用一个单一实例的事务协调者的缺点是,在某个时间点你需要Block并等待事务协调者告诉你决策是什么。
事务协调者成功的得到了所有参与者的ACK,它就知道所有的参与者知道了事务已经Commit或者Abort,所有参与者必然也完成了它们在事务中相应的工作,并且永远也不会需要知道事务相关的信息。
类似的,当一个参与者收到了Commit或者Abort消息,完成了它们在事务中的相应工作,持久化存储事务结果并释放锁,那么在它发送完ACK之后,参与者也可以完全忘记相关的事务。
如果事务协调者不能收到ACK,这时它会假设丢包了并重发Commit消息。
这时,如果一个参与者收到了一个Commit消息,但是它并不知道对应的事务,因为它在之前回复ACK之后就忘记了这个事务,那么参与者会再次回复一个ACK。
因为如果参与者收到了一个自己不知道的事务的Commit消息,那么必然是因为它之前已经完成对这个事务的Commit或者Abort,然后选择忘记这个事务了。(如果是在commit或abort之前故障重启,参与者的日志中会有该事务的全部数据)
(1)优点
两阶段提交实现了原子提交,两阶段提交在大量的将数据分割在多个服务器上的分片数据库或者存储系统中都有使用。
(2)缺点
然而,两阶段提交有着极差的名声。原因:
a. 因为有多轮消息的存在,它非常的慢。
b. 这里有大量的写磁盘操作,期间,锁都被参与者持有着,其他使用相关数据的事务都会被阻塞。
如果你不够幸运进入到Block区间,参与者需要在持有锁的状态下等待一段长时间。
(1)两阶段提交的架构中,本质上是有一个Leader(事务协调者),将消息发送给Follower(事务参与者),Leader只能在收到了足够多Follower的回复之后才能继续执行。这与Raft非常像,但是,这里协议的属性与Raft又非常的不一样。这两个协议解决的是完全不同的问题。
(2)两阶段提交和Raft协议的结合
然而,是有可能结合这两种协议的:可以构建一个合并的系统,同时具备Raft的高可用性,但同时又有两阶段提交的能力将事务分包给不同的参与者。实际上,通过Raft或者Paxos或者其他协议,来复制两阶段提交协议里的每一个组成部分。
(3)事务协调器会是一个复制的服务,包含了三个服务器,我们在这3个服务器上运行Raft。
每个事务参与者也同样是一个Raft集群。
最终,消息会在这些集群之间传递。
在Lab4,我们会构建一个类似的系统,实际上就是个分片的数据库,每个分片以这种形式进行复制,同时还有一个配置管理器,来允许将分片的数据从一个Raft集群移到另一个Raft集群。
Spanner描述了Google使用的一种数据库,Spanner也使用了这里的结构来实现事务写。