许多实用的二叉树(如AVL树或红黑树)被称为高度平衡树,这意味着树的高度(从根节点到叶子节点)被限制为Ο(log ),因此查找操作的时间复杂度也是Ο(log )。
B树同样是一种高度平衡的树;所有叶子节点的高度相同。
n叉树可以从二叉树推广而来(反之亦然)。一个典型的例子是2-3-4树,它是一种特殊的B树,其中每个节点可以有2、3或4个子节点。2-3-4树与红黑树等价,但为了理解B树,我们不需要深入探讨这些细节。
以下是一个排序序列 [1, 2, 3, 4, 6, 9, 11, 12]
的2层B+树示例:
[1, 4, 9]
/ | \
v v v
[1, 2, 3] [4, 6] [9, 11, 12]
在B+树中:
在这个例子中,内部节点 [1, 4, 9]
表示其三个子树的键范围分别为:
[1, 4)
:第一个子树 [1, 2, 3]
。[4, 9)
:第二个子树 [4, 6]
。[9, +∞)
:第三个子树 [9, 11, 12]
。然而,实际上只需要两个键即可划分三个区间:
(-∞, 4)
:表示小于4的所有键。[4, 9)
:表示大于等于4且小于9的键。[9, +∞)
:表示大于等于9的所有键。因此,第一个键 1
可以省略,简化后的键范围如下:
(-∞, 4)
。[4, 9)
。[9, +∞)
。这种设计使得B+树的内部节点能够更高效地存储键,并减少冗余信息。
叶节点存储实际数据:
内部节点仅存储引导键:
高度平衡:
高效的范围查询:
[4, 9)
范围内的所有数据时,只需遍历相关的叶节点。空间利用率高:
相比于普通的B树,B+树更适合数据库和文件系统等需要频繁进行范围查询和顺序访问的应用场景。以下是B+树的主要优势:
理解B树的一种方法是从排序数组的角度出发,尤其是通过构建多级嵌套数组来模拟B树的行为。
对于一个简单的排序数组,更新操作的时间复杂度是Ο()。为了优化这一点,可以将数组分割成个较小的、互不重叠的数组。这样,更新操作的时间复杂度降低为Ο( / )。然而,为了找到需要更新或查询的具体小数组,我们需要另一个包含对这些小数组引用的排序数组,这实际上对应于B+树中的内部节点。
例如:
[[1,2,3], [4,6], [9,11,12]]
在这个例子中,外层数组(即内部节点)包含了指向各个子数组(即叶节点)的引用。查找操作仍然可以通过两次二分查找完成,总时间复杂度为Ο(log )。如果选择为 ( N ) (\sqrt{N}) (N),那么更新操作的时间复杂度变为 O ( N ) O(\sqrt{N}) O(N),这是两层排序数组能达到的最佳效果。
O ( N ) O(\sqrt{N}) O(N)
尽管 O ( N ) O(\sqrt{N}) O(N)的更新成本对于数据库来说可能还是不够理想,但如果继续增加层级,通过进一步分割数组,可以进一步减少成本。具体做法如下:
对于插入和删除操作,找到对应的叶节点后,更新叶节点的时间复杂度通常是常数Ο()。剩下的挑战在于维护节点大小不超过且非空的不变性条件。
为了确保每个节点的大小保持在以内并且不为空,需要采取一些策略来处理插入和删除操作带来的影响:
分裂与合并:当某个节点超过最大容量时,需要将其分裂成两个较小的节点,并将中间元素提升到父节点。相反,当删除操作导致某个节点过小时,可能需要从兄弟节点借取元素或者与兄弟节点合并。
平衡树结构:通过上述分裂和合并操作,确保整个树的高度保持平衡,从而保证查找、插入和删除操作的时间复杂度均为Ο(log )。
这种多级嵌套数组的设计思想实际上揭示了B树的核心机制:通过合理组织数据结构,使得即使在大规模数据集上也能高效地进行各种操作。它不仅提高了更新效率,还保证了查找性能的一致性。这种方法为理解和实现B树提供了一个直观的视角。
在更新B+树时,需要维护以下三个不变性(invariants):
当向叶节点插入数据时,可能会违反第二个不变性(节点大小超出限制)。此时,可以通过将节点分割成更小的部分来恢复这一不变性。
例如,假设我们在叶节点L2中插入了一个新元素,导致其大小超过了限制:
Before:
parent
/ | \
L1 L2 L6
After inserting an element into L2:
parent
/ | \
L1 L2 L6
*
Splitting L2:
parent
/ | | \
L1 L3 L4 L6
* *
分割叶节点后,其父节点会获得一个新的分支。如果这个父节点也超出了大小限制,则可能也需要进行分割。这种节点分割的过程可以一直传播到根节点,从而增加树的高度。
Before growing the tree height:
root
/ | \
L1 L2 L6
After growing the tree height by splitting nodes:
new_root
/ \
N1 N2
/ | | \
L1 L3 L4 L6
由于所有的叶子节点同时增加了高度,因此第一个不变性得到了保持。
删除操作可能导致某些节点变为空节点,这就违反了第三个不变性(节点不能为空)。为了恢复这一不变性,可以通过将空节点与其兄弟节点合并来实现。合并是分割的逆过程,它同样可以从某个节点开始,并可能传播至根节点,导致树的高度减少。
例如,假设我们从L2中删除了一个元素,使得L2变为空节点:
Before merging:
parent
/ | \
L1 L2* L6
Merging L2 with a sibling (e.g., L1):
parent
/ | \
L1' L6
在这个例子中,L2与L1进行了合并,形成了新的L1’节点。合并可以在非空节点达到下限时提前执行,以减少空间浪费。这有助于维持树结构的紧凑性并提高性能。
在内存中实现B树已经可以通过上述原则完成,但在磁盘上实现B树时需要额外考虑一些因素。
一个关键细节是如何限制节点大小。对于内存中的B+树,可以通过限制节点中键的最大数量来控制节点大小,而在磁盘上的数据结构中,则没有malloc/free
或垃圾回收机制可供依赖;空间分配和重用完全取决于我们自己。
如果所有分配都是相同大小的,可以使用空闲列表来进行空间重用,这将在后续实现。目前,所有的B树节点都被设定为相同的大小。
为了使磁盘数据更新具有抗崩溃能力,我们已经了解了三种方法:重命名文件、日志记录以及LSM树。核心思想是在更新过程中不破坏任何旧数据。这个概念也可以应用于树结构:通过复制节点并在副本上进行修改。
插入或删除操作从叶节点开始;在创建带有修改的副本后,必须更新其父节点以指向新的节点,这也需要在其副本上完成。这种复制过程会传播到根节点,最终形成一个新的树根。
d
/ \
b e
/ \
a c
更新叶节点c:
D*
/ \
B* e
/ \
a C*
这里,被复制的节点用大写字母表示(D, B, C),而共享的子树用小写字母表示(a, e)。
这种方法被称为写时复制数据结构,也被描述为不可变的、仅追加的(不是字面意义上的),或者是持久化的(与耐久性无关)。需要注意的是,数据库术语并不总是有一致的意义。
写时复制B树还剩下两个问题:
保留旧版本的一个优势是自动获得了快照隔离。事务从树的一个版本开始,并不会看到其他版本的变化。崩溃恢复也变得轻松;只需使用最后一个旧版本即可。
另一个优势是它适合多读取者单一写入者的并发模型,且读者不会阻塞写入者。这些将在后面探讨。
虽然写时复制数据结构在崩溃恢复方面很明显,但由于高写放大效应,它们可能是不可取的。每次更新都需要复制整个路径Ο(log ),而大多数就地更新只触及一个叶节点。
可以在不使用写时复制的情况下进行带崩溃恢复的就地更新:
fsync
保存副本。(此时可以响应客户端。)fsync
更新。崩溃后,数据结构可能处于半更新状态,但我们无法确切知道。我们所做的是盲目应用保存的副本,这样无论当前状态如何,数据结构最终都会处于更新后的状态。
双写(double-write)在MySQL术语中指的是保存的更新副本。但如果双写本身损坏了呢?这与处理日志的方式相同:使用校验和。
fsync
之前,所以主数据仍处于良好且旧的状态。一些数据库实际上将双写存储在日志中,称为物理日志记录。存在两种类型的日志记录:逻辑日志和物理日志。逻辑日志描述了高级别的操作,如插入键,这些操作只能在数据库处于良好状态时应用,因此只有物理日志(低级别的磁盘页面更新)对恢复有用。
崩溃恢复的原则是比较双写与写时复制:
两者基于不同的理念:
如果我们保存原始节点而不是更新节点用于双写,那就是第三种从损坏中恢复的方法,并且像写时复制一样恢复到旧版本。我们可以将这三种方式合并为一个想法:在任何时候都有足够的信息来恢复到旧状态或新状态。
此外,某些复制总是必要的,因此较大的树节点更新速度较慢。我们将使用写时复制,因为它更简单,但可以根据具体情况偏离这一做法。
代码仓库地址:database-go