其实我做过去年的lab,但没有写年轻人的第一棵B+树难免是一种遗憾,于是寒假决定写完B+树就收手!
Task #1 - Extendible Hash Table
这一部分是要实现一个可拓展哈希表,作为去年的Project-2,我已经接触过了,所以写起来没啥太难得地方,但还是有个bug卡了一会儿。
Bucket的插入删除查找很简单,不再赘述,主要介绍可拓展哈希的扩容原理。
可拓展哈希有一个目录,目录元素是指针,指针指向一个实际的Bucket
1 | std::vector<std::shared_ptr<Bucket>> dir_; |
可拓展哈希最大的特点就是多个指针可以指向同一个bucket,这也是为什么目录要存shared_ptr了。
当一个bucket存满了,会触发split操作。
在介绍split操作之前,我们先熟悉一下Extendible Hash的插入流程。
将一个键值对 (K,V) 插入哈希表时,会先用哈希函数计算 K 的哈希值 H(K),并用此哈希值计算出索引,将 键值对放入索引对应的 bucket 中。
Extendible Hash 计算索引的方式是直接取哈希值 H(K) 的低 n 位。在这里,我们把 n 叫做 global depth。例如,K 对应的 H(K) = 1010 0010b,假设 global depth 为 4,则对应的 index 为 0010,即应将 V 放入 directory 里 index 为 2 的指针指向的 bucket 中。
刚初始化的Extendible Hash有一个directory page和一个bucket page,此时的global depth为0,因此不论H(K)得到什么结果,都会存进这个index为0的bucket。
哈希表的初始化
1 | template <typename K, typename V> |
Bucket的初始化,local depth设为0
1 | template <typename K, typename V> |
假设bucket 的容量为 2,现在向表中插入 KV 对,仅关注 K。假设 H(K) = K。
首先插入 0 和 1。由于 global depth 为 0,所以 H(K) 计算出的 index 均为 0:
再插入 2。index 仍然为 0。而此时 bucket 已满,无法继续插入 2,则需要进行之前提到的 split 操作。这时的 split 包含如下几个步骤:
- 判断bucket的local depth是否等于global depth;如果等于,转到2;否则转到3
- local depth++,global depth++,directory 容量翻倍
- local depth++
- 创建一个新的 bucket
- 重新安排指针
- 重新分配 KV 对
初始时global depth = local depth = 0,因此global depth需要加1,directory翻倍。
前面提到对于一个bucket,可能有多个指针指向它。假如index为0的指针指向的bucket的local depth为1,global depth为3,如果此时有四个数0、2、4、6需要插入,分别计算得到H(K1) = 000b、H(K2) = 010b、H(K3) = 100b、H(K4) = 110b,因此它们应该分别被插入索引为0、2、4、6的指针指向的bucket,而四个哈希结果的相同点是最低一位都是0,index为0的指针指向的bucket的local depth为1,它只看最低的一位是否为0,000b、010b、100b、110b显然符合要求,因此这四个数都会存入index为0指针指向的bucket:
我们不难发现规律,对于一个bucket,如果其local depth为L,假设当前global depth为G=2,则一共有2^(G - L)个指针指向它。
当这个bucket满了的时候,需要一个更长的哈希值来区分bucket内的元素,比如当前bucket内的元素哈希后低四位都是1010b,为了能将这些元素进一步区分,需要将这些元素划分为1
1010b和0
1010b两组。
当L < G时,local depth还有增长的空间,只需要新建一个bucket,将其local depth设为L + 1,将这个2^(G - L)个指针分为两组,按要求分别指向原来的bucket或新建的bucket。
当L = G时,只有一个指针指向这个bucket,bucket内的元素的低G位完全相同,因此必须先将global depth加1,使bucket内的这些元素可以按低G+1位哈希值分为两组,插入到新建的bucket或保留在当前这个已满的bucket中。当global depth增加时,目录需要扩大,因为我们计算H(K)然后得到的低G位实际是就是目录下标而已。
因为今年的哈希表不需要考虑缩容,所以只要关注插入就行了。
1 | template <typename K, typename V> |
首先记得加锁
在插入前需要查一遍是否已经存在key/value,如果存在,需要先移除再插入,这是跑测试样例才发现的。
随后我们需要判断当前插入的bucket是否已满,如果是满的,需要进行分裂。
注意这里需要while而不能用if,因为有可能当前bucket里的元素在分裂后仍然可能放进同一个bucket里,如果bucket里存放了0010b和1010b两个key,bucket的局部深度为2,看后两位,即使扩容了之后局部深度达到3,看后三位,它们俩还是会分到这个bucket里。
还需要注意的是循环里要用dir_[IndexOf(key)],而不能在之前用一个变量存IndexOf(key),然后在循环里使用这个变量
1 | template <typename K, typename V> |
IndexOf函数的返回值与global_depth相关,而global_depth在分裂的时候是可能改变的,如果全局深度变了,那么计算得到的bucket的位置也就变了,然后导致错误。可以推导一下按0 1 2插入和按0 2 1插入的区别,这也是在跑测试时才发现的,因为多线程的原因,有时会按0 2 1的顺序插入,然后导致bug。
然后就是分裂的过程了
首先判断是否需要扩容
1 | if (split_bucket_local_depth == GetGlobalDepthInternal()) { |
如果当前bucket局部深度等于全局深度,需要扩容,将指针数翻倍,然后将后面这部分指针依次赋值,建议将指针元素下标的十进制转换成2进制算一下为什么要像上面这样赋值了。
扩容后增加需要分裂bucket的局部深度
随后我们需要真正的新创建一个bucket,上面的扩容只是shared_ptr的复制,增加的只是引用计数罢了。
1 | std::shared_ptr<Bucket> image_bucket = std::make_shared<Bucket>(bucket_size_, split_bucket_local_depth); |
然后我们需要找到目录中某个位置的bucket A,A下标的2进制形式和当前bucket B下标的2进制表示,在B的局部深度的长度下只有最高位不同。
假设当前全局深度为4,B的下标表示为 1010b,满了需要分裂,局部深度由2变成3,那么看后三位是010b ,由于B内的元素本来只要看两位的,即10b,现在满了需要区分它们,所以需要将它们分为010b和110b两组,而这个110b就是我们要找的那个bucket A在目录里的位置
1 | int image_bucket_index = 1 << (split_bucket_local_depth - 1) ^ split_bucket_index; |
随后我们需要将指向A和B的指针重新分配,因为它们在分裂前肯定都是指向B的,现在需要将它们中的一半指向A,一半指向B。
1 | int diff = 1 << split_bucket_local_depth; |
剩下的指向B的指针不需要改变指向。
随后将B的元素按哈希值插入两个bucket即可。
Task #2 - LRU-K Replacement Policy
这一部分是实现LRU-K淘汰策略。我的实现是用哈希表维护frame信息
1 | std::unordered_map<frame_id_t, FrameInfo> frame_map_; |
1 | struct FrameInfo { |
frameInfo里保存当前frame存放的page的最近k次访问记录,这里隐含的意思就是timestamps_list_的长度最长为k,如果超过k次了,需要将最久远的那一次访问时间戳从队头移除,然后在队尾插入最新的这次时间戳。
然后需要一个is_evictable_来判断当前frame上的page是否可以淘汰,初值需要为false
LRU-K的淘汰策略,简而言之是先看有没有哪个frame的访问次数少于K次的,如果有,则挑出来,然后从它们之中淘汰最早访问时间最久远的,即比较这些frame的frameInfo的队头元素,然后淘汰队头元素最小的。如果每个frame都被访问了至少K次,那么就从所有的frame里淘汰最早访问时间最久远的。
这部分理解了淘汰流程后实现相对简单,但也有一些需要注意的:
replacer_size_是淘汰队列的最大长度,基本没有,反正超出了这个大小就抛异常。curr_size_才是真正的淘汰队列的长度。
SetEvictable函数,只有操作的frame的is_evictable_为true且set_evictable为false或者这两个正好相反时才需要处理curr_size。
Evict函数直接遍历哈希表就行了,我也想不到别的好方法…
Task #3 - Buffer Pool Manager Instance
这一部分没啥好说的,网上博客很多,配合注释很好理解。
对于New和Fetch,都是先从freelist找,没找到就淘汰一个frame(Fetch函数需要先查哈希表看有没有要找的page,有的话就直接返回了),所以可以抽出一个公共的函数以供调用。
1 | auto BufferPoolManagerInstance::GetFreeFrame(frame_id_t *frame_id) -> bool { |
还需要注意的一点是只有Fetch和New才需要调用RecordAccess,每次调用RecordAccess还记得调用一下SetEvictable,以防万一。