CMU-15445 2021 Project 2-Extendible Hash Index (可扩展的哈希索引)

本文档详细介绍了CMU-15445项目2——可扩展哈希索引的实现,包括哈希表的页面布局、目录页和bucket页的设计,以及并发控制策略。内容涵盖哈希表的目录页和bucket页的需求、实现细节,以及如何判断实现的健壮性。项目难点在于对锁的粒度控制以保证高性能,并提供了关于如何扩展和收缩哈希表的指导。
摘要由CSDN通过智能技术生成

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_id
  • void 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满了的时候需要扩容, 也就是调用后面要讲的SplitInsert
  • Remove 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手段

  • 31
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值