CMU15-445:Project #2 - B+Tree

Project #2 - B+Tree


本文是对CMU15-445课程第二个项目的一个粗略总结和翻译。仅供个人(M1kanN)复习使用。


Overview

  • 第二个项目是实现一个在你的数据库系统中的索引。这个索引的目的是快速获取数据,而不需要搜索数据库表中的每一行,为快速随机查找和有效访问有序记录提供基础。

  • 实现的数据结构是B+树动态索引结构。它是一个平衡的树,其内部页指导搜索,叶子页面包含实际的数据条目。由于树形结构是动态增长和收缩的,你需要处理合并和分裂的逻辑。项目由以下任务组成,有两个检查点。

  • 在开始这个项目之前,确保已经是最新的代码:

    git pull public master
    

Checkpoint #1

  • Task #1 - B+Tree Pages
  • Task #2 - B+Tree Data Structure (Insertion, Deletion, Point Search)

Checkpoint #2

  • Task #3 - Index Iterator
  • Task #4 - Concurrent Index

Project Specification

  • 与之前项目一样,也提供了包含API的stub classes。只需要实现这些就行了。不要修改函数签名!也不要移除任何成员变量。但是可以增加你的成员变量和成员函数。
  • B+树的正确实现,依赖于第一个项目Buffer Pool的正确实现。由于第一个检查点与第二个检查点密切相关,其中你将在现有的B+索引中实现索引抓取(index crabbing)。我们传入了一个名为transaction的指针参数,默认值为nullptr。你可以安全地忽略第一检查点的参数;你不需要改变或调用任何与该参数有关的函数,直到Task #4。

CheckPoint #1

Task #1 - B+Tree Pages

B+Tree Parent Page

  • 这是内部页和叶子页都继承自的父类。父页只包含两个子类共享的信息。父页被分为几个字段,如下表所示。

    B+Tree Parent Page Content

    Variable Name Size Description
    page_type_ 4 Page Type (internal or leaf)
    lsn_ 4 Log sequence number (Used in Project 4)
    size_ 4 Number of Key & Value pairs in page
    max_size_ 4 Max number of Key & Value pairs in page
    parent_page_id_ 4 Parent Page Id
    page_id_ 4 Self Page Id

    必须在指定文件中实现你的父页。只需修改头文件:src/include/storage/page/b_plus_tree_page.h
    与其对应的源文件:
    src/storage/page/b_plus_tree_page.cpp

B+Tree Internal Page

  • 一个内部页不存储任何真实数据,而是存储一个有序的m个键项和m+1个子指针(又称page_id)。由于指针的数量不等于键的数量,第一个键被设置为无效,查找方法应该总是从第二个键开始。在任何时候,每个内部页面至少有一半是满的在删除过程中,两个半满的页面可以连接成一个合法的页面,或者可以重新分配以避免合并,而在插入过程中,一个满的页面可以被分成两个。这是一个例子,是你在实现B+树的过程中要做的许多设计选择之一。
  • 需要实现的文件reinterpret cast:
    src/include/storage/page/b_plus_tree_internal_page.h
    src/storage/page/b_plus_tree_internal_page.cpp

B+Tree Leaf Page

  • 叶子页存储一个有序的m个key条目和m个value条目。在实现中,值只应该是64位的record_id,用来定位实际tuple的存储位置,见src/include/common/rid.h 中定义的RID类。叶子页在键/值对的数量上有与内部页相同的限制,并且应该遵循相同的合并、重新分配和分割操作。
  • 实现文件:
    src/include/storage/page/b_plus_tree_leaf_page.h
    src/storage/page/b_plus_tree_leaf_page.cpp

IMPORTANT:
即使叶子页和内部页包含相同类型的键,它们可能有不同类型的值,因此叶子页和内部页的最大尺寸可能不同。

  • 每个B+Tree的叶子/内部页都对应于由缓冲池获取的内存页的内容(即,数据_部分)。因此,每当你试图读或写一个叶子/内部页时,你需要首先使用其唯一的page_id从缓冲池中获取该页,然后重新解释(reinterpret cast)为一个叶子或一个内部页,并在任何写或读的操作后取消(unpin)该页。(表示该线程不再使用)

Task #2 - B+Tree Data Structure

  • 实现的B+Tree索引应该只支持唯一的键。也就是说,当你试图在索引中插入一个键值重复的键值对时,它不应该执行插入并返回错误。如果删除导致某些页面低于占用阈值,你的B+Tree索引也必须正确执行合并或重新分配(在教科书中称为 "凝聚" coalescing)。

  • 对于检查点#1,你的B+Tree索引只需要支持插入(Insert())、点搜索(GetValue())和删除(Delete())。如果插入触发了分割条件(插入后的键/值对数量等于叶子节点的最大尺寸,插入前的子节点数量等于内部节点的最大尺寸),你应该正确地执行分割。由于任何写操作都可能导致B+Tree索引中root_page_id的改变,你有责任更新头页(src/include/storage/page/header_page.h)中root_page_id。以确保索引在磁盘中的持续性。在BPlusTree类中,我们已经为你实现了一个名为UpdateRootPageId的函数;你所需要做的就是在B+Tree索引的root_page_id发生变化时调用这个函数。

  • B+Tree实现必须隐藏键/值类型和相关比较器的细节,像这样:

    template <typename KeyType,
              typename ValueType,
              typename KeyComparator>
    class BPlusTree{
       // ---
    };
    

    这些类已经为我们实现了:

    • KeyType:
      索引中每个键的类型。这只会是GenericKeyGenericKey的实际大小是通过模板参数指定和实例化的,并取决于索引属性的数据类型。
    • ValueType:
      索引中每个值的类型。只会是64位的RID。
    • KeyComparator:
      用来比较两个KeyTpye的实例是否大于/小于对方的类。这些被包含在KeyType的实现文件中。

CheckPoint #2

Task #3 - Index Iterator

  • 我们将建立一个通用的索引迭代器,来有效检索所有的叶子页面。基本的想法是将它们组织成一个单一的链表,然后按照特定的方向遍历存储在B+Tree叶子页中的每个键/值对。你的索引迭代器应该遵循C++17中定义的迭代器的功能,包括使用一组操作符和for-each循环(至少有增量、减量、等量和不等量操作符)来迭代一系列元素的能力。注意,为了支持你的索引的for-each循环功能,你的BPlusTree应该正确实现begin()end()
  • 我们必须在指定的文件中实现索引迭代器。只允许修改:
    src/include/storage/index/index_iterator.h
    src/index/storage/index_iterator.cpp
    我们必须在这些文件中找到IndexIterator类中实现以下函数。在索引迭代器中的实现中,只要有下面这三个方法,就可以添加任何辅助方法:
    • isEnd()
      返回该迭代器是否指向最后一个键值对。
    • operator++():
      移动到下一个键值对。
    • operator*():
      返回当前迭代器所指的键值对。
    • operator==():
      然后是否两个迭代器相等。
    • operator!=():
      返回是否两个迭代器不等。

Task #4 - Concurrent Index

  • 这个任务需要更新原来的单线程B+树索引,使其能够支持并发操作。我们将使用课堂上和课本上所描述的锁存器抓取技术(latch crabbing technique)。遍历索引的线程将获得、释放B+Tree页面的锁存器。如果一个线程上的子页被认为是安全的,则只能释放父页上的锁存器。
    注意:“安全“的定义可以根据线程正在执行的操作种类而有所不同。
    • Search:
      从根页开始,抓住(grab)子页的读锁存器(R),然后一到子页就释放父页的锁存器。
    • Insert:
      从根页开始,抓住子页的写锁存器(W)。一旦子页被锁定,检查它是否安全(不是满的)。如果安全,则释放祖先结点所有的锁。
    • Delete:
      从根页开始,抓住子页的写锁存器(W)。一旦子页被锁定,检查是否安全(至少为半满)(注意:对根页面,我们需要用不同的标准来检查)。如果孩子安全,释放祖先上所有的锁。
  • Important:
    本文只描述了锁存器抓取(latch crabbing)背后的基本概念,在你开始实施之前,请参考讲义和教科书第15.10章。

Road Map

  • 可以通过几种方式来建立一个B+Tree索引。这个路线图只是作为建立一个粗略的概念性指导。这个路线图是基于教科书中概述的算法。你可以选择忽略路线图的部分内容,最终仍然可以得到一个语义正确的B+Tree,并通过我们所有的测试。这完全是个人选择。
    • Simple Inserts:
      给定一个键值对KV和一个非满节点N,将KV插入N中后:
      自我检查:有哪些不同类型的节点,键值可以插入到所有的节点中吗?
    • Tree Traversals:
      给定一个键K,在树上定义一个遍历机制,以确定该键的存在。
      自我检查:键能否存在于多个节点中,这些键是否都是相同的?
    • Simple Splits:
      给定一个键K,与一个满的目标叶子结点L。将键插入树中,同时保持树的一致性。
      自我检查:什么时候选择分割一个结点?如何定义分割?
    • Multiple Splits:
      定义一个键K在叶子结点L上的插入,该结点是满的,其父节点也可能是满的。
      自我检查:当M的父节点也是满的时候会发生什么?
    • Simple Deletes:
      给定一个键K和一个至少有一半目标叶子结点L,从L中删除K。
      自我检查:叶子节点L是唯一包含键K的节点吗?
    • Simple Coalesces:
      为一个叶子结点L上的键K定义删除。在删除操作后,该键K小于一半的容量。
      自我检查:当L小于半满时,是否必须进行凝聚(合并),如何选择与哪个节点凝聚?
    • Not-So-Simple Coalesces:
      为一个节点L上的键K定义删除,该节点不包含合适的节点来凝聚在一起。
      自我检查:凝聚行为是否因节点的类型而不同?This should take you through to Checkpoint 1
    • Index Iterators:
      关于任务3的部分描述了B+Tree的迭代器的详细实现。
    • Concurrent Indices:
      关于任务4的部分描述了锁存器抓取技术的详细实现,以在你的设计中引入并发支持。

Requirements and Hints

  • 你不允许使用全局范围锁存器来保护你的数据结构。换句话说,你不能锁定整个索引,只有在操作完成后才解锁锁。我们将从语法上和手工上进行检查,以确保你以正确的方式进行锁存抓取。
  • 我们已经提供了读写锁存的实现(src/include/common/rwlatch.h)。并且已经在page头文件下添加了获取和释放锁存器的辅助函数(src/include/storage/page/page.h
  • 我们不会在B+Tree索引中添加任何强制性接口。你可以在你的实现中添加任何功能,只要你保持所有原来的公共接口不动,以便测试。
  • 不要使用malloc/new来为你的树分配大块的内存。如果你需要需要为你的树创建一个新的节点,或者需要一个缓冲区进行某些操作,你应该使用缓冲池。
  • 对于这项任务,你必须使用传入的名为transaction的指针参数(src/include/concurrency/transaction.h)。它提供了一些方法来存储你在遍历B+树时获得的锁存器的页面,也提供了一些方法来存储你在移除操作中删除的页面。
    我们的建议是仔细研究B+树中的FindLeafPage方法,你可以修改你以前的实现(注意,你可能需要改变这个方法的返回值),然后在这个特定的方法中加入抓取锁存器的逻辑。
  • 缓冲池管理器中FetchPage()的返回值是一个指向Page实例的指针(src/include/storage/page/page.h)。你可以抓取(grab) Page上的锁存器,但不能抓取B+Tree节点上的锁存器(无论是内部节点还是叶子节点)

Common Pitfalls

  • 在这个项目中,没有被测试到线程安全的扫描(no concurrent iterator operations will be tested)。然而,一个正确的实现会要求Leaf Page在无法获得同级别的锁存器时抛出一个std::exception,以避免潜在的死锁。
  • 仔细想想缓冲池管理器类的UnpinPage(page_id, is_dirty)方法和页类的UnLock()方法之间的顺序和关系。在你从缓冲池中解锁同一页面之前,你必须先释放该页面上的锁。
  • 如果你正确地实现了并发的B+tree索引,那么每个线程都会从根部到底部获取锁存器。当你释放锁存器时,请确保你遵循同样的顺序(又称从根到底)。
    其中一种情况是,当插入和删除时,成员变量root_page_idsrc/include/storage/index/b_plus_tree.h)也会被更新。你有责任保护这个共享变量不被并发更新(提示:在B+树索引中添加一个抽象层,你可以使用std::mutex来保护这个变量)。

Instructions

CheckPoint #1

CheckPoint #2

Testing

Contention Benchmark

Tree Visualization

Submission

posted @ 2023-02-09 15:20  M1kanN  阅读(177)  评论(0编辑  收藏  举报