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
:
索引中每个键的类型。这只会是GenericKey
。GenericKey
的实际大小是通过模板参数指定和实例化的,并取决于索引属性的数据类型。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 1Index 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_id
(src/include/storage/index/b_plus_tree.h
)也会被更新。你有责任保护这个共享变量不被并发更新(提示:在B+树索引中添加一个抽象层,你可以使用std::mutex
来保护这个变量)。