12.2 并发控制(Concurrency Control)

第一个要介绍的是并发控制(Concurrency Control)。在并发控制中,主要有两种策略,在这门课程中我都会介绍。

第一种主要策略是悲观并发控制(Pessimistic Concurrency Control)。

这里通常涉及到锁,我们在实验中的Go语言里面已经用过锁了。实际上,数据库的事务处理系统也会使用锁。这里的想法或许你已经非常熟悉了,那就是在事务使用任何数据之前,它需要获得数据的锁。如果一些其他的事务已经在使用这里的数据,锁会被它们持有,当前事务必须等待这些事务结束,之后当前事务才能获取到锁。在悲观系统中,如果有锁冲突,比如其他事务持有了锁,就会造成延时等待。所以这里需要为正确性而牺牲性能。

第二种主要策略是乐观并发控制(Optimistic Concurrency Control)。

这里的基本思想是,你不用担心其他的事务是否正在读写你要使用的数据,你直接继续执行你的读写操作,通常来说这些执行会在一些临时区域,只有在事务最后的时候,你再检查是不是有一些其他的事务干扰了你。如果没有这样的其他事务,那么你的事务就完成了,并且你也不需要承受锁带来的性能损耗,因为操作锁的代价一般都比较高;但是如果有一些其他的事务在同一时间修改了你关心的数据,并造成了冲突,那么你必须要Abort当前事务,并重试。这就是乐观并发控制。

实际,这两种策略哪个更好取决于不同的环境。如果冲突非常频繁,你或许会想要使用悲观并发控制,因为如果冲突非常频繁的话,在乐观并发控制中你会有大量的Abort操作。如果冲突非常少,那么乐观并发控制可以更快,因为它完全避免了锁带来的性能损耗。今天我们只会介绍悲观并发控制。几周之后的论文,我们会讨论一种乐观并发控制的方法。

所以,今天讨论悲观并发控制,这里涉及到的基本上就是锁机制。这里的锁是两阶段锁(Two-Phase Locking),这是一种最常见的锁。

对于两阶段锁来说,当事务需要使用一些数据记录时,例如前面例子中的X,Y,第一个规则是在使用任何数据之前,在执行任何数据的读写之前,先获取锁。

第二个对于事务的规则是,事务必须持有任何已经获得的锁,直到事务提交或者Abort,你不允许在事务的中间过程释放锁。你必须要持有所有的锁,并不断的累积你持有的锁,直到你的事务完成了。所以,这里的规则是,持有锁直到事务结束。

所以,这就是两阶段锁的两个阶段,第一个阶段获取锁,第二个阶段是在事务结束前一直持有锁。

为什么两阶段锁能起作用呢?虽然有很多的变种,在一个典型的锁系统中,每一个数据库中的记录(每个Table中的每一行)都有一个独立的锁(虽然实际中粒度可能更大)。一个事务,例如前面例子中的T1,最开始的时候不持有任何锁,当它第一次使用X记录时,在它真正使用数据前,它需要获得对于X的锁,这里或许需要等待。当它第一次使用Y记录时,它需要获取另一个对于Y的锁,当它结束之后,它会释放这两个锁。如果我们同时运行之前例子中的两个事务,它们会同时竞争对于X的锁。任何一个事务先获取了X的锁,它会继续执行,最后结束并提交。同时,另一个没有获得X的锁,它会等待锁,在对X进行任何修改之前,它需要先获取锁。所以,如果T2先获取了锁,它会获取X,Y的数值,打印,结束事务,之后释放锁。只有在这时,事务T1才能获得对于X的锁。

如你所见的,这里基本上迫使事务串行执行,在刚刚的例子中,两阶段锁迫使执行顺序是T2,T1。所以这里显式的迫使事务的执行遵循可序列化的定义,因为实际上就是T2完成之后,再执行T1。所以我们可以获得正确的执行结果。

这里有一个问题是,为什么需要在事务结束前一直持有锁?你或许会认为,你可以只在使用数据的时候持有锁,这样也会更有效率。在刚刚的例子中,或许只在T2获取记录X的数值时持有对X的锁,或许只在T1执行对X加1操作的时候持有对于X的锁,之后立即释放锁,虽然这样违反了两阶段锁的规则,但是如果立刻释放对于数据的锁,另一个事务可以早一点执行,我们就可以有更多的并发度,进而获得更高的性能。所以,两阶段锁必然对于性能来说很糟糕,所以我们才需要确认,它对于正确性来说是必要的。

如果事务尽可能早的释放锁,会发生什么呢?假设T2读取了X,然后立刻释放了锁,那么在这个位置,T2不持有任何锁,因为它刚刚释放了对于X的锁。

因为T2不持有任何锁,这意味着T1可以完全在这个位置执行。从前面的反例我们已经知道,这样的执行是错误的(因为T2会打印“10,9”),因为它没能生成正确结果。

类似的,如果T1在执行完对X加1之后,就释放了对X的锁,这会使得整个T2有可能在这个位置执行。

我们之前也看到了,这会导致非法的结果。

如果在修改完数据之后就释放锁,还会有额外的问题。如果T1在执行完对X加1之后释放锁,它允许T2看到修改之后的X,之后T2会打印出这个结果。但是如果T1之后Abort了,或许因为银行账户Y并不存在,或许账户Y存在,但是余额为0,而我们不允许对于余额为0的账户再做减法,这样会造成透支。所以T1有可能会修改X,然后Abort。Abort的一部分工作就是要撤回对于X的修改,这样才能维持原子性。这意味着,如果T1释放了对于X的锁,事务T2会看到X的虚假数值11,这个数值最终不存在,因为T1中途Abort了,T2会看到一个永远不曾存在的数值。T2的结果最好是看起来就像是T2自己在运行,并没有T1的存在。但是这里,T2会看到X加1,然后打印出11,这与数据库的任何状态都对应不上。

所以,使用了两阶段锁可以避免这两种违反可序列化特性的场景。

对于这些规则,还有一些需要知道的事情。首先是,这里非常容易产生死锁。例如我们有两个事务,T1读取记录X,之后再读取记录Y,T2读取记录Y,之后再读取记录X。如果它们同时运行,这里就是个死锁。

每个事务都获取了第一个读取数据的锁,直到事务结束了,它们都不会释放这个锁。所以接下来,它们都会等待另一个事务持有的锁,除非数据库足够聪明,这里会永远死锁。实际上,事务有各种各样的策略,包括了判断循环,超时来判断它们是不是陷入到这样一个场景中。如果是的话,数据库会Abort其中一个事务,撤回它所有的操作,并表现的像这个事务从来没有发生一样。

所以这就是使用两阶段锁的并发控制。这是一个完全标准的数据库行为,在一个单主机的数据库中是这样,在一个分布式数据库也是这样,不过会更加的有趣。

最后更新于