CMU 15-445 2022 Project2 B Plus Tree

前言

因为2021版没办法进行线上测试了,受不了,转战2022版。

对比2021年版的使用Extendible hashing完成索引到RID的映射,B+树则是更为经典的方法。

在之前,没具体学习过B+树的相关知识,所以这个实验做的十分波折,幸运的是最后坚持了下来。
image.png
哈哈,速度勉勉强强,和大佬没法比。没办法,前面的实验省了懒,都是一把大锁走天下。

什么是B+树?

这里引用知乎大佬的B+树基本知识总结

B+ 树是一种树数据结构,是一个n叉树,每个节点通常有多个孩子,一颗B+树包含根节点、内部节点和叶子节点。B+ 树通常用于数据库和操作系统的文件系统中。 B+ 树的特点是能够保持数据稳定有序,其插入与修改拥有较稳定的对数时间复杂度。 B+ 树元素自底向上插入。
一个m阶的B树具有如下几个特征:
1.根结点至少有两个子女。
2.每个中间节点都至少包含ceil(m / 2)个孩子,最多有m个孩子。
3.每一个叶子节点都包含k-1个元素,其中 m/2 <= k <= m。
4.所有的叶子结点都位于同一层。
5.每个节点中的元素从小到大排列,节点当中k-1个元素正好是k个孩子包含的元素的值域分划。

1.dot.png

上图就是一个非常简单的B+树,它的内部节点的最大容量是3,叶子节点的最大容量是2。
最底层的叶子节点其实就是一个递增的列表,查找时,从顶层的根节点往下遍历。

叶子节点和中间节点的Key没有区别,都是hash函数得到的KeyType。但是叶子节点存储的Value是RID,中间节点的Value则是page_id(用于找到下一层的节点,因为里面的每一个节点都可以看作为一个Page)

后面开始看实验的具体任务。

CHECKPOINT #1

B+Tree Pages

需要实现三个Page类来存储B+树的数据。

  • B+Tree Parent Page
  • B+Tree Internal Page
  • B+Tree Leaf Page

B+Tree Parent Page

这是Internal Page和Leaf Page都继承自的父类。父页面仅包含两个子类共享的信息。父页面分为几个字段,如下表所示:
image.png

它里面的实现都非常简单,唯一值得注意的是 GetMinSize 函数,因为上面说过叶子节点和中间节点的 MinSize 是有点区别的。

auto BPlusTreePage::GetMinSize() const -> int {
  if (IsLeafPage()) {
    return max_size_ >> 1;
  }
  return (max_size_ + 1) >> 1;
}

B+Tree Leaf Page

Leaf Page存储有序的m个键条目和m个值条目。在实现中,值应该仅为64位record_id,用于定位实际元组的存储位置,具体参阅 src/include/common/RID.h 中在下定义的RID类。叶页对键/值对的数量的限制与内部页相同,并且应该遵循相同的合并、重分发和拆分操作。

每个B+树叶/内部页面对应于缓冲池获取的内存页面的内容(数据部分 data_)。因此,每次尝试读取或写入叶/内部页面时,都需要首先使用其唯一的page_id从缓冲池中获取页面,然后将强制转换重新解释为叶子或内部页面,并在执行任何写入或读取操作后Unpin页面。

给我们的代码里面只有一些最基础的功能,我们需要自己多加完善,下面是我加的几个对后面操作有帮助的方法。

INDEX_TEMPLATE_ARGUMENTS
auto B_PLUS_TREE_LEAF_PAGE_TYPE::Insert(const KeyType &key, const ValueType &value, const KeyComparator &comparator)
    -> int {
  int idx = LowerBound(key, comparator);
  if (idx == GetSize()) {
    *(array_ + idx) = {key, value};
    IncreaseSize(1);
    return GetSize();
  }

  if (idx == -1) {
    return GetSize();
  }

  std::move_backward(array_ + idx, array_ + GetSize(), array_ + GetSize() + 1);
  *(array_ + idx) = {key, value};

  IncreaseSize(1);
  return GetSize();
}

用于插入一个KV。

INDEX_TEMPLATE_ARGUMENTS
auto B_PLUS_TREE_LEAF_PAGE_TYPE::Remove(const KeyType &key, const KeyComparator &comparator) -> int {
  if (GetSize() == 0) {
    return GetSize();
  }
  int idx = KeyIndex(key, comparator);
  if (idx == -1) {
    return GetSize();
  }

  if (idx == GetSize() - 1) {
    IncreaseSize(-1);
    return GetSize();
  }

  std::copy(array_ + idx + 1, array_ + GetSize(), array_ + idx);
  IncreaseSize(-1);
  return GetSize();
}

用于删除Key对应的KV

INDEX_TEMPLATE_ARGUMENTS
auto B_PLUS_TREE_LEAF_PAGE_TYPE::LowerBound(const KeyType &key, const KeyComparator &comparator) const -> int {
  auto target = std::lower_bound(array_, array_ + GetSize(), key,
                                 [&comparator](const auto &pair, auto k) { return comparator(pair.first, k) < 0; });
  int idx = std::distance(array_, target);
  if (idx != GetSize() && comparator(array_[idx].first, key) == 0) {
    return -1;
  }
  return idx;
}

返回第一个比key大的槽位,相同返回-1

INDEX_TEMPLATE_ARGUMENTS
auto B_PLUS_TREE_LEAF_PAGE_TYPE::KeyIndex(const KeyType &key, const KeyComparator &comparator) const -> int {
  auto target = std::lower_bound(array_, array_ + GetSize(), key,
                                 [&comparator](const auto &pair, auto k) { return comparator(pair.first, k) < 0; });
  int dis = std::distance(array_, target);
  if (dis == GetSize() || comparator(array_[dis].first, key) != 0) {
    return -1;
  }
  return dis;
}

返回key所在槽位,无则返回-1

还有一些其他的工具方法,就不一一展示了,每个人的实现不同,所以会有不同的工具方法。

B+Tree Internal Page

内部页面不存储任何实际数据,而是存储有序的m个密钥条目和m+1个子指针(也称为Page_id)。由于指针的数量不等于键的数量,因此第一个键被设置为无效,查找方法应始终从第二个键开始。在任何时候,每个内部页面都至少有一半是满的。在删除过程中,可以将两个半完整的页面合并为合法页面,也可以重新分发以避免合并,而在插入过程中,一个完整的页面可以一分为二。这是在实现B+树时将要进行的众多设计选择之一的示例。

因为有这些不同,虽然需要实现的方法和叶子节点大差不差,但细节很多需要注意。如:

INDEX_TEMPLATE_ARGUMENTS
auto B_PLUS_TREE_INTERNAL_PAGE_TYPE::KeyIndex(const KeyType &key, const KeyComparator &comparator) const -> int {
  auto target = std::lower_bound(array_ + 1, array_ + GetSize(), key,
                                 [&comparator](const auto &pair, auto k) { return comparator(pair.first, k) < 0; });
  int dis = std::distance(array_, target);
  if (dis == GetSize() || comparator(array_[dis].first, key) != 0) {
    dis--;
  }
  return dis;
}

第一个Key没有意义,所以我们要从下标1开始找。

INDEX_TEMPLATE_ARGUMENTS
auto B_PLUS_TREE_INTERNAL_PAGE_TYPE::LowerBound(const KeyType &key, const KeyComparator &comparator) const -> int {
  auto target = std::lower_bound(array_ + 1, array_ + GetSize(), key,
                                 [&comparator](const auto &pair, auto k) { return comparator(pair.first, k) < 0; });
  int dis = std::distance(array_, target);
  if (dis != GetSize() && comparator(array_[dis].first, key) == 0) {
    return -1;
  }
  return dis;
}

找第一个大于Key的槽也是一样。

B+Tree Data Structure

B+树索引应该只支持唯一的键。也就是说,当您试图将具有重复键的键值对插入到索引中时,它不应该执行插入并返回false。如果删除导致某些页面低于占用阈值,您的B+树索引也必须正确执行合并或重新分发(在教科书中称为“合并”)。

对于检查点#1,仅需要B+树索引来支持插入(insert()),点搜索(getValue())和删除(delete(delete())。如果插入触发分裂条件(插入后的键/值对数量等于叶子节点的max_size,插入之前的儿童数量等于内部节点的max_size)。

CHECKPOINT #2

Index Iterator

除了上面提到的我们需要实现 Index Iterator 用于从小到大遍历所有的叶子节点里面的KV。

构建一个通用的索引迭代器来高效地检索所有的叶页。基本思想是将它们组织成一个单独的链表,然后在特定方向上遍历存储在B+树叶页中的每个键/值对。索引迭代器应该遵循C++17中定义的迭代器的功能,包括使用一组运算符迭代一系列元素的能力,以及每个循环(至少使用增量、解引用、相等和不相等运算符)的能力。

这一部分的实现较为简单,只要确保 == 的重载能正常运行就好(用于迭代的时候与end比较然后终止)。还有就是 ++ 运算符,当此时KV是页尾的时候,需要将自身指向下一页的开头。

同时,我们也要考虑多线程的情况。这里使用的是页级锁,它已经在 Page 中实现好了,可以直接调用。

GetValue

这个函数是 BPlusTree 中用于查询给出了 public 方法。

为了避免公开代码,这里(包括后面所有的代码)省略很多的实现细节(函数参数,锁和解锁)

INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::GetValue(const KeyType &key, std::vector<ValueType> *result, Transaction *transaction) -> bool {
  if (IsEmpty()) {
    return false;
  }

  Page *page = FindLeaf(key, Operation::Find);

  if (page != nullptr) {
    LeafPage *node_leaf = Cast2LeafPage(page);
    int idx = node_leaf->KeyIndex(key, comparator_);
    if (comparator_(key, node_leaf->KeyAt(idx)) == 0) {
      result->push_back(node_leaf->ValueAt(idx));
    }
    buffer_pool_manager_->UnpinPage(page->GetPageId(), false);
  }
  return !result->empty();
}

里面的核心是 FindLeaf,用于锁定Key值所在的叶子节点。

INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::FindLeaf(const KeyType &key, const Operation &ope) -> Page * {
  Page *page = buffer_pool_manager_->FetchPage(root_page_id_);
  BPlusTreePage *node = Cast2BPlusTreePage(page);

  if (ope == Operation::Find) {
    while (!node->IsLeafPage()) {
      InternalPage *node_inter = Cast2InternalPage(node);
      int idx = node_inter->KeyIndex(key, comparator_);

      Page *next_page = buffer_pool_manager_->FetchPage(node_inter->ValueAt(idx));
      BPlusTreePage *next_node = Cast2BPlusTreePage(next_page);

      buffer_pool_manager_->UnpinPage(page->GetPageId(), false);

      page = next_page;
      node = next_node;
    }
    return page;
  }
}

正如开头所介绍的那样,我们可以自上而下迅速地找到目标节点。
虽然都是查找,但是由于并发锁的设计,查找、插入和移除所对应的 FindLeaf 里面的逻辑也是不同的。

Insert

插入有两种情况:

  • 插入KV后不会引发分裂(安全)
  • 插入KV后会引发分裂(不安全)

首先我们先对叶子节点直接插入:

INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::InsertLeaf(const KeyType &key, const ValueType &value, Transaction *transaction) -> bool {
  // 插入时可能为空
  if (root_page_id_ == INVALID_PAGE_ID) {
    Page *root = buffer_pool_manager_->NewPage(&root_page_id_);
    if (root == nullptr) {
      throw Exception(ExceptionType::OUT_OF_MEMORY, "Cannot allocate new page");
    }
    auto *root_as_leaf = reinterpret_cast<LeafPage *>(root->GetData());
    root_as_leaf->Init(root_page_id_, INVALID_PAGE_ID, leaf_max_size_);
    root_as_leaf->Insert(key, value, comparator_);
    left_page_ = root_page_id_;

    buffer_pool_manager_->UnpinPage(root_page_id_, true);
    UpdateRootPageId(1);
    return true;
  }

  Page *page = FindLeaf(key, Operation::Insert);
  LeafPage *page_as_leaf = Cast2LeafPage(page);
  int old_size = page_as_leaf->GetSize();

  if (IsSafe(page_as_leaf, Operation::Insert)) {
    int new_size = page_as_leaf->Insert(key, value, comparator_);
    buffer_pool_manager_->UnpinPage(page->GetPageId(), true);
    // 插入失败说明有重复值
    return new_size > old_size;
  }

  int new_size = page_as_leaf->Insert(key, value, comparator_);
  if (new_size == old_size) {
    buffer_pool_manager_->UnpinPage(page->GetPageId(), true);
    return false;
  }

  // 插入后达到了MaxSize
  // 新建一个节点,分MaxSize - MinSize的数据到里面
  // 如果当前叶子节点是根节点,则直接新建一个根节点,然新旧节点指向新的根节点
  // 否则转到 InsertInter 里面
  return true;
}

当产生分裂节点,就需要将这个节点的KV存到父节点中去。如果父节点在存入前已经达到了MaxSize,则应该先分裂在存放。依此迭代。

INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::InsertInter(page_id_t page_parent_id, const KeyType &key, page_id_t page_id, Transaction *transaction) -> void {
  Page *page = buffer_pool_manager_->FetchPage(page_parent_id);
  InternalPage *page_as_inter = Cast2InternalPage(page);

	// 安全直接插入,返回
  if (IsSafe(page_as_inter, Operation::Insert)) {
    page_as_inter->Insert(key, page_id, comparator_);
    buffer_pool_manager_->UnpinPage(page_parent_id, true);
    return;
  }

	// 新建分裂的节点
  page_id_t page_id_new = INVALID_PAGE_ID;
  Page *page_new = buffer_pool_manager_->NewPage(&page_id_new);
  InternalPage *page_as_inter_new = Cast2InternalPage(page_new);

  for (int i = 0; i < page_as_inter_new->GetSize(); i++) {
    // 将分出去的KV对应的子节点重新设置父节点为分裂节点
  }

  if (page_as_inter->IsRootPage()) {
    // 当前如果是根节点,则旧节点和分裂节点需要将父节点设置为新建的根节点
  } else {
    // 不是根节点那只需要更改新节点的父节点,将分裂节点的KV放入当前节点的父节点
    InsertInter(page_parent_id, key0, page_id_new);
  }
}

Remove

和Insert逻辑是类似的先直接在叶子节点移除,如果安全直接返回,不安全再向上迭代删除,直至安全为止。

INDEX_TEMPLATE_ARGUMENTS
void BPLUSTREE_TYPE::RemoveLeaf(const KeyType &key, Transaction *transaction) {
  if (IsEmpty()) {
    return;
  }

  Page *page = FindLeaf(key, Operation::Remove);
  BPlusTreePage *node = Cast2BPlusTreePage(page);
  LeafPage *node_leaf = Cast2LeafPage(node);

  if (IsSafe(node_leaf, Operation::Remove)) {
    node_leaf->Remove(key, comparator_);
    return;
  }

  node_leaf->Remove(key, comparator_);
	// 不安全向上迭代删除
  RemoveParent(page, root_locked, transaction);
}
INDEX_TEMPLATE_ARGUMENTS
auto BPLUSTREE_TYPE::RemoveParent(Page *page) -> bool {
  BPlusTreePage *node = Cast2BPlusTreePage(page);

  if (node->IsRootPage()) {
    if (node->IsLeafPage()) {
	    // 如果是根节点,且此时大小为0,则将整个B+树设空
      if (node->GetSize() == 0) {
        root_page_id_ = INVALID_PAGE_ID;
        UpdateRootPageId();
      }
    } else {
	    // 还有一个特殊情况,根节点大小为1,此时需要将其唯一的子节点设为根节点
      InternalPage *old_root = Cast2InternalPage(node);
      if (old_root->GetSize() == 1) {
		    ...
      }
    }
    return need_flush;
  }

	// 安全状态旧直接返回
  if (node->GetSize() >= node->GetMinSize()) {
    return true;
  }

	// 不安全:size < minsize
	// 获取父节点
  Page *parent_page = buffer_pool_manager_->FetchPage(node->GetParentPageId());
  InternalPage *parent_node = Cast2InternalPage(parent_page);
  assert(parent_node->GetSize() >= 2);

  int idx = parent_node->FindIndex(page->GetPageId());
  if (idx == 0) {
    // idx == 0 必有右兄弟节点
    BPlusTreePage *right_node = Cast2BPlusTreePage(right_page);

    if (right_node->GetSize() > right_node->GetMinSize()) {
	    // 向右借
      BorrowFromRight(node, right_node, idx + 1);
    } else {
	    // 把右边合并,删除右节点
      Merge(node, right_node, idx);
    }
  } else {
    BPlusTreePage *left_node = Cast2BPlusTreePage(left_page);

    if (left_node->GetSize() > left_node->GetMinSize()) {
      // 向左借
      BorrowFromLeft(node, left_node, idx - 1, transaction);
    } else {
	    // 将自身合并到左节点,删除自己
      Merge(left_node, node, idx - 1, root_locked, 
  }
}

注意,只有合并才会引起节点删除,也意味着在合并完后需要迭代进入 RemoveParent,继续进行父节点的调整。

并发

并发是难度最大的点。
任务要求以 latch crabbing 方案设计闩锁。闩保护的是数据结构,隔离的是线程;锁保护的是内容,隔离的是事务。

如果其子页面被视为“安全”,线程在父页面上释放 latch 。请注意,“安全”的定义可能会根据线程执行的哪种操作而有所不同。

可是怎样实现节点的删除,才能不让删除与加锁解锁相冲突。

latch crabbing

Latch Crabing 的基本思想:线程在遍历时,先获取 parent 的 latch,再获取 child 的 latch,若 child "safe",则释放 parent 的 latch。

在查找时,很容易设计:对当前页面设置读锁;获取子节点;对子节点加锁,当前节点解锁;将子节点设为当前节点。一直往下,直到当前节点是叶子节点,读取数据后,释放读锁。

插入和移除则可能会在操作的时候锁住很多节点,此时我们需要一个队列来存放锁住的节点(页面),然后在安全或者完成操作时依次释放。(使用队列的原因是便于更早地释放高层节点,降低线程冲突)

大致思路是:从根节点开始,使用写锁锁住当前节点,获取子节点;写锁锁住子节点,将当前节点放入队列;判断子节点是否安全,安全则释放队列被锁住的节点,否则不对队列中的节点进行操作。

对于插入和删除有不同的安全标准,插入中,叶子节点和中间节点也有不同的安全标准。

其他注意:删除时,借和合并需要对邻居节点进行上锁,防止其他线程或操作对邻居节点造成影响,操作完成后即可释放;删除时,向上迭代的过程中如果遇到对父节点大小没有影响的操作(对左右节点的借操作),则可以将父节点的祖宗节点提前全部释放(队列中)。

根节点保护

所有的操作都需要从根节点开始向下寻找,为了防止使用同时根节点造成冲突,实验建议单独设置一个 std::mutex 来保护根节点。每个操作开始的时候就需要获取这个锁,后续在释放根节点的锁(读锁或解锁)前都不能对其解锁。

持有锁节点的队列

实验中每个函数都有一个事务相关的参数:Transaction *transaction。如果进入这个类的定义可以发现里面就有内置的队列,我们只需要调用 GetPageSet()AddIntoPageSet() 就可以实现锁住页面(节点)存取。

删除的时机

如果细心一点就会发现,在一次删除操作中,可能有很多被删除的节点,其中一些对应就是被锁住的节点,因为锁的缘故,我们只能在最后全部将锁释放了才能安全地删除他们。所以在操作过程需要有数据结构记录这些需要删除的节点。幸运的是 Transaction *transaction 里面也有这样的内置容器:unordered_set。具体操作起来非常简单。

并发测试

image.png

实验给出的较优拥塞比是[2.5, 3.5],不知道是不是有细节没控制好,测试一拥塞率有点高,测试二还可以(测试二的叶子节点最大容量设置的更高)。

其他需要注意的点就自己思考去吧,比如乐观锁的的设计、UnLock和UnPin的先后、Index Iterator的优化、降低缓冲池的访问技巧等等。

继续加油,骚年。

posted @ 2023-08-17 20:41  Zkun6  阅读(91)  评论(0)    收藏  举报