<audio id="audio" title="14 | 隔离性：实现悲观协议，除了锁还有别的办法吗？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/08/f0b57e197e62934988cbb7c62f0c7a08.mp3"></audio>

你好，我是王磊，你也可以叫我Ivan。

我们今天的主题是悲观协议，我会结合[第13讲](https://time.geekbang.org/column/article/282401)的内容将并发控制技术和你说清楚。在第13讲我们以并发控制的三阶段作为工具，区分了广义上的乐观协议和悲观协议。因为狭义乐观很少使用，所以我们将重点放在了相对乐观上。

其实，相对乐观和局部悲观是一体两面的关系，识别它的要点就在于是否有全局有效性验证，这也和分布式数据库的架构特点息息相关。但是关于悲观协议，还有很多内容没有提及，下面我们就来填补这一大块空白。

## 悲观协议的分类

要搞清楚悲观协议的分类，其实是要先跳出来，从并发控制技术整体的分类体系来看。

事实上，并发控制的分类体系，连学术界的标准也不统一。比如，在第13讲提到的两本经典教材中，“[Principles of Distributed Database Systems](https://link.springer.com/content/pdf/bfm%3A978-1-4419-8834-8%2F1.pdf)”的分类是按照比较宽泛的乐观协议和悲观协议进行分类，子类之间又有很多重叠的概念，理解起来有点复杂。

而“[Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)”采用的划分方式，是狭义乐观协议和其他悲观协议。这里狭义乐观协议，就是指我们在第13讲提到过的，基于有效性验证的并发控制，也是学术上定义的OCC。

我个人认为，狭义乐观协议和其他悲观协议这种分类方式更清晰些，所以就选择了“ Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery”中的划分体系。下面我摘录了书中的一幅图，用来梳理不同的并发控制协议。

<img src="https://static001.geekbang.org/resource/image/ed/a1/ede7edbb88108b76d015fa69d14425a1.png" alt="">

这个体系首先分为悲观和乐观两个大类。因为这里的乐观协议是指狭义乐观并发控制，所以包含内容就比较少，只有前向乐观并发控制和后向乐观并发控制；而悲观协议又分为基于锁和非锁两大类，其中基于锁的协议是数量最多的。

## 两阶段封锁（Two-Phase Locking，2PL）

基于锁的协议显然不只是2PL，还包括有序共享（Ordered Sharing 2PL, O2PL）、利他锁（Altruistic Locking, AL）、只写封锁树（Write-only Tree Locking, WTL）和读写封锁树（Read/Write Tree Locking， RWTL）。但这几种协议在真正的数据库系统中很少使用，所以就不过多介绍了，我们还是把重点放在数据库系统主要使用的2PL上。

2PL就是事务具备两阶段特点的并发控制协议，这里的两个阶段指加锁阶段和释放锁阶段，并且加锁阶段严格区别于紧接着的释放锁阶段。我们可以通过一张图来加深对2PL理解。

<img src="https://static001.geekbang.org/resource/image/54/75/540f30e8c3877b4951711e3c7305df75.png" alt="">

在t1时刻之前是加锁阶段，在t1之后则是释放锁阶段，我们可以从时间上明确地把事务执行过程划分为两个阶段。2PL的关键点就是释放锁之后不能再加锁。而根据加锁和释放锁时机的不同，2PL又有一些变体。

**保守两阶段封锁协议**（Conservative 2PL，C2PL），事务在开始时设置它需要的所有锁。

<img src="https://static001.geekbang.org/resource/image/66/81/6680049eacdd2bab61a65eyy0e36a881.png" alt="">

**严格两阶段封锁协议**（Strict 2PL，S2PL），事务一直持有已经获得的所有写锁，直到事务终止。

<img src="https://static001.geekbang.org/resource/image/b7/ec/b7291c52385b627282f5c4f010acacec.png" alt="">

**强两阶段封锁协议**（Strong Strict 2PL，SS2PL），事务一直持有已经获得的所有锁，包括写锁和读锁，直到事务终止。SS2PL与S2PL差别只在于一直持有的锁的类型，所以它们的图形是相同的。

理解了这几种2PL的变体后，我们再回想一下[第13讲](https://time.geekbang.org/column/article/282401)中的Percolator模型。当主锁（Primary Lock）没有释放前，所有的记录上的从锁（Secondary Lock）实质上都没有释放，在主锁释放后，所有从锁自然释放。所以，Percolator也属于S2PL。TiDB的乐观锁机制是基于Percolator的，那么TiDB就也是S2PL。

事实上，S2PL可能是使用最广泛的悲观协议，几乎所有单体数据都依赖S2PL实现可串行化。而在分布式数据库中，甚至需要使用SS2PL来保证可串行化执行，典型的例子是TDSQL。但S2PL模式下，事务持有锁的时间过长，导致系统并发性能较差，所以实际使用中往往不会配置到可串行化级别。这就意味着我们还是没有生产级技术方案，只能期望出现新的方式，既达到可串行化隔离级别，又能有更好的性能。最终，我们等到了一种可能是性能更优的工程化实现，这就是CockroachDB的串行化快照隔离（SSI）。而SSI的核心，就是串行化图检测（SGT）。

## 串行化图检测（SGT）

SSI是一种隔离级别的命名，最早来自PostgreSQL，CockroachDB沿用了这个名称。它是在SI基础上实现的可串行化隔离。同样，作为SSI核心的SGT也不是CockroachDB首创，学术界早就提出了这个理论，但真正的工程化实现要晚得多。

### 理论来源：PostgreSQL

PostgreSQL在论文“[Serializable Snapshot Isolation in PostgreSQL](http://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)”中最早提出了SSI的工程实现方案，这篇论文也被VLDB2012收录。

为了更清楚地描述SSI方案，我们先要了解一点理论知识。

串行化理论的核心是串行化图（Serializable Graph，SG）。这个图用来分析数据库事务操作的冲突情况。每个事务是一个节点，事务之间的关系则表示为一条有向边。那么，什么样的关系可以表示为边呢？

串行化图的构建规则是这样的，事务作为节点，当一个操作与另一个操作冲突时，在两个事务节点之间就可以画上一条有向边。

具体来说，事务之间的边又分为三类情况：

1. 写读依赖（WR-Dependencies），第二个操作读取了第一个操作写入的值。
1. 写写依赖（WW-Dependencies），第二个操作覆盖了第一个操作写入的值。
1. 读写反依赖（RW-Antidependencies），第二个操作覆盖了第一个操作读取的值，可能导致读取值过期。

我们通过一个例子，看看如何用这几条规则来构建一个简单的串行化图。

<img src="https://static001.geekbang.org/resource/image/0d/73/0ddedf5e27966fac0388f36ddde7f473.png" alt="">

图中一共有三个事务先后执行，事务T1先执行W(A)，T2再执行R(A)，所以T1与T2之间存在WR依赖，因此形成一条T1指向T2的边；同理，T2的W(B)与T3的R(B)也存在WR依赖，T1的W(A)与T3的R(A)之间也是WR依赖，这样就又形成两条有向边，分别是T2指向T3和T1指向T3。

<img src="https://static001.geekbang.org/resource/image/7e/f5/7e66dc945b0ed425781f1e922ed338f5.png" alt="">

最终，我们看到产生了一个有向无环图（Directed Acyclic Graph，DAG）。能够构建出DAG，就说明相关事务是可串行化执行的，不需要中断任何事务。

我们可以使用SGT，验证一下典型的死锁情况。我们知道，事务T1和T2分别以不同的顺序写两个数据项，那么就会形成死锁。

<img src="https://static001.geekbang.org/resource/image/a2/7f/a2042eef98d2a84556fcbfb1814b517f.png" alt="">

用串行化图来体现就是这个样子，显然构成了环。

<img src="https://static001.geekbang.org/resource/image/88/ef/88d4955b226a9ee089cd8d361d86d0ef.png" alt="">

在SGT中，WR依赖和WW依赖都与我们的直觉相符，而RW反向依赖就比较难理解了。在PostgreSQL的论文中，专门描述了一个RW反向依赖的场景，这里我把它引用过来，我们一起学习一下。

这个场景一共需要维护两张表：一张收入表（reciepts）会记入当日的收入情况，每行都会记录一个批次号；另一张独立的控制表（current_batch），里面只有一条记录，就是当前的批次号。你也可以把这里的批次号理解为一个工作日。

同时，还有三个事务T1、T2、T3。

- T2是记录新的收入（NEW-RECEIPT），从控制表中读取当前的批次号，然后在收入表中插入一条新的记录。
- T3负责关闭当前批次（CLOSE-BATCH），而具体实现是通过将控制表中的批次号递增的方式，这就意味着后续再发生的收入会划归到下一个批次。
- T1是报告（REPORT），读取当前控制表的批次号，处理逻辑是用当前已经加一的批次号再减一。T1用这个批次号作为条件，读取收据表中的所有记录。查询到这个批次，也就是这一日，所有的交易。

其实，这个例子很像银行存款系统的日终翻牌。

因为T1要报告当天的收入情况，所以它必须要在T3之后执行。事务T2记录了当天的每笔入账，必须在T3之前执行，这样才能出现在当天的报表中。三者顺序执行可以正常工作，否则就会出现异常，比如下面这样的：

<img src="https://static001.geekbang.org/resource/image/b2/01/b2223b52a92e76f5dfe338e366238501.png" alt="">

T2先拿到一个批次号x，随后T3执行，批次号关闭后，x这个批次号其实已经过期，但是T2还继续使用x，记录当前的这笔收入。T1正常在T3后执行，此时T2尚未提交，所以T1的报告中漏掉了T2的那笔收入。因为T2使用时过期的批次号x，第二天的报告中也不会统计到这笔收入，最终这笔收入就神奇地消失了。

在理解了这个例子的异常现象后，我们用串行化图方法来验证一下。我们是把事务中的SQL抽象为对数据项的操作，可以得到下面这张图。

<img src="https://static001.geekbang.org/resource/image/88/7a/884e0f7b80fc32b9b8491ee76f8f127a.png" alt="">

图中batch是指批次号，reps是指收入情况。

接下来，我们按照先后顺序提取有向边，先由T2.R(batch) -&gt; T3.W(batch)，得到T2到T3的RW依赖；再由T3.W(batch)-&gt;T1.R(batch)，得到 T3到T1的WR依赖；最后由T1.R(reps)-&gt;T2.W(reps)，得到T1到T2的RW依赖。这样就构成了下面的串行化图。

<img src="https://static001.geekbang.org/resource/image/bf/e8/bf6019d3e3953a4075b001f4853963e8.png" alt="">

显然这三个事务之间是存在环的，那么这三个事务就是不能串行化的。

这个异常现象中很有意思的一点是，虽然T1是一个只读事务，但如果没有T1的话，T2与T3不会形成环，依然是可串行化执行的。这里就为我们澄清了一点：我们直觉上认为的只读事务不会影响事务并发机制，其实是不对的。

### 工程实现：CockroachDB

RW反向依赖是一个非常特别的存在，而特别之处就在于传统的锁机制无法记录这种情况。因此在论文“[Serializable Snapshot Isolation in PostgreSQL](http://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)”中提出，增加一种锁SIREAD，用来记录快照隔离（SI）上所有执行过的读操作（Read），从而识别RW反向依赖。本质上，SIREAD并不是锁，只是一种标识。但这个方案面临的困境是，读操作涉及到的数据范围实在太大，跟踪标识带来的成本可能比S2PL还要高，也就无法达到最初的目标。

针对这个问题，CockroachDB做了一个关键设计，**读时间戳缓存**（Read Timestamp Cache），简称RTC。

基于RTC的新方案是这样的，当执行任何的读取操作时，操作的时间戳都会被记录在所访问节点的本地RTC中。当任何写操作访问这个节点时，都会以将要访问的Key为输入，向RTC查询最大的读时间戳（MRT），如果MRT大于这个写入操作的时间戳，那继续写入就会形成RW依赖。这时就必须终止并重启写入事务，让写入事务拿到一个更大的时间戳重新尝试。

具体来说，RTC是以Key的范围来组织读时间戳的。这样，当读取操作携带了谓词条件，比如where子句，对应的操作就是一个范围读取，会覆盖若干个Key，那么整个Key的范围也可以被记录在RTC中。这样处理的好处是，可以兼容一种特殊情况。

例如，事务T1第一次范围读取（Range Scan）数据表，where条件是“&gt;=1 and &lt;=5”，读取到1、2、5三个值，T1完成后，事务T2在该表插入了4，因为RTC记录的是范围区间[1,5]，所以4也可以被检测出存在RW依赖。这个地方，有点像MySQL间隙锁的原理。

RTC是一个大小有限的，采用LRU（Least Recently Used，最近最少使用）淘汰算法的缓存。当达到存储上限时，最老的时间戳会被抛弃。为了应对缓存超限的情况，会将RTC中出现过的所有Key上最早的那个读时间戳记录下来，作为低水位线（Low Water Mark）。如果一个写操作将要写的Key不在RTC中，则会返回这个低水位线。

## 相对乐观

到这里，你应该大概理解了SGT的运行机制，它和传统的S2PL一样属于悲观协议。但SGT没有锁的管理成本，所以性能比S2PL更好。

CockroachDB基于SGT理论进行工程化，使可串行化真正成为生产级可用的隔离级别。从整体并发控制机制看，CockroachDB和上一讲的TiDB一样，虽然在局部看是悲观协议，但因为不符合严格的VRW顺序，所以在全局来看仍是一个相对乐观的协议。

这种乐观协议同样存在[第13讲](https://time.geekbang.org/column/article/282401)提到的问题，所以CockroachDB也在原有基础上进行了改良，通过增加全局的锁表（Lock Table），使用加锁的方式，先进行一轮全局有效性验证，确定无冲突的情况下，再使用单个节点的SGT。

## 小结

有关悲观协议的内容就聊到这里了，我们一起梳理下今天课程的重点。

1. 并发控制机制的划分方法很多，没有统一标准，我们使用了[Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)提出的划分标准，分为悲观协议与乐观协议两种。这里的乐观协议是上一讲提到的狭义乐观协议，悲观协议又分为锁和非锁两大类，我们简单介绍了2PL这一个分支。
1. 我们回顾了Percolator模型，按照S2PL的定义，Percoloatro本质就是S2PL，因此TiDB的乐观锁也属于S2PL。
1. S2PL是数据库并发控制的主流技术，但是锁管理复杂，在实现串行化隔离级别时开销太大。而后，我们讨论了非锁协议中的串行化图检测（SGT）。PostgreSQL最早提出了SGT的工程实现方式SSI。CockroachDB在此基础上又进行了优化，降低了SIREAD的开销，是生产级的可串行化隔离。
1. CockroachDB最初和TiDB一样都是局部采用悲观协议，而不做全局有效性验证，是广义的乐观协议。后来，CockroachDB同样也将乐观协议改为悲观协议，采用的方式是增加全局的锁表，进行全局有效性验证，而后再转入单个的SGT处理。

今天的课程中，我们提到了串行化理论，只有当相关事务形成DAG图时，这些事务才是可串行化的。这个理论不仅适用于SGT，2PL的最终调度结果也同样是DAG图。在更大范围内，批量任务调度时DAG也同样被作为衡量标准，例如Spark。

<img src="https://static001.geekbang.org/resource/image/a2/98/a2de442d44b6fd69c16e83a509c0a698.png" alt="">

## 思考题

课程的最后，我们来看看今天的思考题。

在[第11讲](https://time.geekbang.org/column/article/280925)中我们提到了MVCC。有的数据库教材中将MVCC作为一种重要的并发控制技术，与乐观协议、悲观协议并列，但我们今天并没有单独提到它。所以，我的问题是，你觉得该如何理解MVCC与乐观协议、悲观协议的关系呢？

欢迎你在评论区留言和我一起讨论，我会在答疑篇回复这个问题。如果你身边的朋友也对悲观协议或者并发控制技术这个话题感兴趣，你也可以把今天这一讲分享给他，我们一起讨论。

## 学习资料

最早的SSI工程实现方案：[Serializable Snapshot Isolation in PostgreSQL](http://vldb.org/pvldb/vol5/p1850_danrkports_vldb2012.pdf)

按照狭义乐观协议和其他悲观协议划分并发控制协议：[Transactional Information Systems : Theory, Algorithms, and the Practice of Concurrency Control and Recovery](http://www.gbv.de/dms/weimar/toc/647210940_toc.pdf)
