CMU 15445 Project 2C 实现B+树并发INDE
暴力法
并发的最暴力解法,就是在每个FUNCTION,在B+树里就是GET ,REMOVE, INSERT,调用前上锁,做完释放锁。
针对这个作业,我大概写了11个测试,涵盖了方方面面,唯一没还有涵盖的是INDEX ITERATOR和基本操作的并发,因为会触发死锁,但是没有实现重试机制。但PROJECT里也有提到,这个不做考察重点。
用暴力加锁的做法,确实可以通过我写的所有并发测试。确保测试本身没有问题。
吐槽
下面碰巧在学习别人的解法时候(GIT HUB上搜了几个最高票数的解法),发现所有的他们的解法根本过不了我的这11个测试。正确性都没满足啊。
整个CRABING PROTOCAL的解法 对之前实现的B+树的算法的正确性 和 简洁性 有很高的依赖。也就是说之前的B+树的算法越严谨,实现这个协议就越容易。不然代码会改的坑坑洼洼,还写不对。
新的不变量
上一节,我已经介绍了B+树本身的一些不变量验证,也确保了我的算法没有破坏这些不变量,同时过了INSERT,DELETE和PAGE和TREE的测试
但是再实现CRABING PROTOCAL的时候,依然有个很严重的问题。
首先,老师给的提示里,有这么一句话
You have to release the latch on that page BEFORE you unpin the same page from the buffer pool.
这很重要,因为一旦有个PAGE被UNPIN到0,当POOL里的PAGE不够用的时候,这个PAGE会被回写磁盘,然后给牺牲掉。这时如果还没释放锁就会产生问题。
基于这个思考,我们有了一个新的不变量,也就是要求我们的B+树,在释放锁的时候,那时PAGE的PIN COUNT一定不应该为0. 如果为0,就说明之前算法是有问题的。
加上这个代码来确保这个情况不会发生。
![](https://img.haomeiwen.com/i10803273/83f295b02467a3dc.png)
当然在单线程的测试环境里,下一个ASSERT是可以被满足的,如果不满足,说明DELETE PAGE写的有问题(多线程的时候允许这个情况发生,我后面会介绍WHY)
![](https://img.haomeiwen.com/i10803273/df95ccc1a54aea1f.png)
根据新的不变量,优化代码
基于上述新的不变量,我上次提交的2B作业 是不满足的。因为我利用UNPIN PAGE 如果已经为0,就RETURN FALSE,来保证了幂等。但是多线程的情况下,他可能会造成先UNPIN到0,在FREE LOCK的悲剧。所以这里必须改掉。
巧合的是,在上个版本的代码实现里,我确实很多方法的BOOL值没有用上,也违背了CMU的作业设计。
在解决这个问题的时候,我发现这些变量正好可以派上用场。
比如
![](https://img.haomeiwen.com/i10803273/7be8445bf71d42ae.png)
算法本身
首先我还是来讲一下B+树,并发INDEX的算法。
https://15445.courses.cs.cmu.edu/fall2018/slides/09-indexconcurrency.pdf
首先LATCH,是用来保护DB自己需要保护的数据的,LOCK是用来保护CLIENT的TRANSACTION的
LATCH 分为读,写2个模式。可以有多个读锁,但是写锁是排他的。
![](https://img.haomeiwen.com/i10803273/2651e88c903830b7.png)
如果不用锁来保护INDEX,会发生2种悲剧。
- 2个线程同时修改一个索引。
- 一个线程再遍历的时候,另一个再做合并或分裂。
下图就举个一个CASE 2,找41的时候,另一个做了REBALANCE,41不见了。
![](https://img.haomeiwen.com/i10803273/cd3c7ff06e4239f4.png)
![](https://img.haomeiwen.com/i10803273/f654665b8db17888.png)
下面就开始介绍LATCH CRABBING/COUPLING 算法。
对于查询操作,从根节点开始,首先获取根节点的读锁,然后在根节点中查找key应该出现的孩子节点,获取孩子节点的读锁,然后释放根节点的读锁,以此类推,直到找到目标叶子节点,此时该叶子节点获取了读锁。
对于删除和插入操作,也是从根节点开始,先获取根节点的写锁,一旦孩子节点也获取了写锁,检查根节点是否安全,如果安全释放孩子节点所有祖先节点的写锁,以此类推,直到找到目标叶子节点。节点安全定义如下:如果对于插入操作,如果再插入一个元素,不会产生分裂,或者对于删除操作,如果再删除一个元素,不会产生并合。
![](https://img.haomeiwen.com/i10803273/4d5a69536c662376.png)
查找
![](https://img.haomeiwen.com/i10803273/4b50223e769b641a.png)
插入
![](https://img.haomeiwen.com/i10803273/d37ea0e30d214413.png)
算法本身应该是改动不多的。
基本上在实现了上述的思想后,如果不引入ITERATOR的横向遍历,几乎可以不用改别的地方。比如DELETE 的SIBLING PAGE是可以不用上锁保护起来,因为动到这个,PARENT一定是锁住的,不会有第2个线程可以走到这个SIBLING。 前提是不利用叶子节点的横向指针,之能事从ROOT走下来的情况。
实现思路
基于上述思想,整个2C 大概分为这么几个步骤。
-
优化代码,解决新的不变量的问题。
-
重写FIND LEAF PAGE,以及做完INSERT ,GET, REMOVE ,需要释放TRANSACTION里的PAGE,同时需要对SIBING 加锁保护
3.实现逻辑可以保护ROOT_PAGE_ID 在并发时不出问题。
上面三步可以确保能过所有测试了。
- 实现ITERATOR的线程安全。
上面这步可以确保,在没有删除叶子节点的时候,去找左SIBLING的情况下,线程安全,如果有这样的操作,会发生死锁。
因为DELETE锁住了右边的叶子,要去找左SIBILING(读锁锁住了),ITERATOR先拿到左SIBLING的读锁,去等下一个右边的叶子节点的锁(被DELTEE的写锁占用)
产生死锁。
鉴于这个LAB想实现正确难度还是非常大,所以我带着大家一点点实现出来。本解法基于我的PROJECT 2B的代码,需要的同学可以去我的GIT上取。
https://github.com/yixuaz/CMU-15445
第一步,移除多余的UNPIN PAGE
加上2处,ASSERT,确保过测试的时候不会走到这里,还有一处上面写了。
![](https://img.haomeiwen.com/i10803273/de45bd2e646eece7.png)
基于报错和小数据集来研究为啥会重复UNPIN,也可以基于分析代码。下面给出如何SOLUTION。
加了2个ASSERT,后测试INSERT没问题。基本上引入问题的是REMOVE。
第一处
![](https://img.haomeiwen.com/i10803273/f2f6e8ba80fcb9d6.png)
修改REMOVE的时候,我的假设是这样的。 如果这个PAGE已经被MOVE 完了,没有作用了,当下会直接UNPIN 然后DELETE,同时返回TRUE给外层。 外层只有发现是FALSE的时候,会帮忙去做UNPIN。
这样就不会重复UNPIN了。
![](https://img.haomeiwen.com/i10803273/4b7e0b7425796f2f.png)
![](https://img.haomeiwen.com/i10803273/cf1700d808875099.png)
![](https://img.haomeiwen.com/i10803273/5533a0d5768bf49e.png)
![](https://img.haomeiwen.com/i10803273/a202588f39049413.png)
重跑TEST 测试全过。
我们这个基础,提交到PROJECT2C 里的2C-PREPARATION
第二步 重写FIND LEAF PAGE
下面是原来的代码
![](https://img.haomeiwen.com/i10803273/5ce3f74b78e1dad4.png)
基于思考,我们需要把FETCH PAGE这个地方做个加强。
里面的逻辑大概是,首先拿到PAGE,锁住,然后看这个节点是不是SAFE,是的话,释放掉TRANSACTION里管理的PAGE。然后把这个PAGE加进去。
但是我写到后面发现ITERATOR BEGIN()的时候,是不会有TRANSACTION传过来,这个时候我们需要额外维护一个逻辑,直接释放前一个PAGE(释放包括先UNLOCK,再UNPIN)
实现3个辅助函数
在TREE PAGE里加这个PAGE是否是SAFE
![](https://img.haomeiwen.com/i10803273/8051a2a4b99126d7.png)
![](https://img.haomeiwen.com/i10803273/0f368fe953dbe8e1.png)
![](https://img.haomeiwen.com/i10803273/d2ea0bb9b7d11963.png)
新的FIND LEAFPAGE
![](https://img.haomeiwen.com/i10803273/e1a1bc798e02d392.png)
第三步 做完INSERT ,GET,需要释放TRANSACTION里的PAGE
![](https://img.haomeiwen.com/i10803273/c062fe5cfe460c03.png)
![](https://img.haomeiwen.com/i10803273/52b79d9d2789662f.png)
通过INSERT TEST的测试
![](https://img.haomeiwen.com/i10803273/2ba334d60223aebf.png)
第四步 做完REMOVE 的改动
有2点,除了像上面那样更新2个方法。还有一个是要把所有UNPIN+DELETE的地方延后。加入到DELETED PAGE SET
![](https://img.haomeiwen.com/i10803273/c02a9f3333a984b3.png)
因为节点如果有被删除的风险,一定是会UNSAFE被送进TRANSACTION里。
所以我们需要统一在最后处理他们。
为了达到这个效果,我们需要把所有要删除TRANSACTION节点的地方给延迟。就加到TRANSACTION的另外一个叫DELETED SET的集合里
但是有个地方我们做了一次SWAP,这样在方法内部会混淆谁是原来的节点。为了记住这个信息,我们用负号来表示交换过。
![](https://img.haomeiwen.com/i10803273/143f4e6393b60a0c.png)
![](https://img.haomeiwen.com/i10803273/effa8399aef22a91.png)
![](https://img.haomeiwen.com/i10803273/baec4e73310cfe36.png)
跑DELETE 测试通过。
![](https://img.haomeiwen.com/i10803273/8b2621ff098e6f85.png)
跑综合测试,通过
![](https://img.haomeiwen.com/i10803273/6401e377d272d859.png)
上面的做法,在后来跑并发测试时遇到问题
经过仔细研究DEBUG时的现象,大概琢磨出问题的来源在于下面这个情况。
![](https://img.haomeiwen.com/i10803273/63f769bd008d85f8.png)
所以DELETE的时候,SIBLING是一定要锁上的。
修改代码如下,就是无论是redistribute 还是 Coalesce 都不用去UNPIN了,
然后取SIBLING的时候,要加写锁,随后送进TRANSACTION PAGESET。最后统一释放。
![](https://img.haomeiwen.com/i10803273/2f60bc31f021109b.png)
![](https://img.haomeiwen.com/i10803273/d302030617f44216.png)
![](https://img.haomeiwen.com/i10803273/278c452d83a2974b.png)
![](https://img.haomeiwen.com/i10803273/51918b1630a2fdc5.png)
分裂的时候同理
![](https://img.haomeiwen.com/i10803273/c75c30175893a69f.png)
![](https://img.haomeiwen.com/i10803273/5410a28fc96dcda1.png)
第五步 实现逻辑可以保护ROOT_PAGE_ID 在并发时不出问题。
ROOT PAGE ID 的重要性,因为在INSERT的时候 是读后写。
什么意思,如果判断ROOT PAGE ID 是INVALID,就要START NEW ROOT
所以要做保护,必须是COVER住全部的。
还有就是在FIND LEAF PAGE 的时候 都会去读,这个时候 可能需要一直保护到 ROOT PAGE 被FREE掉的时候。
基于上述思考,我决定再用一把读写锁,然后加一个抽象层来封装加锁放锁逻辑,同时为了记录自己加锁的次数 (因为是读写锁)需要一个THREAD LOACL INT。 如果没有这个变量,可能会错误的释放了其他线程加的保护ROOT PAGE ID的锁。
![](https://img.haomeiwen.com/i10803273/bf97a8a249485176.png)
![](https://img.haomeiwen.com/i10803273/0b0cd8ba91b9ddd3.png)
![](https://img.haomeiwen.com/i10803273/b6ee58a5024c5fb5.png)
![](https://img.haomeiwen.com/i10803273/b856a66d39670250.png)
![](https://img.haomeiwen.com/i10803273/4c110771a94cddb3.png)
![](https://img.haomeiwen.com/i10803273/da5fd7bc6c003ee5.png)
![](https://img.haomeiwen.com/i10803273/dbb2420215487123.png)
第六步 ITERATOR 线程安全
![](https://img.haomeiwen.com/i10803273/0069e4c83ef78a95.png)
通过全部11个测试1000次
测试脚本
![](https://img.haomeiwen.com/i10803273/134ff8f711bbd809.png)
单个测试耗时
![](https://img.haomeiwen.com/i10803273/aa0e143f4399aca8.png)
全部
![](https://img.haomeiwen.com/i10803273/6a0a4b368a2a4531.png)
内存泄漏
![](https://img.haomeiwen.com/i10803273/205cc1d1178509ba.png)
因为这个实现 是一个里程碑,我会把整个PROJECT 的SNAPSHOT 给放进2C
https://github.com/yixuaz/CMU-15445