本文只关于实现以及Debug思路
Task #1 - Read/Write Page Guards
在Project1的BufferPoolManager中,FetchPage和NewPage这两个API都是直接返回Page *
指针。然后通过程序员手动地调用UnpinPage
方法来使得页面会处于Evictable的状态,一旦程序员在使用完页面之后忘记调用UnpinPage
方法,可能就会导致这个页面一直处于Unevictable的状态而一直留在BufferPool中。
为了可以避免这个问题,就采用一个数据结构将Page *
指针包裹起来——XXPageGuard
。
class BasicPageGuard {
BufferPoolManager *bpm_{nullptr};
Page *page_{nullptr};
bool is_dirty_{false};
}
通过PageGuard
中的API实现:1、当Page离开其作用域,不能再被访问到的时候,PageGuard
销毁时自动调用UnpinPage
;2、程序员可以主动调用Drop()
方法告诉BufferPoolManager,将不再使用这个页面。
🚀注意:移动构造函数和移动赋值函数的区别!
移动赋值函数,赋值等号左边的PageGuard可能已经初始化,正在guard一个page;
但是对于移动构造函数则不存在这个问题~
Task #2 - Extendible Hash Table Pages
如图所示是一个extendible hash table,它的header page的最大深度为2,directory page的最大深度为2,而且bucket pages最后存放两个entry。
Hash Table Header Page
/**
* Header page format:
* ---------------------------------------------------
* | DirectoryPageIds(2048) | MaxDepth (4) | Free(2044)
* ---------------------------------------------------
*/
由于最大深度是4,所以在header page中实际上最多只能存放16(1<<4)
个指向directory page的page id。
Hash Table Directory Page
/**
* Directory page format:
* --------------------------------------------------------------------------------------
* | MaxDepth (4) | GlobalDepth (4) | LocalDepths (512) | BucketPageIds(2048) | Free(1528)
* --------------------------------------------------------------------------------------
*/
-
MaxDepth:directory page的最大深度
-
GlobalDepth:当前directory page的全局深度,使用了几个bit位
-
LocalDepths:对于其中一个bucket page,使用的bit位数
-
BucketPageIds:存放指向bucket page的page id
-
下图展示了这些概念的含义:
Hash Table Bucket Page
/**
* Bucket page format:
* ----------------------------------------------------------------------------
* | METADATA | KEY(1) + VALUE(1) | KEY(2) + VALUE(2) | ... | KEY(n) + VALUE(n)
* ----------------------------------------------------------------------------
*
* Metadata format (size in byte, 8 bytes in total):
* --------------------------------
* | CurrentSize (4) | MaxSize (4)
* --------------------------------
*/
-
Remove()
的实现:考虑两种:1、在移除了key-value之后,做compression,使得[0, size)
中始终都是有效的键值对;2、只移除键值对,不做compression,在[0, max_size_)
进行Lookup和Insert(如何判断某个entry是空的?)-
使用的是第一种
-
后面在优化的时候选择了第二种
-
Task #3 - Extendible Hashing Implementation
Extendible Hashing (Dynamic approach to DBMS) - GeeksforGeeks
需要实现三个API:
-
GetValue
:只需要从HeaderPage开始向下检索即可。 -
Remove
:-
Merge Bucket:如果做完Remove操作之后,Page变为空,则需要进行Merge;Merge之后还可能需要进行Directory Shrink。
-
Merge Bucket只会出现在
local_depth==global_depth
的bucket上-
这个是错误的,导致这个想法的本质原因是GetSplitImageIndex的错误实现!!!
-
-
-
Insert
:需要考虑几种情况-
由于HashTable初始化完之后,只有一个HeaderPage,所以第一个插入的Key-Value,需要新建DirectoryPage和BucketPage。
-
如果BucketPage满了的话,需要进行Bucket Split和Directory Expansion。
-
-
注意点
-
一次Split Bucket可能不能达到目的,Split之后,要插入的页面可能还是满的,需要while循环直到能够顺利插入
-
注意GetValue和Remove方法中取出来的directory_page_id和bucket_page_id不一定是有效的(比如在没有插入任何Key-Value就进行GetValue或者Remove)
-
Merge和Split的一样,也不是一次就结束的,在完成一次Merge之后需要判断新的BucketPage的SplitImage是否为空(手册中已经给了提示),同样需要while循环
-
Task #4 - Concurrency Control
没有什么特殊的,使用Task#1中的API——FetchPageRead和FetchPageWrtie即可。
✋为什么? 首先BufferPoolManager是线程安全的,对于public的三个API:Insert、Remove和GetValue,它们执行的过程都是依次获取HeaderPage、DirectoryPage、BucketPage,然后根据Page上的内容做后续的操作。使用FetchPageRead和FetchPageWrite就是得线程在获取到对应的Page的同时也持有Page上的读锁或者写锁,能够在线程持有的时候做到并发控制。
Debug
有三个测试一直过不去
BUG1. InsertTest3
BUG2. RecursiveMergeTest
估计是漏了一次BucketMerge
BUG3. GrowShrinkTest
原因应该是在一次SplitBucket时,BufferPoolManager的NewPage失败,返回了空指针。
Solve1. 没有理解BucketSplit和BucketMerge的真正过程
解决了前两个bug!!
💬关于GetSplitImageIndex函数,之前的理解有误!
-
首先是对这个函数的含义理解错误:这个函数返回的是这个bucket在前面split的时候同时产生的另一个bucket的索引。所以这个函数应该是用在BucketMerge中的。
-
之前的理解是,用于计算一个bucket在split后产生的新的bucket的索引。
-
-
对bucket-spliting和bucket-merging的理解出现偏差
-
将它们简单地理解成了两个bucket merge或者一个bucket split。但是一个bucket page是会涉及多个bucket_idx的,所以要充分考虑所有指向这个bucket page的bucket_idx。
-
本质上,spliting和merging还是local_depth对应的
lsb
的收缩和扩展,考虑的应该是hash值的最低有效位,而不应该是索引。
-
Slove2. 没有注意BufferPool的size
🍳考虑一个情况:BufferPool的大小只有3个Page时,如果在插入时需要做BucketSplit,会发生什么?
进而需要考虑:BufferPool的大小为2(Page)时应该怎么办呢?为1呢?最小能为多少呢?
-
分析对于每个API,[当前实现]一次最多会使用到多少个Page?
-
GetValue:最多3个——Header、Directory、Bucket各一个。
-
Insert:最多4个——Header、Directory、Bucket各一个,如果需要进行BucketSplit,在while循环中会多一个BucketPage。
-
Remove:最多3个——Header、Directory、Bucket各一个。
-
-
这三个API理论上最少需要多少个Page同时在BufferPool中:
-
GetValue:最少只需要一个;可以依次取出HeaderPage、DirectoryPage、BucketPage。
-
Insert:最少需要三个;HeaderPage可以Drop掉。Split的时候需要DirectoryPage和BucketPage,以及一个新建的BucketPage
-
Remove:最少需要两个;HeaderPage可以Drop掉。Merge的时候需要DirectoryPage和BucketPage
-
-
所以,BufferPool的Size最少为3(Page)时,ExtensibleHashTable才能正常工作。
BUG4. 超时
但是这个test仍然无法通过,原因是超时!!
似乎是真的太慢了?猜测应该是BucketPage里的Insert和Remove实现太慢了
改进了BucketPage存储data的方式,添加一个bitmap,bitmap[i]表示array中index为i的键值是否为空,这样最坏情况下只需要[0, max_size_]
的遍历。
好消息:变快了;坏消息:还是超时?!
🙌需要明确的是:修改几千次就需要30s以上,确实是某些地方实现不恰
在本地统计了一下每insert/remove and lookup 10 keys所需要的时间,可以看到无论是insert还是remove,越往后所需要的时间越多。
完结!
经过接近一个星期的Debug,终于通过了
在重新实现了P1的BufferPoolManager之后,顺利通过了P2,在重写的时候优化了LRUKNode里对访问记录的存储:
-
之前是:一股脑全部存进
history_
-
优化后:
history_
中只存放最近的十条访问记录
下面是优化前后,ExtensibleHashTable运行过程中在LRUKNode的Access中打印出来的日志:优化前,history_中要存放6000左右个访问记录,而优化后只需要10个;而且在1500次左右的Insert-and-Lookup和Remove-and-Lookup的操作中,有18000次对Node的访问操作。
原因可能是:history_
中存储过多的数据,导致内存频繁换页,空间局部性丢失。