0. 前言·
从这个proj开始,难度上升了很多个档次,做起来明显吃力。
proj2要求实现一个线程安全的B+ tree。收货满满。
1. 要求·
proj2的checkpoint1要求实现B+ tree的节点class,包括internal page和leaf page,以及它们的公共父类parent page。之后在此基础上实现B+ tree的insert和point search。
点这里查看详细要求。
2. 题解·
虽然我之前说过所有projects都不会透漏代码,但是结合我的做题体验来说,我是参考了不少博文和代码才做出来的。所以本文除了记录坑点外,也会贴部分关键的代码或伪代码。对于才开始做的朋友来说,checkpoint1
肯定是无从下手的,因为他只是提供了一些函数和注释,不明白具体该做什么。比如下面这个函数:
如果没有做完整个proj,起手就写它的话,我是不知道如何下手的。建议阅读下整个public
的函数作用,leaf page
同理,然后从b_plus_tree.h/cpp
入手。
ok, 建议说完,额外推荐一些学习资料,主要是B+树相关:
- geeksforgeeks上的B+树文章:这里, 只是介绍了B+树的概念和插入操作代码。
- B+树删除操作视频:这里
- B+树删除操作文章:这里
- B+树开源源码:这里
- cmu15445课程参考书籍:database system concepts
额外推荐知乎文章中提到的群,我在里面受到了不少帮助,群主在piazza上开设的讨论帖也能找到不少帮助资料。
这里
2.1 B+ tree parent page·
这个类实现起来很方便,要说明的只有两点:
size_
这个成员变量说的是一个节点的孩子指针个数,而不是key的个数。GetMinSize
函数,获取一个节点中最少的孩子指针个数是多少 。
这个给个总结表:
1 | 根据书籍p665(DataBase System Concepts第7版)所说,如果规定阶数(也就是一个节点的最大指针分支数量)为n,则 |
所以GetMinSize
的实现为:
1 | int BPlusTreePage::GetMinSize() const { |
2.2 B+ tree internal Page·
internal page作用为route,value的个数=max_size, key的个数=max_size - 1。 所以key
的索引应该从1号位置开始。主要说一下我认为在Checkpoint1
中需要实现的重点函数:
-
ValueIndex
表示在array
数组中找到array[i].second == 传入的value
的i
, 由于value不是有序的,所以目前只能线性扫描 -
Lookup
表示在array
数组中找到最大的一个K,这个K满足足K<=key
,然后返回这个K的对应V。 采用 二分查找即可。 -
PopulateNewRoot
表示this
指针所代表的的节点为root
节点,且目前size=0
, 这个函数要做的工作是设置root
节点的左孩子和右孩子分别为old_value
和new_value
-
InsertNodeAfter
这个没什么好说的,在old_value
后面插入new_key
和new_value
-
MoveHalfTo
, 这个函数将在后续实现B+ tree的插入分裂时使用,初看这个函数不知道如何移动,是移动this node
的前半段数据,还是移动this node
的后半段数据? 所以当时我是参考了别人代码,最后知道了是将this node
的后半段数据移动到recipient
中。参考代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18/*
* Remove half of key & value pairs from this page to "recipient" page
*/
INDEX_TEMPLATE_ARGUMENTS
void B_PLUS_TREE_INTERNAL_PAGE_TYPE::MoveHalfTo(BPlusTreeInternalPage *recipient,
BufferPoolManager *buffer_pool_manager) {
/*
拆分后,将this->page中节点的一半移动到recipient中
+----------+ +------------+
|this page ---> recipient |
+----------+ +------------+
*/
int size = GetSize();
int remain_size = size / 2;
recipient->CopyNFrom(array + remain_size, size - remain_size, buffer_pool_manager);
SetSize(remain_size);
} -
CopyXXXFrom相关函数,这几个函数主要从拷贝一份数据到
this node
。 只不过要注意的是,拷贝key value的同时,还需要更改value所代表的的节点的parent_page_id为this->page_id
。 如CopyLastFrom
函数的参考代码实现:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19/* Append an entry at the end.
* Since it is an internal page, the moved entry(page)'s parent needs to be updated.
* So I need to 'adopt' it by changing its parent page id, which needs to be persisted with BufferPoolManger
*/
INDEX_TEMPLATE_ARGUMENTS
void B_PLUS_TREE_INTERNAL_PAGE_TYPE::CopyLastFrom(const MappingType &pair, BufferPoolManager *buffer_pool_manager) {
PushBack(pair.first, pair.second); // 这个函数是自己添加的
SetParentToMe(pair.second, buffer_pool_manager); // 将pair.second所代表的节点的parent_page_id设置为this->page_id
}
INDEX_TEMPLATE_ARGUMENTS
void B_PLUS_TREE_INTERNAL_PAGE_TYPE::SetParentToMe(page_id_t page_id, BufferPoolManager *buffer_pool_manager) {
Page *page = buffer_pool_manager->FetchPage(page_id);
// 注意对一个page的访问,需要通过buffer_pool_manager来管理
BPlusTreePage *bp_tree_page = reinterpret_cast<BPlusTreePage *>(page);
bp_tree_page->SetParentPageId(this->GetPageId());
// 访问完成后,还需要unpin
buffer_pool_manager->UnpinPage(page_id, true);
}
ok,重点函数就这些。
2.3 B+ tree leaf page·
leaf page是用于真实存储数据的。key和value的个数都=max_size,所以leaf page的key扫描从索引号0开始。其余函数和internal page类似。
2.4 B+ tree point search·
B+ tree的点查询还是相对简单的,贴一下书中提到的伪代码:
为了实现Search(GetValue)
,需要实现FindLeafPage
, 实现 FindLeafPage
函数后,GetValue
函数就非常简单了。
这里贴一个实现:
1 | /* |
最后就是checkpoint1
的重头戏,实现insert
函数,因为insert
涉及到分裂,所以要实现以下函数:
StartNewTree
如果当前tree还是空的,则调用本函数,生成root节点。InsertIntoLeaf
,如果当前tree非空,则将key,value插入到叶节点。
贴一个伪代码:
具体实现时,无需将整个旧节点都拷贝一份,然后再一半一半的分别拷贝到两个孩子节点。只用将原本满的节点当作左孩子,然后申请一个右孩子即可。
然后是两个子函数:
额外关注,proj中的B+ tree是不支持重复key插入的,所以在真正执行插入前,可以Lookup
一次key是否已经存在叶节点中,如果已经存在则直接return
。
这里贴一下InsertIntoLeaf
中的核心代码:
1 | LeafPage *left_page = leaf_page; |
先分裂,然后取右孩子的第一个key,插入到parent节点中。
Split函数:
Split时,叶节点和内部节点需要单独处理。
最后是InsertIntoParent
, InsertIntoParent
是一个递归函数。关键点:
- 如果
old_node
已经是root page
,那么直接生成新的root page
,并设置old_node, new_node
的parent_page_id
- 如果
old_node
不是root page
, 需要找到parent_page
, 执行插入,然后检查parent_page
是否已满,如果满递归分裂插入。
其中第2点中所说的是否已满
的判断条件为:
1 | if (parent_page->GetSize() == parent_page->GetMaxSize() + 1) { |
为什么是 max_size + 1?
首先这里的parent_page
肯定内部节点。现在考虑如下的场景:如果tree初始化的leaf_max_size=2, internal_max_size=3。 之后依次插入1,2,3。 插入2时,[1,2]这个节点将分裂一次,得到root节点[2], 叶节点[1]和[2]。 然后插入3, 此时[2,3]这个节点会分裂一次,叶节点为[1],[2],[3]。 同时内部节点为[2,3], 此时内部节点因为达到了max_size=3(因为三个孩子),所以再次触发分裂,不过此时是没办法分裂的。结果如下图:
[2,3]这个节点是不可能分裂出去的。
此时你可能要问,强制要求internal_max_size
不能小于3,即必须>=4不就可以了吗。这个不行,因为官方提供的测试用例中,有一句:
所以,实现中必须兼容internal_max_size=3
的情况。
那内部节点的分裂时机设置为size == max_size + 1
有什么问题? 有问题,那就是可能产生越界的情景。默认为的INTERNAL_PAGE_SIZE
为:
1 |
所以当插入第max_size + 1
个元素时,实际上是插入到下一个page
去了。
那该怎么处理?两种方案:
- 先检查是否可能造成分离,如果会,则先分裂,再插入。这种方案可行,但是实现麻烦。
- 修改
INTERNAL_PAGE_SIZE
的默认值,留一个空槽出来即可。
这里采用方案2,修改下宏定义即可:
1 |
3. 其它坑·
如果一切梳理,至此你可以通过本地print test
,通过dot file
和graphviz
绘制出自己的B+ tree。 但是可能提交到 gradescope
上只能得到20分。 在memory_test
上会丢失10分。
原因大概率是是因为 buffer_pool_manger
这个类有点问题,即使顺利通过了proj1,也是有可能有问题。常见问题有两个:
UnpinPageImpl
实现中,对于page_table
找不到的page_id
,需要返回trueDeletePageImpl
实现中,处理 擦除page_table
中的元素,归还到free_list
中以外,还需要从replacer_
中踢出相应的frame_id
ok,大概就是这么多。