Project 2 最后一篇,讲解 B+ 树并发控制的实现。说实话一开始博主以为这块内容不会很难(毕竟有 Project 1 一把大锁摆烂秒过的历史x),但实现起来才发现不用一把大锁真的极其痛苦,折腾了一周多才弄完。
本文分基础版算法和改进版算法两部分,基础版算法部分我就只讲实现的一些要素,改进版算法再放重要代码,避免两个版本的代码引起混乱。由于加了并发控制后代码改变的位置比较多,我这里贴的截图不能覆盖到所有,如果需要源码可以评论区或私信联系。
开始之前先推荐知乎上的两篇文章,写得都非常好,而且有带图的例子方便理解。
CMU 15445-2022 P2 B+Tree Concurrent Control
做个数据库:2022 CMU15-445 Project2 B+Tree Index
理论基础
基础版算法
首先,请把 Lecture #09: Index Concurrency Control 这个课件第 36 页开始的内容仔细看一遍,实验文档中关于并发控制的描述很简略,而这个课件中详细讲解了并发控制的基本算法和改进算法,还带了很多具体例子方便理解。这里我就简要摘抄一下核心内容。
定义 “Safe Node 安全节点”:
- 如果操作为插入,则没有满的节点为安全节点;
- 如果操作为删除,则超过半满的节点为安全节点;
- 如果操作为读取,则所有节点均为安全节点。
一句话概括,就是操作时不会使树的结构发生改变的节点(插入的分裂,删除的合并)。从课件前面的例子也能看出,B+ 树实现并发访问的一个主要问题就是树的结构的改变会和读取或另一个结构改变发生冲突,造成读取无效位置或树的逻辑结构错误。而安全节点就是那些我们不用担心出现这个问题的节点。
于是,可以得出一个基础版的并发控制算法:
对于读操作,在子节点获取读锁然后把父节点的读锁释放掉;对于插入/删除操作,在子节点获取写锁,判断子节点是否安全:如果安全,将祖先节点的所有写锁释放,否则继续持有。这里也不难理解:如果子节点安全,那么对其下面的节点做插入/删除操作引起的树结构变化最多会传递到该层,而不会影响上层节点的结构,所以可以放掉祖先节点的锁允许其它操作访问。
算法的名称也非常形象:Latch Crabbing / Coupling,因为获取锁的方式就像螃蟹前进一样,先把一条腿迈到下一个位置(子节点),然后把另一条腿从上一个位置(父节点)收回来。
改进版算法
以上算法在插入/删除时第一步总是要先获得根节点的写锁(独占性),这是树形结构本身决定的,但这在性能上很容易成为瓶颈。而另一个观察是,大多数的插入/删除并不会使节点发生分裂或合并(只要节点的 max size 不设置得太小),所以实际上获取根节点(或者说那些较为靠上节点)的写锁大部分情况是不必要的。因此,课件给出了一个改进版的算法:
其思路是先 “乐观” 地假设插入/删除不会发生分裂或合并,于是只需沿路径像读取一样获取和释放读锁,然后检查叶节点:如果叶节点安全,那么假设正确,对其更新只需要叶节点的写锁;而如果不安全,说明假设错误,重新像基础版算法一样跑一遍。显然,如果上面的观察成立,那么这种算法带来的收益是会超过其代价的。
扫描(迭代器)问题
上一节我们还实现了 B+ 树的一种操作:迭代器,也就是顺序访问所有叶节点的元素。但到目前为止我们讨论的都是从根到叶路线的并发控制,那么能否直接从叶跳到下一个叶呢?答案是不可以,课件中也给出了死锁的例子。最终给出的方案是:从一个叶节点跳到下一个叶节点时,只有立刻能获取到其读锁,才可以继续,否则直接报告错误,也就是不要等待。这部分实验讲义中说明了不在评测范围之内。
基础版算法实现
回顾上一节的代码,我们已经写了一个函数 GetLeafPage()
负责从根节点搜索到值可能所在的叶节点。并发控制的主体代码应该添加到该函数中。首先定义操作的枚举类型:
Transaction
原理讲解中提到插入/删除涉及所有祖先节点的释放,这就要用到我们前面一直忽视的参数:transaction
,它携带了一个数据库事务的所有信息,不过这里我们只需要关注其两个成员