持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第18天,点击查看活动详情
缓存池的操作单位是页,但是这一节就变成了抽象数据结构,因此依然会有一些割裂感
DBMS为系统内部的许多不同部分使用不同的数据结构,在算子执行到缓冲池之间的查询部分我们会用到不同的两大类数据结构,一类是树,一类是哈希表
哈希表
DBMS用哈希表存储许多类的数据,包括:
- 内部元数据
- 大量数据存储(很多KV数据库如redies)
- 临时数据结构(如hash join)
- 表索引(数据的索引)
在设计哈希表时,我们有两大方面的考虑:一是哈希表本身的结构,我们需要弄清楚如何布局内存,以及在数据结构中存储哪些信息,以支持高效访问。二是哈希表的并发性,我们还需要考虑如何使多个线程能够访问数据结构而不会造成问题
哈希表的本质是可重复的键值对,它的空间复杂度一般是5n左右,而且是线性增长的,时间复杂度最好为O(1),最坏为O(n)。
哈希表实现由两部分组成:
哈希函数
如何将一个较大的密钥空间映射到一个较小的域。它用于将索引计算为一组桶或槽。我们需要考虑快速执行和碰撞速率之间的权衡。在一个极端,我们有一个哈希函数,它总是返回一个常数(非常快,但一切都是冲突)。在另一个极端,我们有一个“完美”的哈希函数,其中没有冲突,但需要非常长的时间来计算。这两者之间的权衡是我们所需要考虑的。
哈希表结构
如何处理冲突?我们需要考虑在分配大哈希表以减少冲突和必须在冲突发生时执行附加指令之间的权衡。
哈希函数
哈希函数是一种计算键的函数,我们当然不想使用复杂到用于加密的哈希函数(例如SHA256)。我们想要速度快、碰撞率低的哈希函数。
下图是一些哈希函数的基准测试,横轴是输入的键的长度。纵轴是每秒能够处理的输入量。
可以看出,一些函数在数据量非常大的时候性能是非常的好的,但是数据量比较一般的时候性能都差不多。
静态哈希表结构
静态哈希表结构表示哈希表大小固定的方案。这意味着,如果DBMS耗尽了哈希表中的存储空间,那么它必须从头开始重建一个更大的哈希表,这是非常昂贵的。为了减少浪费性比较的数量,重要的是避免哈希键的冲突。通常,我们使用的插槽数是预期元素数的两倍。也就是说新建的哈希表大小是原来的两倍。空间线性增长。
Linear Probe Hashing
假如我们可支配的空间够大的话,我们可以使用线性哈希结构,另一种称呼是开放地址哈希,这是最基本的哈希方案。它通常也是最快的。对于查找,我们可以检查密钥散列到的插槽,然后线性搜索,直到找到所需的条目(或者一个空插槽,在这种情况下,密钥不在表中)。
这种方式实现虽然简单,但是删除操作很麻烦,因为这可能会阻止以后的查找,这个问题有两种解决方案:
第一种方式是tombstone,我们没有删除条目,而是将其替换为“墓碑”条目,告诉之后的指令继续扫描。
第二种方式是movement,删除条目后,将该条目之后可以移动的条目整体向上移动一段。但是这样可能会造成循环结构冲突
Non-unique Keys
对于哪些同一个健,不同数据的存储方式,我们有以下两种解决方案。
Robin Hood Hashing
这是线性探测散列的一个扩展,旨在减少每个键在散列表中的最佳位置(即它们被散列到的原始插槽)之间的最大距离。这种策略从“富”键中窃取插槽,并将其分配给“穷”键。
具体的过程比较复杂,每个条目记录了它们与最佳位置的“距离”。然后,在每次插入时,如果要插入的键在当前插槽中距离其最佳位置的距离大于当前条目的距离,我们将替换当前条目,并继续尝试将旧条目插入表中更远的位置。
Cuckoo Hashing
这种方法不使用单个哈希表,而是使用不同的哈希函数维护多个哈希表。散列函数是相同的算法(例如XXHash、CityHash);它们通过使用不同的种子值为同一个密钥生成不同的哈希。
这种方法需要大量进行哈希函数操作,容易遇上循环的情况,用的很少。
动态哈希表结构
静态哈希方案要求DBMS知道要存储的元素数。否则,如果需要扩大/缩小表的大小,它必须重建表。而动态哈希方案能够根据需要调整哈希表的大小,无需重建整个表。实现动态哈希表结构有多种不同的方法。
链式哈希表
使用最多的是链式哈希表, DBMS为哈希表中的每个槽维护一个桶的链表。散列到同一插槽的键只需插入该插槽的链接列表中
可扩展哈希表
改进了链式散列的变体,它可以拆分存储桶,而不是让链式散列永远增长。这种方法允许哈希表中的多个插槽位置指向同一个桶链。
重新平衡哈希表背后的核心思想是在split上移动bucket条目,并增加要检查的位数,以在哈希表中查找条目。这意味着DBMS只需在拆分链的存储桶中移动数据;所有其他桶都保持原样
下图中插入C时发生溢出,此时global加一,所有元素进行rehash。在java和go的哈希map实现中都用到了这种思想,当链式哈希表无法解决问题时,java和go就会以这种思想来增加桶的数量。让单一桶内的元素减少。
线性哈希表
上面的哈希结构方案一次重新哈希的开销太大了,线性哈希表没有在存储桶溢出时立即拆分存储桶,而是保留一个拆分指针,跟踪下一个要拆分的存储桶。不管这个指针是否指向溢出的存储桶,DBMS总是会分裂。溢出标准由实现决定。
这种方法比较复杂,且效率不高,但是在高响应度的系统中可以使用这种方法。
B+树
在数据库中哈希表用于很多数据的存储和索引,但是哈希表通常不适合用于表索引。表索引是一个表列子集的副本,通过使用这些属性的子集对其进行组织和/或排序,以实现高效访问。因此,DBMS可以查找表索引的辅助数据结构,以更快地找到元组,而不是执行顺序扫描。说人话就是,索引的本质就是大表中某些列抽取出来做成小表。因此,DBMS要确保表和索引的内容始终在逻辑上同步
B+树结构和操作
DBMS总是使用索引来查询表项,索引越多,查询越快,但是索引增加意味着存储开销和维护开销增加,因此这里有一个关于索引大小的trade-off。(大表5个G,索引2个G)
几乎所有支持保序索引的现代DBMS都使用B+树。B+树是一种自平衡树数据结构,它保持数据排序,并允许在O(log(n)) 中进行搜索、顺序访问、插入和删除。并且它针对读/写大数据块的面向磁盘的DBMS进行了优化。B+ Tree Visualization (usfca.edu)
具体的,B+树是M-way的搜索树,M是超参数,需要设置,这表示B+树中子节点可以最多为M个,它带有以下特性
- 完全平衡(即每个叶节点在树中的深度相同)
- 除根节点外的每个节点至少为半满:M/2-1≤ Keys ≤ M-1
- 每个具有k个键的内部节点都有k+1个非空子节点,即每个叶子节点有多个数据
具体的B+树可以看下面的图片:
为什么说B+树对读/写大数据块有优势呢?因为叶结点的大小是可控的,所以我们可以将叶结点的大小定为一个或n个页。因此适合大数据块的存取。依照这个逻辑,我们可以画出叶结点的数据存储模型如下图所示
我们已经知道存储的Kye是ID或者ID地址,那么Value具体是什么呢?
- 记录ID:指向索引项所对应的元组位置的指针。例如PG、SQL server等
- 元组数据: 叶节点存储元组的实际内容。SQL lite
查找
因为B+树是按顺序排序的,所以查找具有快速遍历的特性,也不需要整个key(像数组一样)。并且由于顺序特性,B+树支持准确查找和模糊(前缀)查找。
插入
首先找到插入节点的位置,然后确定是否有足够的空间插入。如果能插入则直接插入,不能插入就分裂为两个节点。当树太满时,我们偶尔不得不拆分树叶,如果删除导致树不到半满,我们必须合并以重新平衡树。
删除
首先找到删除节点的位置,然后删除数据,但是假如删除数据后叶结点数据太少,暂时不合并该结点。因为不确定之后会不会有大量数据再次插入这个结点。所以为了缓解磁盘压力,就要进入预合并的状态。
复制Key
在B+树中复制关键点有两种方法。第一种方法是在密钥中附加记录ID。由于每个元组的记录ID都是唯一的,这将确保所有键都是可识别的。第二种方法是允许叶节点溢出到包含重复密钥的溢出节点中。虽然没有存储冗余信息,但这种方法的维护和修改更加复杂。
聚簇索引
表以主键指定的排序顺序存储,可以是聚簇存储,也可以是索引存储。由于某些DBMS总是使用聚簇索引,因此如果表没有显式索引,它们会自动生成隐藏的行id主键,但其他DBMS则根本无法使用它们。
什么是聚簇索引呢,简单来讲就是B+树的page和物理page有对应顺序关系,例如MySQL就是这种方式。非聚簇索引并不是不好,而是可以简化设计。
由于直接从未聚集索引中检索元组效率很低,DBMS可以首先找出它需要的所有元组,然后根据它们的页面id对它们进行排序。
B+Tree Design Choices
B+树的设计可以从下面四个方面来着手:
- 结点大小
- 合并阈值
- 可变长Keys
- 结点内搜索
结点大小
根据存储介质的不同,我们可能更喜欢较大或较小的节点大小。
首先B+树结点的大小最好和文件页大小一致或者是整数倍,第二是磁盘速度越低,结点的大小大一些更好,反之亦然。因为点查询希望页面尽可能小,以减少加载的不必要的额外信息量,而大型顺序扫描可能希望页面较大,以减少需要执行的回迁次数。
合并阈值
- 延迟合并操作,节省B+树组织的时间
- 允许批量合并,即多个合并操作同时发生,从而减少了磁盘读取和锁的开销。
可变长Keys
目前我们只讨论了具有固定长度键的B+树。然而,我们也可能希望支持可变长度的键,例如,大型键的一小部分会导致大量空间浪费的情况。有几种方法可以做到这一点
- 指针:我们可以只存储一个指向键的指针,而不是直接存储键。由于必须为每个键追踪一个指针的效率很低,所以在生产中使用这种方法的唯一地方是嵌入式设备,在嵌入式设备中,其微小的寄存器和缓存可能会受益于这样的空间节省
- 结点本身是可变长的:我们还可以像普通一样存储密钥,并允许可变长度的节点。这是不可行的,而且在很大程度上没有使用,因为处理可变长度节点会带来巨大的内存管理开销。
- Padding:我们可以将每个键的大小设置为最大键的大小,并填充所有较短的键,而不是改变键的大小。在大多数情况下,这是对内存的巨大浪费,所以你也看不到任何人使用它。
- 键映射/间接寻址:几乎每个人都使用的方法是用单独字典中的键值对索引替换键。这可以显著节省空间,并可能为点查询提供快捷方式(因为索引指向的键值对与叶节点指向的键值对完全相同)。由于字典索引值的大小很小,因此有足够的空间将每个键的前缀放在索引旁边,这可能允许一些索引搜索和叶扫描甚至不必追踪指针(如果前缀与搜索键完全不同)。
结点内搜索
一旦我们到达一个节点,我们仍然需要在节点内搜索,虽然这比较简单,但仍有一些权衡要考虑。有三种简单的方法
- 线性查找:最多人用
- 二分查找:就是二分
- 推断查找:奇奇怪怪
B+Tree optimization
前缀树压缩
大多数情况下,当我们在同一个节点中有键时,每个键的某些前缀都会有部分重叠,只需在节点的开头存储一次前缀,然后只在每个插槽中包含每个密钥的唯一部分,直观理解如下图所示。
重复数据消除
在允许非唯一键的索引的情况下,叶子结点可能反复包含相同的键,并附加不同的值。对此的一个优化可能是只写一次键,然后跟随它及其所有相关值。
按批插入
对于B+树的插入,一个一个的插入是低效的,我们可以将数据提前按B+树排序好,然后建立一个小B+树,之后整树Merge进B+树中。
具体的,由于我们已经给出了叶子的同级指针,如果我们构造一个叶子节点的排序链表,然后使用每个叶子节点的第一个键从下到上轻松地构建索引,那么数据的初始插入将更加高效。