二叉搜索树不适合应用到磁盘上,因为它的扇出数较低并且平衡时需要大量的节点重定位和指针更新。B树通过增加每个节点存储项的数量(高扇出)和减少频繁的平衡操作来解决这些问题。下面我们将讨论了B树的内部结构,B树的查找、插入和删除操作算法概要,以及用于保持B树平衡的拆分和合并操作。
B树实际上是平衡二叉树的扩展,不同之处在于B树具有更大的扇出数(即更多的子节点)和更低的树高。前文讨论二叉树时节点以圆形表示,而B树节点通常以矩形表示,当然二叉树也可以使用矩形来表示。图2-7使用矩形的方式表示二叉树,2-3树和B树,从中我们可以看出它们之间的相似性和差异性。
B树的节点也是有序排列的,因此B树可以像二叉搜索树一样进行节点查找。这也就是说B树查找节点的时间复杂度是对数的,通过32次比较就能从包含40亿个节点的B树中找到某个键。
对于磁盘数据结构,如果每次比较都要经过磁盘扇区定位,这样的性能显然是不能接受的。但是每个B树节点存储几十甚至上百个条目,这就避免了每次比较都需要定位新的磁盘扇区,仅仅在进入B树下一层的时候才需要重定位加载新扇区。后面我们会更加详细的讨论查询算法的细节。
B树上可以非常高效的查询单个数据或者是查询某个范围内的数据。在查询语言里(如SQL),查询某个数据通常表示为等于(=),而查询某个范围数据通常表示为比较(, ≤和≥)。
B树的层次结构
B树由多个节点构成,每个节点又包含N个键和N+1个指向子节点的指针。从逻辑上节点可以分成3类:
根节点(Root Node): 根节点没有父节点,位于树的最顶部;
叶子节点(Leaf Nodes): 树的最底层节点,而且没有任何子节点。
内部节点(Internal Nodes): 所有连接根节点和叶子节点的节点,通常树包含多层内部节点。
由于B树是一种磁盘页面组织技术(即用于组织固定大小的页面),通常一个节点即是一个磁盘页面,术语节点和页面通常是同一概念。节点容量与它实际持有的键数之间的关系称为占用率。
B树的显著特征是高扇出数(每个节点有较多的子节点)。高扇出数减少了为维持树平衡而需要做的结构改动,也可以减少查询时磁盘扇区定位的时间。B树只有在节点空或满的情况下才触发平衡操作(即分裂和合并)。
分隔键(Separator Keys)
B树节点上半部分存储的键称为分隔键。它们把树划分成子树,每个子树包含相应范围内键的节点。分隔键以有序的方式保存在节点内,因此节点内能通过二分法进行键查找;找到键对应区间后,沿着对应的指针指向的子树进入B树的下一层。
B树节点的下半部分保存了指向子树的指针,第一个指针指向保存了所有小于第一个分隔键的子树,最后一个指针指向了保存所有大于或等于最后一个分隔键的子树,其它中间指针指向包含了所有大于等于其左侧分隔键同时小于右侧分隔键节点的子树。 如图2-10。
B树的查复杂度
B树查询的复杂性需要从两个方面考虑:磁盘页传输的数量和查找时进行键比较的数量。
在磁盘页传输数量方面,每个节点的分隔键划分当前搜索空间为原来的1/N。因此在从根节点到叶子节点的遍历过程中,需要读取磁盘页的数量为B树的层数,即B树的高度。
在键比较次数方面,每个节点中以二分法进行搜索,每一次比较都将当前搜索空间减半,因此复杂度为log₂M (M为节点的数目)。
B树的查询
为了从B树上查询某个键,我们需要从根节点到叶子节点遍历B树。首先在根节点保存的分隔键上执行二分查找,找到第一个大于查询键的分隔键,找到该分隔键对应的子树;在子树上重复二分查询操作,直到到达目标叶子节点。这时我们要么找到了需要查询的键,或者查询的键不存在。
查询时我们从最粗粒度的层次(树根)开始查找,然后进入到粒度更细的下一层,其中每层分隔键表示更精确、更详细的范围。重复这个过程,直到最后到达叶节点,即数据记录所在的位置。
单键查询时,在找到查询键时或确定没有查询键后搜索即告结束;而在范围查询时,在找到最接近范围开始的键值对后继续沿着它的兄弟节点查询,直到到达查询范围的末尾。
B树节点的拆分
当插入记录到B树时,首先需要找到插入目标位置,使用前一节中描述的查找算法即可找到目标位置;键值被附加到找到的叶子节点后面,键被添加到对应节点分隔键列表的适当位置。如果是B树键值的更新,使用查找算法找到目标叶节点,并将新值与现有键关联即可。
如果目标位置没有足够的空间存储新键值,为了存储更多键值我们必须要拆分该节点成两个节点。拆分节点可能有下面两种情况:
- 分隔叶子节点:如果叶子节点最多能够保存N个键值对,新插入键值对前该叶子节点就已经保存有N个键值对;
- 分隔非叶子节点:如果节点可以保存N+1个指向子树的指针,新插入一个子树指针前该节点就已经保存了N+1个指针;
拆分节点时,通常我们需要创建一个新的节点,然后从被拆分节点上转移一半的元素到新创建的节点上;添加新创建节点的第一个分隔键和指针都其父节点对应的位置上(通常称这个键被晋级)。新创建的节点和原来的节点称为兄弟节点。
拆分节点的父节点也有可能没有更多的空间保存被晋级的键和新建节点的指针,这时其父节点也需要拆分。这样的拆分操作有可能需要一直传递到根节点。当根节点也达到它的容量上限时,根节点也必须进行拆分。
这种情况下,首先会新创建一个新的根节点,其中保存了用于拆分旧根节点的键;其次为旧的根节点创建一个兄弟节点,以拆分键平分旧根节点的元素,并转移到新创建的兄弟节点上;最后,添加旧根节点和其兄弟节点的指针到新根节点的子树指针列表中。这个过程中,旧的根节点和新为其创建的兄弟节点一起被降级到第二层,树的高度也增加了1。当根节点被拆分或两个节点合并为新的根节点时,树的高度会发生变,而在叶子和内部节点层,B树只会水平扩展。
图2-11展示了将一个新元素11加入到一个已经完全占用的叶节点的过程。其中一半的元素留在老的节点,另一半元素被转移到新创建的节点上。而拆分点的键(13)被作为分隔键保存到父节点对应的位置。
图2-12展示了插入元素11前非叶子节点被完全占用的情况。首先一个新的节点被创建, 待分割节点中从N/2+1开始的元素被移入新创建的节点;其次拆分键被晋级到其父节点;最后新建的节点的指针被加入到其父节点的适当位置。
因为非叶节点拆分总是从下往上的,所以父节点需要一个额外的指针位置(指向下一层新创建的节点)。如果父节点没有足够的空间,那么它也必须被拆分。
拆分完成后,原来的一个节点变成两个节点,我们必须选择正确的节点来完成插入操作。如果插入的键小于新晋升的分隔键,则插入新元素到被拆分节点;否则,插入新元素到新创建的节点。
总而言之,节点拆分大致分为四步:
- 创建一个新的节点;
- 从待分割节点上转移一半元素到新创建的节点上;
- 把新元素插入到相应的节点上;
- 把新分隔键和新建节点的指针加入到被拆分节点的父节点中;
B树节点的合并
在进行删除操作时,首先找到目标叶子节点,然后删除键和与之关联的值。删除后,如果该叶子节点和它相邻兄弟节点保存的键值对过少(低于某个阀值),这时候就需要合并节点。
准确的说,如果以下条件成立,则需要合并两个节点:
- 合并叶子节点:如果节点能够保存N个键值对,相邻两个兄弟节点保存的键值对数量之和少于或等于N。
- 合并非叶子节点: 如果节点能够保存N+1个子树指针,相邻两个兄弟节点保存的指针数量之和少于或等于N+1。
图2-13展示了删除元素16后,B树合并两个叶子节点后结构的变化。通常,我们把右边兄弟节点的元素移入左边节点。但是也可以把左边的元素移入右边节点,只要保证键是有序的即可。
图2-14展示了删除元素10后,B树合并两个相邻非叶子节点后结构的变化。我们合并两个节点包含的元素到一个节点中,删除另一个多余的节点。在合并非叶节点的过程中,我们还必须从父节点中转移相应的分隔键到合并后子节点的适当位置(即分隔键的降级)。由于合并导致父节点的指针也减少了一个,由此有可能触发父节点的合并操作。和节点拆分一样,节点的合并也可能需要一直传递到根节点。
总而言之,节点合并大概也分为三步:
- 转移所有右节点的元素到左边节点;
- 从父节点中移除右节点的指针(如果是非叶节点则降级分隔符);
- 移除右节点;
在B树中,为了减少节点拆分和合并操作的数量,经常会使用一种称为重新平衡的技术(Rebalancing),我们会在后面的文章中再具体讨论这个问题。
深入理解数据库系统(储存引擎概述1)
深入理解数据库系统之存储存引擎2(数据和索引)
深入理解数据库系统之存储存引擎(二叉搜索树)