本系列为 CMU 15-445 Fall 2022 Database Systems 数据库系统 [卡内基梅隆] 课程重点知识点摘录,附加个人拙见,同样借助CMU 15-445课程内容来完成MIT 6.830 lab内容。
本节开始之前,先看一下目前课程的进度状态:
为了支持 DBMS 更高效地从 pages 中读取数据,DBMS 的设计者需要灵活运用一些数据结构及算法,其中对于 DBMS 最重要的两个是:
它们可能被用在 DBMS 的多个地方,包括:
在做相关的设计决定时,通常需要考虑两个因素:
Hash Table 主要分为两部分:
由于 DBMS 内使用的 Hash Function 并不会暴露在外,因此没必要使用加密(cryptographic)哈希函数,我们希望它速度越快,collision rate 越低越好。目前各 DBMS 主要在用的 Hash Functions 包括:
当 keys 可能出现重复,但 value 不同时,有两种做法:
如下图所示:
通常为了减少 collision 和 comparisons,Hash Table 的大小应当是 table 中元素量的两倍以上。
这样做的好处是什么呢?最大的好处就是各个键值对离自己的理想位置的距离变得“均衡”了,或许对查询的平均时间复杂度有一定的优化,但是一定复杂化了插入操作的时间复杂度。
实际上,经过实验发现,这种方法的效率不如线性探测法,不过这种方法的思想还是很有价值的。
本部分参考: 布谷鸟哈希(Cuckoo hash)
首先要理解布谷鸟的行为,这也是算法的核心:
布谷鸟哈希有几种变种,先介绍一个哈希桶和两个哈希函数的版本:
insert逻辑:
lookup逻辑:查找逻辑非常简单,去可能的两个巢穴里寻找,即去下标h1(x)和h2(x)寻找,若没有匹配上,则不存在(从这里可以发现查找是非常快的,且时间复杂度稳定是O(1))
下图展示的是两个哈希函数,两个哈希桶的版本:
为了防止我们不会陷入一个无限循环中,一旦我们发现了一个死循环或者达到最大循环次数,我们就需要对现有的hash table进行扩容。
以上介绍的 Hash Tables 要求使用者能够预判所存数据的总量,否则每次数量超过范围时都需要重建 Hash Table。它可能被应用在 Hash Join 的场景下,如下图所示:
由于 A, B 表的大小都知道,我们就可以预判到 Hash Table 的大小。
与 Static Hash Tables 需要预判最终数据量大小的情况不同,Dynamic Hash Tables 可以按需扩容缩容,本节主要介绍 Chained Hashing,Extendible Hashing 和 Linear Hashing。
Chained Hashing 是 Dynamic Hash Tables 的 HelloWorld 实现,每个 key 对应一个链表,每个节点是一个 bucket,装满了就再往后挂一个 bucket。需要写操作时,需要请求 latch。
这么做的好处就是简单,坏处就是最坏的情况下 Hash Table 可能降级成链表,使得操作的时间复杂度降格为 O(n)。
当然,如果桶中只有一个元素,那么可以将其视为链表,可以在链表长度到达指定阈值后,变成二叉查找树,提升查询效率。并且当负载因子达到指定阈值后,可以对哈希表进行扩容,此时如果不考虑性能,可以采用copy all策略,如果考虑性能,可以考虑渐进式扩容策略。
在Chained Hashing中,一种可行的方法是在链表变得过长时,将桶(bucket)进行分割,而不是让链表无限增长。这种方法可以避免链表过长导致的性能问题,并且需要进行分割时,只需要对特定的桶进行重新分配。
可扩展哈希方式包含一个slot数组和一系列的buckets,每个slot中保存对应bucket的指针。对于slot数组有一个全局bit位,记录在这个哈希表中需要多少位才能找到对应bucket,对于每一个bucket,有一个本地bit位,记录找到本地的bucket需要多少位。
extendible hash像一种结合hash与redix(前缀树)的技术的哈希方法
Extendible Hashing 的基本思路是一边扩容,一边 rehash,如下图所示:
可拓展哈希允许插入哈希值相同的记录。所以当插入4条哈希值一样的记录,一个桶就一定放不下(假设桶的容纳上限是3条)。这种情况被叫做overflow,而解决方法是用指针指向overflow bucket,也就是人为增加桶。这种方式看上去不美,也暴露了可拓展哈希的局限性,但一个方法在实际应用中确实无法确保永远的统一性,总是会需要“补丁”。实际应用中,可拓展哈希也不是最普遍的方法,更多则是B-Tree。
线性哈希是可扩展哈希的改进,可扩展哈希有一个小的性能瓶颈,在bucket分裂且需要扩展slot array时,需要对整个slot array加锁直到bucket分裂完成。为了解决这个问题,提出了线性哈希方式。哈希表维护一个指针,指向下一个准备分裂的bucket,并且线性哈希采用多个哈希函数来寻找正确的bucket。
线性哈希的数学原理:
此时所有 key % 4 = 1
5 % 8 = 5
9 % 8=1
13 % 8 = 5
由上面的规律可以得出
线性哈希的具体实现如所示:
为了方便叙述,我们作出以下假定:
分裂过程如下:
现在插入key = 27
对所有key进行新哈希函数运算后,将产生如下的哈希表:
key的读取:
假如我们现在希望去掉分割点,一旦哪个桶满了,马上对这个桶进行分割。
可以考虑了以下方案:
采用此种方案实现的LinerHash简易代码如下所示:
/**
* lineHash简单实现模型
*/
public class LineHash {
/**
* 单桶最多容纳元素个数
*/
public final int bucketMaxSize;
/**
* 分裂点索引
*/
public int overPoint = 0;
/**
* 每一轮的哈希桶数组初始大小
*/
private int initSize;
/**
* 哈希桶数组
*/
public Bucket[] buckets;
public LineHash(int bucketMaxSize) {
this.bucketMaxSize = bucketMaxSize;
// 初始桶数组大小为4
buckets = new Bucket[4];
initSize = 4;
}
public String get(int key) {
int index = calculateIndex(key);
return buckets[index].get(key);
}
public void put(int key, String val) {
int index = calculateIndex(key);
Bucket bucket = buckets[index];
// 判断当前桶是否满了
if (bucket.size() < bucketMaxSize) {
bucket.put(key, val);
} else {
//满了就进行分裂
bucket.put(key, val);
splitHash();
}
}
private int calculateIndex(int key) {
//根据分裂轮数调用不同的哈希函数
int index = hashFun(key, 1);
//当前桶产生了分裂
if (index < overPoint) {
//采用新的哈希函数进行计算
index = hashFun(key, 2);
}
lazyInitIfNeed(index);
return index;
}
private void lazyInitIfNeed(int index) {
if (buckets[index] == null) {
buckets[index] = new Bucket(bucketMaxSize);
}
}
public int hashFun(int key, int round) {
return key % (initSize * round);
}
public void splitHash() {
// 待分裂的旧桶
Bucket oldBucket = buckets[overPoint];
//分裂产生的新桶
Bucket newBucket = new Bucket(bucketMaxSize);
// 对旧桶中的元素进行rehash
Set<Integer> keySet = oldBucket.keySet();
List<Integer> removeKeyList=new ArrayList<>(oldBucket.size()/2);
keySet.forEach(key -> {
int index = hashFun(key, 2);
// 说明当前key需要被rehash到新桶中
if (index >= buckets.length) {
newBucket.put(key, oldBucket.get(key));
removeKeyList.add(key);
}
});
removeKeyList.forEach(oldBucket::remove);
// 将新桶添加进哈希桶数组中
addBucket(newBucket);
// 分裂点前移
overPoint++;
// 分裂点移动了一轮就更换新的哈希函数
if (overPoint >= initSize) {
initSize = initSize * 2;
overPoint = 0;
}
}
private void addBucket(Bucket newBucket) {
Bucket[] temp = new Bucket[buckets.length + 1];
System.arraycopy(buckets, 0, temp, 0, buckets.length);
temp[buckets.length] = newBucket;
buckets = temp;
}
/**
* 桶
*/
private static class Bucket {
private final Map<Integer, String> map;
private Bucket(int bucketMaxSize) {
this.map = new HashMap<>(bucketMaxSize);
}
public void put(Integer key, String val) {
map.put(key, val);
}
public String get(Integer key) {
return map.get(key);
}
public int size() {
return map.size();
}
public Set<Integer> keySet() {
return map.keySet();
}
public void remove(Integer key) {
map.remove(key);
}
}
}
这段代码逻辑不完全正确,本人目前能力所限,对liner hash理解还不够透彻,如果有大佬有补充,可以在评论区留言。
Hash Tables 提供 O(1) 的访问效率,因此它被大量地应用于 DBMS 的内部实现中。即便如此,它并不适合作为 table index 的数据结构,而 table index 的首选就是下节将介绍的 B+ Tree。