gitbook/分布式数据库30讲/docs/283385.md
2022-09-03 22:05:03 +08:00

165 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)