CMU 15445 2022 Fall P2 B+Tree Index

OverView

Project 2 需要为Bustub实现B+树索引,拆分为两个部分:

  • Checkpoint 1:单线程B+树
  • Checkpoint 2:多线程B+树

Checkpoint 1 :Single Thread B+ Tree

Checkpoint 分为两个部分:

  • Task 1:B+ Tree Pages,B+树中的各种page。在Bustub索引B+树中,所有的节点都是page。包含leaf page,internal page和它们的父类tree page。
  • Task 2:B+ Tree Data Structure (Insertion,Deletion,Point Search)。Checkpoint 1的重点是B+树的插入、删除和单点查询。

Task 1 B+Tree Pages

关于各种页基本的get、set方法这里暂且不说,这部分主要介绍一下page的内存布局

在P1 中我们第一次与page打交道。page实际上可以存储数据库内很多种类的数据。例如索引和实际的表数据等。

/** The actual data that is stored within a page. */
char data_[BUSTUB_PAGE_SIZE]{};
/** The ID of this page. */
page_id_t page_id_ = INVALID_PAGE_ID;
/** The pin count of this page. */
int pin_count_ = 0;
/** True if the page is dirty, i.e. it is different from its corresponding page on disk. */
bool is_dirty_ = false;
/** Page latch. */
ReaderWriterLatch rwlatch_;

其中,data_是实际存放page数据的地方,大小为BUSTUB_PAGE_SIZE,为4KB。其他的成员是page的metadata。

B+树中的tree page数据均存放在page的data成员中。

B_PLUS_TREE_PAGE

b_plus_tree_page是另外两个page的父类,即B+树中tree page的抽象。

IndexPageType page_type_;   // leaf or internal. 4 Byte
lsn_t lsn_  // temporarily unused. 4 Byte
int size_;  // tree page data size(not in byte, in count). 4 Byte
int max_size_;  // tree page data max size(not in byte, in count). 4 Byte
page_id_t parent_page_id_; // 4 Byte
page_id_t page_id_; // 4 Byte
// 24 Byte in total

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

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

B_PLUS_TREE_INTERNAL_PAGE

对应B+树中的内部节点。

MappingType array_[1];

internal page中没有新的metadata,header大小仍为24B。它唯一的成员是一个数组,这个数组是一个柔性数组,存储着KV对。

为什么数组要这样定义?

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

在这个项目中,利用柔性数组的特性来自动填充page data 4KB 减掉 header 24 bytes后剩余的内存。剩下的这些内存用来存放KV对。

 internal page中,KV对的K是能够比较大小的索引,V是page id,用来指向下一层的节点。

Project中,第一个Key为空。主要就是因为在internal page中,n个key可以将数轴划分为n+1个区域,也就对应着n+1个value。如下:

通过比较key的大小选中下一层的节点。

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

B_PLUS_TREE_LEAF_PAGE

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比key多1个。在Bustub所有的B+树索引中,无论是主键索引还是二级索引都是非聚簇索引。

Task 2 B+Tree Data Structure 

Task 2是单线程B+树的重点。这部分需要实现的是插入(Insert),点搜索(GetValue)和删除(Delete)。

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是有序的,可以直接进行二分查找。

internal page中存储的是key和child page id,在拿到page id之后可以用P1实现的buffer pool获取page指针,如下:

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。

Insert

与Search相同,第一步是根据key找到需要插入的leaf page。同样是调用FindLeaf,得到leaf page后,将key插入leaf page。要注意的是,插入时还需要保证key的有序性,同样可以二分搜索找到合适的位置插入。

在插入后,需要检查当前leaf page size是否等于max size。若相等,则要进行一次leaf page分裂操作。具体步骤为

  1. 新建一个空的page
  2. 将原page的一半转移到新的page中(新page放在原page的右边,转移原page的右半部分)
  3. 更新原page和新page的next page id
  4. 获取parent page
  5. 将用于区分原page和新page的key插入parent page中
  6. 更新parent page中所有child page的父节点指针

这些步骤都比较好理解,需要给parent page插入一个新key的原因是,多了一个子节点,自然需要多一个key来区分。其中第4步是重点,获取parent page并不是简单地通过当前page的parent id来获取,因为parent page也可能发生分裂。

在第5步我们可以拿到parent page安全地插入KV对,是因为在第4步中,我们需要返回一个安全的parent page。

第4步的具体操作如下:1.根据parent page id拿到parent page,2.判断parent page size是否等于max size,(插入前检查)3.若小于,直接返回parent page,4.否则,分裂当前internal page。并根据此后需要插入的key选择分裂后的两个page之一作为parent page返回。

分裂internal page的步骤为:1.新建一个空的page,2.将原page的一半转移到新page中,需要注意原page和新page的第一个key都是无效的,3.更新新page所有child page的父节点指针,指向新page,4.获取parent page,5.将用于区分原page和新page的key插入parent page中,6.更新parent page中所有child page的父节点指针。

可以发现,第4步同样是需要重复上述步骤。这里就发生了向上的递归,直到遇到安全的父节点或者遇到根节点。在遇到根节点时,若根节点也需要分裂,则除了需要新建一个节点用来容纳原根节点一半的KV对,还需要新建一个新的根节点。

Insert的整个流程大概就是先向下递归找到leaf page,插入KV后再向上递归分裂。

Delete

同样地,先找到leaf page。删除leaf page中key对应的KV对后,检查size是否小于min size。如果小于的话,首先尝试从两侧的兄弟节点中拿一个KV对。注意只能从兄弟节点,即父节点相同的节点中选取。假如存在一侧节点有富余的KV对,则成功拿取,结束操作。若两侧都没有多余的KV对,则与一侧节点进行合并。

拿取的过程比较简单,从左侧节点拿取时,把左侧节点最后一个KV对转移到当前节点的第一个KV对位置,从右侧节点拿取时,把右侧节点的第一个KV对转移至当前节点的最后一个KV对位置。leaf page和internal page的拿取过程基本相同,仅需要注意internal page拿取后更新子节点的父节点指针。

稍难的是合并的过程。同样,任选左右侧兄弟节点进行合并。将一个节点的所有KV对转移至另一节点。若合并的是leaf page,要注意更新next page id。若合并的是internal page,记得更新合并后page的子节点的父节点指针。然后,删除parent节点中对应的key,删除后,再次检查size是否小于min size,形成向上递归。

当合并leaf page后,删除父节点中对应的key即可,如下:

合并internal page后,并不是简单地删除父节点中对应的key,而是有一个父节点key下推的过程:

需要注意的是,root page不受min size的限制。但如果root page被删除到size只剩下1,即只有一个child page的时候,应将此child page设置为新的root page。

另外,在合并的时候,两个page合并成一个page,另一个page应该删除,释放资源,删除page时,仍是调用buffer pool的DeletePage()函数。

和Insert类似,Delete过程也是先向下递归查询leaf page,不满足min size后先尝试拿取,无法拿取则合并,并向上递归检查parent page是否满足KV对数量大于min size。 

Checkpoint2 Multi Thread B+Tree

Checkpoint2也分为两个部分:

Task 3:实现leaf page的range scan

Task 4:Concurrent Index,支持B+树并发操作

Task 3 Index Iterator

实现一个遍历leaf page的迭代器,在迭代器中存储当前leaf page的指针和当前停留的位置即可。遍历完当前page后,通过next page id找到下一个leaf page。同样,记得unpin已经遍历完的page。

Task 4 Concurrent Index

这是并发B+树的重点,也是P2最难的部分。

直接给整棵树加一把锁理论上是可行的,但是并发执行性能会非常糟糕。

在这个部分,我们会使用一种特殊的加锁方式,即latch crabbing。顾名思义,就像螃蟹一样,移动一只脚,放下,移动另一只脚,再放下。基本思想是:

  1. 先锁住parent page
  2. 再锁住child page
  3. 假设child page是安全的,则释放parent page的锁。安全指当前page在插入或删除操作下都不会发生split/steal/merge。同时,对不同的操作,安全的定义是不同的。Search时,任何节点都是安全的,Insert时,判断max size,Delete时,判断min size。

Search

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

 

 

 Insert

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

在child page不安全的时候,需要持有祖先的写锁,并在出现安全的child page后,释放所有祖先的写锁。

如何记录哪些page当前持有锁呢?

  • Transaction

transaction就是Bustub里面的事务。当一个事务(插入、删除或者查找)开始时,我们需要在函数的一开始创建一个transaction对象,然后在对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里面记录了,只是临时使用,使用完之后直接释放sibling锁。

重点:脏页记录

在插入或删除的过程中,我们会对B+树上的节点进行修改,此时要设置某些页是脏的,在插入或删除时,可能会发生分裂或者合并等操作,我们使用一个全局整数变量dirty来记录当前transaction操作的页序列中最后面有几个页被修改过了,即dirty表示的是脏的页层数,也即transaction操作页序列的倒数dirty个页面,在释放所有锁的时候,我们会在unpin某个页的同时结合这个页在操作序列中的位置以及dirty大小设置该页是否被修改过,被修改过的话要设置为dirty。

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开始重新悲观地进行这次操作,即沿途上写锁。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值