曾经沧海难为水,除却巫山不是云。|

Joey-Wang

园龄:4年2个月粉丝:17关注:0

2023-04-09 08:00阅读: 525评论: 0推荐: 1

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。如此向下递归。

image-20230409035526893

📍 Insert/Delete

从 root 开始向下遍历树:

先获取 parent 的 W latch,再获取 child 的 W latch。如果 child "safe" 则释放所有祖先的 W latch;否则不释放锁。如此向下递归。

image-20230409035640031

持锁 (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。

image-20230409035726893

📍 Delete

向下递归的情况与 Insert 相同,路径为一条线。到达 leaf page 后,情况有所不同。

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

image-20230409040047638

通过持锁规律的讨论,可得出此处 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:调用 transactionAddIntoDeletedPageSet() 可以记录 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 提到的一个需要思考的问题,正确操作是先 UnlockUnpin

因为 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 不能解锁。

image-20230409014638739

📍 总结 解锁 (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 都是获取下一层后立刻释放上一层,所以不需要用到 transactionpage_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() 逻辑:

  1. 调用 GetLeafPage() 前,Read/Insert/Remove 操作中都需先判断空树,因此 Read 函数开头对 root_latch_ 上读锁,Insert/Remove 函数开头对 root_latch_ 上写锁。
  2. 因为 Read 操作要释放上一个节点的锁,所以添加一个 prev_page 指针,初始化为 nullptr。Read 操作已先给 root_latch_ 上读锁。 GetLeafPage() 每一轮中,先 page->RLatch(),然后判断 prev_page,如果是 nullptr,则将 root_latch_ RUnlock;否则,将 prev_page RUnlatch 并 Unpin 掉。
  3. Insert/Remove 操作已先给 root_latch_ 上写锁。 GetLeafPage() 每一轮中,先 page->WLatch(),然后判断当前 page 是否 safe,若 safe 则释放所有祖先结点写锁。将当前 page 添加到 page_set_

因为改进算法中,Insert/Remove 操作两轮加锁方式不同,因此添加 bool 参数 first_round 来判断当前进行第几轮加锁,默认值为 true。

  1. 在调用 GetLeafPage() 前, Read/Insert/Remove 这些函数开头一律对 root_latch_ 上读锁。
  2. 第一轮搜索中,因为要释放上一个节点的锁,因此添加 prev_page 指针。从 root 搜索到 leaf,一路按 Crabbing 的方式加读锁、解读锁;到了 leaf,如果是 Read 操作加读锁,如果是 Insert/Remove 操作加写锁并加入 page_set_
  3. 判断 leaf 是否 safe。安全则直接返回叶结点;不安全,则将叶结点解锁,调用 GetLeafPage(..., false) 进入第二轮搜索。
    • Read 操作肯定会判定 leaf 为 safe。
  4. 若进入第二轮搜索,首先要获取 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 值”。所以,只需在 ReaderWriterLatchPage 上相应添加一对 TryRLock(), TryWLock() 的包装函数即可,然后在 operator++() 中调用相应的函数。

❗️ 在该项目的 Gradescope 提交代码中不要这么写,因为提交代码并不包含 page.hrwlatch.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_testb_plus_tree_contention_test。后者会评估 B+ 树使用全局锁和你的并发控制实现的耗时比(所以如果你的 B+ 树也只用了一把大锁,这个比值应该接近 1)。正确的实现应该在 [2.5,3.5] 这个区间内。

也能通过 Gradescope 上 Project #2 - B+ Tree Index Checkpoint 2 部分的测试了。

image-20230409074756341 image-20230409075358893

可以看到耗时比是 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(下)

【十一】做个数据库:2022 CMU15-445 Project2 B+Tree Index

【xiao】CMU 15445-2022 P2 B+Tree Concurrent Control

本文作者:Joey-Wang

本文链接:https://www.cnblogs.com/joey-wang/p/17299785.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   Joey-Wang  阅读(525)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 【.NET】调用本地 Deepseek 模型
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开