cmu15445-project2 B+Tree Checkpoint 2 实验总结
完成了Project-2的checkpoint1之后,就可以开始搞并发控制了。在checkpoint1中虽然能过测例,但不一定是没问题的,checkpoint-2必须解决考虑不完全的并发问题。
一、实验内容
实现的接口
Task #2c – Index Iterator
Task #2d – Concurrency Control
checkpoint1需要实现tree如下接口:Remove Insert GetValue
迭代器接口:Begin End operator++
并发控制:利用TransactionManager实现INSERT,UPDATE,SELECT,DELETE的并发版本和乐观锁版本
二、实现细节
A concurrency control protocol is the method that the DBMS uses to ensure “correct” results for concurrent operations on a shared object.
latch和lock的区别:
在本课程的语境下,latch表示的是数据库底层数据结构,保护操作安全性用到的锁;lock则是更上层的用于保护逻辑结构行的锁。
Locks
→ Protect the database's logical contents from
other txns.
→ Held for txn duration.
→ Need to be able to rollback changes.
Latches
→ Protect the critical sections of the DBMS's internal data structure from
other threads.
→ Held for operation duration.
→ Do not need to be able to rollback changes
因此project2里要实现的主要是B+tree的并发保护。在正确地实现B+树的基础上用锁对数据进行保护。
最简单的方式是对整个树进行加锁,而对header page即对整个树进行加锁;显然可以看到逻辑结构的表跟物理结构的tree是一一对应的;
但是这样以来并发性显然很弱,因此实际每个page都持有锁,尽量只加必要page的锁,以提高并发性。
1.Crabbing Protocol锁协议与性能优化
蟹行协议是一个形象的比喻,简单来将就是从根到叶节点加锁,再依次解锁上层的锁。其加锁的轨迹就像螃蟹在走路。这个其实是比较基础的细粒度锁,在此基础上还有其他的变种。
为了保护树的结构,同时树支持查询SEARCH,插入INSERT,删除DELETE的操作,使用了读写锁,读加读锁,插入和删除加写锁。除此之外,树中没有UPDATE的操作。
crabbing协议的示例如下,对插入25的操作,从根节点开始加写锁。
其并发保护的规则如课程PPT所示:
Search : Starting with root page, grab read (R) latch on child. Then release latch on parent as soon as you land on the child page.
Insert : Starting with root page, grabwrite (W) latch on child. Once child is locked, check if it is safe, in this case,not full. If child is safe, release all locks on ancestors.
Delete : Starting with root page, grab write (W) latch on child. Once child is locked, check if it is safe, in this case, at least half-full. (NOTE: for root page, we need to check with different standards) If child is safe, release all locks on ancestors.
概括地说,查询加读锁,意味着同一页面,同时可以有其他的读锁,但是不能有写锁;插入,删除加写锁。
插入可能造成节点分裂(新增节点),删除可能造成节点合并(进而删除某个节点),因此要判断子节点是否安全,以决定在操作执行过程中是否释放分裂节点上一层的锁。
2.对根节点加的乐观锁
由于大部分情况下节点分裂是少数情况,基于这一假设,可以只在根节点加读锁(因为写锁会导致阻塞),如果最后也未发生分裂,则正常执行;否则要从头再执行一遍根加写锁,接着释放所有已获得锁的操作;
3.发生拆借、合并对sibling加锁
叶节点、内部节点发生borrow/merge 时,要先对兄弟节点加写锁,即使已经判断节点不安全且已经加了父节点的锁。这是因为父节点加锁可能在另一个操作释放锁到完成该操作之间;例如A,B是叶节点兄弟,t1删除叶子节点A的值,t2删除B的值,B是安全的,按照以下顺序
1)t2加父节点锁,发现B安全,释放锁;
2)t1加父节点锁,发现A不安全,不释放锁;
3)t2删除节点B内的值;
4)t1合并A,B。
t1必须在4进行的时候对B加锁,直到B完成。参考[1]。
borrow时,兄弟节点的锁要在操作完成后立刻释放并且unpin。
4.乐观锁的实现
为了方便释放父节点的锁,需要用到事务transaction。这是个主要在P4用到的类,但是在P2中只需要用到事务的 PageSet、DeletedPageSet相关方法。其中PageSet是一个双端队列,DeletedPageSet是一个map。
这样,在乐观锁的实现中,可以缓存访问过的页面,直接利用PageSet释放各个祖先的锁,而不用重新fetch页面并unlock。
另一种情况是,由于分裂、合并节点的时候可能会发生递归向上的操作,如递归向上继续分裂、合并,因此PageSet也可能记录了当前页(old_page)。
分裂、删除每次操作的对象最多有三个,需要仔细地操作page, parent_id,lock,page_set,并区分各种情况。由于主体已经在P2的checkpoint1实现过了,因此这里主要就是考虑unpin的工作。
5.Index Iterator
跟大部分容器一样,b_plus_tree也有迭代器;而这里的index_iterator是为了实现对叶节点的遍历,IndexScanExecutor是一个执行遍历的类,在真正执行的时候会调用b_plus_tree去遍历,但由于应对大量数据查询避免内存过大的考虑,数据库不会希望一次性全部给出数据,所以要使用迭代器记录当前移动位置;
由于支持多线程并发,对页面的操作是临界操作,因此index_iterator的内部实现必须加上锁。具体来说,就是index_iterator维护当前保存的页面数据,每次读取一个页面的时候加上锁,并按调用依次读出,更新当前位置;当一个页面读取完成的时候,解锁当前页,并获取下一个页面的锁,直至遍历完成。
这便是遍历的流程。然而有人可能会想到,如果遍历的时候前面的页面被修改或删除,当前加锁的页面是否能正常工作,或者这样遍历出来的结果是不是我们一开始要的。这个考虑的其实是不可重复读的问题,不是在这个层次解决的,要在隔离级别用其他粒度的锁(行锁或表锁)进行解决。仅仅在页面层次,加锁保证的是页面数据的更新的完整性。
由于在22fall没有自动处理unpin_page的逻辑(23spring用的是page_guard),所以iterator++移动到新page的时候也需要unpin_page。
6.Unpin操作的错误
23spring对unpin实现PageGuard的重要原因,就在于unpin的时机比较难把握,要降低学生实现的负担。那么unpin操作有哪些错误?因为页缓冲区初始化的时候size是固定的,如果页面没有及时释放,就会造成剩余区大小不足,造成分配失败,实际上是一种内存泄漏。这样可能造成一些分配就是空指针,直接造成段错误。
而事实上在大小设置合理的情况下,页面缓冲区是够用的,可以存储很大的结果集。
三、 调试技巧
如果希望自动生成不同结构的B+树操作序列case,可以用随机的方式生成B+树操作序列;并且用如下命令运行直到运行出错;
INFO=concurrent; while true; do ./test/b_plus_tree_${INFO}_test; [ $? -ne 0 ] && break; done
References
[1]CMU 15445-2022 P2 B+Tree Concurrent Control https://zhuanlan.zhihu.com/p/593214033
[2]https://15445.courses.cs.cmu.edu/fall2022 课程官网
[3]https://github.com/cmu-db/bustub Bustub Github Repo
[4] Database System Concepts 6th version, Abraham.Silberschatz.
[5]自动测评网站 GradeScope,course entry code: PXWVR5 https://www.gradescope.com/