# 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其中一个事务，撤回它所有的操作，并表现的像这个事务从来没有发生一样。

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


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/lecture-12-distributed-transaction/12.2-bing-fa-kong-zhi-concurrency-control.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
