感谢CMU的教授们给我们分享了如此精彩的一门课程, 希望您能尊重教授们和TAs的劳动成果!
本篇文章记录本人对实验中各个板块的理解以及踩坑, 如果您发现我过多的涉及到了实验的内容, 有违学术诚信, 请告诉我!
正文
project2要求实现一个B+树索引。 其中, B+树的节点为TreePaga基类。 由基类派生出子类数据页和内部页:LeafPage和InternalPage。 其中, B+树的叶子节点为数据页LeafPage, 内部的路径节点为InternalPage。B+树节点内部保存键值对,内部节点和数据节点的 键 都用做比较定位 值 的位置。内部节点的值为基类节点的指针;叶子节点的值为存储值。 同时,B+树特性, 叶子节点之间使用指针链接起来, 该项目中使用单链表进行链接。
TreePage
基类page都是一些LeafPage和InternalPage都可以用到的函数。 比如GetSize(), GetMaxSize(), ChangeSize()等等。
其中比较有问题的就是GetMinSize()。 B+树要保证除根节点, 其他节点存储的键值对数量最少为最大值的一半, 即节点最少要半满状态。 但是奇偶性不同, 所以要取证。取整分为两种方法, 向上取整和向下取整, 两种方法决定了节点分裂合并的时机不同。
这里采用向上取整, 并且要求节点插入和删除之后合并或者分裂。
另外要注意的就是LeafPage和InternalPage两个的MaxSize代表的含义, 其中LeafPage的MaxSize代表节点中键值对的最大数量。 InternalPage的MaxSize代表最大的键值对数量 + 1。 向上取整,取的是MaxSize的大小, 不是键值对数量的大小!
LeafPage & InternalPage
两个叶子节点都比较简单, 都是实现对特定位置 键值 的获取。


HeaderPage
哨兵位的头节点, 保存了B+的根节点, 当根节点替换时, 要修改哨兵位头节点的指向。
BPlusTree Operations
B+树要求实现增删查。 实现了这三个函数, 基本上project就完成了一大半了。
三个函数要注意:GetValue简单, 需要实现多个辅助函数, 并且辅助函数可以被Insert和Remove复用; Insert难度居中, 但是边界情况很多, 个人在本地测试, 出现问题基本上都是出现在Insert, Remove反而不容易出错; Remove最难以及代码量最多, 但是不容易出错(不清楚为何)。
如何获取Page

获取B+树中的节点的方法仿照构造函数中的用法。 先利用缓冲池获取PageGuard, 然后从PageGuard中拿到帧的地址就可以了。
有两种获取帧地址的方法, 一种是WritePageGuard可读可写, 另一种ReadPageGuard只读。
辅助函数
查找数据页
个人实现:
FindLeafOnlyRead查找页节点(后面叫做数据页),返回页节点的ReadPageGurad。先获取哨兵位, 从哨兵位拿到TreePage类型的rootPage, 获取到头节点之后, GetType()判断头节点是否是数据页, 不是,则继续向下找,直到找到数据页返回。(FindLeafOnlyRead位自己定义的辅助函数)
因为查找下一层节点需要比较并找到下一个页的指针。 所以封装一个辅助函数FindLeafMidSearch()。使用FindLeafMidSearch()查找要查找的下一层页的指针。 当我们获取到下一层的页的指针时, 需要先使用基类类型接收页,根据GetType()类型查看是否是数据页, 如果是则返回, 否则继续向下找。 这里封装FindIndexMidSearch()的原因是方便使用二分查找, 提高效率。
GetValue只需要FindLeafReadOnlyRead, 因为它只读, 不需要修改页面。
Insert和Remove可能需要修改页面,即在页面内的数组中插入和删除某个键值对, 或者分裂和合并。所以也需要获取数据页的可写指针。即获数据页的WritePageGuard, 实现一个FindLeafMaybeWrite()。方法类似, 同样是先获取哨兵位、获取rootpage、向下一层一层找页节点, 找到返回WritePageGuard。
二分查找搜索下标
在数据页寻找下一层的下标或者数据页寻找目标值的时候, 不再使用顺序查找, 利用二分查找,查找下标。 同样可以实现两个, 一个是查找内部页的下一层指针的下标FindLeafMidSearch,另一个查找数据页的目标值的下标FindKeyMidSearch。(两个函数名均为自己定义的辅助函数)
这里需要注意的是查找数据页中目标值的下标要使用左趋近的二分查找,因为如果有这么一组数:
- 1, 2, 3, 4, 5, 6, 7, 9, 10, 11
- 假如现在想要插入8, 那么使用左趋近查找查找到的下标是7, 也就是9的位置。
- 但是如果使用右趋近, 那么查找到的下标就是6, 但是6是不正确的, 我们应该把8插入到原本9的位置。 所以对数据页使用二分查找的时候要使用左趋近二分查找。而对内部页因为要找最大的小于new_key的位置,所以可以使用右趋近二分查找, 得到的下标处即为目标页指针
GetValue

首先查找数据页, 使用FindLeafOnlyRead完成。 拿到数据页后就从数据页中查找数据, 使用FindKeyMidSearch完成。然后将找到的数据保存到result中, 返回true。
Insert
对于Insert和Remove,主要是实现数据结构的增删逻辑。
首先是关于Insert和Remove, 都是要修改数据页, 所以要拿到数据页的WritePageGuard。利用上面实现的FindLeafMaybeWrite拿到数据页的WritePageGuard, 进而拿到数据页。
值得注意的是, 根据并发控制的要求,以及我们可能需要回溯查找父节点, 所以要将路径节点都保存到ctx.write中。 保存路径节点我们只需要在Insert和Remove查找数据页的过程中处理(即在FindLeafMaybeWrite向下遍历页的时候保存路径页),GetValue为只读, 并且不需要访问父节点,无需处理路径节点(即FindLeafOnlyRead向下遍历页的时候无需做额外处理)。
下面梳理一下Insert的实现逻辑(建议直接去看教材索引部分的伪代码, 下面的逻辑梳理是个人根据伪代码总结,用来加深印象的)
逻辑梳理

Remove
逻辑梳理


Index Iterator
项目要求实现了一个索引迭代器。 没有给出任何属性, 完全要自己设计。 但是我们根据要实现的方法可以猜测需要什么。 首先operator*()返回键值对, 所以一定要有leafpage, 然后迭代器必须能够进行operator++(), 所以必须有一个指针。这个指针可以使用int类型作为leafpage的下标。在数据页内++就是index += 1, 但是如果是到了末尾, 就要替换新数据页, 即调用leafpage的NextPageId方法。另外, 我们如何获取新的数据页? 需要有缓冲池管理器, 所以也要有缓冲池管理器。 我们还希望我们的迭代器遍历到某个位置时,不会出现数据不一致问题, 所以要保护某个数据页, 那么我们就可以不单单将leafpage作为成员变量,而是将一个数据页的ReadPageGuard作为成员变量。

所以有三个成员属性: ReadPageGuard、int index、 BufferPoolManager。
operator++
需要注意的是operator++, operator++要分为两种情况, 一种是index指向了键值对数组末尾, 另一种是没有指向末尾。 如果指向了末尾, 则拿到next_page_id, 然后构造新的ReadPageGuard赋值给成员属性, index置为0; 否则index++即可。
Concurrency Control
本项目中的并发控制不需要做额外的处理, 只需要Insert和Remove在向下遍历节点的时候将路径节点push_back到Context.write_set_中,回溯的时候pop_back。目的是为了同一时间只能有一个写线程访问该索引或者多个读线程访问该索引。
这就是p2的全部内容, 借助教材的伪代码, 还是可以达成目标的,虽然比p1难了很多。
小总结
注:下面是个人的总结,为了加深个人理解
p1主要实现的是一个BufferPoolManager, 除了DiskSchedule, BufferPoolManger就是数据库的最底层。

上面这张图其实就是整个BufferPool和Index的核心。 首先对于这个Directory, 就是上一节实现的BufferPoolManager中的page_table_, 它映射了page_id -> frame_id。 DBMS所有的数据都是存储在了BufferPool的Frames中。而磁盘中的Directory和Pages, 其实是磁盘的文件系统, 和DBMS无关。
如果不考虑索引, 当上层发一个请求(p3中会实现, 其实就是seq_scan遍历页面槽位或者index_scan使用刚刚实现的GetValue发的请求(并且索引要先发送一次请求获取页面槽位, 然后再发送请求从页面槽位中获取数据)), 这个请求被BufferPoolManager获取然后将请求中的page_id映射成Frames, 再根据请求中的槽位从frames的特定槽位拿到元组。

以上的是对p1的一个小总结。 接下来是重点:

本项目中实现的B+树索引,其实是保存在BufferPool的帧中的。 当查找一个数据的时候(其实就是p3中的Index_scan), 外部就会调用GetValue, 然后就是实现的GetValue的逻辑------DBMS首先会给BufferPool一个root_page_id。 然后BufferPool就将根页加载到了帧中。 一层一层向下直到找到数据页, 并找到value,push_back到result中返回。 这里面的value其实是一个rid(类似于tuple<page_id, slot>)。
所以说还是上面的图, 其实就是每次DBMS给BufferPool一个page_id, 将下一层的页加载到BufferPool中, 然后获取leafpage或者internalpage指针就是获取BufferPool的帧, 然后就能从page的键值对数组中找到下一层的page_id或者value。 所以, 这里的索引的查找和元组的获取不一样,虽然都要获取frame的指针, 但是保存元组的页一个一个的槽,下标定位槽; 保存索引的页是一个一个的键值对, 下标定位键值对。
回到GetValue的返回值。 当GetValue给上层返回了rid。 然后DBMS就拿着rid再去向BufferPool发送请求, BufferPool就利用rid的page_id从磁盘加载页, 然后根据rid的slot_id从页的槽拿元组。 最终元组返回。
以上。