CMU-15445 2021 Project 2-Extendible Hash Index (可扩展的哈希索引)
先贴结果图和LeaderBoard:
CMU禁止公开源代码哦~, 有问题欢迎私聊, 评论或者加群: 484589324交流~
哈希对于单点查询有很强大的性能, 很多数据库都同时实现了哈希和树型索引, 以此来提高吞吐
本次的Project是实现一个可扩展的哈希表, 难度应该来说是比较高的, 比Pro1会高一些, 对锁的粒度要严格控制才能有一个比较好的吞吐
本次实验官方说是有三个子实验, 但是第三个实验是对第二个实验提供并发安全, 我认为后两个实验应该合并起来, 这样一来难度2 > 1
1. PAGE LAYOUTS (哈希表的页面布局)
我们的哈希表是基于磁盘可持久化的, 也就是说, 我们要以页为单位来进行哈希表的存储, 这样一来我们也能充分利用Project1中的buffer pool manager来进行页的抓取
我们要实现的哈希表是可扩展的哈希表, 为了确保你理解这个哈希表的过程, 建议去学习一下官网的HomeWork2, 否则接下来的实验可能会有困难
我们要实现的有两个类型的页, 一个是目录页(存储slot和bucket的映射关系), 一个是实际存储数据的bucket页, 其中目录页只有一页, 而bucket页是很多的, 我们首先来看一下目录页
1-1. HASH TABLE DIRECTORY PAGE(哈希表的目录页)
1-1-1. 讲解一下需求
我们要维护哈希表的全局深度global_depth, 还要维护每一个slot的local_depth, 当然了, 还有最关键的bucket_page_ids, 维护的是slot和bucket的映射关系
下面给出一些我认为需要注意的函数:
uint32_t GetGlobalDepthMask()
: 获取全局深度掩码, 根据官方的注释来即可void IncrGlobalDepth()
: 不能只简单的增加全局深度, 全局深度增加的同时带动slot数组扩容, 我们要维护新的slot的local_depth和page_idvoid DecrGlobalDepth()
: 简单的减小全局深度即可, 因为扩容的时候一切都会被重置, 所以不要有额外的思维负担uint32_t GetSplitImageIndex(uint32_t bucket_idx)
: 你现在可能还不理解这个东西在干什么, 他的作用是获取兄弟bucket的bucket_idx(也就是所谓的splitImage), 也就是说, 我们要将传入的bucket_idx的local_depth的最高位取反后返回bool CanShrink()
: 判断能否缩容, 也就是判断是否所有的local_depth都比global_depth小
1-1-2. 注意点
- 无注意的点, 位运算仔细一些即可, 无并发问题需要考虑(目录页被修改的时候整个哈希表都是锁的)
接下来是bucket页
1-2. HASH TABLE BUCKET PAGE
1-2-1. 讲解一下需求
该哈希表允许重复的key, 但是不允许(key, val)同时重复
三个经过压位的数组成员如下, 其中occupied_没什么用, 维护意思一下即可, 也许是历史包袱:
occupied_
: 用于判断该页的某个位置是否曾经被访问过(一次访问, 永远为true)readable_
: 用于判断该页的某个位置现在是否有数据array_
: 存储真正的(key, value)键值对
函数的实现很依赖位运算, 很多函数都是寥寥一行或几行, 但是要小心仔细
-
bool GetValue(KeyType key, KeyComparator cmp, std::vector<ValueType> *result)
: 我们遍历整个bucket, 在Readable()的位置, 用cmp比较一下key值, 相同的话就存入*result里 -
bool Insert(KeyType key, ValueType value, KeyComparator cmp)
: 找一个空位插入即可, 规则前面也说过了, 是不允许相同的(key, val), 允许只有相同的key, 当然, 没有空位了就直接返回吧 -
bool Remove(KeyType key, ValueType value, KeyComparator cmp)
: 和Insert差不多, 比较简单, 会写Insert不可能不会写Remove -
void RemoveAt(uint32_t bucket_idx)
: 将某一个位置的Readable置为0, 位运算需谨慎, 先定位到数, 再定位到位是一个好的思路, 官方这边以单字节的char进行存储, 注意优先使用低位还是高位自己要想清楚, 并且始终如一, 优先高还是低位其实Both OK -
IsOccupied SetOccupied IsReadable等
: 都差不多, 会写RemoveAt的话这些相信都不是问题 -
IsFull IsEmpty
: 不要挨个判断IsReadable什么的, 如果先用一整个char为单位来判断, 再去处理细节, 相信效率会快8倍左右
1-2-1. 注意点
- 多看头文件的注释, 一个char我们优先从左边还是右边使用要想清楚, 不用注意并发问题
2. HASH TABLE IMPLEMENTATION + CONCURRENCY CONTROL (哈希表的实现, 重头戏)
注意, 我把官方的第二和第三个实验合并了, 因为这就是我本人完成的过程, 我认为合并会更好
2-1. 讲解一下需求
哈希表在磁盘上如何存取已经实现完了, 接下来是真正的哈希表逻辑
成员中主要的是:
page_id_t directory_page_id_
: 在构造函数中初始化即可, 用来获取目录页BufferPoolManager *buffer_pool_manager_
: bpm, 用来获取目录页和bucket页, 肯定要有的ReaderWriterLatch table_latch_
: 表锁, 平时用读锁, 只有在split或merge的时候才加写锁, 性能会非常高(看LeaderBoard), 而且完全合理
注意:
- 当需要页级锁的时候, 要将页reinterpret_cast<Page*>强转为Page指针, 这样就能加锁了
要实现的函数不多, 但是我觉得应该分组来讲:
2-1-1. 构造函数
ExtendibleHashTable()
: 初始化(new一个)目录页, 思考一下, 初始global_depth为0, 有一个bucket即可, 我们new出一个bucket页, 让目录页中下标为0的slot指向该bucket即可
2-1-2. Fetch(取页)函数
FetchDirectoryPage FetchBucketPage
: 取的时候利用BufferPool即可, 取出来之后调用者要记得及时的Unpin
2-1-3. 页级函数(一些页级的增删改查)
表级别加个读锁即可, 页级根据需要选锁, 表级别加读锁的原因是防止后面splitInsert和Merge的时候乱入
bool Insert(Transaction *transaction, const KeyType &key, const ValueType &value)
: 加锁规则如上, 针对Insert, 要注意的是bucket满了的时候需要扩容, 也就是调用后面要讲的SplitInsertRemove GetValue
: 都和Insert差不多, 只是Remove的时候如果页空了要Merge, Merge里又会Shrink, 官方也都有讲, 下面讲解最难的SplitInsert和Merge函数
2-1-4. 表级函数
表级写锁加上, 整个表就瘫痪了, 此时我们可以干想干的任何事情
bool SplitInsert(Transaction *transaction, const KeyType &key, const ValueType &value)
: 加表级写锁, 然后我们此时要判断一下key所在的bucket是否还是满的(并发问题, 说不定在抢到表级写锁之前Remove乱入了, 说的极端一点, 可能表都被清空了, 这个时候当然不要再继续Split了), 如果不再是满的了, 就返回. 如果依然爆满, 就循环增加所有page_id是bucket所在的page_id的slot的local_depth, 然后我们获取原bucket的所有kv(我回去加了个GetAllPairs方法), 然后清空原bucket(我又回去加了个Clear方法), 然后重新new一个bucket, 让local_depth最高位为1且page_id为原bucket_id的slot指向new_page. 最后我们重新插入所有kv, 再重新调用Insert即可(这是因为split后可能还是一个bucket满一个bucket空的情况, 要递归的split, 官方也有提示)void Merge(Transaction *transaction, const KeyType &key, const ValueType &value)
: 注意官方注释里给出的何时取消merge的三点, 最重要的一点就是如果key所在的bucket又不空了(并发问题, Insert乱入), 就可以返回了. 如果没有返回, 我们让bpm_->Delete掉老bucket, 然后获取bucket的兄弟bucket(SplitImage, 想起前面的那个方法了吗), 让所有page_id是老bucket所在的page的id的slot的指向兄弟bucket所在的page, 当然, 别忘了减少local_depth. Merge完了之后, 还可以用CanShrink()尝试缩容, 因为只有Merge之后才有可能缩容, 所以只在这里缩容我认为是合理的
3. 如何判断自己的实现是否健壮
官方给的test是非常弱的, 你只要把数值简单的增大例如50倍, 100倍也许就能命中一些问题, 再根据你自己的逻辑多加一些test就更好了, 不要怕写test麻烦, 这是非常有效的 Debug手段