# 14 | 隔离性:实现悲观协议,除了锁还有别的办法吗? 你好,我是王磊,你也可以叫我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”中的划分体系。下面我摘录了书中的一幅图,用来梳理不同的并发控制协议。 ![](https://static001.geekbang.org/resource/image/ed/a1/ede7edbb88108b76d015fa69d14425a1.png) 这个体系首先分为悲观和乐观两个大类。因为这里的乐观协议是指狭义乐观并发控制,所以包含内容就比较少,只有前向乐观并发控制和后向乐观并发控制;而悲观协议又分为基于锁和非锁两大类,其中基于锁的协议是数量最多的。 ## 两阶段封锁(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理解。 ![](https://static001.geekbang.org/resource/image/54/75/540f30e8c3877b4951711e3c7305df75.png) 在t1时刻之前是加锁阶段,在t1之后则是释放锁阶段,我们可以从时间上明确地把事务执行过程划分为两个阶段。2PL的关键点就是释放锁之后不能再加锁。而根据加锁和释放锁时机的不同,2PL又有一些变体。 **保守两阶段封锁协议**(Conservative 2PL,C2PL),事务在开始时设置它需要的所有锁。 ![](https://static001.geekbang.org/resource/image/66/81/6680049eacdd2bab61a65eyy0e36a881.png) **严格两阶段封锁协议**(Strict 2PL,S2PL),事务一直持有已经获得的所有写锁,直到事务终止。 ![](https://static001.geekbang.org/resource/image/b7/ec/b7291c52385b627282f5c4f010acacec.png) **强两阶段封锁协议**(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),第二个操作读取了第一个操作写入的值。 2. 写写依赖(WW-Dependencies),第二个操作覆盖了第一个操作写入的值。 3. 读写反依赖(RW-Antidependencies),第二个操作覆盖了第一个操作读取的值,可能导致读取值过期。 我们通过一个例子,看看如何用这几条规则来构建一个简单的串行化图。 ![](https://static001.geekbang.org/resource/image/0d/73/0ddedf5e27966fac0388f36ddde7f473.png) 图中一共有三个事务先后执行,事务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。 ![](https://static001.geekbang.org/resource/image/7e/f5/7e66dc945b0ed425781f1e922ed338f5.png) 最终,我们看到产生了一个有向无环图(Directed Acyclic Graph,DAG)。能够构建出DAG,就说明相关事务是可串行化执行的,不需要中断任何事务。 我们可以使用SGT,验证一下典型的死锁情况。我们知道,事务T1和T2分别以不同的顺序写两个数据项,那么就会形成死锁。 ![](https://static001.geekbang.org/resource/image/a2/7f/a2042eef98d2a84556fcbfb1814b517f.png) 用串行化图来体现就是这个样子,显然构成了环。 ![](https://static001.geekbang.org/resource/image/88/ef/88d4955b226a9ee089cd8d361d86d0ef.png) 在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之前执行,这样才能出现在当天的报表中。三者顺序执行可以正常工作,否则就会出现异常,比如下面这样的: ![](https://static001.geekbang.org/resource/image/b2/01/b2223b52a92e76f5dfe338e366238501.png) T2先拿到一个批次号x,随后T3执行,批次号关闭后,x这个批次号其实已经过期,但是T2还继续使用x,记录当前的这笔收入。T1正常在T3后执行,此时T2尚未提交,所以T1的报告中漏掉了T2的那笔收入。因为T2使用时过期的批次号x,第二天的报告中也不会统计到这笔收入,最终这笔收入就神奇地消失了。 在理解了这个例子的异常现象后,我们用串行化图方法来验证一下。我们是把事务中的SQL抽象为对数据项的操作,可以得到下面这张图。 ![](https://static001.geekbang.org/resource/image/88/7a/884e0f7b80fc32b9b8491ee76f8f127a.png) 图中batch是指批次号,reps是指收入情况。 接下来,我们按照先后顺序提取有向边,先由T2.R(batch) -> T3.W(batch),得到T2到T3的RW依赖;再由T3.W(batch)->T1.R(batch),得到 T3到T1的WR依赖;最后由T1.R(reps)->T2.W(reps),得到T1到T2的RW依赖。这样就构成了下面的串行化图。 ![](https://static001.geekbang.org/resource/image/bf/e8/bf6019d3e3953a4075b001f4853963e8.png) 显然这三个事务之间是存在环的,那么这三个事务就是不能串行化的。 这个异常现象中很有意思的一点是,虽然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条件是“>=1 and <=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这一个分支。 2. 我们回顾了Percolator模型,按照S2PL的定义,Percoloatro本质就是S2PL,因此TiDB的乐观锁也属于S2PL。 3. S2PL是数据库并发控制的主流技术,但是锁管理复杂,在实现串行化隔离级别时开销太大。而后,我们讨论了非锁协议中的串行化图检测(SGT)。PostgreSQL最早提出了SGT的工程实现方式SSI。CockroachDB在此基础上又进行了优化,降低了SIREAD的开销,是生产级的可串行化隔离。 4. CockroachDB最初和TiDB一样都是局部采用悲观协议,而不做全局有效性验证,是广义的乐观协议。后来,CockroachDB同样也将乐观协议改为悲观协议,采用的方式是增加全局的锁表,进行全局有效性验证,而后再转入单个的SGT处理。 今天的课程中,我们提到了串行化理论,只有当相关事务形成DAG图时,这些事务才是可串行化的。这个理论不仅适用于SGT,2PL的最终调度结果也同样是DAG图。在更大范围内,批量任务调度时DAG也同样被作为衡量标准,例如Spark。 ![](https://static001.geekbang.org/resource/image/a2/98/a2de442d44b6fd69c16e83a509c0a698.png) ## 思考题 课程的最后,我们来看看今天的思考题。 在[第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)