CMU_15445_P2_Extendible_Hash_Table
到Project2, 我们依然在处理数据库存储相关的部分, 从 Project1 中我们应该Get到两个概念:
- 数据库底层数据操作的基本单元是
Page
. buffer_pool_manager
是管理以及组织数据单元Page
的工具, 在Project2的第一部分, 我们还新增了页面守护(PageGuard
)的机制更加优雅的获取以及释放页, 还新增了对页面的读写锁的处理.
可扩展Hash表(Extendible Hash Table)
Project1 与 PageGuard讲述的是页面的存储的步骤与方式, 而这一节我们要讲的是数据库中数据的存储形式, 按照关系型数据库中的键值对的形式, 我们选取了可扩展Hash作为数据的存储形式. 可扩展Hash数据结构的存储方式可以参考之前的博客 传送门.
在Project2中实际上使用的是可扩展Hash的一个变体, 新增了一个Hash表头, 也就是Header的部分, 这里的可扩展Hash的结构如下:
这里的可扩展Hash表是一个带有Hash表头的表, 与我们之前介绍的可扩展Hash表存在下面的不同以及特征.
- 表头(header), 单个页目录(Directory), 单个 Bucket的大小均为一个Page大小, 但是实际存储的数据项的个数可以自行设置
Max_Size
. - 使用Hash函数获取一个Key的Hash值后, 使用最高的
Max_Depth
(Header的Depth) 作为页目录的索引, 指向使用的页目录. 使用Hash
值的最低Global_Depth
位作为页目录中目录项的下标, 也就是Bucket
的索引. - Hash表中有多个页目录, 页目录可以扩充, 但是存在大小限制, 可以自定义这个限制. 页目录的Global_Size最小可以是 0.
- 在Header以及Directory中, 页目录项存储的内容都是 Page_ID, 而不是一个指针, 它的功能和指针相同, 我们可以通过这个 Page_ID 来获取对应的页存储的信息, 或者修改这个页.
可扩展Hash表实现步骤
可扩展Hash表的索引
在Project2的可扩展Hash的索引过程中, 主要涉及两处, 分别是从Header到Directory, 以及从 Directory到Bucket.
从Header到Directory的过程使用的是Hash值的最高Max_Depth
位, 代码实现也很简单, 如下:
auto ExtendibleHTableHeaderPage::HashToDirectoryIndex(uint32_t hash) const -> uint32_t {
// 这里有一个bug就是当向右移动32位的时候, 存在循环移动的问题
if (max_depth_ == 0) {
return 0;
}
return hash >> (32 - this->max_depth_);
}
在从 Directory 到 Bucket的过程中, 使用的是 Hash值的最低 Global_Depth位作为 Bucket 的索引, 但是我们知道 Bucket 的Local_Depth是小于Global_Depth的, 因此指向在从Header指向Directory的时候, 一个slot只会指向一个Directory页面, 但是从Directory的页目录项指向 Bucket 的时候, 可能存在多个页目录项指向同一个Bucket, 指向一个Bucket的 Directory 中的 slots 的个数就是 \(2^{Global\_Depth - Local\_Depth}\) 也就是下面的这种情况.
如上图所示, 我们在获取页目录中某个 Bucket 的下标的时候, 一开始使用的是 Global_Depth, 这个 Bucket_Index 是指向该 Bucket 的多个 Index 之一, 当页面分裂, 页面合并的时候, 我们需要修改多个页目录的slot中存储的下标, 因此使用上述的方式快速的列出指向某个 Bucket 的所有下标. 例如:
当一个 Bucket 分裂之后, 我们很容易知道这个分裂的 Bucket 的初始下标, 然后使用下面的方式列出 Directory 中所有最低 Local_Depth 位相同的 Bucket_Index, 设置新的 Local_Depth 与 Bucket 的Page_ID.
// 这个函数的作用是设置页目录中所有指向某个Bucket的页目录下标, 指向这个Bucket的第一个页目录下标就是new_bucket_idx
template <typename K, typename V, typename KC>
void DiskExtendibleHashTable<K, V, KC>::UpdateDirectoryMapping(ExtendibleHTableDirectoryPage *directory,
uint32_t new_bucket_idx, page_id_t new_bucket_page_id,
uint32_t new_local_depth) {
// 设置页目录的 Index 到 Bucket 的新的映射关系, 也就是 Bucket Index
for (uint32_t prefix_idx = 0; prefix_idx < (1 << (directory->GetGlobalDepth() - new_local_depth)); prefix_idx++) {
// 找到所有与 bucket_index 指向同一个 Bucket 的所有 Index, 设置这些 Index
uint32_t add_bucket_idx = (prefix_idx << new_local_depth) | new_bucket_idx;
directory->SetBucketPageId(add_bucket_idx, new_bucket_page_id);
directory->SetLocalDepth(add_bucket_idx, new_local_depth);
}
}
可扩展Hash的插入操作
本次Project中插入操作实际较为简单, 我将插入步骤总结为下面的具体步骤:
- 通过键值对
<Key, Value>
的 Key 计算得到 Hash 值, 获取Header中的页目录的下标(Directory_Index), 以及在页目录中获取 Bucket 的下标(Bucket_Index). - 如果页目录, 或者 Bucket 为空, 新建页目录 Directory, 新建 Bucket并插入键值对. 插入后返回
- Directory 与 Bucket 存在, 判断 Bucket 是否为 Full, 否直接插入, 返回, 是进行第四步
- 计算 Bucket 分裂后需要的新的 New_Local_Depth, 以及计算分裂后指向第一个 Bucket 的 Index. 然后计算出分裂后指向第二个 Bucket 的 Index, 需要知道这两个 Bucket_Index 之间的关系, 这两个 Bucket_Index 的最低 (New_Local_Depth-1) 位相同, 但是第
Local_Depth
位不同, 分别位 0 和 1. - 将旧的 Bucket 中的键值对向新的 Bucket 中移动.
- 判断页目录是否需要扩充, 如果需要扩充, 页目录进行扩充
- 更新页目录的下标, 参考上一节中索引的更新, 使用
UpdateDirectoryMapping()
函数.
插入的过程如上述所示, 实际比较简单, 但是有一个细节的地方是, 我们知道一个 Bucket 中所有键值对的 Hash 值的最低 Local_Depth位是相同的, 但是当插入一个元素导致 Bucket Full的时候, Bucket需要分裂, 分裂的通常情况是 Local_Depth 增加 1, 但是存在 Local_Depth 增加超过 1 的情况, 这种情况如下:
上图中的这种情况需要在分裂的时候判断分裂之后的 New_Local_Depth 为多少, 我的判断方法如下:
template <typename K, typename V, typename KC>
auto DiskExtendibleHashTable<K, V, KC>::GetNewLocalDepth(ExtendibleHTableBucketPage<K, V, KC> *bucket_page,
uint32_t local_depth, uint32_t key_hash) -> uint32_t {
// 首先计算Local Depth 需要增加多少分裂才有效, 例如Bucket中为 7, 15, 31, 23的情况, 当前Local_Depth为1
// Local Depth 需要增加4才有效, 因为最低三位都是相同的
uint32_t new_local_depth = local_depth + 1;
while (true) {
// 提取 key_hash 第一个数字的倒数第 Local_Depth 位
uint32_t first_kth_bit = (key_hash >> (new_local_depth - 1)) & 1;
for (uint32_t key_value_index = 0; key_value_index < bucket_page->Size(); key_value_index++) {
// 判断每个Key的Hash值的最小第 new_local_depth是否相同, 不相同表示New_Local_Depth 满足大小
uint32_t kth_bit = (this->Hash(bucket_page->KeyAt(key_value_index)) >> (new_local_depth - 1)) & 1;
if (kth_bit != first_kth_bit) {
return new_local_depth;
}
}
new_local_depth += 1;
}
}
可扩展Hash的删除操作
首先我们解释一下如何查看一个 Bucket 的 Split_Bucket. 示例如下:
- 我们首先要知道, 一个计算一个 Bucket 的 Split_Bucket 的Index的计算方法, 也就是我们在插入的时候计算的, 最低的 (Local_Depth-1) 位相同, 但是 Local_Depth 位不同. 所以通常一个 Bucket 的 Index 的 Local_Depth位 为 0, 而它的 Split_Bucket 的Local_Depth位为 1.
- 我们删除后为空的 Bucket 可能不是原来的 Bucket, 而是 Split_Bucket. 上面的例子就是如此, 页目录的 Global_Depth 为 4, Local_Depth为 3, 可以找到指向这个 Bucket 的第一个 Index 为
0110
. 而0110
的第三位是 1, 不是 0, 因此这是一个被 Split 出来的 Bucket. 它原来的 Bucket 的 Index为0010
. 最低两位相同, 而第 Local_Depth 位不同. - 当我们发现一个被 Split_Bucket 的为空的时候, 需要找到原来的 Bucket, 同理, 如果一个 Bucket 为空, 我们要找到它的 Split_Bucket.
- 无论是 Bucket 为空还是 Split_Bucket 为空, 我们在合并的时候, 都将 Split_Bucket 向 Bucket 合并.
删除流程可以用下面的图示简单描述:
- 将 Bucket 与 Split_Bucket 合并
- 某次Bucket合并之后, 需要再次合并, 递归和合并空的 Bucket
删除操作与插入操作实际上是相反的, 插入的流程实际也比较简单, 总结如下:
- 键值对正常删除, 如果删除后 Bucket 不为空, 直接退出, 如果为空需要执行下面的步骤将 Buckets 合并.
- 判断这个 Bucket 的Local_Depth是否大于0, 等于0 不再进行合并, 退出循环, 否则执行下面的合并步骤.
- 判断这个 Bucket 与其对应 Split_Bucket 的 Local_Depth 是否相同, 相同进行合并, 否则 break, 需要注意这里 Split_Bucket 的 Index 的计算方式如下:
// 获取第一个指向这个 Bucket 的 Bucket_Index
uint32_t start_bucket_idx = bucket_index & (1 << (directory_page->GetLocalDepth(bucket_index) - 1)) - 1;
// Split_Bucket 的 Index的 directory_page->GetLocalDepth(bucket_index) - 1 位与Bucket_Index相同, 但是 directory_page->GetLocalDepth(bucket_index)位不同
uint32_t end_bucket_idx = start_bucket_idx | (1 << (directory_page->GetLocalDepth(bucket_index) - 1));
- 将Bucket_Index 的Local_Depth降低
- 如果一个 Bucket_Index 指向的 Bucket 不存在, 本质上是空, 可以合并, 但是无需移动 Bucket 中的元素, 直接修改 Directory 中的 Index 下标即可. 这里是我前面 Insert 的时候的实现方式不同, 如果Local_Depth 一下子增加很多, 会存在一个空的页目录项.
- 将两个 Bucket 合并, 合并的时候, 我们均将 Split_Bucket 向 原来的 Bucket 移动.
- 合并 Bucket 之后更新页目录项的下标, 指向合并后的 Bucket.
- 由于 Local_Depth 降低, 可能会导致 Global_Depth 小于 Local_Depth, 然后判断页目录是否需要缩小, 缩小页目录.
- 跳回到 2, 循环进行判断.
总结
首先列一下自己在实现过程中的一些 BUG, 大部分都是不够细心导致的.
- 单个页目录的大小需要考虑最大值, 也就是当某个Bucket需要分裂到
Local_Depth
为页目录的Max_Depth+1
的时候, 实际无法分裂, Hash 表溢出, 返回 False. GetValue
应该返回一个 bool 值, 这个值表示是否在Hash Table中找到 Key 对应的 Value, 并且写入 result数组中- Remove 中, Bucket中的某一个元素会被删除, 因此后面的元素应该向前移动, 进行元素替换, 否则下次查找的时候会找到错误的元素上. 例如
[{0,0}, {1,1}, {2,2}, {3,3}]
. 因为Bucket
中的 array_是一个数组, 因此删除一个后, 后面的要向前移动 - 数据类型转换存在错误, 在获取页目录的时候使用下面的代码转换:
auto *directory_page = this->bpm_->FetchPageWrite(directory_page_id).template AsMut<ExtendibleHTableDirectoryPage>();
,这里的AsMut
模板函数仅仅将一个 Page 的 data 部分返回, 但是在释放锁的, 将页面返回的时候使用Page *original_header_page = reinterpret_cast<Page *>(header);
是错误的, 因为 header 只是数据部分, 直接转换为 Page 类型是错误的. - 在可扩展HashTable中, 每次申请一个 guard 守护页面的时候, 函数结束的时候会自动释放这个 gurad, 也就会调用对应的析构函数, 释放在函数开始时申请的锁, 所以每次锁无需提前手动释放, 可以自动释放. 可以直接在析构函数中判断是否已经释放过了这个资源, 如果已经释放过, 直接返回.
- 当一个 Bucket 变成空之后需要将两个 Bucket 合并, 假设将 Start_Bucket 与 End_Bucket 合并, 那么先前指向这两个Bucket的所有页目录的 Index 都需要指向新的 Bucket, 并且 Local_Depth 需要减一
- Mistake, 在判断新的 Local_Depth的时候, 将一个新增的Local写到循环外面了.
- 插入一个元素计算新的 Local_Depth的时候应该将这个新的键值对考虑到计算新的 Local_Depth 中, 放在一起计算
- 删除Bucket合并的过程中, 如果有 Invalid 的页面, 还是需要合并, 而不是直接break, 还有将页目录缩小的时候, 需要循环的判断, 直到不可以缩小.
- 删除Bucket之后, 合并Bucket之后, 需要将页目录中所有指向这个 Bucket 的index对应的Local_depth降低再判断这个页目录是否需要缩小, 否则有些Local_Depth没有更新, 页目录不会缩小.
- 这个BUG找了好久, 感觉是可扩展Hash的思想和我不一致, 测试的可扩展Hash运行Global_Depth小于1, 并且允许非空的两个Bucket合并, 只要两个合并之后不溢出即可.
工作之余完成这个实验还是花费了我不少的时间, 时间跨度巨大, 但是这个实验收获还是蛮多的, 从最开始的各种数据结构的复习, 到C++移动构造函数这些语法的学习, 不管是学习还是复习, 用的还是蛮多的, 还有就是 Debug, 学会了使用 LOG_DEBUG
就开始偷懒提交到 GradeScope, 然后判断是否通过, 偷看一些中间日志, 不过有些地方确实可能只是思路不同.