Skip to content

Lec 7 索引和访问方法

阅读资料:

[B-Tree Basics](https://mit.primo.exlibrisgroup.com/discovery/openurl?institution=01MIT_INST&rfr_id=info:sid%2Fprimo.exlibrisgroup.com-safari&rft.au=Alex Petrov&rft.btitle=Database Internals&rft.date=2019-10-02&rft.eisbn=9781492040347&rft.genre=book&rft.isbn=1492040339&rft.pub=O'Reilly Media, Inc&rft_dat=9781492040330<%2Fsafari>&rft_val_fmt=info:ofi%2Ffmt:kev:mtx:book&svc_dat=viewit&url_ctx_fmt=info:ofi%2Ffmt:kev:mtx:ctx&url_ver=Z39.88-2004&vid=01MIT_INST:MIT). 阅读第2章

The R*-Tree: An Efficient and Robust Access Method for Points and Rectangles pdf

索引(index)和(数据)访问方法(Access Method)

DBMS使用多种数据结构实现系统的内部功能,主要包括以下类型:

  • 内部元数据。记录数据库状态以及系统运行信息。比如页表,页目录
  • 核心数据存储。作为元组的基础存储结构。典型实现:堆文件(Heap Files)、行存储(Row Stores)或列存储(Column Stores)
  • 临时数据结构。查询执行过程中动态构建的瞬时数据结构以加速处理。示例,哈希表用于哈希连接(Hash Join)
  • 表索引。辅助快速定位特定元组。示例B+树索引、哈希索引、位图索引等

数据结构设计的两大关键决策

  • 数据组织:如何布局内存及存储信息以支持高效访问。考虑因素,行存vs列存;数据对齐;
  • 并发控制:确保多线程安全访问,维持数据一致性。

思考题

  • 在什么情况下,二级索引优于堆文件的顺序(按顺序)扫描?在什么情况下,二级索引扫描更可取?
  • 在B+树中邻居指针的目的是什么,什么情况下他们有用?
  • 为什么B+树不足以存储和索引由R*树存储的数据类型。

总览

  • 哈希表

  • 索引

    • 聚簇索引 vs 聚簇索引
  • 访问方法

    • 堆文件/堆扫描

    • Hash索引/索引查找

    • B+树索引/索引扫描

哈希表

哈希表是一种实现关联数组(Associative Array)的抽象数据类型,它允许你通过键(key)而不是索引来访问或更新对应的值(value)。提供了平均O(1)的操作复杂度(最坏的情况是O(n)),以及O(n)的空间复杂度。即使理论复杂度为O(1),常数因子优化仍对性能至关重要。

一个哈希表有两大核心组件:

  • hash函数,将大范围键空间映射到有限桶(Bucket)数组索引。涉及速度与冲突率的设计权衡。极端来说,我们的hash函数总是返回恒定常数(非常快,但是所有键冲突),另外一个极端说,完美哈希(零冲突,但是计算耗时)
  • 冲突解决方案,如何处理不同键映射到同一桶的情况。涉及到分配大哈希表与冲突发生时需额外的处理之间的成本。

哈希函数

一个哈希函数能够接受任意key,并确定性的返回一个整数表示。DBMS通常无需SHA-256等加密哈希(因仅内部使用,无信息泄露风险),因此我们只需要关注哈希函数的速度以及碰撞率。

工业级推荐

  • Facebook XXHash3:当前最先进的非加密哈希函数,兼具速度与低冲突率

静态哈希方案

静态哈希是一种哈希表大小固定的哈希方案。这意味着一旦 DBMS 在哈希表中用尽了存储空间,就必须从头构建一个更大的哈希表,这是一项代价昂贵的操作。通常,新表的大小是原始哈希表的两倍。为了减少不必要的比较操作,我们要尽量避免哈希冲突。一个常见的做法是:预留的槽位数量为预期元素数量的两倍。

但以下假设通常在实际中并不成立

  1. 事先已知元素数量;
  2. 键是唯一的;
  3. 存在完美的哈希函数。

因此,我们需要合理地选择哈希函数和哈希方案。

线性探测哈希

线性探测哈希(Linear Probe Hashing),这是一种最基本的哈希方案,通常也是速度最快的。它使用一个循环数组作为哈希表槽位。哈希函数将key映射到槽。当冲突发生时,就线性地向后查找下一个空槽;对于查找操作,从哈希出的槽位开始,线性搜索,直到找到目标条目。如果我们遇到空槽或便利了整个哈希表还未找到,则认为不再表中。注意:必须将键和值都存入槽中,因为我们需要比较键才能判断是否为目标条目。

删除操作技巧性大。直接删除会导致查找过程提前停止,找不到本应被“探测”到的条目。有两个方法解决:

  • 墓碑机制:不真正删除,而是将条目标记为”墓碑“
  • 元素移动:删除后将后面的条目向前“移动”来填补空位。但必须小心,仅移动那些“本来就被探测到此处”的条目。这种方式性能开销大,实践中不常见。

索引

表索引

数据库系统中可以使用多种数据结构,用于处理内部元数据、核心数据存储、临时数据结构或表索引等目的。对于表索引而言,如果涉及范围扫描(range scans)的查询,哈希表可能不是最优选择,因为它本质上是无序的。

表索引(table index)是对某些表列的子集进行复制并重新组织/排序,以便于高效地根据这些属性进行访问。因此,DBMS可以不必执行顺序扫描,而是通过在索引上查找来加速找到需要的元组。DBMS 会确保表和索引中的内容在逻辑上始终保持一致。

在数据库中为每张表建立多少索引存在一个权衡:

  • 虽然索引越多,查询查找速度越快,
  • 但索引会占用存储空间,并需要维护,
  • 同时在保持索引与原表同步时还涉及并发控制的问题。

B+树

B+树(B+Tree)是一种自平衡的树形数据结构,它保持数据有序,并支持以 O(log(n)) 时间复杂度进行查找、顺序访问、插入和删除操作。它专为磁盘导向的DBMS而优化,适用于读取/写入大块数据的场景。因此几乎所有支持“顺序保持索引”(order-preserving indexes)的现代DBMS都使用B+树。虽然“B-Tree”是一个具体的数据结构,但在实际中,人们也常用这个术语泛指这类树形结构。B-Tree和B+Tree的主要区别是,B-Tree在所有节点都存储键值对,而B+树只在叶子结点中存储值,内节点仅用于导航。现代 B+Tree 实现中通常也融合了其他 B-Tree 变种的特性,例如 Blink-Tree 使用的兄弟指针(sibling pointers)。

image-20250415085145750

正式定义

B+树是一个 M 叉搜索树(M 表示一个节点最多能有多少个孩子),具备以下性质:

  • 完全平衡:所有叶子节点都在相同的深度。
  • 非根内部节点至少半满,即满足 ⌊M/2⌋ − 1 <= 关键字数 <= M − 1
  • 内部节点中含有 k 个键时,一定有 k+1 个非空孩子指针。

节点结构

每个B+树节点的节点都存储一个键值对数组。

  • 对于叶子结点: 键来源于被索引的属性。虽然定义中没有强制要求,但数组几乎总是按键排序的。键的值有两种形式,一种是记录ID(指向实际元组所在的位置的指针,通常是主键位置); 另外一种是元祖数据,直接存储元组的内容
  • 对于内部节点,值是指向其他节点的指针,而键可以被看作“引导点”(guide posts)。这些键用于指导树的遍历过程,但它们本身并不代表叶子节点中的键或其对应的值。也就是说,内部节点中可能存在某个键(作为引导点),但这个键在叶子节点中并不存在

NULL键

此外,根据索引的类型(例如 NULL 值排在前或排在后),所有的 NULL 键会集中出现

在第一个叶子节点或最后一个叶子节点中

插入操作

在 B+Tree 中插入新项,需要先遍历树,找到合适的叶子节点,然后执行插入:

  1. 查找正确的叶子节点 L。
  2. 将新项按顺序插入 L:
    • 如果 L 有足够空间,插入完成;
    • 否则,将 L 拆分成两个节点 L 和 L2,平均分配键值对,并将中间键上提插入到 L 的父节点中,L2 被作为新孩子连接到父节点。
  3. 若父节点也满了,继续向上传播分裂,内部节点的分裂同样需要平均分配项,并上推中间键。

删除操作

在插入操作中,当树太满时,我们有时需要拆分叶子节点。而在删除操作中,如果删除导致某个节点少于半满,我们就必须合并节点,以重新平衡整棵树。

操作流程如下:

  1. 找到正确的叶子节点 L。
  2. 移除目标项:
    • 如果 L 删除后仍然至少半满,那么操作完成。
    • 否则,可以尝试重新分布(redistribute),即从兄弟节点借一些项过来。
    • 如果重新分布失败,就需要将 L 和兄弟节点合并。
  3. 如果发生了合并操作,那么还需要从父节点中删除指向原 L 的那条记录。

这个过程会在必要时递归向上传播,确保整棵树继续保持平衡结构。

选择条件

选择条件(Selection Conditions)。由于 B+ 树是有序结构,查找过程不仅可以快速遍历,而且不需要提供完整的键值。只要查询中提供了搜索键中的任意属性,DBMS 就可以使用 B+ 树索引。这与哈希索引不同,哈希索引要求提供搜索键的所有属性。

示例: 假设我们有这样的表,Employees(id INT, name TEXT, age INT, department TEXT),且在(age, department)上建立了一个B+树索:

sql
CREATE INDEX idx_age_dept ON Employees(age, department);

假设我们执行下面的查询

sql
SELECT * FROM Employees WHERE age = 30;

虽然索引是 (age, department) 组合键,但 只提供了 age,B+ 树仍然可以使用这个索引,因为它是有序结构,可以根据 age 的范围来查找。

image-20250415203612087

重复键

Duplicate Keys,B+ 树处理重复键有两种方法:

  1. 将记录 ID 作为键的一部分追加:由于每条记录的 ID 是唯一的,这样可以确保所有键都是可以区分的。
  2. 使用溢出节点(overflow nodes)来存储重复键:叶子节点中的键指向溢出节点,而溢出节点中保存的是具有相同键的所有记录。这种方式不会存储冗余数据,但维护和修改的复杂度较高。

索引结构

  • 叶节点存储数据

    截屏2024-08-16 08.47.06

    • 称为“主索引”Primary Key(基于主键构建,没有重复)
  • 叶节点存储指向数据(堆文件)的指针

    image-20240816091417067

    • 称为辅助索引Secondary Index(基于非主键字段构建,可重复查找键)

NOTE

后面讲的都是辅助索引,并会区分辅助索引下的聚簇索引和非聚簇索引

非聚簇索引

下面是一个辅助索引Secondary indeces(也称非聚簇索引nonclustering indices,以Attr1作为查找键)的例子

截屏2024-08-09 08.42.37

现在我们需要查找一个范围时

截屏2024-08-09 08.43.09

截屏2024-07-29 23.13.45

需要随机访问!——这就是非聚簇索引

随机访问的开销

截屏2024-08-16 09.26.28

  • 假设有一个SSD,其延迟为100us,带宽为1 GB/sec。

  • 访问的数据量B字节,每条记录大小为R字节,整个表的大小为T字节。

  • 顺序扫描时间S = T / 1 GB/sec。

  • 通过索引进行随机访问的时间 = 100us * B/R + B / 1 GB/sec。

    • 100us * B/R:这代表了访问B字节数据所需的随机访问次数
    • B / 1 GB/sec:这代表从SSD读取B字节数据所需的传输时间
  • 假设R为100字节,T为10 GB。

问题:在什么情况下,进行顺序扫描比通过索引进行随机查找更便宜?

(a) 当扫描的数据大于约1MB (0.01%) 时 (b) 当扫描的数据大于约10MB (0.1%) 时 (c) 当扫描的数据大于约100MB (1%) 时 (d) 当扫描的数据大于约1GB (10%) 时

100106/100+B/1109>10109/1109

B>9.99106

IMPORTANT

相比扫描超过10MB,扫描10GB的表要比使用索引更加便宜

聚簇索引

磁盘上的数据页按照索引的顺序进行排列

(下图是非聚簇索引,作为对比用)

截屏2024-08-09 08.52.21

聚簇索引如下

截屏2024-08-09 08.52.43

IMPORTANT

从原来每个记录的随机I/O,转变为对数据页的随机I/O,减少了I/O次数

但是这仅仅对索引扫描Attr1有效

聚簇索引的好处

类似前面所说的例子

  • 假设有一个SSD,其延迟为100us,带宽为1 GB/sec。
  • 访问的数据量B字节,每条记录大小为R字节,整个表的大小为T字节。
  • 每页有P字节
  • 顺序扫描时间S = T / 1 GB/sec。
  • 通过聚簇索引的访问时间 = 100us * B/PR + B / 1 GB/sec。
    • 100us * B/P:这代表了访问B字节数据所需的随机访问次数
    • B / 1 GB/sec:这代表从SSD读取B字节数据所需的传输时间
  • 假设R为100字节,T为10 GB,P是1MB

什么情况下顺序扫描比通过聚簇索引的随机查找更便宜?

100106/106+B/1109>10109/1109

B>9.99109

也就是说

NOTE

B(查询需要访问的字节数)超过9.99 GB时,顺序扫描整个表(10 GB)比通过聚簇索引逐一查找这些记录更便宜。

索引的分类

B树索引

  • B+-树
  • Blink-树
  • Bw-树

Hash 索引

位图索引Bitmpa

跳表索引Skiplist

LSM-树索引

高维索引

  • R树
  • 4叉树Quadtree
  • KD树
  • Z曲线

学习型索引

访问方法

为了确定访问方法开销,我们约定几个关键指标

  • n:代表元组数量
  • P:代表存储一个数据库文件所需的页数
  • B:代表B-树的分支因子
  • R:代表扫描范围的内的页的数量

堆文件

截屏2024-08-16 11.11.24

顺序存储页,在记录之间或者是页之间没有寻道(不需要磁盘的读写头在不同位置之间移动)

哈希索引

思路:

  • 使用哈希表存储指向堆文件中记录的指针
  • 哈希表以特定属性作为key
    • 也可以使用复合key
  • 支持O(1)时间复杂度的等值查找记录
    • 例如,查找名字为"sam"的员工

image-20240816111858676

在n个磁盘页上就有n个桶

问题

  • 做这个表需要多大?
    • 如果搞错了, 要么浪费空间,要么会溢出需要重新hash

扩展哈希

思路:

  • 扩展一组由参数k定义的哈希表

    Hk(x)=H(x) mod 2k

  • 从k=1开始(即两个桶)

  • 使用目录结构来跟踪每个哈希值映射到的桶(页)

  • 当一个桶溢出时,增加k值,创建一个新桶,重新还出桶中的键,并更新目录

例子

截屏2024-08-16 11.27.42

分别用键0,0,2,3,2插入记录

上面我们可以看到当,插入最后一个记录2时,发现第0页的已经满了,因此我们需要增加k值并重哈希。

截屏2024-08-16 11.33.25

截屏2024-08-16 11.33.44

需要额外的记录来跟踪第 0 和第 2 页已经分裂,而第 1 页尚未分裂

B+树索引

截屏2024-08-16 12.13.42


截屏2024-08-16 12.14.09


叶子节点,记录按照属性A排序,用

截屏2024-08-16 12.14.35

RID: 记录id,是对堆文件上一个记录的引用

B+树的属性

  • 分支因子 = B
  • 分成logB(tuples)
  • 插入、删除、查找为对数级性能
  • 支持范围查找
  • 在插入、删除需要平衡算法来再平衡
  • 内部页没有数据
  • 用于在叶子节点之间建立链表,方便顺序访问和范围查询
  • 填充因子: 所有的节点(除了根节点)保持至少50%的满叶子

高维索引

B+树不适合用于多维度数据

  • 考虑我们有(x, y)的点,并希望对这些点进行索引
  • 假设我们在B+树中存储键(x, y)的元组
  • 问题: 无法在不读取x的情况下查找特定范围的y
  • 多维索引的替代方案: R-树和QuadTree

例子

对先排x后排y的数据建立索引

截屏2024-08-16 14.16.50

  • 支持在x上高效的范围查找
  • 允许对Y进行进一步过滤,但是效率较低
  • 不支持在Y上查找

image-20240816141919068

问题:必须扫描出所有的x值才能查找与之匹配的y值

R树/空间索引

截屏2024-08-16 14.25.07

截屏2024-08-16 14.25.18

四叉树Quad-Tree

截屏2024-08-16 14.26.42

截屏2024-08-16 14.27.21

总结

截屏2024-08-16 13.19.20

如果你要为下面的查询创建索引(假设每个查询都是数据库的唯一查询),你会用哪一种方式

sql
-- a
SELECT MAX (sal) FROM emp
-- b
SELECT sal FROM emp WHERE id = 1
-- c
SELECT name FROM emp WHERE sal > 100k
-- d
SELECT name FROM emp WHERE sal > 100k AND dept = 2

Sulution:

a) 对emp.sal做B+树索引

b) 对emp.id做哈希索引

c) 对emp.sal做B+树索引(可能)

d) 对emp.sal做B+树索引(可能), 对Hash.dno做哈希索引

参考书:B-树

我们将存储结构分为两类:可变的和不可变的,大多数可变存储结构使用原地更新机制。存储引擎通常允许数据库中存在相同数据记录的多个版本,例如(MVCC,或者是插槽页),为了简单起见,目前我们假设每个键只关联一个数据记录,并且该记录具有唯一的位置。其中一种流行的存储结构是B树。在我们深入了解B树之前,首先要讨论一下为什么我们应该考虑传统搜索树的替代方案,例如二叉搜索树、2-3树和AVL树 。

二叉搜索树

BST是在内存中有序的数据结构,用于高效的key-value查询。

截屏2024-07-29 23.13.45

树平衡

元素的插入可能导致树的不平衡, 最坏的情况,得到一个病态树,看起来跟链表一样。平衡树被定义为具有对数高度,并且两个子树之间的高度差不超过1。如果没有平衡,将失去BST的优势。允许插入和删除的顺序决定树的形状。保持树平衡的一种方法是添加和删除节点后执行旋转步骤。如果插入操作使某个分支不平衡,可以围绕中间节点进行旋转。

截屏2024-07-29 23.24.16

用于磁盘存储的树

由于低分支因子(分支因子是每个节点允许的最大子节点数),我们必须频繁地进行平衡,重新定位节点和更新指针。如果我们想在磁盘上维护一个 BST,将面临几个问题。

  • 一个问题是局部性:由于元素是以随机顺序添加的,无法保证新创建的节点会靠近其父节点写入,这意味着节点的子指针可能跨越多个磁盘页面。通过修改树的布局并使用分页二叉树(参见“分页二叉树”第33页),我们可以在一定程度上改善这种情况
  • 另一个问题是树的高度,这与追踪子指针的成本密切相关。由于二叉树的分支因子只有两个,高度是lg N,我们必须执行 O(lg N) 次寻道来定位被搜索的元素,并随后执行相同数量的磁盘传输。

2-3-树和其他低分支因子的树也有类似的限制:尽管它们在内存数据结构中很有用,但较小的节点大小使得它们在外部存储中不切实际

适合磁盘实现的树版本必须具有以下特性:

  • 高分支因子以改善相邻键的局部性。
  • 低高度以减少遍历过程中需要的寻道次数。

基于磁盘的数据结构

当数据量大到无法在内存中保持整个数据集时,通常会使用基于磁盘的数据结构。任何时候只能将数据的一部分缓存到内存中,其余的数据则需要以一种允许高效访问的方式存储在磁盘上。

磁盘驱动器

大多数传统算法是在旋转磁盘是最广泛使用的持久存储介质时开发的,这显著地影响了它们的设计。在旋转磁盘上,寻道增加了随机读取的成本,因为它们需要磁盘旋转机械磁头移动来将读写头定位到所需位置。然而,一旦昂贵的部分完成,从磁盘中读取或写入连续字节(即顺序操作)相对便宜。磁盘驱动器的最小传输单元是扇区,因此在执行某些操作时,至少可以读取或写入整个扇区。扇区大小通常范围从 512 B到 4 KB。磁头定位是硬盘驱动器操作中最昂贵的部分。这也是我们经常听到顺序 I/O(sequential I/O) 原因之一:从磁盘中读取和写入连续的存储段。

固态硬盘

截屏2024-07-29 23.50.54

固态驱动器(SSD)没有移动部件:没有旋转的磁盘,也没有需要定位的读写头。典型的 SSD 是由内存单元(memory cells)组成的,这些单元连接成串(string)(通常每串 32 到 64 个单元),串组合成阵列(arrays),阵列组合成页(page),页组合成块(block) [LARRIVEE15]。

一个单元可以存储一个或多个比特的数据, 在设备之间的大小有所不同,但通常范围在 2 到 16 KB 之间。块通常包含 64 到 512 页。块组织成平面(plane),最终平面被放置在一个芯片(die)上。SSD 可以有一个或多个芯片。可以写入(编程)或读取的最小单位是页面。然而,我们只能对空的内存单元(即在写入之前已经被擦除的单元)进行更改。最小的擦除单位不是页面,而是一个包含多个页面的,因此它通常被称为擦除块(erase block)。空块中的页面必须按顺序写入.

负责将页面 ID 映射到其物理位置、跟踪空的、已写入的和已丢弃的页面的部分称为闪存转换层(FTL)。它还负责垃圾回收,在此过程中,FTL 找到可以安全擦除的块。一些块可能仍包含活动页面。在这种情况下,它会将这些块中的活动页面移动到新位置,并重新映射页面 ID 以指向这些位置。之后,它擦除现在未使用的块,使其可用于写入。

由于在 HDD 和 SSD 两种设备中,我们都是以块为单位而不是单个字节来寻址(即,按块读取数据),大多数操作系统具有块设备抽象 [CESATI05]。它隐藏了内部磁盘结构并在内部缓冲 I/O 操作,因此当我们从块设备中读取单个字时,整个包含它的块会被读取。这是我们无法忽视的约束,在处理磁盘驻留的数据结构时应始终考虑到这一点。

在 SSD 中,我们不如在 HDD 中那样强调随机与顺序 I/O,因为随机读取与顺序读取之间的延迟差异没有那么大。尽管垃圾回收通常是后台操作,但其效果可能会对写入性能产生负面影响,特别是在随机和未对齐写入负载的情况下。仅写入完整块,并将后续写入合并到同一块中,可以帮助减少所需的 I/O 操作数量

磁盘上的结构

除了磁盘访问本身的成本外,构建高效的磁盘结构的主要限制和设计条件是磁盘操作的最小单位是块。为了跟踪块内的特定位置,我们必须获取整个块。既然我们已经必须这么做,我们可以更改数据结构的布局以利用这一点。

我们已经多次提到指针,但这个词在磁盘上的结构中有稍微不同的语义。在磁盘上,大多数时候我们手动管理数据布局(除非我们使用memory mapped files)。这与常规的指针操作相似,但我们必须计算目标指针地址并明确地跟踪这些指针。

大多数时候,磁盘上的偏移量是预计算的(在指针在磁盘上写入之前的情况下)或缓存到内存中,直到它们被刷新到磁盘上。在磁盘结构中创建长依赖链会大大增加代码和结构的复杂性,因此通常希望将指针及其跨度的数量保持在最低限度。

总之,磁盘结构的设计考虑了其目标存储的具体情况,通常优化以减少磁盘访问次数。我们可以通过提高局部性、优化结构的内部表示以及减少跨页指针的数量来实现这一点。

分页二叉树

截屏2024-07-30 00.06.10

将二叉树布局为按页分组的节点,可以改善局部性问题。要找到下一个节点,只需跟随已经获取的页面中的指针即可。然而,节点和它们之间的指针仍然会带来一定的开销。在磁盘上布局结构以及后续的维护工作并非易事,尤其是当键和值未排序且以随机顺序添加时。平衡操作需要重新组织页面,这反过来又会导致指针更新

无处不在的B树

B 树可以被看作是图书馆中的一个庞大目录室:你首先要挑选正确的柜子,然后是柜子中的正确架子,再然后是架子上的正确抽屉,最后在抽屉中的卡片上查找你要找的那张。类似地,B 树构建了一个层次结构,帮助快速导航并定位所搜索的项。

B 树是有序的: B 树节点内部的键是按顺序存储的。因此,为了定位搜索的键,我们可以使用类似二分搜索的算法。这也意味着在 B 树中的查找具有对数复杂度。例如,在 40 亿(4 × 10^9)个项中找到一个搜索的键大约需要 32 次比较,如果我们每进行一次比较都需要一次磁盘寻道,那会显著降低速度,但由于 B 树节点存储了几十甚至几百个项,我们每层只需要一次磁盘寻道。截屏2024-07-30 04.00.04

使用 B 树,我们可以高效地执行点查询和范围查询。点查询在大多数查询语言中由等于(=)谓词表示,用于定位单个项。另一方面,范围查询由比较(<,>,≤ 和 ≥)谓词表示,用于按顺序查询多个数据项。

由于 B 树是一种页面组织技术(即,用于组织和导航固定大小的页面),我们通常互换使用节点和页面这两个术语。 节点容量与其实际持有的键的数量之间的关系称为占用率。 B 树的特征在于其分支因子:每个节点中存储的键的数量。较高的分支因子有助于摊销保持树平衡所需的结构变化的成本,并通过将键和指向子节点的指针存储在一个块或多个连续块中来减少寻道次数。当节点已满或几乎为空时,会触发平衡操作(即分裂和合并)。

IMPORTANT

B+ 树

我们使用术语 B 树作为共享所有或大多数上述属性的数据结构家族的总称。更精确地描述这种数据结构的名称是 B+ 树。[KNUTH98] 将高扇出的树称为多路树。

B 树允许在任何级别存储值:在根节点、内部节点和叶子节点中。而 B+ 树仅在叶子节点存储值。内部节点仅存储用于指导搜索算法找到存储在叶子级别的关联值的分隔键。

由于 B+ 树中的值仅存储在叶子级别,所有操作(插入、更新、删除和检索数据记录)仅影响叶子节点,并且仅在分裂和合并期间传播到更高层级。

B+ 树变得广泛应用,并且我们将它们称为 B 树,类似于其他关于该主题的文献。例如,在 [GRAEFE11] 中,B+ 树被作为默认设计提及,而 MySQL InnoDB 将其 B+ 树实现称为 B 树。

分离的键

B 树节点中存储的键称为索引项、分隔键或分隔单元。它们将树分割成子树(也称为分支或子范围),每个子树包含相应的键范围。键按排序顺序存储,以允许二分搜索。通过定位一个键并从较高级别跟随相应的指针到较低级别,可以找到子树。

节点中的第一个指针指向包含小于第一个键的项的子树,最后一个指针指向包含大于或等于最后一个键的项的子树。其他指针则引用位于两个键之间的子树:Ki1Ks<Ki,其中 K 是一组键,Ks 是属于子树的键

一些 B 树变种在叶子级别还具有兄弟节点指针,以简化范围扫描。这些指针有助于避免回到父节点以找到下一个兄弟节点。一些实现中,指针在两个方向上都有,在叶子级别形成双向链表,使得反向迭代成为可能。

B 树的独特之处在于,它们不是从上到下构建的(如二分搜索树),而是从下到上构建的。叶子节点的数量增加,这增加了内部节点的数量和树的高度。

由于 B 树在节点内部为将来的插入和更新预留了额外空间,树的存储利用率可能低至 50%,但通常要高得多。较高的占用率不会对 B 树的性能产生负面影响。

截屏2024-07-30 04.08.43

查找复杂度

可以从两个角度看: 块传输次数和查找过程过程中的比较次数。从传输次数看,对数底数是N(每个节点的键的数量)。每深入一层,节点数量增加K倍,跟随子指针将搜索空间减少N倍。所以最多会寻址logKM个页面才能找到查找的键。从根到叶子的过程中需要跟随的子指针数量也等于树的高度h。

从比较次数看,因为每个节点内部查找键是使用二分搜索完成的。每次比较将搜索空间减半,因此复杂度是lgN.

在教科书和文章中,B 树查找复杂度通常视为logM​,复杂度分析通常不使用对数底数,因为改变底数只是增加常数因子。

B树节点的插入

分裂

为了将值插入 B-树中,我们首先需要定位目标叶节点并找到插入点。为此,我们使用前面描述的算法。当找到叶节点后,键和值会被附加到该节点。如果目标节点没有足够的空间,我们称该节点已经“溢出”(overflow),并需要将其分裂为两个节点以适应新数据。更具体地说,当满足以下条件时,节点会被拆分:

  • 对于叶节点:如果节点可以容纳最多 N 个键值对,并且插入一个键值对后会超出最大容量 N
  • 对于非叶节点:如果节点可以容纳最多 N + 1 个指针,并且插入一个指针后会超出最大容量 N + 1

一般来说,B树的阶数为奇数,实际中N的取值通常在200~300之间,因此十亿条记录只需要4~5层。

拆分操作是通过分配新节点,将拆分节点的一半元素转移到新节点中,并将新节点的第一个键和指针添加到父节点中来完成的。在这种情况下,我们称键为“提升”(promoted)。拆分执行的索引被称为“拆分点”(split point),也称为“中点”(midpoint)。拆分点之后的所有元素(在非叶节点拆分的情况下包括拆分点)被转移到新创建的兄弟节点中,其余元素保留在拆分节点中。

如果父节点已满,没有足够的空间容纳提升的键和指针,则父节点也必须进行拆分。这一操作可能会递归传播到根节点。

一旦树达到其容量(即拆分传播到根节点),我们必须拆分根节点。当根节点被拆分时,会分配一个新的根节点,持有一个拆分点键。旧根节点(现在只包含一半条目)被降级到下一层,并与其新创建的兄弟节点一起存在,树的高度增加了一层。树的高度变化发生在根节点拆分并分配新根节点时,或者当两个节点合并形成新根节点时。在叶节点和内部节点层次上,树仅在水平方向上增长。

截屏2024-08-09 12.30.42

图 2-11 显示了在插入新元素 11 时完全占满的叶节点。我们在满节点的中间画一条线,将一半的元素留在节点中,其余的元素移动到新的节点中。拆分点值被放置到父节点中,作为分隔键

截屏2024-08-09 12.30.57

图 2-12 显示了在插入新元素 11 时完全占满的非叶节点(即根节点或内部节点)的拆分过程。为了执行拆分,我们首先创建一个新节点,并将从索引 N/2 + 1 开始的元素移动到新节点中。拆分点键被提升到父节点。

由于非叶节点拆分始终是从下层传播的拆分的表现,我们有一个额外的指针(指向新创建的节点的下一级)。如果父节点没有足够的空间,也必须进行拆分。

无论是叶节点还是非叶节点的拆分(即节点是否包含键和值或仅包含键),拆分过程的处理方式都是相同的。在叶节点拆分的情况下,键与其相关的值一起移动。

完成拆分后,我们得到两个节点,需要选择正确的节点来完成插入操作。为此,我们可以使用分隔键不变性。如果插入的键小于提升的键,我们通过在拆分节点中插入来完成操作。否则,我们在新创建的节点中插入。

  1. 分配一个新节点。
  2. 将拆分节点的一半元素复制到新节点中。
  3. 将新元素放入相应的节点。
  4. 在拆分节点的父节点中,添加一个分隔键和一个指向新节点的指针。

B树节点的合并

删除操作首先通过定位目标叶节点来进行。当找到叶节点后,将其键和值删除。如果相邻节点的值过少(即它们的占用率低于阈值),则需要将兄弟节点合并。这种情况被称为“欠载”(underflow),[BAYER72] 描述了两种欠载场景:如果两个相邻的节点有一个共同的父节点,并且它们的内容可以容纳在一个节点中,则它们的内容应合并(连接);如果它们的内容不能容纳在一个节点中,则需要在它们之间重新分配键以恢复平衡(参见第70页的“重新平衡”)。更准确地说,当以下条件满足时,两个节点会合并:

  • 对于叶节点:如果一个节点可以容纳最多 N 个键值对,并且两个相邻节点的键值对总数小于或等于 N。
  • 对于非叶节点:如果一个节点可以容纳最多 N + 1 个指针,并且两个相邻节点的指针总数小于或等于 N + 1。

截屏2024-08-09 12.19.01

图 2-13 显示了删除元素 16 时的合并过程。为此,我们将一个兄弟节点的元素移动到另一个兄弟节点。通常,元素从右侧兄弟节点移动到左侧节点,但也可以反过来,只要保持键的顺序不变即可。

截屏2024-08-09 12.19.49

图 2-14 显示了在删除元素 10 时需要合并的两个兄弟非叶节点。如果我们将它们的元素合并,它们适合一个节点,因此我们可以用一个节点代替两个节点。在合并非叶节点时,我们需要从父节点中提取相应的分隔键(即降级它)。指针的数量减少一个,因为合并是由下层页面删除的指针传播导致的。与拆分类似,合并也可能传播到根节点。

总结一下,节点合并的过程分为三个步骤,假设元素已经被删除:

  1. 将右侧节点的所有元素复制到左侧节点。
  2. 从父节点中移除右侧节点指针(或在非叶节点合并时降级它)。
  3. 移除右侧节点。

满的节点y有(m-1个关键字)按其中间节点y.key[[m/2](向上取整,如果从1开始索引)]。 一般来说,阶数是奇树, 比如9阶B树,有叶子节点有9个关键字,需分出中间关键字y[ [m/2] (向上取整) ] ,两边就有相同的分裂节点。将中间关键字提升到y的父节点,如果也是满树、则,必沿着树向上传播。

论文阅读: R*-树

摘要

R树是用于矩形访问的最流行方法之一,它基于一种启发式优化策略,即在每个内部节点中最小化包围矩形的面积。我们成功设计了 R*-树,这种结构结合了对目录中每个包围矩形的面积、边界和重叠的综合优化。从实际应用的角度来看,R*-树非常有吸引力,原因如下:1. 它能够同时高效地支持点数据和空间数据;2. 它的实现成本仅比其他 R-树略高。

本文的结构如下:在第二部分中,我们介绍了R树的原理及其优化标准;第三部分展示了Guttman和Greene的现有R树变体;第四部分详细描述了我们新设计的R*-树;第五部分报告了R*-树与其他R树变体的比较结果;第六部分是本文的总结。

R树原理和可能的优化标准

R树类似B+树的结构,它直接存储多维矩形作为完整对象,而不是先对他们进行裁剪或者转换为更高维度的点