Project #2 - B+Tree (下)
本篇中完成 Project 2 的 Task 4,实现 B+Tree 的并发部分,我们要基于 Latch Crabing 策略将之前实现的单线程 B+Tree 变为多线程。
理论部分
课件 Lecture #09: Index Concurrency Control 中详细讲解了并发控制的基本算法和改进算法,还带了很多具体例子方便理解。
多线程操作下 B+Tree Index 有两方面的并发问题:
- 结点内部的数据的安全性,不能让多线程同时修改。
- 结点的安全性,保护结点间分裂合并操作。
所以我们需要锁来保护 B+Tree 的并发安全,并提高并发度。Latch Crabing 是B+Tree 常用的并发策略,意思是锁的释放和获取就像螃蟹一样在节点间爬行。
Latch Crabing 的基本思想:线程在遍历时,先获取 parent 的 latch,再获取 child 的 latch,若 child "safe",则释放 parent 的 latch。
这里 safe
的定义是:节点在进行操作后,不会触发 split 或 merge,从而影响父节点。
- 插入元素时,结点未满;
- 删除元素时,结点超过半满;
- 搜索元素时,所有节点都为 safe node。
但具体来说:对 Insert 操作,leaf page 和 internal page 的安全性应该分情况考虑,因为它们分裂的时机不同;对 Remove 操作,节点 size 要大于 minsize。
- insert
- leaf_page size < maxSize - 1
- internal_page size < maxSize
- delete size > minSize
- leaf_page minSize = maxSize / 2
- internal_page minSize = (maxSize + 1) / 2
基础版并发算法
📍 Search
从 root 开始向下遍历树:
先获取 parent 的 R latch,再获取 child 的 R latch,释放 parent 的 R latch。如此向下递归。
📍 Insert/Delete
从 root 开始向下遍历树:
先获取 parent 的 W latch,再获取 child 的 W latch。如果 child "safe" 则释放所有祖先的 W latch;否则不释放锁。如此向下递归。
持锁 (latch) 规律
📍 Search
仅向下递归,拿到 child_page 的读锁就释放 parent_page 的读锁。这个比较简单。获取 page 的路径从 root 到 leaf 是一条线。到达 leaf 时,仅持有 leaf 的资源。
📍 Insert
先向下递归,可能会持有多个 parent page 的写锁 (记录在 transaction
中)。获取 page 的路径从 root 到 leaf 也是一条线,区别是,到达 leaf 时,还可能持有其祖先的资源。
再向上递归。向上递归的路径与向下递归的完全重合,仅是方向相反。因此,向上递归时不需要重复获取 page 的资源 (包括锁),可以直接从 transaction
里拿到 page 指针,绕过对 buffer pool 的访问。在分裂时,新建的 page 由于还未连接到树中,不可能被其他线程访问到,因此也不需要上锁,仅需 Unpin。

📍 Delete
向下递归的情况与 Insert 相同,路径为一条线。到达 leaf page 后,情况有所不同。
由于可能需要对 sibling 进行 borrow/merge,还需获取 sibling 的 W latch (这里是为了避免其他线程正在对 sibling 进行 Search/Insert/Remove 操作,从而发生 data race)。因此,在向上递归时,主要路径也与向下递归的重合,但除了这条线,还会沿途获取 sibling 的资源,sibling 需要加锁,而 parent page 无需再次加锁。sibling 只是暂时使用,使用完之后可以直接释放。而向下递归路径上的锁在整个 Delete 操作完成之后再释放。

通过持锁规律的讨论,可得出此处 Insert/Remove 释放资源的时机:
向下递归路径上的 page 需要全程持有(除非节点安全,提前释放),在整个操作完成后统一释放。其余 page 要么是重复获取 (从 transaction 中),要么是暂时获取(borrow/merge sibling)。重复获取无需加锁。暂时获取需要加锁,使用完后 unlatch & unpin。
注意点
这里列下相关实现需注意的点。
可以发现,Latch Crabing 是在 FindLeafPage
的过程中进行的,因此需要修改 Checkpoint1 中的 FindLeafPage
流程,根据操作的不同沿途加锁。
📍 根结点保护
这里说的根节点保护不是对根节点那个 Page 的保护,而是对获取哪个 Page 是根节点,也就是 root_page_id_
访问的保护 (这正是 FindLeaf
中搜索的第一步)。
只有判断根节点是安全的情况下,才能解锁。
📍 记录 child 持有的所有写锁
Insert/Remove 操作中,child_page 不安全时,需要持续持有祖先的 W latch,并在出现安全的 child_page 后,释放所有祖先的 W latch。如何记录 child_page 当前持有的所有 latch?这里就要用到在 Checkpoint1 里一直没有提到的一个参数:transaction
,它携带了一个数据库事务的所有信息。
transaction
就是 Bustub 里的事务。在 Project 2 中,可以暂时不用理解事务是什么,而是将其看作当前在对 B+Tree 进行操作的线程。调用 transaction 的 AddIntoPageSet()
方法,可记录当前线程获取的 page latch 到成员变量 page_set_
。在发现一个 safe child_page 后,一次性将 transaction 中记录的 page 释放 latch 并 Unpin。按理来说,释放锁的顺序可以从上到下也可以从下到上,但由于上层节点的竞争一般更加激烈,所以最好是从上到下地释放锁。
在完成整个 Insert/Remove 操作后,释放所有锁。
BTW:调用 transaction
的 AddIntoDeletedPageSet()
可以记录 Remove 操作中要删除的 page 到成员变量 deleted_page_set_
。在完成整个 Remove 操作后,通过 buffer_pool_namager 的 DeletePage()
一次性删除。
📍 对 sibling 加锁
Delete 操作中,当需要 borrow/merge sibling 时,也需要对 sibling 加 W latch,并在完成 borrow/merge 后马上释放。这里是为了避免其他线程正在对 sibling 进行 Search/Insert/Remove 操作,从而发生 data race。这里对 sibling 的加锁就不需要在 transaction 里记录了,只是临时使用,使用完后立即释放 latch 并 Unpin。
📍 修改 child 的 parent_page_id 无需对 child 加锁
internal page borrow/merge 后,修改子结点的的 parent_page_id 要不要加写锁?不需要,会发生死锁。
其实 internal page 会发生 borrow/merge 本质上是 Delete 操作先一路向下到 child (持有child 的写锁),child 删除 kv 后发生 merge 导致父结点要删除被合并 page 位置的 kv,然后再向上递归处理父结点可能发生的下溢出。父结点的下溢出可能会 borrow/merge。因此此时已经持有了 child 的锁,不用再对 child 加锁。
📍 死锁
可以看出,需要持多个锁时,都是从上到下地获取锁,获取锁的方向是相同的。在对 sibling 上锁时,一定持有其 parent page 的锁,因此不可能存在另一个既持有 sibling 锁又持有 parent page 锁的线程来造成循环等待。因此,死锁是不存在的。
但如果把 Index Iterator 也纳入讨论,就有可能产生死锁了。Index Iterator 是从左到右地获取 leaf page 的锁,假如存在一个需要 borrow/merge 的 page 尝试获取其 left sibling 的锁,则一个从左到右,一个从右到左,可能会造成循环等待,也就是死锁。因此在 Index Iterator 无法获取锁时,应放弃获取。
📍 Unlock 与 Unpin 顺序
讲义 Common Pitfalls 提到的一个需要思考的问题,正确操作是先 Unlock
后 Unpin
。
因为 Unpin
后这个 Page 的 pin_count 可能降为 0,buffer_pool_manager 可能会将该 Page 指针的内容改写为另一个 Page 的,导致 Unlock
错误 (如果不清楚可以回看 FetchPgImp 的实现)。
📍 可优化的提前解锁场景
删除的一般场景下,所持有的锁会在删除完成后统一解锁,在发生 borrow 的情况下,若不影响父结点大小,提前释放父结点所有祖先的锁,提高并发。
考虑此场景,删除 5 时,P8/6/4 依次上锁且不释放,删除 5 后,P4 与 P5 要发生重分布,P4 与 P5 的重分布会改变 P6 的数据,但 P6 节点是安全的,可以提前解锁 P6 祖先,但 P6 不能解锁。

📍 总结 解锁 (latch) 时机
FindLeaf
过程中,发现结点 safe,释放所有祖先的锁。- Insert/Remove 完成后,统一释放所有持有的锁。
- 优化:Remove 时结点发生 borrow,导致其和兄弟结点发生数据重分布,但若不影响父节点大小,可提前释放父节点所有祖先的锁。(这个本篇没实现😅)
改进版并发算法
基础的 Latch Crabbing 是悲观锁,Insert, Remove 都要对根节点加写锁,根节点是锁的瓶颈,因为越上层的结点被访问的可能性越大,锁竞争也越激烈,频繁对上层结点上互斥的写锁对性能影响较大,高并发场景下导致糟糕的多线程扩展性。
另一个观察是,大多数的 Insert/Remove 并不会使结点发生 split/borrow/merge (只要节点的 maxSize 不设置得太小),所以实际上获取根结点 (或者说那些较为靠上节点) 的写锁大部分情况是不必要的。
因此提出了对 Latch Crabbing 的一种改进优化:
📍 Search
操作不变
📍 Insert/Delete
先乐观地认为不会发生 split/borrow/merge,对沿途的结点像 Search 操作一样获取和释放 R latch,最终对叶结点上 W latch。
若叶结点 safe,则假设正确,则 Insert/Delete 操作只需叶结点的写锁,可直接完成操作;若叶结点 unsafe,则重新走一遍基础的 Latch Crabbing 算法。
如果 B+Tree 已经有好几层,每一个叶结点可以容纳大量键值对信息,在写操作下不会那么容易发生 split, merge 操作,乐观锁可以显著提升性能。
但是如果树只有几个节点,频繁发生 split, merge,性能就比较差了。
这个优化实现起来比较简单,修改一下 FindLeafPage
即可。
Task #4 - Concurrent Index
此处我们直接实现改进版的并发算法。
回顾上一节的代码,我们已经实现了一个函数 GetLeafPage()
来实现讲义中 FindLeafPage
的功能,负责从根结点搜索到 key 可能存在的叶结点。并发控制的主体代码应该添加到该函数中。
首先定义操作的枚举类型:
enum class Operation { Read, Insert, Remove };
Transaction
理论部分我们提到,会使用参数 transaction
来记录 Insert/Remove 操作中 child 持有的所有写锁。其中,我们需关注 transaction
的两个成员变量:
1️⃣ std::shared_ptr<std::deque<Page *>> page_set_
2️⃣ std::shared_ptr<std::unordered_set<page_id_t>> deleted_page_set_
分别记录 B+Tree 查找过程中访问的 page 集合和要删除的 page 集合。
注意 page_set_
是用双端队列记录的,能维持插入的顺序,因此释放锁的时也能按照从 root 往下的顺序释放。元素之所以是 Page *
,是因为解锁函数在 Page
类 (src/include/storage/page/page.h) 中。而删除集合的顺序就无所谓了,只需记录 page_id
,因为最后是采用 buffer_pool_manager_->DeletePage(page_id)
删除。
根结点保护
理论部分我们提到,要保护对 root_page_id_
的访问,因此在 BPlusTree
类中添加一个成员 ReaderWriterLatch root_latch_
,每次访问前第一步先上这个锁,再进行 Latch Crabbing。
但这个独立定义的锁如何放入 transaction 的 page_set 集合呢?我们可以规定:在 page_set_
中放入一个 nullptr
,表示锁定 root_latch_
,在访问 page_set_
解锁 root_latch_
时只需判断是否为 nullptr
即可。
判断节点 safe
这里直接按照 Latch Crabbing 中对结点 safe 的定义来写即可。
唯一要注意的是 Remove 操作对 root 的判断:若是叶结点则需至少 2 个元素 (size > 1),若是内部结点则需至少 3 个元素 (size > 2)。
否则,若是叶结点,只有一个元素,删除后要变为空树;若是内部结点,只有两个元素,删除后只剩一个元素,要将 child 结点变为新 root,树的高度 - 1.
- search
- all safe
- insert
- leaf_page size < maxSize - 1
- internal_page size < maxSize
- delete size > minSize
- leaf_page minSize = maxSize / 2
- internal_page minSize = (maxSize + 1) / 2
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::IsSafePage(BPlusTreePage *page, Operation op) -> bool {
if (op == Operation::Read) {
return true;
}
// if the page is not full, then it won't split on insertion
if (op == Operation::Insert) {
if (page->IsLeafPage()) {
return page->GetSize() < page->GetMaxSize() - 1;
}
return page->GetSize() < page->GetMaxSize();
}
// if the page is more than half-full, then it won't merge/borrow on deletion
if (op == Operation::Remove) {
if (page->IsRootPage()) {
if (page->IsLeafPage()) {
return page->GetSize() > 1;
}
return page->GetSize() > 2;
}
return page->GetSize() > page->GetMinSize();
}
return false;
}
释放所有祖先写锁
R latch 都是获取下一层后立刻释放上一层,所以不需要用到 transaction
的 page_set_
记录,只有 W latch 才会需要记录多个祖先结点然后一次性释放。
于是可以写一个函数进行所有写锁的释放:
注意锁释放的顺序与加锁顺序相同,都是从 root 往下的顺序。
INDEX_TEMPLATE_ARGUMENTS
void BPLUSTREE_TYPE::ReleaseWLatches(Transaction *transaction) {
if (transaction == nullptr) {
return;
}
auto page_set = transaction->GetPageSet();
while (!page_set->empty()) {
Page *page = page_set->front();
page_set->pop_front();
if (page == nullptr) {
root_latch_.WUnlock();
} else {
page->WUnlatch();
buffer_pool_manager_->UnpinPage(page->GetPageId(), true);
}
}
}
从 transaction 获取 parent_page
理论部分我们提到,Insert/Remove 操作中,向上递归时不需要重复获取 parent_page 的资源 (包括锁),可以直接从 transaction
里拿到 Page*
,绕过对 buffer pool 的访问。节约了 Fetch 和 Unpin 的时间。
于是编写一个函数来实现从 transaction 中 page 的获取:
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::GetPageFromTransaction(page_id_t page_id, Transaction *transaction) -> Page * {
BUSTUB_ASSERT(transaction != nullptr, "Transaction can't be nullptr if you want to get page from transaction.");
auto page_set = transaction->GetPageSet();
// 我们只会使用此函数来获取最近的 parent page,因此需要倒序遍历 page_set
for (auto it = page_set->rbegin(); it != page_set->rend(); it++) {
Page *temp = *it;
if (temp != nullptr && temp->GetPageId() == page_id) {
return temp;
}
}
throw std::logic_error("This page is not exist in transaction.");
return nullptr;
}
修改 GetLeafPage() 函数
若采用基础版 Latch Crabbing 算法,则
GetLeafPage()
逻辑:
- 调用
GetLeafPage()
前,Read/Insert/Remove 操作中都需先判断空树,因此 Read 函数开头对root_latch_
上读锁,Insert/Remove 函数开头对root_latch_
上写锁。- 因为 Read 操作要释放上一个节点的锁,所以添加一个
prev_page
指针,初始化为nullptr
。Read 操作已先给root_latch_
上读锁。GetLeafPage()
每一轮中,先page->RLatch()
,然后判断prev_page
,如果是nullptr
,则将root_latch_
RUnlock;否则,将prev_page
RUnlatch 并 Unpin 掉。- Insert/Remove 操作已先给
root_latch_
上写锁。GetLeafPage()
每一轮中,先page->WLatch()
,然后判断当前 page 是否 safe,若 safe 则释放所有祖先结点写锁。将当前 page 添加到page_set_
。
因为改进算法中,Insert/Remove 操作两轮加锁方式不同,因此添加 bool 参数 first_round
来判断当前进行第几轮加锁,默认值为 true。
- 在调用
GetLeafPage()
前, Read/Insert/Remove 这些函数开头一律对root_latch_
上读锁。 - 第一轮搜索中,因为要释放上一个节点的锁,因此添加
prev_page
指针。从 root 搜索到 leaf,一路按 Crabbing 的方式加读锁、解读锁;到了 leaf,如果是 Read 操作加读锁,如果是 Insert/Remove 操作加写锁并加入page_set_
。 - 判断 leaf 是否 safe。安全则直接返回叶结点;不安全,则将叶结点解锁,调用
GetLeafPage(..., false)
进入第二轮搜索。- Read 操作肯定会判定 leaf 为 safe。
- 若进入第二轮搜索,首先要获取
root_latch_
的写锁;另外能进入第二次说明一定是 Insert/Remove 操作,直接按基础版 Latch Crabbing 算法处理即可(也就是先对 page 上写锁,判断当前 page 是否 safe,若 safe 则释放所有祖先结点写锁。将当前 page 添加到page_set_
)。
❗️ 第一轮中搜索到叶结点,若是 Insert/Remove 操作,一定要先加写锁,再判断是否 safe!不能先判断 safe 再加写锁,unsafe 就进入下一轮。这样会导致报错 address points to the zero page
,因为你不先加写锁的话,其他线程可能会更改 leaf 导致 safe 的状态改变。
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::GetLeafPage(const KeyType &key, Transaction *transaction, Operation op, bool first_round) -> Page * {
if (transaction == nullptr && op != Operation::Read) {
throw std::logic_error("Insert/Remove operation must has a not-null transaction.");
}
if (!first_round) {
root_latch_.WLock();
transaction->AddIntoPageSet(nullptr);
}
page_id_t child_page_id = root_page_id_;
Page *prev_page = nullptr; // 为了 latch 操作,记录上一个 page (即 parent page)。prev_page = nullptr 表示 root。
while (true) {
Page *page = buffer_pool_manager_->FetchPage(child_page_id);
// B+Tree 的 internal pages/leaf pages 是 buffer pool 获取的内存页的 data 部分
auto tree_page = reinterpret_cast<BPlusTreePage *>(page->GetData());
// 优化的 latch crabbing:第一轮中,乐观地假设 Insert/Remove 不会发生分裂或合并,只需要获取 leaf page 的 W latch。
// 若 leaf page safe,就完成了。否则证明假设错误,要再跑一轮基础的 latch crabbing。
if (first_round) {
// 若是 leaf_page, 且进行 Insert/Remove 操作
if (tree_page->IsLeafPage() && op != Operation::Read) {
// leaf_page 加写锁
page->WLatch();
transaction->AddIntoPageSet(page);
} else {
page->RLatch();
}
// 对 prev_page 解锁
if (prev_page == nullptr) {
root_latch_.RUnlock();
} else {
prev_page->RUnlatch();
buffer_pool_manager_->UnpinPage(prev_page->GetPageId(), false);
}
} else {
// 只有 Insert/Remove 会进入第二轮
// 获取 child 的 W latch,检查 child 是否 safe,若 safe,则释放所有祖先上的 latches
BUSTUB_ASSERT(op != Operation::Read, "Only insert/remove operation can enter the second round.");
page->WLatch();
if (IsSafePage(tree_page, op)) {
ReleaseWLatches(transaction);
}
transaction->AddIntoPageSet(page);
}
if (tree_page->IsLeafPage()) {
if (first_round && !IsSafePage(tree_page, op)) {
ReleaseWLatches(transaction); // 释放transaction中唯一的一个leaf page,进入下一轮
return GetLeafPage(key, transaction, op, false);
}
return page;
}
auto internal_page = static_cast<InternalPage *>(tree_page);
// 在内部结点,通过二分查找找到第一个 >= key 的结点,得到它的 pointer(即 child_page_id)
child_page_id = internal_page->Find(key, comparator_);
prev_page = page;
}
}
修改 查/增/删 函数
我们要对 GetValue()
, Insert()
, Remove()
函数进行修改,以适配 B+Tree 的并发。
1️⃣ 对于 GetValue()
, Insert()
, Remove()
, 给 root_latch_
上读锁;再进行 IsEmpty()
判断等操作,若不为空树则调用 GetLeafPage()
。
2️⃣ 对于资源的释放
我们已经通过调用 GetLeafPage()
锁定了并发相关 page,并返回操作所需的 leaf_page。此后我们在对应函数中进行相关功能逻辑处理,但这之后如何释放资源呢?
- 对于
GetValue()
,只有叶结点被上读锁,所以最后要做的清理是对 leaf_page RUnlatch & Unpin。 - 对于
Insert()/Remove()
,要清理 transaction 中page_set_
存储的 page,这我们之前已经写好了ReleaseWLatches()
函数完成这些 page 的 WUnlatch & Unpin,所以要在所有情况的 return 之前调用。
❗️调用 GetLeafPage()
则无需对 root_latch_
解锁。因为 GetLeafPage()
中已经解锁了。
3️⃣ Insert()/Remove()
中,向上递归时 parent_page 可直接从 transaction
里拿到,绕过对 buffer pool 的访问。
4️⃣ 对于 Insert()
,while 循环中以及循环后,都把原有的页 (old_tree_page
) 和新建的分裂页 (new_tree_page
) Unpin 掉。而现在,可能发生分裂的节点 (即那些 old_tree_page
) 都已记录在 transaction 的 page_set_
中,它们会在最后调用 ReleaseWLatches()
时被 Unpin。所以,现在只需 Unpin 所有 new_tree_page
。
5️⃣ 对于 Remove()
,访问兄弟结点进行 borrow/merge 要上写锁,且不用放在 transaction 中,访问完毕后立即 WUnlatch & Unpin。把需删除的 page 记录在 transaction 的 delete_page_set_
中,最后对这个集合中的 page 使用 buffer_pool_manager 的 DeletePage()
清理掉。
下面展示下重要的修改部分:
GetValue()
给 root_latch_
上读锁。
调用完 GetLeafPage()
后对 page RUnlatch & Unpin。
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::GetValue(const KeyType &key, std::vector<ValueType> *result, Transaction *transaction) -> bool {
root_latch_.RLock();
if (IsEmpty()) {
root_latch_.RUnlock();
return false;
}
Page *page = GetLeafPage(key, nullptr, Operation::Read);
auto leaf_page = reinterpret_cast<LeafPage *>(page->GetData());
// 在叶子结点,通过二分查找找到第一个 >= key 的结点
ValueType v;
bool found = leaf_page->Find(key, v, comparator_);
if (found) {
result->emplace_back(v);
}
page->RUnlatch();
buffer_pool_manager_->UnpinPage(page->GetPageId(), false);
return found;
}
Insert()
注意空树的处理,因为改进版中 root_latch_
一开始加的是读锁 (如果这里不采用改进版,而采用基础班 Latch Crabbing,则应该直接加写锁),所以如果判定为空要新建 root 的话需要 “升级” 为写锁。然而 std::shared_mutex
不支持原子的 “升级” 操作,所以只能先解锁再加锁。加上写锁后还要再判定一下是否仍为空树,是的话则建根、解锁、返回,否则应该再 “降级” 为读锁继续后面的操作。
调用完 GetLeafPage()
后,所有情况的 return 之前调用 ReleaseWLatches()
,清理 transaction 中 page_set_
存储的 page。
parent_page 可直接从 transaction
里拿到,绕过对 buffer pool 的访问。
结尾部分,现在只需要 Unpin new_tree_page
,因为 old_tree_page
都在 ReleaseWLatches()
中处理。
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::Insert(const KeyType &key, const ValueType &value, Transaction *transaction) -> bool {
root_latch_.RLock();
/// 1. 若是空树,则创建一个 leaf page 作为 root
if (IsEmpty()) {
root_latch_.RUnlock();
root_latch_.WLock();
if (IsEmpty()) {
Page *page = buffer_pool_manager_->NewPage(&root_page_id_);
UpdateRootPageId(1);
auto leaf_page = reinterpret_cast<LeafPage *>(page->GetData());
leaf_page->Init(root_page_id_, INVALID_PAGE_ID, leaf_max_size_);
leaf_page->SetKeyValueAt(0, key, value);
leaf_page->IncreaseSize(1);
leaf_page->SetNextPageId(INVALID_PAGE_ID);
buffer_pool_manager_->UnpinPage(root_page_id_, true);
root_latch_.WUnlock();
return true;
}
root_latch_.WUnlock();
root_latch_.RLock();
}
/// 2. 不然就找到对应 leaf page,若 key 重复,则 return false
Page *page = GetLeafPage(key, transaction, Operation::Insert);
auto leaf_page = reinterpret_cast<LeafPage *>(page->GetData());
ValueType temp_v;
if (leaf_page->Find(key, temp_v, comparator_)) {
ReleaseWLatches(transaction);
return false;
}
/// 3. 将 kv pair 插入 leaf page, 若插入后 size < leaf_max_size_,则无需分裂(leaf_max_size_ = n)
leaf_page->Insert(key, value, comparator_);
if (leaf_page->GetSize() < leaf_max_size_) {
ReleaseWLatches(transaction);
return true;
}
...
while (true) {
...
page_id_t parent_page_id = old_tree_page->GetParentPageId();
// 因为这里是需要分裂,所以肯定是 not safe 状态,因此 transaction 中肯定存了对应的 parent_page, 不用再从 buffer pool 中获取
auto parent_page = reinterpret_cast<InternalPage *>(GetPageFromTransaction(parent_page_id, transaction)->GetData());
parent_page->Insert(split_key, new_tree_page->GetPageId(), comparator_);
new_tree_page->SetParentPageId(parent_page_id);
// 若 parent 不满,则无需分裂
if (parent_page->GetSize() <= internal_max_size_) {
break;
}
...
buffer_pool_manager_->UnpinPage(new_tree_page->GetPageId(), true);
...
}
ReleaseWLatches(transaction); // 所有 old_tree_page 都会记录在 transaction 中,在最后这里被 Unpin
// unpin 最后一个 new page
buffer_pool_manager_->UnpinPage(new_tree_page->GetPageId(), true);
return true;
}
Remove()
给 root_latch_
上读锁。
最后调用 ReleaseWLatches()
函数完成存储在 transaction page_set_
的 page 的 WUnlatch & Unpin。并将 deleted_page_set_
中的 page delete。
INDEX_TEMPLATE_ARGUMENTS
void BPLUSTREE_TYPE::Remove(const KeyType &key, Transaction *transaction) {
root_latch_.RLock();
/// 1. 若是空树,return。
if (IsEmpty()) {
root_latch_.RUnlock();
return;
}
/// 2. 否则找到 key 所在 leaf page, 从中删除 kv。若删除后没有下溢出或 leaf page 是 root, return。
Page *page = GetLeafPage(key, transaction, Operation::Remove);
auto leaf_page = reinterpret_cast<LeafPage *>(page->GetData());
leaf_page->Remove(key, comparator_);
/// 3. 处理叶结点下溢出
if (leaf_page->GetSize() < leaf_page->GetMinSize()) {
HandleUnderflow(leaf_page, transaction);
}
ReleaseWLatches(transaction);
auto delete_pages = transaction->GetDeletedPageSet();
for (auto &page_id : *delete_pages) {
buffer_pool_manager_->DeletePage(page_id);
}
delete_pages->clear();
}
HandleUnderflow()
parent_page 可直接从 transaction
里拿到,绕过对 buffer pool 的访问。
访问兄弟结点进行 borrow/merge 要上写锁,且不用放在 transaction 中,访问完毕后立即 WUnlatch & Unpin (逻辑放在 ReleaseSiblings()
中)。
在把需删除的 page (如 Merge()
操作后的 right_page
) 记录在 transaction 的 delete_page_set_
中,在外部 Remove()
函数中会 使用 buffer_pool_manager 的 DeletePage()
清理掉该集合中的 page。
INDEX_TEMPLATE_ARGUMENTS
void BPLUSTREE_TYPE::HandleUnderflow(bustub::BPlusTreePage *page, bustub::Transaction *transaction) {
// 下溢出到 root
if (page->IsRootPage()) {
// 若 root 是叶子结点且没有孩子,则树要变为空
if (page->IsLeafPage() && page->GetSize() == 0) {
...
transaction->AddIntoDeletedPageSet(page->GetPageId());
return;
}
// 若 root 不是叶结点且只有一个 child,那树应该减少一层,将唯一的子节点设为新的 root
if (!page->IsLeafPage() && page->GetSize() == 1) {
...
transaction->AddIntoDeletedPageSet(page->GetPageId());
return;
}
return;
}
// 尝试向兄弟结点借一个 kv
// 获取兄弟结点
Page *left_sibling = nullptr;
Page *right_sibling = nullptr;
BPlusTreePage *left_sibling_page = nullptr;
BPlusTreePage *right_sibling_page = nullptr;
auto parent_page = reinterpret_cast<InternalPage *>(GetPageFromTransaction(page->GetParentPageId(), transaction)->GetData());
int index = parent_page->FindValueIndex(page->GetPageId());
if (index == -1) {
throw std::logic_error("This page id is not in parent's value.");
}
if (index != 0) {
page_id_t left_sibling_id = parent_page->ValueAt(index - 1);
left_sibling = buffer_pool_manager_->FetchPage(left_sibling_id);
left_sibling->WLatch();
left_sibling_page = reinterpret_cast<BPlusTreePage *>(left_sibling->GetData());
}
if (index != parent_page->GetSize() - 1) {
page_id_t right_sibling_id = parent_page->ValueAt(index + 1);
right_sibling = buffer_pool_manager_->FetchPage(right_sibling_id);
right_sibling->WLatch();
right_sibling_page = reinterpret_cast<BPlusTreePage *>(right_sibling->GetData());
}
if (left_sibling_page == nullptr && right_sibling_page == nullptr) {
throw std::logic_error("Non-root page " + std::to_string(page->GetPageId()) + " has no sibling.");
}
if (TryBorrow(page, left_sibling_page, parent_page, true) || TryBorrow(page, right_sibling_page, parent_page, false)) {
ReleaseSiblings(left_sibling, right_sibling);
return;
}
// 兄弟结点不够借,合并
if (left_sibling_page != nullptr) {
Merge(left_sibling_page, page, parent_page);
transaction->AddIntoDeletedPageSet(page->GetPageId());
} else {
Merge(page, right_sibling_page, parent_page);
transaction->AddIntoDeletedPageSet(right_sibling_page->GetPageId());
}
...
}
修改迭代器
BPlusTree 类的接口
BPlusTree
类会通过 Begin()
, End()
函数获取迭代器。
1️⃣ Begin()
要先获取子结点的 RLatch,再释放父结点的读锁。返回迭代器时要保证已获取该 page 的读锁。
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::Begin() -> INDEXITERATOR_TYPE {
root_latch_.RLock();
if (IsEmpty()) {
root_latch_.RUnlock();
return End();
}
// 比 GetLeafPage 简单,一路向下找到最左边的 leaf page 即可
page_id_t child_page_id = root_page_id_;
Page *prev_page = nullptr;
while (true) {
Page *page = buffer_pool_manager_->FetchPage(child_page_id);
page->RLatch();
auto tree_page = reinterpret_cast<BPlusTreePage *>(page->GetData());
if (prev_page == nullptr) {
root_latch_.RUnlock();
} else {
prev_page->RUnlatch();
buffer_pool_manager_->UnpinPage(prev_page->GetPageId(), false);
}
if (tree_page->IsLeafPage()) {
return INDEXITERATOR_TYPE(child_page_id, 0, buffer_pool_manager_);
}
auto internal_page = static_cast<InternalPage *>(tree_page);
if (internal_page == nullptr) {
throw std::bad_cast();
}
child_page_id = internal_page->ValueAt(0);
prev_page = page;
}
}
2️⃣ Begin(const KeyType &key)
,返回迭代器时要保证已获取该 page 的读锁。
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::Begin(const KeyType &key) -> INDEXITERATOR_TYPE {
root_latch_.RLock();
if (IsEmpty()) {
root_latch_.RUnlock();
return End();
}
Page *leaf_page = GetLeafPage(key, nullptr, Operation::Read);
int index = reinterpret_cast<LeafPage *>(leaf_page->GetData())->LowerBound(key, comparator_);
return INDEXITERATOR_TYPE(leaf_page->GetPageId(), index, buffer_pool_manager_);
}
3️⃣ End()
函数不用改。
IndexIterator类
迭代器是用于范围搜索的,因此加读锁就可以。我们在迭代器构建前确保获取 Page 的读锁,在析构函数中解锁。
INDEX_TEMPLATE_ARGUMENTS
INDEXITERATOR_TYPE::IndexIterator(page_id_t page_id, int index_in_leaf, BufferPoolManager *bpm)
: page_id_(page_id), index_in_leaf_(index_in_leaf), buffer_pool_manager_(bpm) {
page_ = buffer_pool_manager_->FetchPage(page_id);
leaf_page_ = reinterpret_cast<B_PLUS_TREE_LEAF_PAGE_TYPE *>(page_->GetData());
}
INDEX_TEMPLATE_ARGUMENTS
INDEXITERATOR_TYPE::~IndexIterator() {
if (page_id_ != INVALID_PAGE_ID) {
page_->RUnlatch();
buffer_pool_manager_->UnpinPage(page_id_, true);
}
}
这里要更改对 operator++()
跳页的处理。若跳到下一个 leaf_page,则要解锁当前 page,获取下一个 page 的读锁。
讲义 Common Pitfalls 中提到,不会测试并发的 iterator 操作,这部分不做要求。但如果想实现课件中所述 “立刻获取下一页的锁,否则不等待立刻返回失败”,需要阅读源码:src/include/storage/page/page.h, src/include/common/rwlatch.h
Page
的锁就是一个ReaderWriterLatch
,而ReaderWriterLatch
本质是std::shared_mutex
,只是用RLock()
,WLock()
替换了lock_shared()
,lock()
的名称。而std::shared_mutex
还有一对函数:try_lock_shared()
,try_lock()
,效果正是 “尝试获取锁,立刻返回成功或失败的 bool 值”。所以,只需在ReaderWriterLatch
和Page
上相应添加一对TryRLock()
,TryWLock()
的包装函数即可,然后在operator++()
中调用相应的函数。❗️ 在该项目的 Gradescope 提交代码中不要这么写,因为提交代码并不包含
page.h
和rwlatch.h
,因此在operator++()
中写TryRLatch()
无法被识别!
INDEX_TEMPLATE_ARGUMENTS
auto INDEXITERATOR_TYPE::operator++() -> INDEXITERATOR_TYPE & {
if (page_id_ == INVALID_PAGE_ID) {
return *this;
}
if (index_in_leaf_ < leaf_page_->GetSize() - 1) {
index_in_leaf_++;
} else {
index_in_leaf_ = 0;
int prev_page_id = page_id_;
page_id_ = leaf_page_->GetNextPageId();
if (page_id_ != INVALID_PAGE_ID) {
auto new_page = buffer_pool_manager_->FetchPage(page_id_);
new_page->RLatch();
page_->RUnlatch();
page_ = new_page;
leaf_page_ = reinterpret_cast<B_PLUS_TREE_LEAF_PAGE_TYPE *>(page_->GetData());
} else {
page_->RUnlatch();
page_ = nullptr;
leaf_page_ = nullptr;
}
buffer_pool_manager_->UnpinPage(prev_page_id, false);
}
return *this;
}
测试
完成本篇后,就能通过所有本地测试,其中并发控制的测试是 b_plus_tree_concurrent_test
和 b_plus_tree_contention_test
。后者会评估 B+ 树使用全局锁和你的并发控制实现的耗时比(所以如果你的 B+ 树也只用了一把大锁,这个比值应该接近 1)。正确的实现应该在 这个区间内。
也能通过 Gradescope 上 Project #2 - B+ Tree Index Checkpoint 2 部分的测试了。


可以看到耗时比是 3.8 左右,已经高于实验讲义给的 3.5,说明并发控制的优化很有效。但 GradeScope 的运行时间与其他大佬相比还是差了点,大概率是因为本身的实现效率不够高,尤其是 Project 1 Extendible HashTable 和 BufferPoolManager 都是直接一把大锁摆烂实现的,这个结果也比较正常。
若想继续优化,可采用 gprof 或 perf 进行程序运行耗时统计,看那个部分耗时比较高,对它进行进一步优化。
记录一次 perf 性能调优 CMU 15-445
参考链接
理论参考:
笔记1 Lecture #09: Index Concurrency Control
笔记2 Lecture #09: Index Concurrency Control
课件 Lecture #09: Index Concurrency Control
实验参考:
【CMU15-445数据库】bustub Project #2:B+ Tree(下)
本文作者:Joey-Wang
本文链接:https://www.cnblogs.com/joey-wang/p/17299785.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库