> For the complete documentation index, see [llms.txt](https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/lecture-12-distributed-transaction/12.2-bing-fa-kong-zhi-concurrency-control.md).

# 12.2 并发控制（Concurrency Control）

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

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

![](/files/-MG6KIVE92oQAciCUHLT)

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

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

![](/files/-MG6OXJcoGCMgJ8sMdx4)

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

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

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

![](/files/-MG6QwlAkpfEeQUzP0i8)

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

![](/files/-MG6TXwEBrryCYzDP4kv)

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

![](/files/-MG6VQqg-frdABL8qbGC)

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

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

![](/files/-MG6df9Drk3So5OR4-of)

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

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

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

![](/files/-MG6f9xSgkwMWET2rVkh)

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

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

![](/files/-MG6fyptzmCRdXYL_xFs)

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

如果在修改完数据之后就释放锁，还会有额外的问题。如果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。如果它们同时运行，这里就是个死锁。

![](/files/-MG6ilLTc_xyzzMZvV2O)

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

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