课程地址
在开始项目之前首先要先了解 B+树的概念,同时项目中还有可以打印当前B+树的函数,可以用来找BUG,
Task 1 实现三个页面类的一些函数
b_plus_tree_page: 是leaf和internal的公共函数,其中只有GetMinSize这个函数需要注意,根据课程给出的B+树例子,其最小值是不一样的。同时根节点不需要满足最小Size,根节点的判断留到后面函数里面去判断,这边就只判断叶节点和内部节点、
leaf_page:最小值是 ⌊ M a x S i z e ⌋ / 2 \lfloor MaxSize \rfloor/2 ⌊MaxSize⌋/2 internal_page:最小值是 ⌊ M a x S i z e + 1 ⌋ / 2 \lfloor MaxSize+1 \rfloor/2 ⌊MaxSize+1⌋/2
b_plus_tree_internal_page和b_plus_tree_leaf_page:这两个页面需要注意的就是array[1]柔性数组,一开始对这个也是有点懵,后面了解了之后就当作一个数组用就行,其他的函数到后面慢慢添加就行。其中在内部节点存储的Value就是PageId。
Task 2 实现页的查找和插入
2.1 查找
上一个Project中BasicPageGuard提供了类型转换的方法:可以将获取的页转换为leaf_page 或者 internal_page
BasicPageGuard guard = bpm_->FetchPageBasic(next_page_id);
auto page = guard.AsMut<BPlusTreePage>();
踩坑点:由于在Project1中,我们实现了FetchPageRead和FetchPageWrite两个函数,可以分别对页面添加写锁和读锁,想结合后面的并发控制一起直接写了,但是由于函数的设计问题和获得的WritePage和ReadPage析构就解锁了,不好控制加锁和解锁,索性后面就全部使用FetchPageBasic。
B+树的查找、插入和删除都有一个共同特点,都是首先找到值对应的叶节点。
所有可以编写一个根据Key找到,该key所属叶节点的函数。
GetPageLeaf:可以使用一个while循环来逐步从根节点,迭代到叶节点。
有了GetPageLeaf函数后,查找函数就很好写了,直接在获取后的叶节点上查找是否包含Key即可。
在开始其他项目之前首先要先了解B+树的概念,同时项目中还有可以打印当前B+树的函数,可以用来找BUG,
2.2插入
仔细查看前面给的B+树例子,需要注意的是,对于叶节点,当到达MaxSize是就需要分裂,而对于内部节点需要达到MaxSize+1才需要分裂。
Insert函数的实现流程图如下。 需要注意的点就是内部节点和叶节点的区别,同时内部节点为了保持Key和Value的数量一致,array[0].first是没有用的,不过我们可以将它当作一个标志位。
2.3删除
在做删除之前,还是使用项目给出的网站查看删除的情况,可以看到主要就分为两种情况,一种就是咸香左右兄弟节点借,如果兄弟节点的Size>MinSize,则借,如果左右兄弟节点都不满足,就需要采用合并的策略。
情况 1
情况 2
下面是Remove函数的流程图:
需要注意的点就是,在借节点时,借的左兄弟节点,则借最后一个key,若借的是右兄弟节点,则借第一个key(这里可以用上前面内部节点的第一个key)。同时父节点更新的地方和值也不一样,通过画图就可以知道,这里就不在赘述了。
在合并时也是一样,要区分和左兄弟节点合并还是和右兄弟节点合并。同时删除父节点中的key。
相较于2022fall,2023spring中的page中删去了ParentId成员,对于插入,删除都不需要考虑更新Parentid的情况,减少了很多代码的编写。
Task 3 迭代器实现
项目中对迭代器的实现好像不需要要求并发控制,所以实现起来就比较简单了。
page_id_t page_id_ = INVALID_PAGE_ID; //当前迭代器所属的page_id
B_PLUS_TREE_LEAF_PAGE_TYPE *leaf_page_ = nullptr;
int page_index_ = 0; // 在页中的位置
BufferPoolManager *bpm_ = nullptr;
往.h文件添加上述成员变量,使用INVALID_PAGE_ID来表示迭代器到达END。
operator==:只有在page_id和page_index都相等的时候迭代器才相等。
operator++:需要注意++可以会导致迭代器从一个页面,跳到另一个页面,所以需要注意更新成员变量,因为在实现的时候就能从页面获得NEXTPAGEID,所以该task也不是特别难。难的地方在于理解题目所要实现的目的。
Task 4 并发控制
在开始并发控制之前,需要确保前面的实现没有问题,可以在每个函数前加一把大锁,提交到gradescope看看自己的实现是否正确。
课上也是同样给了两种加锁方案:
基础算法 先给自己加锁,在判断为安全节点后,才能释放祖先节点的锁。
改进算法 因为分裂的情况发生的占比较少,所以一开始乐观的认为都是安全节点,加读锁,当叶节点不安全时,再像基础算法一样一步步加写锁。
安全节点定义:
读:都是安全的
插入:当节点数量小于MaxSize-1时是安全的(internal节点为小于MaxSize)
删除:当节点数量大于MinSize都是安全的
当然,根节点的情况需要另外说明,删除时根节点时叶节点则>1是安全的,内部节点>2是安全的。
根节点的保护
因为再插入和删除这些操作之前都要判断B+树是否为空。
- 该项目提供了一个BPlusTreeHeaderPage类,可以用该类来充当根节点的父节点,也能使用page的加锁,解锁方式。
- 可以定义一个读写锁来当作根节点的父节点来保护根节点。
unlock和unpin
第一次实现的时候先unpin在unlock导致,在测试的时候一直报错,找了好久都找不到问题在哪,后面在其他的博文中找到答案,如果先unpin,pin_count=0该页面可能会被换出,导致页面失效,无法unlock。
优化
- 使用一把大锁,提交后得分50000多,排在倒数~~~~~
- 使用基础螃蟹算法优化,提交后得分接近90000,好像还是倒数~~~~~
- 因为统一使用了AsMut,会将读取的页面置脏位为true,将一些安全节点脏位改为false,减少磁盘的读写,提交后分数有104000了,但还是倒数~~~~~
- 乐观锁算法//TODO
BUG
- pageguard上的&& 要把page置为nullptr 不然会重复释放锁 造成不定义的行为 卡住
总结
Project 2完成的时间比Project 1长了一倍花了20天左右,当然因为对于一些概念性的理解花了较多的时间,一个是删除画的时间多一点,另一个就是并发控制一大半的时间就花在这了,因为看到2022fall中存储了ParentId,所以使用了一个map来存储各个节点的ParentId,一把大锁提交Grapescope,满分通过后,就开始使用改进算法,因为死锁还花了一些时间排查,死锁解决后就总是出现一些莫名奇妙的bug,都不知道从哪入手,后面发现map是线程不安全的,所以在每次读写map时我都加了一把额外的锁。
这把锁真的要我的老命,加入这把锁后,因为有些test的线程太多,好多线程都停在lock中,而占据了很多buffer pool中的空间大小,导致无法换出页面,到最后才发现真的项目用到ParentId的地方特别少,遂删除map,才通过测试。(这边消耗的时间差不多花了五天,还是太菜了)。
最后目前的优化效果还不是特别好,等有时间了回头再来搞搞优化。
- 会不会是Project 1中使用1把大锁的缘故?
- 不论在插入、删除、查找都将页面统一置为脏会不会增加了很多磁盘的读写?