MIT6.824/lecture-12-distributed-transaction/12.2-bing-fa-kong-zhi-concurrency-control.md
2022-01-25 02:41:31 +00:00

7.6 KiB
Raw Blame History

12.2 并发控制Concurrency Control

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

类似的如果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之后再读取记录YT2读取记录Y之后再读取记录X。如果它们同时运行这里就是个死锁。

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

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