曾经沧海难为水,除却巫山不是云。|

Joey-Wang

园龄:4年3个月粉丝:17关注:0

2022-11-22 18:10阅读: 164评论: 0推荐: 0

Lecture#06 HashTables

review:

image-20220308145716969

我们已讨论了如何往磁盘上存储数据,如何在磁盘上表示 page;如何将这些 page 放入内存、buffer pool 及其优化;并且通过⼀种策略来决定何时将 page 从 buffer pool 中移除,以及当我们在做写操作时,该如何锁住(pin)东⻄。

1 数据结构

现在我们要来讨论下 buffer pool manager 之上的东⻄,即 Access methods。这是一种用于对数据库数据进行读/写的方式(数据库存放在磁盘的page中)。接下来几节中,我们会讨论数据系统内部所维护的数据结构—— hash tables、trees。

一个 DBMS 为系统内部的许多不同部分使用不同的数据结构:

  • Internal Meta-Data(内部元数据):跟踪数据库和系统状态的信息。
    🌰 Page Directory 或 Page Table 来检索对应的Page时,就是一个hash table。

    Page Directory(heap文件表示形式之一)维护 pageID 到磁盘物理位置(文件路径+偏移量)的映射

    Buffer Pool 中提到额外的 indirection 层 Page Table 维护 pageID 到内存中的位置(frame)的映射

  • Core Data Storage(核心数据存储):可以用作数据库中 tuple 的基础存储。数据库所存储的数据,可以被组织成一个 hash table 或 B+ tree 或者其他树结构。
    🌰 memcache 本质是一个 hash table;MySQL 的 innodb 引擎使用的是 B+ tree(将tuple存在叶子节点)

  • Temporary Data Structures(临时数据):执行查询或者高效计算时临时创建的数据结构。
    🌰 例如 join 时创建的 hash table。

  • Table Indexes(表索引):本质是用 tuple 的 key 构建一个目录,辅助我们快速查找到某个 tuple,避免顺序检索。

这些数据结构的设计需考虑的问题:

  • Data Origanization:如何在内存/ Page 中组织数据的存储,并且支持快速的读写与增删。
  • Concurrency:如何支持多线程环境对数据结构的访问。🌰 对于同一个内存位置的数据,一个线程在写,另一个线程同时在读,就可能会出现问题(脏读)。并发控制对于后续的事务处理将会是很重要的一个考量。

这节课的重点是 hash table,且只考虑单线程的情况(这样我们就能使用 latch 保护物理数据结构,它能防止我们去读取某些无效的内存地址 or 无效的 page 位置)。之后会讨论如何在这些数据结构内部进行并发控制。

2 Hash Table

hash table 是个抽象数据类型,提供无序的关联数组实现的 API,它能将 key 映射到 value。

  • 可以将任意的 key 映射到对应的 value 上。在 hash table 中并没有顺序的说法,也就是说 hash table 的 key 是无序的(但是具体实现时,例如树状哈希,还是有一定的顺序)。

  • 对于数组形式的哈希,它使用 hash 函数计算给定 key 在数组中的 offset,从中可以找到对应的 value。

  • hash table 的空间复杂度是 O(n),查找的平均复杂度是 O(1),但是最坏情况下是 O(n)

    最坏情况 O(n) 意味着我们必须进⾏循序查找或者是线性搜索来对每个可能的 key 进⾏查找,以找到要找的 key

对于一个理想情况下的 hash 表,可以视作一个数组。我们知道所有 key 的数量,并且每个 key 都是唯一的,则每个 key 经过哈希函数映射后都会是不同的值(每次 hash 结果都不同)。这样就可以做到一个理想的 hash 表。然而实际情况下,很难找到一个完美 hash 函数,不存在任何能保证 hash 结果唯一的 hash 函数,这就意味着一定会有 hash 冲突的产生,因此我们得对 hash 冲突进行处理。

hash table 是由二部分组成的数据结构:

  1. Hash Function:如何将一个大的 key 映射到一个相对小范围的 integer 值。要权衡 hash 速度与冲突率。
  2. Hash Scheme:如何处理 hash 冲突。要权衡使用的内存空间与处理冲突时的额外操作。

我们首先讨论 hash 函数,展示有哪些 hash 函数以及人们现在使用的是哪些;然后讨论两类 hash scheme——static hashing scheme、dynamic hashing scheme

3 Hash Functions

Hash Function 接受任何 key 作为其输入。然后返回该 key 的整数表示(即“hash”)。函数的输出是确定性的(即相同的 key 应该总是生成相同的哈希输出)。

常见的hash函数有 SHA-256、MD5。

  • SHA-256 是非对称加密,也就是不可逆的加密 hash
  • MD5 是可逆的加密 hash

DBMS 不希望使用加密 hash 函数,因为我们不需要担心保护密钥的内容。这些哈希函数主要由 DBMS 内部使用,因此信息不会泄露到系统外部,而且加密 hash 函数的速度也很慢(MD5 是单向散列 hash 但也很慢)。在这一讲中,我们只关心 hash 函数的速度和冲突率


以下是人们使用的部分 hash 函数:

  • CRC-64:(1975)生成 hash 的碰撞率合理,但速度非常慢
  • MurmurHash:(2008)从数据库层面说,其诞生进入了现代 hash 函数时代,google 对其修改使长度更短的 key 获得更快的速度
  • Google 的 CityHash:(2011)google 在之后做出了 CityHash
  • Facebook 的 XXHash:(2012)之后发行的 XXHash3 是目前速度最快、碰撞率最低的Hash函数
  • Google 的 FarmHash:(2014)google 对 CityHash 进行修改得到 FarmHash,碰撞率比 CityHash 更低

👇图展示了各 hash 函数速度随 key 增大的变化,可见 XXHash3 的性能是最好的,实际上它的碰撞率表现也很不错。

所以,在我们自己的数据库系统中,尽可能多的使用 Facebook 的 XXHash3。(不要自己写 hash 函数,很浪费时间)

image-20220320213452518

图像上,当 key 大小为32、64、128byte 时,FarmHash、CityHash、XXHash3都出现了尖峰。

这是因为它们进行计算处理后的 key 刚好填满单个 Cache Line(CPU与内存间数据传输的最小单位)因此,当我们从内存中读取一次数据时可一次性操作。

4 Static Hashing Schemes

静态哈希方案是指哈希表的大小是固定的。这意味着,当 DBMS 用完哈希表中的存储空间后,它必须扩容并且重建(将原来的值全复制过去)哈希表。通常,新哈希表的大小是原哈希表的两倍。

扩容代价非常高,理想情况下,我们需要大概知道要保存的元素(key)的上限,这样就无须扩容。

为了减少浪费的比较次数,重要的是避免哈希键的碰撞。这要求哈希表的槽数(slots)是预期元素数的两倍 slots = 2*keys。

首先讨论最基本的静态哈希方案——线性探查 hash。之后讨论在它之上的一些变种,Robin Hood Hashing、Cuckoo Hashing,他们都是基于Linear Probe Hashing 修改的。

4.1 Linear Probe Hashing

线性探查法,是最基本的哈希方案,通常也是最快的。它是个 slot 表。slot 中需存储 key 值,这样才能支持查找和删除。

How 解决 hash 冲突 ——(hash表逻辑上是个环形的buffer)

插入数据:若 hash(key) 映射的 slot 位置无数据,就直接插入 key|value 键值对;否则就线性向后探查,直到找到一个能插入数据的空 slot 为止。(value 是指向某个 page 或 tuple 的指针)

查找数据:找到 hash(key) 映射的 slot,若不是要找的 key,就线性向后探查,若直到一个空 slot 还未找到,证明没有此 key。

删除数据:对于某个 key 不能直接删除,否则会影响数据的查找👇🌰。

image-20220320224111800
  • 方一:设置标记(Tombstone 墓碑)。删除 slot 上的值并设置 Tombstone,逻辑上这里没有 entry 但物理上这个 slot 被占用。这样就不会影响数据的查找,但会浪费空间,稍后需进行清理。
  • 方二:移动数据。即删除 slot 上数据的同时,将后面的数据都往前挪动。

移动数据的方法在实际处理中会出现很多问题👇🌰,且很复杂,一般会选择标记 Tombstone。

image-20220320230314668

non-unique keys

之前我们都假设 key 是唯一的,对 primary index 来说,这没问题。但实际数据集中 key 可能不唯一。

如果我们想在哈希表中记录相同的 key 的不同 value,有 2 种方法:

  1. 将 key 的值存储在单独的存储区域:每个 key 的 slot 指向一个单独的链表,链表上保存的 value 对应的都是同一 key
  2. 在 hash 表中存储含冗余 key 的元素
image-20220320231935500

这两种方法可用在任何一种 Hashing Scheme 中。实战中一般都使用第二种方法,即使它更浪费存储空间(因为 key 被多次重复记录)。

4.2 Robin Hood Hashing

Robin Hood(罗宾汉)是一个英国的民间传说,说的是一个劫富济贫的侠盗的故事,这也是这个算法的思想来源。

在简单的线性探查时,遇到冲突会线性探查下去,但是这样可能会造成一种不平衡,可能有某个 key 的 value 的位置距离 hash(key) 的位置非常远,这样就要付出很大的代价去查找。 因此我们可以考虑记录下每个 (key,value) 值距离 hash(key) 的相对位置,相对位置越大,就越 “poor”,就可以从 “rich” 的 key 手中抢夺 slot 以达到一种平衡。

这是线性探查法的扩展,会对整个 hash table 进行平衡,试图让每个 key 尽可能靠近它原本的位置(减少它到在 hash 表中的最佳位置 hash(key) 的距离)。允许线程从 “rich” key 中窃取 slot,并将它们给 “poor” key。

  • 每个 key 跟踪它们在表中最优位置的位置。
  • 插入时,若要插入的键比此处原本的键更 poor(到最佳位置的距离更远),则窃取该 slot。原本的键必须重新插入到表中。因此会使写入或插入的代价更高(原本一次的写入操作变成了多次)
image-20221010171722819

罗宾汉哈希可以显著降低探查长度的方差。🌰 该算法倾向于让两个 key 都离最佳位置一个单位,而不是一个 key 在最佳位置,另一个离最佳位置两个单位。

实际表现中,大多数情况下线性探查法还是强无敌,某些情况下 Robin 算法将会有很大的代价。

4.3 Cuckoo Hashing

这种方法不是使用单个 hash 表,而是维护多个具有不同 hash 函数的表。hash 函数是相同的算法(例如,XXHash, CityHash);它们通过使用不同的 seed 值为同一个键生成不同的 hash 值。

  • 插入时,检查每个表,并挑选有空闲 slot 的表(若有多个,可随机选一个)。
  • 如果没有表有空闲 slot,则从中随机选择一个 slot 窃取,对该 slot 原本的元素重新 hash,为之找到一个新的位置。
  • 我们可能会卡在一个循环里(如A选择在hash表2中窃取B,B需在hash表1中窃取C,C在hash表2中需窃取A),我们需标记是否已经访问过某个 slot 以发现循环。那么我们可以用新的 hash 函数种子(不太常用)重建所有的 hash 表,或者用更大的表(更常用)重建 hash 表。

实战中,大多数人使用 Cuckoo Hashing 时都只使用两个 hash table。超过三个没必要。

查找始终是O(1),但插入的代价很大。

image-20221010175141054

5 Dynamic Hashing Schemes

静态哈希方案 要求 DBMS 知道它想要存储的元素的数量。否则,如果需要增大/缩小表的大小,它将重建 hash 表。
动态哈希方案 能够根据需要调整 hash 表的大小,而不需要重建整个 hash 表。这些方案以不同的方式执行这种调整,可以最大化读或写。

5.1 Chained Hashing

这是最常见的动态哈希方案。是 java 中 hashmap 采用的默认数据结构。

DBMS 为 hash 表中的每个 slot 维护一个 bucket 的 linked list每个 slot 不是存储元素,而是存储一个指向 bucket 组成链表的指针(引用)

image-20221010180514195
  • 通过将具有相同 hash key 的元素放入相同的 bucket 来解决冲突。
  • 如果 bucket 已满,添加另一个 bucket 到该 list。hash 表可以无限增长,因为 DBMS 不断添加新的 bucket。
  • 判断某个 key 是否存在,将它 hash 到一个 bucket 然后扫描该 bucket。

可以将 bucket 当做 page,在 heap文件中,当某个 page 满了会分配新 page,并将它们链接在一起,通过 page id 指明该如何遍历。

可能会退化为顺序扫描。删除和插入操作很简单,因为修改的是 bucket 而不是 slot array。可以做到近乎无限的扩容,也很容易实现线程安全,我们只需要对每个 slot/bucket 加上 latch 就好。

5.2 Extendible Hashing

改进的链列哈希,对 overflow 的 bucket 进行拆分,而不是让链永远增长。这种方法允许 hash 表中的多个 slot 位置指向同一个 bucket chain。

重新平衡哈希表的核心思想是在拆分时移动 bucket 项,并增加 bit 数,以便在 hash 表中查找条目。这意味着 DBMS 只需要在分割链的 bucket 中移动数据;所有其他 bucket 都保持原样。

拆分 🆚 重建:拆分只针对那些满了的 bucket,重建针对整个 hash 表。

  • Extendible Hashing 将 key 计算哈希值后转换为一个二进制表示,然后它会维护一个 global counter,记录定位到 bucket 指针数组 (slot array),需要取 key 的二进制的多少位;每个 bucket 也有一个 counter,记为 local counter,表示的是定位到该 bucket 需要二进制的多少位
  • 当存储 bucket 满时,DBMS 将 bucket 拆分并重新洗牌它的元素。如果拆分 bucket 的 local counter < global counter,则新 bucket 将被添加到现有的 slot 数组中。否则,DBMS 将 slot 数组的大小翻倍以容纳新的 bucket,并增加 global counter。
  • 删除操作就是将插入操作逆向进行。
image-20221010204013230

此处 insert 要做的:对两个 page 进行写入(拆分原有 page,产生一个新的 page),更新相应的(这俩page的)slot array 的映射。这种代价不大,因为改变的只是映射,而不是所指向的数据的实际存储位置。

5.3 Linear Hashing

在 Extendible Hashing 中,尽管调整指针数组的代价并不高,但调整时需要对整个 slot 数组上 latch,这会成为一种性能的瓶颈,因此我们可以只对溢出的 bucket 进行扩容,这样就不用上一个全局的latch。
——linear Hashing

线性哈希是一种动态扩展哈希表的方法。这个方案不是在 bucket overflow 时立即拆分 bucket,而是维护一个 split pointer 来跟踪下一个要拆分的 bucket。不管这个指针是否指向溢出的 bucket,DBMS 总是分裂。overflow 标准由实现决定。

  • 假定最初 split pointer 指向第一个 bucket。
  • 当任何 bucket overflow 时,在 pointer 位置添加一个新的 slot 位条目拆分 bucket,并创建一个新的 hash 函数(使用的是相同的 hash 函数,只是使用了不同的 seed),对该 bucket 中的所有 key 采用新的 hash 函数进行拆分。溢出桶的溢出 kv 会放在溢出页,直到该 bucket 分裂。
  • 后续查找中,如果 hash 值映射到之前被 pointer 指向的 slot(桶号小于 split pointer),再应用新的 hash 函数得到 value。
  • 当 pointer 到达最后一个 slot 时,删除原来的 hash 函数,并用新的 hash 函数替换它。
  • 若进行删除操作,删除到需要缩减合并 bucket(有bucket为空)时,split pointer 会往回移动,但这在实战中很棘手。

线性hash 基于 split pointer 最终会将 overflow 的 bucket 都进行拆分。

https://www.jianshu.com/p/0bf2400786f6

线性哈希的数学原理

假定key = 5 、 9 、13

key % 4 = 1

现在我们对8求余:

5 % 8 = 5

9 % 8=1

13 % 8 = 5

由上面的规律可以得出

(任意key) % n = M

(任意key) %2n = M 或 (任意key) %2n = M + n

线性哈希的具体实现

我们假设初始化的哈希表如下:

image-20221010212052835

为了方便叙述,我们作出以下假定:

  1. 为了使哈希表能进行动态的分裂,我们从桶0开始设定一个分裂点。
  2. 一个桶的容量为 listSize = 5,当桶的容量超出后就从分裂点开始进行分裂。
  3. hash 函数为 h0 = key %4 h1 = key % 8,h1 会在分裂时使用。
  4. 整个表初始化包含了4个桶,桶号为 0-3,并已提前插入了部分的数据。

分裂过程如下:

现在插入 key = 27

  1. 进行哈希运算,h0 = 27 % 4 = 3
  2. 将key = 27 插入桶 3,但发现桶 3 已经达到了桶的容量,所以触发哈希分裂
  3. 由于现在分裂点处于 0 桶,所以我们对 0 桶进行分割。这里需要注意虽然这里是 3 桶满了,但我们并不会直接从 3 桶进行分割,而是从分割点进行分割。
  4. 对分割点所指向的桶 (桶0) 所包含的 key 采用新的 hash 函数 (h1) 进行分割,将产生如下的哈希表。
  5. 虽然进行了分裂,但桶 3 并不是分裂点,所以桶 3 会将多出的 key,放于溢出页.,一直等到桶 3 进行分裂。
  6. 进行分裂后,将分裂点向后移动一位。
image-20221010212113517

key 的读取:

采用 h0 对 key 进行计算。

  • 如果算出的桶号小于了分裂点,表示桶已经进行的分裂,我们采用 h1 进行 hash 运算,算出 key 所对应的真正的桶号。再从真正的桶里取出 value。
  • 如果算出的桶号大于了分裂点,那么表示此桶还没进行分裂,直接从当前桶进行读取 value。

注意:

  1. 若下一次 key 插入 0、1、2、4 桶,不会触发分裂(因为没有超出桶的容量)。若是插入桶 3,用户在实现时可以自己设定,可以一旦插入就触发,也可以等溢出页达到 listSize 再触发新的分裂。

  2. 现在 0 桶被分裂了,新数据的插入怎么才能保证没分裂的桶能正常工作,已经分裂的桶能将部分插入到新分裂的桶呢?

    A:只要分裂点小于桶的总数,我们依然采用 h0 函数进行哈希计算。

    • 如果哈希结果小于分裂号,那么表示这个 key 所插入的桶已经进行了分割,那么我就采用 h1 再次进行哈希,而 h1 的哈希结果就这个 key 所该插入的桶号。
    • 如果哈希结果大于分裂号,那么表示这个 key 所插入的桶还没有进行分裂。直接插入。

    这也是为什么虽然是桶 3 的容量不足,但分裂的桶是分裂点所指向的桶。如果直接在桶 3 进行分裂,那么当新的 key 插入的时候就不能正常的判断哪些桶已经进行了分裂。

  3. 如果使用分裂点,就具备了无限扩展的能力。当分裂点移动到最后一个桶 (桶3) 再出现分裂,那么分裂点就会回到桶 0,到这个时候,h0 作废,h1 替代 h0, h2(key % 12) 替代 h1。那么又可以开始动态分割。那个整个初始化状态就发生了变化。就好像没有发生过分裂。那么上面的规则就可以循环使用。

  4. ❗️线性哈希的论文中是按上面的规则来进行分裂的。其实我们可以安装自己的实际情况来进行改动:假如我们现在希望去掉分割点,一旦哪个桶满了,马上对这个桶进行分割,可以考虑了以下方案:

    • 为所有桶增加一个标志位。初始化的时候对所有桶的标志位清空。
    • 一旦某个桶满了,直接对这个桶进行分割,然后将设置标志位。当新的数据插入的时候,经过哈希计算 (h0) 发现这个桶已经分裂了,那么就采用新的哈希函数 (h1) 来计算分裂之后的桶号。在读取数据的时候处理类似。

hash table 是一个高效的数据结构,大多时候能够在 O(1) 的情况下插入和查询数据,在数据库系统中,有很多地方都使用到了哈希表,例如前面提到的 page table,page directory,以及执行 sql 查询时一些用于 join 的临时数据结构。

但是哈希表的应用场景也有限,因为它存储的所有 key 都是无序的,这样虽然适合点查(精准匹配 key 的查找),但是无法进行范围扫描(通过关于 key 的部分条件查找,比如找出小于我给出的 key 的所有 key),在更加通用的场景下,数据库中的表索引使用最广泛的还是 B+ 树。

本文作者:Joey-Wang

本文链接:https://www.cnblogs.com/joey-wang/p/16915994.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   Joey-Wang  阅读(164)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开