Project 2:B+ Tree
Project #2 - B+Tree | CMU 15-445/645 :: Intro to Database Systems (Fall 2022)
NOTE:
记录完成该Pro中,一些可能会遇到的问题:
本实验中,有很多API是需要自己去实现的,因此,我推荐把逻辑梳理清楚后,再尝试去实现,函数设计是一门学问,我个人地原则是尽量避免出现重复代码段,即相同地处理置于一个函数中;
相较于Pro0和Pro1,本次指导书中的内容简略了许多,格外要注意到底是在哪实现代码,例如Task2是在src/include/storage/index/b_plus_tree.h和相应的CPP文件中实现(为什么会这么说?因为我就犯蠢了)
官方提供了一个可以正确打印实现的B+树的网页,可以用于检测与本地实现之间的差异,推荐在实现具体函数地时候可以先手动尝试一下插入、删除等操作。
BusTub B+Tree Printer (cmu.edu)
本次实验的实现中,涉及到了很多与迭代器相关的操作,包括但不限于std::prev\ std::next\ std::lower_bound \ std::move_backwrad(),所以我认为可以提前了解一下C++提供的这些语法糖,还能使你的code能力得到一定的提升。推荐网站:cppreference.com
每次用完Page后记得Unpin!
我在进行这次实验的时候,短暂地尝试了使用Copliot来辅助完成编写,可以省去很多简单逻辑的重复编写,但是有些细节逻辑处理的仍不是特别好,或者是有可能是我没有理解AI的意思。
虽然在我下文中地描述中使用了内部页和叶子页这种比较蹩脚地称呼,但是还是要意识到本质上依旧是树中地一个节点,为了方便,在代码中给变量取名地时候可以用page和node加以区分。
Task #3:Index Iterator
将叶子页迭代器化,近似于组织为链表(MySQL中就是如此),迭代器应当具有基本功能,例如运算符和for-each循环。
迭代器的操作仅需在src/include/storage/index/index_iterator.h
和src/index/storage/index_iterator.cpp
中进行实现。
指导书中提供了以下几个成员函数,可以自行实现其他辅助函数
isEnd()
:返回此迭代器是否指向最后一个键/值对。operator++()
:移至下一个键/值对。注意,形参为空的是前置++。operator*()
:返回该迭代器当前指向的键/值对。operator==()
:返回两个迭代器是否相等operator!=()
:返回两个迭代器是否不相等。
首先明确,迭代器中的元素应当为某个叶子页中的某元素,因此必然需要包含一个叶子页和当前叶子中的索引作为成员变量,以及为了能够在不同叶子页之间做到切换,也自然要把BufferPoolManager考虑进去,在发生切换时要记得Unpin前一个page。
本Task总体来说并不困难,相较于实现基础的B+树来说,就是做了一层封装。
但是实现完index_iterator
中的内容后,不要忘记B+树中也是有对应的实现的(目的就是为了在B+树中使用):
区别在于返回类型为迭代器类型。
Task #4:Concurrent Index
直接加一把大锁不是不可以,但是实验的初衷并非如此,由于项目中已经提供了页的锁,所以本Task的难点在于,何时、何处考虑加锁与解锁!
此处提供一篇详细讲述Task4的文章:
CMU 15445-2022 P2 B+Tree Concurrent Control - 知乎 (zhihu.com)
指导书中点明了一种名为latch crabbing
的技术,基本思想是,遍历B+树的线程会在B+树页面上获取和释放锁(latch)。线程只能在子页面被认为是"安全的"时才能释放父页面上的锁。这里的"安全"的定义可以根据线程执行的具体操作而有所变化。在具体实现上,当一个线程需要访问一个页面时,它会首先获取该页面的锁。然后,线程会向上遍历树,逐级获取每个父页面的锁,直到达到根页面。在获取父页面的锁之前,线程需要判断其子页面是否被认为是"安全的",如果是,则可以释放子页面的锁,并继续向上获取父页面的锁。这种锁的获取和释放过程就像螃蟹在爬行时借助爪子一样,因此称为"latch crabbing"。
而“安全”的定义则是:节点在进行操作后,不会触发分裂或合并,影响父节点的指针。对插入操作,leafpage和internalpage的安全性应该分情况考虑,因为它们分裂的时机不同。对删除操作,节点要比minsize大。
这篇文章中还提到了一种测试多线程偶然出错的方式:
while xxxx/build/test/b_plus_tree_concurrent_test ; do echo 1; done;
指导书中对于加锁进行了简要的描述:
Search
:从根页开始,抓住子页上的读(R)闩锁,然后在到达子页时立即释放父页上的闩锁。由于查询的性质是读,读锁是共享的。Insert
:从根页开始,抓住子页的写(W)锁存器。一旦子页被锁住,检查它是否安全,在这种情况下,不安全。如果子页安全,则释放对祖先的所有锁定。Delete
:从根页开始,抓住子页的写(W)锁存器。一旦子页被锁上,检查它是否安全,在这种情况下,至少是半满的。 (注意:对于根页,我们需要使用不同的标准进行检查)如果子页是安全的,则释放祖先上的所有锁。
有以下几个小点需要注意:
-
有关锁的实现在
src/include/common/rwlatch.h
下。 -
此前几乎没怎么被用到的
transaction
中也提供了遍历树时存储已获取的父节点锁的方法。 -
要在Page上加锁,而非在节点上。
-
需要在Leaf Page无法获取到兄弟页的锁时抛出异常以避免潜在的死锁。
-
保护
root_page_id
在插入与删除时不会被并发更新(提示是使用std::mutex
)
无论是查找、插入还是删除,都需要通过FindLeaf
函数找到对应的叶子页或者是对应的页,加锁的重点就在这个函数中实现
- 如果是查找,
FindLeaf
时,每一次向下遍历都需要给子页加读锁,同时释放父页的读锁即可 - 如果是插入,需要给遍历到的页加写锁,如果最终叶子页是安全的,既可以释放所有祖先的写锁
- 如果是删除,与插入同理
在插入和删除的过程中,需要注意的是何时释放锁。
对于插入而言,有两个需要注意的点:
- 如果造成分裂,此时待整个分裂过程完成前,叶子页和祖先的写锁是都不能被释放的,因为分裂时兄弟页的首索引需要上移至父页,父页也是有可能发生分裂的。
- 当插入操作完成时,需要释放叶子页中的锁以及祖先上的所有锁
而对于移除而言,需要额外多提一个点:
- 由于移除时涉及到借兄弟页元素的操作,因此也要记得给兄弟页加锁并且用完后即使释放。
测试记录
本地错误:b_plus_tree_insert_test 段错误
经测试,问题发生在如下代码段:
std::vector<int64_t> keys = {1, 2, 3, 4, 5};
for (auto key : keys) {
int64_t value = key & 0xFFFFFFFF;
rid.Set(static_cast<int32_t>(key >> 32), value);
index_key.SetFromInteger(key);
tree.Insert(index_key, rid, transaction);
}
当进行到第四次插入时,会发生错误如下:
Signal: SIGSEGV (Segmentation fault)
经定位,问题最终发生在InsertIntoParent
函数的调用上,由于CheckPoint #1无需考虑transaction的问题,而InsertIntoParent
本质上是一个递归的过程,在内部递归的该函数调用上缺少了transaction参量。
虽然这个问题看上去是一次疏忽,但是在排查过程中,也发现了另一个问题,即插入引起分裂后
本地错误:并发测试中的DeleteTest1未通过
当我把测试中的线程数改为1,就可以通过测试,说明问题确实发生在并发性上而非别的原因。
线上错误:
说实在的,没想到本地的测试通过后,线上依旧有这么多bug。唯一通过的测试案例是DeleteTest3
。
看了一下输出的log,问题多在想要得到的值与实际得到的值不匹配,相比此前提交的一次多数超时的情况,已经好了很多了(此问题的起因源于IndexIterator在析构的时候,没有Unpin,主要也是因为我写完Task3之后,没有做insert和delete的本地测试)。
从头到尾检查了一番之后,发现出现了死锁问题:
问题可能发生在页的类型上,改日再进行排查。
编译指令&本地测试:
make b_plus_tree_insert_test -j4
make b_plus_tree_delete_test -j4
make b_plus_tree_contention_test -j4
make b_plus_tree_concurrent_test -j4
./test/b_plus_tree_insert_test
./test/b_plus_tree_delete_test
./test/b_plus_tree_contention_test
./test/b_plus_tree_concurrent_test