15-445(2021) PROJECT #2 - EXTENDIBLE HASH INDEX
TASK #1 - PAGE LAYOUTS
先看一下这张图片,留一个对extendible hashing的印象:
左边那个就是directory page,它有一个参数叫做global depth,1<<global depth为directory的大小。它存储了指向各个bucket page的指针。bucket page里面存储的则是实际的数据(在本实验中是std::pair类型的键值),每个bucket都有一个自己的local depth。
插入一个键值的过程是:先把key代入hash函数计算得到一个中间结果,取这个中间结果的最后global depth位(这就是global depth mask的作用),得到一个数组下标,bucket的page id就在这个下标里。根据page id调入bucket,然后把这个键值插入到bucket里面。
1.HASH TABLE DIRECTORY PAGE
如图是directory page的结构。注意,这个类是单独占用buffer pool的一个page的:
里面存储了这个类的page、global depth、以及指向各个bucket的指针以及它们的local depth。类里定义了一大堆方法等着我们实现,好在都有注释,先照着做就可以了。注意看一下注释里global_depth_mask和local_depth_mask的说明。
2.HASH TABLE BUCKET PAGE
这个类很奇怪,为了保证“Memory Safety”一上来就把自己的构造函数删掉了。看一下它的成员:
这个array_[0]是什么?在MSVC里,这么写是直接报错的,但是gcc支持这种写法,文档里称:
"Declaring zero-length arrays is allowed in GNU C as an extension. A zero-length array can be useful as the last element of a structure that is really a header for a variable-length object"。
这里就很明显了:一个HashTableBucketPage类占用一个整页,这个页的开头是两个bitmap数组,存储每个键值的位信息,之后的就全都归键值了。访问第i个键值的时候,可以直接用array_[i]的形式,因为array_[i]完全等价于*(array_ + i)。
每个键值对应两个位,array_[i]是不是空就看它的readable_位是不是0。occupied_的作用是提前结束遍历(可以看一下PrintBucket()这个成员函数)。在插入的时候occupied_位和readable_位要设为1,删除的时候就只需要把readable_置为0了。
插入、删除、读取的时候没有什么好办法,直接遍历搜索出所需位置就可以了。有一点需要注意,本实验是要求支持both unique and non-unique keys。key是可以重复的。所以,需要key和value同时相等才可以说两个键值相同。再有就是需要注意一下bitmap的操作。
假设有一个bitmap名为arr,是char类型的数组:
-
定位到目标位:
int numIndex = i / sizeof(char);
int bitIndex = i / sizeof(char);
通过这两个数就可以访问目标了。
-
获取第i个bit:
int s = (arr[numIndex] >> bitIndex) & 1;
-
把第i个bit置为1:
arr[numIndex] = arr[numIndex] | (1<<bitIndex);
-
把第i个bit置为0:
arr[numIndex] = arr[numIndex] & (~(1<<bitIndex));
虽然课程网站上没有提到,但是test/container目录下有一个hash_page_table_test.cpp,专门用来测试这两个类的实现,写完了之后可以跑一下这个测试。
TASK #2 - HASH TABLE IMPLEMENTATION
1.什么是extendible hashing
这个实验还是比较折磨人的,我做的时候感觉最难的地方不是代码实现,而是在于不知道什么是extendible hashing,因为这门课的Lecture和《数据库系统概念》都只是给了一些模糊不清的图示,原理什么的都不存在的,更别说伪代码了。我在查资料的过程中发现这个讲义还是比较靠谱的,一定要先看一下里面的insert操作伪代码。
www.mathcs.emory.edu/~cheung/Courses/554/Syllabus/3-index/extensible-hashing-new.html
初始情况下global depth为0,directory大小为1<<0=1,因此只有一个bucket,此bucket的local depth为0。这时插入键值,取哈希函数中间结果的后0位,得到的directory下标总是为0,所有的元素全进入这个初始bucket。
现在需要分裂了,现在显然global_depth == local_depth,我们要“Double the logical hash table”。把directory扩大一倍,新项依次指向旧项,global depth加1:
然后开始“Re-hash bucket[j] disk block (physical bucket) into 2 block (physical buckets) using i'+1 bits”。首先需要确定它的split image放在哪个下标。把页a的下标的最高位取反即可得到split image下标。取反某一个比特可以使用异或的方法:
split image index = bucket index ^ (1<<local_depth)
这里split image index显然是0^(1<<0)=1,所以新创建一个数据页,把它的page id覆盖到bucket_page_id_[1]中。
然后是重新分配页a中的键值。对于a中的每一个键值,使用Hash(key) & local_depth_mask来确定它进入a还是b。因为Hash函数可以把key均匀的映射到它的值域之中,所以a和b各能分配到大约一半的键值。最后要“Label each block (physical buckets) with i'+1”,把加1之后的local depth赋给a和b。
现在假设b满了,global和local都为1,把directory扩容一倍:
计算split image index = 01 ^ (1<<1) = 11。(如果从11方向插入键值时检测到b满了,就会得到split image为01,但是结果是一样的)创建新页c,分配键值,local depth加1:
现在b满了,扩大directory:
同上,得到页d:
这时000,010,100,110都指向a。这里就体现了local depth的作用“bits in RandonNumGen(key) used to find the physical bucket”。访问a的时候,只看哈希值的后1位,只要哈希值的最后一位是0,就会访问到a。
现在向a插入数据,当从000方向插入一条键值时,a又满了。这个时候global和local不等,显然可以在directory内部完成分裂。计算可得a的split image是010,执行rehash操作,然后把a和split image的local depth各加1。
但是100和110也指向a,怎么办?这种情况的做法是,从000开始向上下两个方向遍历,每隔1<<local depth=4,就在现在这格里填上a的page id。split image也是同理,从010开始上下遍历,每隔1<<local depth=4就填上它的page id:
2.实现
(1) Insert
实现直接参照前面伪代码即可。这里就总结一下几个坑点:
- 使用FetchPage之后要记得UnpinPage。insert操作会先载入directory page,然后载入需要的bucket page,在函数的任何地方return之前都要先把这两个页还回去。在ExtendibleHashTable的构造函数中,要用NewPage先创建directory page和第1个bucket page,创建完之后也要记得Unpin,否则会凭空多出一次pin count。
- 建议使用assert操作来检测所有的Unpin,这样可以快速定位错误。
- 在rehash的时候,我一开始的做法是,向buffer pool再申请一个临时页,把分配到原bucket的键值暂存到临时页里面,然后清空bucket,把临时页里的内容再插回bucket,最后删除临时页。这么做让我调试了两天,键值稍微一多就删除错误。这里建议是,不需要浪费一个buffer pool的位置,声明一个STL容器,把分配到原bucket的键值放到这个容器里就可以了。因为待分裂的bucket的大小是固定的,所以STL容器的大小不会超过BUCKET_ARRAY_SIZE,空间复杂度O(1),还是可以接受的。
(2) Remove
这里删除键值之后检测一下此bucket是否为空,空了就尝试merge。我的做法是,不看local depth,使用bucket index ^ 1<<(global_depth-1)来指定它的merge对象,如果二者local depth相同,直接把bucket index填上merge对象的page id即可。当所有bucket的local depth都小于global depth的时候就可以把global depth减去1,directory缩减一半了。
TASK #3 - CONCURRENCY CONTROL
这个实验里设置了读写锁,最简单的方法就是把getvalue全局加读锁,insert和remove加写锁。我不太懂多线程编程,所以就是这么写的,能满分通过gradescope测试,但是耗时70多秒。看了一下leaderboard前排能做到30秒左右,竟然还看到了一个嘉心糖哈哈,甚至有神人做到0.2s,果然我还是太菜了。