2022 CMU15445 Project2 B+树

前置知识

flexible array

简单来说就是,当你有一个类,这个类中有一个成员为数组。在用这个类初始化一个对象时,你不能确定该将这个数组的大小设置为多少,但知道这整个对象的大小是多少 byte,你就可以用到 flexible array。flexible array 必须是类中的最后一个成员,并且仅能有一个。在为对象分配内存时,flexible array 会自动填充,占用未被其他变量使用的内存。这样就可以确定自己的长度了。

例如有一个类 C:

 class C {
     int a; // 4 byte
     int array[1]; // unknown size
 };

现在初始化一个 C 的对象,并为其分配了 24 byte 的内存。a 占了 4 byte 内存,那么 array 会尝试填充剩下的内存,大小变为 5。

实际上这就是 C++ 对象内存布局的一个简单的例子。因此 flexible array 为什么只能有一个且必须放在最后一个就很明显了,因为需要向后尝试填充。

此外,虽然成员在内存中的先后顺序和声明的顺序一致,但需要注意可能存在的内存对齐的问题。header 中的数据大小都为 4 byte,没有对齐问题。

到这里,这个大小为 1 的数组的作用就比较清楚了。利用 flexible array 的特性来自动填充 page data 4KB 减掉 header 24byte 后剩余的内存。剩下的这些内存用来存放 KV 对。

参考资料

std::distance

作用是:返回两个迭代器之间的距离,也可以理解为计算两个元素 first 和 last 之间的元素数。

std::findif

1.功能:按条件查找元素

2.函数原型

  • find_if( iterator beg, iterator end, _pred)

  • 按值查找元素,找到的话返回指定位置的迭代器,找不到则返回结束迭代器位置

  • beg :开始迭代器

  • end :结束迭代器

move_backward

头文件algorithm

 template <class BidirectionalIterator1, class BidirectionalIterator2>
   BidirectionalIterator2 move_backward (BidirectionalIterator1 first,
                                         BidirectionalIterator1 last,
                                         BidirectionalIterator2 result);

向后移动元素范围

将结束时从[end,last]开始的元素移动到结束时终止的范围内。

该函数将迭代器返回到目标范围中的第一个元素。

结果范围的元素与[first,last]的顺序完全相同。 要颠倒他们的顺序,请参阅反向。

该函数首先将(last-1)移动到(result-1)中,然后通过前面的元素向后移动,直到第一次到达(并包括它)。

范围不应该以这样的方式重叠:结果(目标范围中的过去元素)指向范围内的元素(first,last)。对于这种情况,请参见move。

此函数模板的行为等效于:

 template<class BidirectionalIterator1, class BidirectionalIterator2>
   BidirectionalIterator2 move_backward ( BidirectionalIterator1 first,
                                          BidirectionalIterator1 last,
                                          BidirectionalIterator2 result )
 {
   while (last!=first) *(--result) = std::move(*(--last));
   return result;
 }

参数 first,last 双向迭代器到要移动的序列中的初始和最终位置。 使用的范围是[first,last),它包含first和last之间的所有元素,包括first指向的元素,但不包括last指向的元素。 result 双向迭代器到目标序列中的过去位置。 这不应指向范围内的任何元素(第一个,最后一个)。

std::lower_bound()/std::upper_bound()

std::lower_bound() 是在区间内找到第一个大于等于 value 的值的位置并返回,如果没找到就返回 end() 位置。而 std::upper_bound() 是找到第一个大于 value 值的位置并返回,如果找不到同样返回 end() 位置。

Task1 B+Tree Pages B+树页面结构

b_plus_tree_page B+树父页

这是Internal Page和Leaf Page都继承自的父类。父页面仅包含两个子类共享的信息。

成员变量

IndexPageType page_type_ (4 Byte)页面类型(interal or leaf)
lsn_t lsn_(4 Byte)日志序列号(用于Project4)
int size_(4 Byte)页面中键值对的数量
int max_size_(4 Byte)页面中键值对的最大数量
page_id_t parent_page_id_(4 Byte)父页ID
page_id_t page_id_(4 Byte)自身页ID

24 Byte in total

以上数据组成了 tree page 的 header。

 page data 的 4KB 中,24Byte 用于存放 header,剩下的则用于存放 tree page 的数据,即 KV 对。

成员函数

auto IsLeafPage() const -> bool;判断是否是叶子节点
auto IsRootPage() const -> bool;判断是否是根节点
void SetPageType(IndexPageType page_type);设置页面种类
auto GetPageType() -> IndexPageType;获得当前页面种类
auto GetSize() const -> int;获得页面保存键值对数量
void SetSize(int size);设置页面保存键值对数量
void IncreaseSize(int amount);页面增加amount个键值对
auto GetMaxSize() const -> int;获得页面最大键值对数量
void SetMaxSize(int max_size);设置页面能保存的最大键值对数量
auto GetMinSize() const -> int;获得结点最少保存的节点数量
auto GetParentPageId() const -> page_id_t;获得父节点页面ID
void SetParentPageId(page_id_t parent_page_id);设置父节点页面ID
auto GetPageId() const -> page_id_t;获得当前页面ID
void SetPageId(page_id_t page_id);设置当前页面ID
void SetLSN(lsn_t lsn = INVALID_LSN);设置日志序列号

b_plus_tree_internal_page B+树内部页

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

 internal page 中,KV 对的 K 是能够比较大小的索引,V 是 page id,用来指向下一层的节点。Project 中要求,第一个 Key 为空。主要是因为在 internal page 中,n 个 key 可以将数轴划分为 n+1 个区域,也就对应着 n+1 个 value。实际上你也可以把最后一个 key 当作是空的,只要后续的处理自洽就可以了。

通过比较 key 的大小选中下一层的节点。实际上等号的位置也可以改变,总之,只要是合法的 B+ 树,即节点大小需要满足最大最小值的限制,各种实现细节都是自由的。

另外需要注意的是,internal page 中的 key 并不代表实际上的索引值,仅仅是作为一个向导,引导需要插入/删除/查询的 key 找到这个 key 真正所在的 leaf page。

成员变量

  • array_存放键值对(key : 索引值, value : page_id)

  • 其他元数据继承了b_plus_tree_page B+树父页的成员变量

成员函数

void Init(page_id_t page_id, page_id_t parent_id = INVALID_PAGE_ID, int max_size = INTERNAL_PAGE_SIZE);初始化内部结点
auto KeyAt(int index) const -> KeyType;获得对应键
void SetKeyAt(int index, const KeyType &key);设置对应键
auto ValueAt(int index) const -> ValueType;获得对应值
void SetValueAt(int index, const ValueType &value);设置对应值
auto ValueIndex(const ValueType &value) const -> int;通过value获得对应索引
auto Lookup(const KeyType &key, const KeyComparator &comparator) const -> ValueType;获得对应键要插入位置的页面ID
void PopulateNewRoot(const ValueType &old_value, const KeyType &new_key, const ValueType &new_value);创建新的根节点
auto InsertNodeAfter(const ValueType &old_value, const KeyType &new_key, const ValueType &new_value) -> int;在array_对应键值对后面插入新键值对
void Remove(int index);删除array_中对应键值对
auto RemoveAndReturnOnlyChild() -> ValueType;
void MoveAllTo(BPlusTreeInternalPage *recipient, const KeyType &middle_key, BufferPoolManager *buffer_pool_manager)将结点中所有键值对转给其他结点
void MoveHalfTo(BPlusTreeInternalPage *recipient, BufferPoolManager *buffer_pool_manager);留半桶键值对,将剩下的键值对插入别的内部页
void MoveFirstToEndOf(BPlusTreeInternalPage *recipient, const KeyType &middle_key, BufferPoolManager *buffer_pool_manager);将当前结点的首个键值对移动到另一结点的尾端
void MoveLastToFrontOf(BPlusTreeInternalPage *recipient, const KeyType &middle_key, BufferPoolManager *buffer_pool_manager);将当前结点的末键值对移动到另一结点的首端
void CopyNFrom(MappingType *items, int size, BufferPoolManager *buffer_pool_manager);将(*items到 *items+size)的所有键值对插入arrays_的末尾
void CopyLastFrom(const MappingType &pair, BufferPoolManager *buffer_pool_manager);将键值对(pair)插入到当前结点arrays_的末尾
void CopyFirstFrom(const MappingType &pair, BufferPoolManager *buffer_pool_manager);将键值对(pair)插入到当前结点arrays_的首部

Lookup

  • 从第二个键开始寻找,由于指针的数量不等于键的数量,因此第一个键被设置为无效,查找方法应始终从第二个键开始。

  • 因为array_存放的是有序键值对,所以可以通过二分查找的方法提高效率

  • 结果分为三种情况

    • 查找出的key 小于要插入的key,返回查找出的key对应的value(page_id)

    • 查找出的key 等于要插入的key,返回查找出的key对应的value(page_id)

    • 查找出的key 大于要插入的key,返回查找出的key 的 索引(index)再减一,即前一个键值对,对应的value(page_id)

InsertNodeAfter

InsertNodeAfter(const ValueType &old_value, const KeyType &new_key, const ValueType &new_value)

把new_key 和 new_value 放在调用页面的array_中的old_value对应kv对后

  • 通过ValueIndex()获得对应节点索引,将索引(index)+1即获得新节点要插入的索引位置

  • 调用std::move_backward()将老结点后的键值对集体往后挪一位

  • 在老节点后放入新的键值对

  • IncreaseSize(1),节点存放键值对+1

  • 返回节点存放的键值对的总数

CopyNFrom

  • 将从别的节点得到的键值对复制一份到array_末尾

  • 遍历复制过去的键值对,通过ValueAt()获得page_id

  • 调用BufferPoolManager::FetchPage()来获取空闲或可驱逐Page页面,替换该页并将其放入缓冲池中

  • 页面保存的data通过reinterpret_cast<BPlusTreePage *>获得BPlusTreePage(B+树父页)(对应页面data中保存的是B+树内部结点信息,可以强制转换)

  • 设置该键值保存的对应映射页面(Page)的父页面为当前结点的page_id(为以后合并做准备)

  • 将当前结点对应页面设为可被驱逐(unpin)

MoveHalfTo

  • 获得最小包含键值对数量minsize(半桶)

  • 获得包含键值对的数量size

  • 设置包含键值对的数量为minsize

  • 另一结点将剩下的键值对(多于半桶)通过调用CopyNFrom()放入结点的array_末尾

middle_key是两个要合并叶子结点的中间值(后结点的首个kv对中的key)

B_PLUS_TREE_LEAF_PAGE

  • 相比于非叶子页面,页面头多了一个字段NextPageId

leaf page 和 internal page 的内存布局基本一样,只是 leaf page 多了一个成员变量 next_page_id,指向下一个 leaf page(用于 range scan)。因此 leaf page 的 header 大小为 28 Byte。

leaf page 的 KV 对中,K 是实际的索引,V 是 record id。record id 用于识别表中的某一条数据。leaf page 的 KV 对是一一对应的,不像 internal page 的 value 多一个。这里也可以看出来 Bustub 所有的 B+ 树索引,无论是主键索引还是二级索引都是非聚簇索引。

聚簇索引、非聚簇索引,主键索引、二级索引(非主键索引)

这里简单介绍一下聚簇索引、非聚簇索引,主键索引、二级索引(非主键索引)的区别。

在聚簇索引里,leaf page 的 value 为表中一条数据的某几个字段或所有字段,一定包含主键字段。而非聚簇索引 leaf page 的 value 是 record id,即指向一条数据的指针。

在使用聚簇索引时,主键索引的 leaf page 包含所有字段,二级索引的 leaf page 包含主键和索引字段。

  • 当使用主键查询时,查询到 leaf page 即可获得整条数据。

  • 当使用二级索引查询时,若查询字段包含在索引内,可以直接得到结果,但如果查询字段不包含在索引内,则需使用得到的主键字段在主键索引中再次查询,以得到所有的字段,进而得到需要查询的字段,这就是回表的过程。

在使用非聚簇索引时,无论是使用主键查询还是二级索引查询,最终得到的结果都是 record id,需要使用 record id 去查询真正对应的整条记录。

  • 聚簇索引的优点是,整条记录直接存放在 leaf page,无需二次查询,且缓存命中率高,在使用主键查询时性能比较好。

  • 缺点则是二级索引可能需要回表,且由于整条数据存放在 leaf page,更新索引的代价很高,页分裂、合并等情况开销比较大。

  • 非聚簇索引的优点是,由于 leaf page 仅存放 record id,更新的代价较低,二级索引的性能和主键索引几乎相同。

  • 缺点是查询时均需使用 record id 进行二次查询。

成员变量

page_id_t next_page_id_(4 Bytes)指向下一个 leaf page(用于 range scan)
MappingType array_[1]柔性数组(Flexible array)

成员函数

void Init(page_id_t page_id, page_id_t parent_id, int max_size);初始化内部结点
auto GetNextPageId() const -> page_id_t;获得下一个叶子结点
void SetNextPageId(page_id_t next_page_id);设置下一个叶子结点
auto KeyAt(int index) const -> KeyType;获得对应键
auto GetItem(int index) -> const MappingType &;
auto KeyIndex(const KeyType &key, const KeyComparator &comparator) const -> int;通过key寻找array_中对应索引
auto Insert(const KeyType &key, const ValueType &value, const KeyComparator &keyComparator) -> int;在当前结点插入键值对
auto Lookup(const KeyType &key, ValueType *value, const KeyComparator &keyComparator) const -> bool;查询是否存在键,并通过value获得page_id
auto RemoveAndDeleteRecord(const KeyType &key, const KeyComparator &keyComparator) -> int;删除键对应的键值对
void MoveHalfTo(BPlusTreeLeafPage *recipient);留半桶键值对,将剩下的键值对插入别的内部页
void MoveAllTo(BPlusTreeLeafPage *recipient);将结点中所有键值对转给其他结点
void MoveFirstToEndOf(BPlusTreeLeafPage *recipient);将当前结点的首个键值对移动到另一结点的尾端
void MoveLastToFrontOf(BPlusTreeLeafPage *recipient);将当前结点的末键值对移动到另一结点的首端
void CopyNFrom(MappingType *items, int size);将(*items到 *items+size)的所有键值对插入arrays_的末尾
void CopyLastFrom(const MappingType &item);将键值对(pair)插入到当前结点arrays_的末尾
void CopyFirstFrom(const MappingType &item);将键值对(pair)插入到当前结点arrays_的首部

Insert

有三种情况,插入位置在末尾,插入位置在中间,array_中已存在key

  • 通过KeyIndex()获得索引

  • 当索引 等于 size_ 说明要在末尾添加新键值对

  • 当key在array_中存在,无需插入,直接返回size _

  • 若插入位置不在末尾也不存在,从插入位置后每个元组往后挪一位,插入

  • size_++

重要!内部结点与叶子结点的区别

内部结点BPlusTreeInternalPage和页子结点BPlusTreeLeafPage的Lookup()函数不一样

内部结点使用缓冲池,而叶子结点未使用,原因在于内部结点要转移kv对时需要重新设置value值中映射页面保存的父节点,类似MoveHalfTo(),但叶子结点只需直接转移,所以不需要缓冲池

     auto page = buffer_pool_manager->FetchPage(ValueAt(i + GetSize()));
     auto *node = reinterpret_cast<BPlusTreePage *>(page->GetData());
     node->SetParentPageId(GetPageId());
     buffer_pool_manager->UnpinPage(page->GetPageId(), true);

Task2 B+Tree Data Structure (Search, Insert, Delete)

Task2 是单线程 B+ 树的重点。首先提供演示一个 B+ 树插入删除操作的 网站。主要是看看 B+ 树插入删除的各种细节变化。当然具体实现是自由的,这仅仅是一个示例。

你的B+树索引只应支持唯一键。也就是说,当你尝试插入具有重复键的键值对时,它不应执行插入操作并返回false。如果删除操作导致某个页面低于占用阈值,你的B+树索引还必须正确执行合并或重新分配(在教材中称为"coalescing")操作。

在CheckPoint#1中,你的B+树索引只需要支持插入(Insert()),点查找(GetValue())和删除(Delete())操作。

如果插入触发了分裂条件(插入后键/值对数量等于叶节点的max_size,插入前子节点数量等于内部节点的max_size),你应该正确执行分裂操作。由于任何写操作都可能导致B+树索引中root_page_id的更改,你有责任在页头(src/include/storage/page/header_page.h)中更新root_page_id。

这是为了确保索引在磁盘上是持久的。在BPlusTree类中,我们已经为你实现了一个名为UpdateRootPageId的函数;你只需要在B+树索引的root_page_id更改时调用此函数即可。

BPlusTree

B+树

成员变量

std::string index_name_;索引名字
page_id_t root_page_id_;根页面ID
BufferPoolManager *buffer_pool_manager_;缓冲池
KeyComparator comparator_;键比较器:用于比较两个KeyType实例是否小于/大于的类。这些将包含在KeyType的实现文件中。
int leaf_max_size_;叶子结点能容纳的最大键值对数量
int internal_max_size_;内部结点能容纳的最大键值对数量
ReaderWriterLatch root_page_id_latch_;根节点读写锁

成员函数

explicit BPlusTree(std::string name, BufferPoolManager *buffer_pool_manager, const KeyComparator &comparator,int leaf_max_size, int internal_max_size);构造函数
auto BPLUSTREE_TYPE::IsEmpty() const -> bool判断根节点是否有效
auto BPLUSTREE_TYPE::GetValue(const KeyType &key, std::vector<ValueType> *result, Transaction *transaction) -> bool查询结点
auto BPLUSTREE_TYPE::Insert(const KeyType &key, const ValueType &value, Transaction *transaction) -> bool插入结点
void BPLUSTREE_TYPE::Remove(const KeyType &key, Transaction *transaction)删除结点
auto GetRootPageId() -> page_id_t;获得root_page_id
auto Begin() -> INDEXITERATOR_TYPE;迭代器指向首位
auto Begin(const KeyType &key) -> INDEXITERATOR_TYPE;迭代器指向key
auto End() -> INDEXITERATOR_TYPE;迭代器指向末尾
auto FindLeaf(const KeyType &key, Operation operation, Transaction *transaction = nullptr, bool leftMost = false,bool rightMost = false) -> Page *;查询叶子结点
void ReleaseLatchFromQueue(Transaction *transaction);解除事务并发锁
void UpdateRootPageId(int insert_record = 0);更新root_page_id
void StartNewTree(const KeyType &key, const ValueType &value);创建根节点
auto InsertIntoLeaf(const KeyType &key, const ValueType &value, Transaction *transaction = nullptr) -> bool;插入叶子结点
void InsertIntoParent(BPlusTreePage *old_node, const KeyType &key, BPlusTreePage *new_node,Transaction *transaction = nullptr);父节点插入元组
template <typename N> auto Split(N *node) -> N *;分裂结点并放入相应键值对
template <typename N> auto CoalesceOrRedistribute(N *node, Transaction *transaction = nullptr) -> bool;再分配或合并
template <typename N> auto Coalesce(N *neighbor_node, N *node, BPlusTreeInternalPage<KeyType, page_id_t, KeyComparator> *parent, int index, Transaction *transaction = nullptr) -> bool;合并
template <typename N> void Redistribute(N *neighbor_node, N *node, BPlusTreeInternalPage<KeyType, page_id_t, KeyComparator> *parent, int index, bool from_prev);在分配
auto AdjustRoot(BPlusTreePage *node) -> bool;删除根节点

IsEmpty

怎么判断一个B+树是否为空呢?看节点数量,看根节点是否有效,如果根节点无效,那么说明是一个空树

 INDEX_TEMPLATE_ARGUMENTS
 auto BPLUSTREE_TYPE::IsEmpty() const -> bool { 
     return root_page_id_ == INVALID_PAGE_ID;
   }

Search

  • 上读锁

  • 调用FindLeaf()函数查询可能包含key的叶子结点

  • 将FindLeaf()函数返回的页面通过 reinterpret_cast<LeafPage *>(leaf_page->GetData()) 强制转换为叶子页面

  • 调用页子结点的BPlusTreeLeafPage::Lookup()遍历叶子结点中的array_数组

  • 遍历结束后取消读页锁(此处的页锁不是开头上的读锁,而是FindLeaf()中给Page上的页锁)并取消页面固定,设置为可驱逐页面

  • 如果key存在,获取value:RID(本B+树使用非聚簇索引),放入result结果集中,否则返回false

先从最简单的 Point Search 开始。B+ 树的结构应该都比较熟悉了,节点分为 internal page 和 leaf page,每个 page 上的 key 有序排列。当拿到一个 key 需要查找对应的 value 时,首先需要经由 internal page 递归地向下查找,最终找到 key 所在的 leaf page。这个过程可以简化为一个函数 Findleaf()

Findleaf() 从 root page 开始查找。在查找到 leaf page 时直接返回,否则根据 key 在当前 internal page 中找到对应的 child page id,递归调用 Findleaf。根据 key 查找对应 child id 时,由于 key 是有序的,可以直接进行二分搜索。15-445 Lecture 中也介绍了一些其他的方法,比如用 SIMD 并行比较,插值法等等。在这里二分搜索就可以了。

internal page 中储存 key 和 child page id,那么在拿到 page id 后如何获得对应的 page 指针?用 Project 1 中实现的 buffer pool。

 Page *page = buffer_pool_manager_->FetchPage(page_id);

同样地,假如我们需要新建一个 page,也是调用 buffer pool 的 NewPage()

在获取到一个 page 后,如何使用这个 page 来存储数据?之前已经提到过,page 的 data_ 字段是实际用于存储数据的 4KB 大小的字节数组。通过 reinterpret_cast 将这个字节数组强制转换为我们要使用的类型,例如 leaf page:

 auto leaf_page = reinterpret_cast<B_PLUS_TREE_LEAF_PAGE_TYPE *>(page->GetData())

reinterpret_cast 用于无关类型的强制转换,转换方法很简单,原始 bits 不变,只是对这些 bits 用新类型进行了重新的解读。可想而知这种转换非常不安全,需要确保转换后的内存布局仍是合法的。在这里原类型是 byte 数组,新类型是我们需要使用的 tree page。

找到 leaf page 后,同样是二分查找 key,找到对应的 record id。

查找的过程比较简单。但还有一个比较重要且复杂的细节,就是 page unpin 的问题。

我们在拿到 page id 后,调用 buffer pool 的 FetchPage() 函数来获取对应的 page 指针。要注意的是,在使用完 page 之后,需要将 page unpin 掉,否则最终会导致 buffer pool 中的所有 page 都被 pin 住,无法从 disk 读取其他的 page。

比较合适的做法是,在本次操作中,找出 page 最后一次被使用的地方,并在最后一次使用后 unpin。

FindLeaf

该函数有个重点难以理解的地方,调用transaction->AddIntoPageSet(page),和ReleaseLatchFromQueue(transaction)的时机。

  • 在Insert和Delete行为中,在锁上child_page后,都要将parent_page加入transaction中的page_set

  • 如果满足child_page不会发生分裂等操作,则调用ReleaseLatchFromQueue(transaction)将parent_page解锁

  • 螃蟹式上锁

查询叶子结点,三种模式 Search Insert Delete

  • 判断是否存在根节点,通过BufferPoolManager::FetchPage获取根节点对应页面并将其放入bufferPool

  • 强制转换为父页BPlusTreePage ,通过 auto *node = reinterpret_cast<BPlusTreePage *>(page->GetData()); 获得节点保存的数据

  • 根据行为操作分三种模式

    • 若行为操作为Search

      • 解开操作行为的锁,因为锁整个Search行为的粒度太大,转为page上页锁

    • 若行为操作为Insert

      • 若当前结点为叶子结点且存放kv对尚未到达最大容纳量-1(size_ < max_size_ - 1),释放所有祖先锁

      • 解释:Insert后续还要往叶子结点里插一对键值对,当叶子结点满了会分裂,所以若插入前 size_ = max_size_ - 1,则插入后size_ = max_size_,要Split,说明leaf_node不安全,则不能释放祖先锁

      • 若当前结点为内部结点且存放kv对尚未到达最大容纳量(size_ < max_size_ ),释放所有祖先锁

      • 释放祖先锁:调用ReleaseLatchFromQueue(transaction),释放Insert()最开始在根节点上的transaction->AddIntoPageSet(nullptr);因为锁整个Search行为的粒度太大,转为page上页锁

    • 若行为操作为Delete

      • 若当前结点存储键值对size_ > 2,当前结点安全。因为内部结点/2 向上取整,所以size=2是,后面delete后size为1,必要合并

      • 释放祖先锁

  • 通过BPlusTreePage 父页结点的page_type判断当前结点种类

  • 若当前结点是叶子结点,直接返回当前结点

  • 若当前结点是内部结点

    • 将当前结点强制转换为内部结点

    • 通过leftMost,rightMost的状态分三种模式

      • 若leftMost为True,获得当前结点的array_中首个kv对存放的value(page_id)

      • 若rightMost为True,获得当前结点的array_中最后一位kv对存放的value(page_id)

      • 若leftMost为False,若rightMost为False,通过内部结点的BPlusTreeInternalPage::Lookup()函数获得当前结点的array_中对应key存放的value(page_id)

    • 通过BufferPoolManager::FetchPage获取child页面并将其放入bufferPool

    • 强制转换为父页BPlusTreePage ,通过auto child_node = reinterpret_cast<BPlusTreePage *>(child_page->GetData());获得节点保存的数据

  • 根据行为操作分三种模式

    • 若行为操作为Search

      • child_page上页锁,page解页锁,并且page页取消确定,可被驱逐

    • 若行为操作为Insert

      • child_page上页锁,page放入并行事务锁列表

      • 若当前结点为叶子结点且存放kv对尚未到达最大容纳量-1(size_ < max_size_ - 1),释放所有祖先锁

      • 解释:Insert后续还要往叶子结点里插一对键值对,当叶子结点满了会分裂,所以若插入前 size_ = max_size_ - 1,说明leaf_node不安全,则不能释放祖先锁

      • 若当前结点为内部结点且存放kv对尚未到达最大容纳量(size_ < max_size_ ),释放所有祖先锁

      • 释放祖先锁:调用ReleaseLatchFromQueue(transaction),释放Insert()最开始在根节点上的transaction->AddIntoPageSet(nullptr);因为锁整个Search行为的粒度太大,转为page上页锁

    • 若行为操作为Delete

      • child_page上页锁,page放入删除并行事务锁列表

      • 若child_node的size_ > min_size_,当前内部结点安全

        • 释放祖先锁

  • 返回child_page,返回的类型是Page,后续处理需调用reinterpret_cast<>强制转换

Insert

  • 目标:插入key和value到B+树上

  • 根节点上写锁(添加事务并发锁)

  • 往事务里添加根节点

 //Concurrent index: the pages that were latched during index operation. 
 //并发索引:在索引操作期间锁存的页面。
 std::shared_ptr<std::deque<Page *>> page_set_;
 inline void AddIntoPageSet(Page *page) { page_set_->push_back(page); } 
 transaction->AddIntoPageSet(nullptr);  // nullptr means root_page_id_latch_
  • 如果B+树为空

    • 添加根节点 StartNewTree(key, value);

    • 释放所有祖先锁 ReleaseLatchFromQueue(transaction);

  • 如果B+树不为空

    • 调用InsertIntoLeaf(key, value, transaction) 插入结点

StartNewTree

创建根节点

  • 通过BufferPoolManager::NewPage()新建缓存页

  • 将Page强制转换为LeafPage(叶子页)

  • 调用BPlusTreeInternalPage::Init()初始化为叶子结点

  • 调用BPlusTreeInternalPage::Insert()插入kv对

  • 调用BufferPoolManager::UnpinPage()设置根页面可驱逐

ReleaseLatchFromQueue

释放所有祖先锁,先进先出(FIFO)

  • 当page_set_中存在开启事务并发锁的节点

  • 取page_set_最先进入的结点(队首)

    • 若结点为根节点,解根节点写锁(此处不驱逐根节点是因为根节点在任何操作中都需调用,无需驱逐)

    • 若节点为内部结点或叶子结点,解写锁,设置当前结点取消固定可被驱逐

InsertIntoLeaf

插入叶子结点

  • 调用FindLeaf(key, Operation::INSERT, transaction);获得叶子结点

  • 获得size 和插入后的 new_size

       auto size = node->GetSize();
       auto new_size = node->Insert(key, value, comparator_);
  • 分三种情况

    • new_size == size,插入键已存在

      • 释放所有祖先锁,调用ReleaseLatchFromQueue(transaction)

      • 解除页锁,并设置叶子页面为可驱逐页面

    • new_size < leaf_max_size, 插入后叶子结点仍未满

      • 释放所有祖先锁,调用ReleaseLatchFromQueue(transaction)

      • 解除页锁,并设置叶子页面为可驱逐页面

    • 否则叶子结点已满,需要分裂

      • 调用Split()获得分裂后的新节点,并向新老节点插入分裂后的值

      • 调用BPlusTreeLeafPage::SetNextPageId(),设置新老节点的next_page

    • 获得新叶子结点的首地址 risen_key

    • 父节点插入元组(new_key, new_value)new_key为新页面的最小值K'',new_value为分裂结点的page_id,调用InsertIntoParent()

    • 叶子结点解除写锁

    • 将新老叶子结点设置为可驱逐页,且为脏页

         buffer_pool_manager_->UnpinPage(leaf_page->GetPageId(), true);
         buffer_pool_manager_->UnpinPage(sibling_leaf_node->GetPageId(), true);

Split

将当前结点分裂,并填充kv对

  • 创建新页来添加新的分裂结点

  • 设置分裂结点的页面种类

  • 根据页面种类分为两种模式:

    • 如果为叶子结点

      • 初始化新老叶子结点

      • 调用BPlusTreeLeafPage::MoveHalfTo()转移kv对

    • 如果为内部结点

      • 初始化内部叶子结点

      • 调用BPlusTreeInternalPage::MoveHalfTo()转移kv对

InsertIntoParent

父节点插入元组 old_node 父节点插入的节点的位置在old_node后,key父节点要插入的键,new_node父节点要插入的值

  • 如果被分裂的节点是根节点

    • 调用BufferPoolManager::NewPage()为新的根节点开辟页面,此根节点为内部结点

    • 初始化根节点

  •      new_root->Init(root_page_id, INVALID_PAGE_ID, internal_max_size_);
    • 调用PopulateNewRoot()设置根节点,此前根节点由Insert插入,插入的是叶子结点,并传入kv值

    • 只有1个key,就是传入的K'

new_key

old_value new_value

  •  INDEX_TEMPLATE_ARGUMENTS
     void B_PLUS_TREE_INTERNAL_PAGE_TYPE::PopulateNewRoot(const ValueType &old_value, const KeyType &new_key,
                                                          const ValueType &new_value) {
       SetKeyAt(1, new_key);
       SetValueAt(0, old_value);
       SetValueAt(1, new_value);
       SetSize(2);
     }
    • 为新老节点设置ParentPageId

    • 将新根节点的页面设为可驱逐页面

    • 调用UpdateRootPageId(0)更新b+树的root_page_id

    • 释放所有祖先锁(此处即为根节点)

  • 如果分裂的节点不是根节点,父节点要插入元组,获取父节点

  • 如果父结点不是根结点,就一定是内部结点

  • 分两种模式,父节点未满,可以直接插入,无需分裂,否则父节点已满,需要分裂

    • 如果父结点的size < internal_max_size_,说明父节点未满

      • 把new_key 和 new_value 放在调用页面的array_中的old_value对应kv对后

      • 通过 BPlusTreeInternalPage::ValueIndex()查询对应插入位置

      •  INDEX_TEMPLATE_ARGUMENTS
         auto B_PLUS_TREE_INTERNAL_PAGE_TYPE::ValueIndex(const ValueType &value) const -> int {
           auto it = std::find_if(array_, array_ + GetSize(), [&value](const auto &pair) { return pair.second == value; });
           return std::distance(array_, it);
         }
  • 否则父节点已满,需要分裂

    • 复制一个父节点,为复制父节点开辟空间(表头大小 + kv键值对大小 * kv键值对数量),因为多一个value,所以要额外加1对键值对的空间

  •  auto *mem = new char[INTERNAL_PAGE_HEADER_SIZE + sizeof(MappingType) * (parent_node->GetSize() + 1)];
    • 复制父节点(copy_parent_node)复制老父节点(parent_node)的所有键值对,并插入新键值对

    • 调用Split()将复制父节点(copy_parent_node)分裂

    • 分裂后的新父节点为(parent_new_sibling_node)

    • 调用memcpy()将copy_parent_node中(0,min_size)的kv对放入parent_node中

    • copy_parent_node再次调用InsertIntoParent()进行递归

    • 将parent_page与parent_new_sibling_node设置为可驱逐且为脏

    • 删除mem,delete[] mem;

二、注意点

  • 内部节点间不能有重复key,但是内部节点和叶子节点可以有重复key。

  • 内部节点分裂时升到父节点的key是不能在本节点保留。

  • 叶子节点分裂时升到父节点的key是要在本节点保留。

Remove

删除kv

  • root_page_id挂写锁

  • 调用transaction->AddIntoPageSet(nullptr) 放入事务并发锁列表中

  • 若B+树为空,则返回

  • 调用FindLeaf()查询叶子结点

  • 叶子结点调用BPlusTreeLeafPage::RemoveAndDeleteRecord(key, comparator_)删除对应键值对

  • 当叶子结点的 size_ == 调用BPlusTreeLeafPage::RemoveAndDeleteRecord(key, comparator_)的返回值,删除kv对后的size_

    • 说明没有删除成功,或不存在对应kv

    • 释放祖先锁

    • 将当前叶子结点释放写锁,取消固定

  • 删除对应键值对后调用CoalesceOrRedistribute(node, transaction)重新分配或合并

  • 解页锁

  • 如果结点已重新分配或合并

  • 加入删除页面集合

  • 将当前叶子结点Unpin,设为可驱逐页,脏页

  • 遍历删除页面集合

    • 调用BufferPoolManager::DeletePgImp删除页面集合(DeletedPageSet)存放的所有对应页都从缓冲池中删除

  • 清空删除页面集合

      transaction->GetDeletedPageSet()->clear();

CoalesceOrRedistribute

重点,放入删除页面集合的条件是,Coalesce()函数把当前结点合并给另外一个结点上了,当前结点变为空结点,所以需要删除

  • 如果删除结点是根节点

    • 调用AdjustRoot()删除根节点

    • 释放删除祖先锁,返回bool值看是否删除成功

  • 如果删除kv值当前结点 size_ >= min_size_,无需重分配或合并

    • 释放删除祖先锁

    • 返回false

    获得当前结点的父节点和子结点在父节点array_上的索引(index)

  • 如果删除kv值当前结点 size_ < min_size_,当前结点需要重分配或合并

    • 如果父节点有两个以上的子结点且当前结点不在父节点的首个kv键上(idx > 0)

      • 获得位于(idx - 1)的兄弟结点,上页写锁,兄弟结点在前,当前结点在后

      • 若兄弟结点的size_ 大于 min_size_,即当前结点size _ < min_size _ 且兄弟结点的size _ > min_size _

        • 当前结点再分配,调用Redistribute()

        • 释放删除祖先结点

        • 兄弟结点解页写锁

        • 当前结点与兄弟结点取消固定,设置为可驱逐页,脏页

        • 返回false,结束

      • 否则兄弟结点的size_ < min_size_,需要合并,调用Coalesce()

      • 若Coalesce()合并成功,则将父结点加入删除页面集合,这代表父节点需要删除

      • 兄弟结点解锁,同时父结点与兄弟结点设置为可被驱逐页,脏页

      • 返回true

    • 如果父节点有两个以上的子结点且当前结点不在父节点的最后一个kv键上(idx != parent_node->GetSize() - 1)

      • 获得位于(idx + 1)的兄弟结点,上页写锁,兄弟结点在后,当前结点在前

      • 若兄弟结点的size_ 大于 min_size_,即当前结点size _ < min_size _ 且兄弟结点的size _ > min_size _

        • 当前结点再分配,调用Redistribute()

          1. 释放删除祖先结点

          2. 兄弟结点解页写锁

          3. 当前结点与兄弟结点取消固定,设置为可驱逐页,脏页

          4. 返回false,结束

        • 否则兄弟结点的size_ < min_size_,需要合并,调用Coalesce()

        • 将兄弟结点加入删除页面集合,因为兄弟结点将所有kv对都放入当前结点,需要删除结点

        • 若Coalesce()合并成功,则将父结点加入删除页面集合,这代表父节点需要被删除

        • 兄弟结点解锁,同时父结点与兄弟结点设置为可被驱逐页,脏页

        • 返回false

  • 若以上条件都不符合,返回false

AdjustRoot

删除根节点

  • 分为两种模式

  • 若根节点是内部结点,即有一个child_page

    • 删除根节点并将child_node设置为根节点

  • 若根节点是叶子结点,即只有一个根节点

    • 删除根节点

Redistribute

再分配

  • 若当前结点与兄弟结点都是叶子结点

  • 根据bool from_prev 共分为两种模式

    • 若from_prev为true,将兄弟结点的首位移到当前结点的末尾

    • 父节点更新兄弟结点key的值,此时兄弟结点key的首地址-1

    • 若from_prev为false,将兄弟结点的末尾移到当前结点的首位

    • 父节点更新当前结点key的值,此时当前结点key的首地址-1

  • 若当前结点与兄弟结点都是内部结点

  • 根据bool from_prev 共分为两种模式

    • 若from_prev为true,将兄弟结点的首位移到当前结点的末尾

    • 父节点更新兄弟结点key的值,此时兄弟结点key的首地址-1

    • 若from_prev为false,将兄弟结点的末尾移到当前结点的首位

    • 父节点更新当前结点key的值,此时当前结点key的首地址-1

Coalesce

合并两个结点

  • 获得两个要合并结点中后结点的首个键值对在父节点的索引

  • 如果当前结点为叶子结点

    • 将后结点的kv对全部放入前结点中

  • 如果当前结点为内部结点

    • 将后结点的kv对全部放入前结点中

  • 删除父节点中后结点的kv值

  • 调用Remove()的父节点调用CoalesceOrRedistribute()

Task3 遍历叶子结点

成员变量

BufferPoolManager *buffer_pool_manager_缓冲池
Page *page_;页面(即叶子结点kv对上的value)
LeafPage *leaf_ = nullptr;叶子结点,初始化为nullptr
int index_ = 0;索引,初始化为0(即叶子结点kv对上的key)

成员函数

IndexIterator(BufferPoolManager *bpm, Page *page, int index = 0);迭代器构造函数
~IndexIterator();析构函数
auto IsEnd() -> bool;是否是到尾部
auto operator*() -> const MappingType &;重置*it
auto operator++() -> IndexIterator &;重置++
auto operator==(const IndexIterator &itr) const -> bool重置==
auto operator!=(const IndexIterator &itr) const -> bool重置!=

Task4 并发控制

以下出自知乎文章做个数据库:2022 CMU15-445 Project2 B+Tree Index - 知乎

这是并发 B+ 树的重点,应该也是 Project2 中最难的部分。我们要使此前实现的 B+ 树支持并发的 Search/Insert/Delete 操作。整棵树一把锁逻辑上来说当然是可以的,但性能也会可想而知地糟糕。在这里,我们会使用一种特殊的加锁方式,叫做 latch crabbing。顾名思义,就像螃蟹一样,移动一只脚,放下,移动另一只脚,再放下。基本思想是: 1. 先锁住 parent page, 2. 再锁住 child page, 3. 假设 child page 是安全的,则释放 parent page 的锁。安全指当前 page 在当前操作下一定不会发生 split/steal/merge。同时,安全对不同操作的定义是不同的,Search 时,任何节点都安全;Insert 时,判断 max size;Delete 时,判断 min size。

这么做的原因和正确性还是比较明显的。当 page 为安全的时候,当前操作仅可能改变此 page 及其 child page 的值,因此可以提前释放掉其祖先的锁来提高并发性能。

Search

Search 时,从 root page 开始,先给 parent 上读锁,再给 child page 上读锁,然后释放 parent page 的锁。如此向下递归。

Insert

Insert 时,从 root page 开始,先给 parent 上写锁,再给 child page 上写锁。假如 child page 安全,则释放所有祖先的锁;否则不释放锁,继续向下递归。

在 child page 不安全时,需要持续持有祖先的写锁。并在出现安全的 child page 后,释放所有祖先写锁。如何记录哪些 page 当前持有锁?这里就要用到在 Checkpoint1 里一直没有提到的一个参数,transaction

transaction 就是 Bustub 里的事务。在 Project2 中,可以暂时不用理解事务是什么,而是将其看作当前在对 B+ 树进行操作的线程。调用 transaction 的 AddIntoPageSet() 方法,来跟踪当前线程获取的 page 锁。在发现一个安全的 child page 后,将 transaction 中记录的 page 锁全部释放掉。按理来说,释放锁的顺序可以从上到下也可以从下到上,但由于上层节点的竞争一般更加激烈,所以最好是从上到下地释放锁。

在完成整个 Insert 操作后,释放所有锁。

Delete

和 Insert 基本一样。仅是判断是否安全的方法不同(检测 min size)。需要另外注意的是,当需要 steal/merge sibling 时,也需要对 sibling 加锁。并在完成 steal/merge 后马上释放。这里是为了避免其他线程正在对 sibling 进行 Search/Insert 操作,从而发生 data race。这里的加锁就不需要在 transaction 里记录了,只是临时使用。

Implementation

可以发现,latch crabbing 是在 Find Leaf 的过程中进行的,因此需要修改 Checkpoint1 中的 FindLeaf(),根据操作的不同沿途加锁。

When should we unlatch and unpin pages?

在这里,我还想提一提 unpin page 的问题。在前面我只是简单地说了一句在最后一次使用的地方 unpin。但实际上,这个问题在整个 Project2 中时时困扰着我。特别是在 Checkpoint2 中引入 page 锁之后。到底该如何优雅地释放我们获得的 page?

首先,为什么要 unpin page?这个应该比较清楚了,避免对 buffer pool 一直占用。可以理解为一种资源的泄露。

说到资源泄露,可以自然地想到 RAII。RAII 的主要思想是,在初始化时获取资源,在析构时释放资源。这样就避免了程序中途退出,或抛出异常后,资源没有被成功释放。常常用在 open socket、acquire mutex 等操作中。其实我们在 Project1 中已经遇到了 RAII 的用法:

 std::scoped_lock<std::mutex> lock(mutex_);

这里其实就是一个经典的 RAII。在初始化 lock 时,调用 mutex_.Lock(),在析构 lock 时,调用 mutex_.Unlock()。这样就通过简单的一行代码成功保证了在 lock 的作用域中对 mutex 全程上锁。离开 lock 的作用域后,由于 lock 析构,锁自动释放。

一开始,我也想过用这种方法来管理 page。例如编写一个类 PageManager,在初始化时,fetch page & latch page,在析构时,unlatch page & unpin page。这个想法好像还行,但是遇到了一个明显的问题:page 会在不同函数间互相传递,以及存在离开作用域后仍需持有 page 资源的情况,比如 latch crabbing 时可能需要跨函数持锁。或许可以通过传递 PageManager 指针的方式来处理,但这样似乎更加复杂了。

此外,还有一个问题。比如 Insert 操作时,假如需要分裂,会向下递归沿途持锁,然后向上递归进行分裂。在分裂时,需要重新从 buffer pool 获取 page。要注意的是,这里获取 page 时不能够对 page 加锁,因为此前向下递归时 page 已经加过锁了,同一个线程再加锁会抛异常。

比如这里的例子。在向上递归时,我们已经获取过 parent page 的锁,因此再次从 buffer pool 获取 parent page 时,无需对 parent page 再次加锁。

那有没有办法能够知道我们对哪些 page 加过锁?transaction。也就是说,如果一个 page 出现在 transaction 的 page set 中,就代表这个线程已经持有了这个 page 的锁。

当然,通过认真分析各个操作获取 page 的路径,我们也可以发现持锁的规律。

Search

仅向下递归,拿到 child page 就释放 parent page。这个比较简单。获取 page 的路径从 root 到 leaf 是一条线。到达 leaf 时,仅持有 leaf 的资源。

Insert

先向下递归,可能会持有多个 parent page 的锁。获取 page 的路径从 root 到 leaf 也是一条线,区别是,到达 leaf 时,还可能持有其祖先的资源。再向上递归。向上递归的路径与向下递归的完全重合,仅是方向相反。因此,向上递归时不需要重复获取 page 资源,可以直接从 transaction 里拿到 page 指针,绕过对 buffer pool 的访问。在分裂时,新建的 page 由于还未连接到树中,不可能被其他线程访问到,因此也不需要上锁,仅需 unpin。

Delete

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

Deadlock?

可以看出,需要持多个锁时,都是从上到下地获取锁,获取锁的方向是相同的。在对 sibling 上锁时,一定持有其 parent page 的锁,因此不可能存在另一个既持有 sibling 锁又持有 parent page 锁的线程来造成循环等待。因此,死锁是不存在的。

但如果把 Index Iterator 也纳入讨论,就有可能产生死锁了。Index Iterator 是从左到右地获取 leaf page 的锁,假如存在一个需要 steal/merge 的 page 尝试获取其 left sibling 的锁,则一个从左到右,一个从右到左,可能会造成循环等待,也就是死锁。因此在 Index Iterator 无法获取锁时,应放弃获取。

Optimization

对于 latch crabbing,存在一种比较简单的优化。在普通的 latch crabbing 中,Insert/Delete 均需对节点上写锁,而越上层的节点被访问的可能性越大,锁竞争也越激烈,频繁对上层节点上互斥的写锁对性能影响较大。因此可以做出如下优化:

Search 操作不变,在 Insert/Delete 操作中,我们可以先乐观地认为不会发生 split/steal/merge,对沿途的节点上读锁,并及时释放,对 leaf page 上写锁。当发现操作对 leaf page 确实不会造成 split/steal/merge 时,可以直接完成操作。当发现操作会使 leaf page split/steal/merge 时,则放弃所有持有的锁,从 root page 开始重新悲观地进行这次操作,即沿途上写锁。

这个优化实现起来比较简单,修改一下 FindLeaf() 即可。

Summary

整个 Project2 的内容大致就是这些。难度相对于 Project1 可以说是陡增。Checkpoint1 的难点主要在细节的处理上,Checkpoint2 的难点则是对 latch crabbing 的正确理解。当看到自己从 0 实现的 B+ 树能够正确运行,特别是可视化时,还是很有成就感的。

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值