Hash Tables
之前的课程学习了Disk Manager和Buffer Pool Manager,接下来开始学习Access Methods.
Access Methods的主要功能就是支持DBMS的执行引擎去从Page中读取或者写入文件。
主要有两种类型的数据结构:
- Hash Tables(无序)
- Trees(有序)
Hash Table实现了可以将任意key映射到具体value的无序关联数组。对于一个给定的key, 他会使用hash函数来计算key在该数组中的offset,然后根据offset就可以拿到想要的value。
对于Hash Table, 空间复杂度是O(n), 时间复杂度 平均是O(1), 最差是O(n)
Hash Table中的两个重要概念
-
Hash Function: 如何把大范围的key映射到一个小区间,并同时权衡好Hash速率和冲突率。
-
Hashing Scheme: 如何处理好hashing之后的冲突,并权衡好hash table的空间和额外的插入/获取操作
Hash Functions
哈希函数就是给定一个key,然后返回一个代表这个key的integer。我们理想的哈希函数就是返回结果很快且哈希冲突也很小。 以下是一些常见的哈希函数。
Static Hash Schemes
静态hash schemes的hash表大小是固定的,当hash表被用完,则会进行扩容和重建。
对于静态hash schemes,处理hash冲突的方法有两种:
- Linear Probe Hashing
- Cuckoo Hashing
Linear Probe Hashing
Linear Probe Hashing(线性探测哈希)是一种解决哈希冲突的方法。它属于开放地址法(Open Addressing)的一种形式。在Linear Probe Hashing中,当发生哈希冲突时,即两个键映射到同一个桶(bucket)时,线性探测哈希会顺序地探测下一个可用的桶,直到找到一个空闲的桶或达到哈希表的末尾。具体步骤如下:
- 使用哈希函数将键映射到哈希表的初始位置。
- 如果该位置为空闲,则将键存储在该位置。
- 如果该位置已被占用,则继续顺序地检查下一个位置,直到找到一个空闲的位置。
- 如果达到哈希表的末尾仍未找到空闲位置,则可以绕回到哈希表的起始位置,继续线性探测。
- 重复步骤2-4,直到成功地插入键或确定键不存在。
线性探测哈希的主要优点是简单而高效,不需要额外的数据结构来处理冲突,且内存访问连续性较好。然而,它容易引起聚集现象(clustering),即相同的哈希值的键倾向于聚集在一起,导致查找性能下降。此外,删除操作也稍显复杂,因为删除一个键后,后续的线性探测链可能会被打断。
插入
如下图所示,插入E时,发现hash值的位置已经被A占用,然后一直探测下一个位置,发现都被C,D,E占用,最终放到了E的下一个bluck中.
删除
在删除元素时,如果直接把对应的bluck给清空,比如把C给清理掉,那么就会导致找不到D和E,因为到C 系统就以为结束了,不会继续往下探测,解决这个问题有两个方法
Movement
该方法会把keys都重新rehash直到找到第一个空的slot, 这个方法代价很高,可能会重新组织整个table, 没有DBMS会这么做
Tombstone
该方法会在被删除的slot位置设置一个标记,表示该slot的数据已经被逻辑上删除了。如果有新的key插入,可以放到这个slot中。用这个方法需要定期的垃圾清理。
Non-Unique Keys
对于特殊场景,比如一个key对应多个value的非唯一key场景, 主要有两个方法
- 每个key都对应单独的列表来存储value
- 存储冗余的元素,大数据系统都是选择这种方法
Cuckoo Hashing
Cuckoo Hashing的核心思想是使用多个hash算法来找到空闲的slot,如果有冲突,则把之前存放的值进行回退。
主要流程如下:
- 使用两个独立的哈希函数,将键分别映射到两个不同的哈希表位置(通常称为两个桶)。
- 当插入一个键时,首先检查两个哈希表位置是否有空闲空间。如果至少有一个位置为空,则将键存储在其中一个位置上。
- 如果两个位置都已被占用,选择其中一个位置,并将已存储的键替换到另一个位置上。这个过程可能会引起链式反应,即替换的键在另一个位置上也引发冲突,进而替换其他键,直到找到一个空闲位置。
- 如果替换过程中找不到空闲位置,需要进行重新哈希操作,即重新选择两个哈希函数,然后重新插入所有的键。
- 重复步骤2-4,直到成功插入键或达到最大重哈希次数
Dynamic Hash Tables
上面提到的静态Hash tables都需要DBMS知道存储的元素个数,如果需要扩容,则需要rebuild整个hash table。 而动态Hash tables则可以在不重建的情况下根据需要增量调整大小。
Chained Hashing
该方法会在每个slot中维护一个buckets链表。如果发生hash冲突,则把数据插入到对应slot的链表中。获取的时候也是通过hash函数找到对应的slot,然后遍历链表即可。
这样就可以基于链表做到几乎无限增长,但问题就是如果数据量太大,链表的查询性能会下降。
Extendible Hashing
相较于上一种方法会让链表无限增长,该方法会超过大小限制的bucket进行拆分。
首先有一个global counter的数值,表示取hash值的前多少位作为key。比如插入B时,B的hash值前两位是10,则把他让入10对应的bucket中。插入C,C的hash值前两位也是10,此时10对应的bucket已经满了,需要进行拆分。
此时会把global counter值加1,变为3,代表取hash值的前三位作为key。同时把之前10对应的bucket进行拆分, 100开头的放入一个bucket, 101的放入一个bucket, C是101开头的,因此放入到拆分后的bucket中。完成了一个插入操作。
Linear Hashing
Linear Hashing会对超过大小的bucket进行扩容,并且新增一个slot,还有一个split pointer来指向需要rehash的slot.
比如当前有4个slot,把17放进去时,发现对应的第二个slot中的bucket已经满了,此时就会对这个bucket进行扩容。
同时会把slot的个数加1,新增一个slot, 并对split pointer指向的slot中的数据利用新的hash函数来重新分配位置,比如之前在第一个slot里面的20,在新hash函数后被分到了第五个slot中。Split Pointer也会向下移动一位。