尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

B树详解:从定义到操作,吃透数据库索引核心结构

B树详解:从定义到操作,吃透数据库索引核心结构 如果你也翻过《Handbook of Data Structures and Applications》大概会认同我的评价这是一本什么都讲了、但每样都讲得很密实的系统书想一口气从头读到尾根本不现实。我的策略是先挑最贴近工程实战的章节下手B树就是第一个被我单拎出来的。这一章篇幅不算长但从定义到删除操作每一页都值得反复比划。这篇博文就是我按那章内容重新消化的产物包含概念对齐、操作拆解、实现骨架以及一些教材中说透了和没说透的坑。如果你正准备补数据结构基础或者工作中和数据库、文件系统打交道应该能从里面拿走不少东西。1. 为什么B树这一章值得从教材里单独拎出来啃1.1 这本手册该怎么读B树为什么被我先挑中《Handbook of Data Structures and Applications》更像一本工具书而非入门教材它由不同作者分章节撰写风格统一但密度极高适合用来查漏补缺不适合线性通读。我给自己定的阅读策略是先看目录圈出与日常开发交集最大的主题再用“定义—操作—实现—应用”的路径逐个击破。B树之所以排在第一位是因为现代软件里到处都有它的影子关系数据库索引、文件系统元数据组织、日志结构的合并树、甚至某些内存分配器都在用它或它的变体。如果你观察过生产环境的慢查询或者试过向一个上亿行的表加索引大概能体会到索引结构选型的分量。B树这一章讲的就是这类索引背后的数学模型和操作规则把这些规则吃透你再去看数据库执行计划、Explain输出里的“Using index”“Index Range Scan”心里会踏实很多。1.2 数据量撑爆内存后二叉树和有序数组先后失去了优势我刚开始学数据结构时总觉得有序数组很完美二分查找 O(log n)内存里跑得飞快。但有个前提被忽略了——数据要能整体放进内存。一旦数据落到磁盘情况就变了访问一次磁盘页的耗时在毫秒级而内存访问是纳秒级两者相差六到七个数量级。此时算法的“比较次数”不再主导一切“磁盘IO次数”才是真正的瓶颈。二叉搜索树只存一个键树高为 O(log₂ n)。一亿条数据时平衡后的树高大约 27 层意味着一次查找在最坏情况下要访问约 27 次节点如果每个节点对应一次磁盘页读取那就是 27 次慢速IO。B树的设计思路完全不同让每个节点“塞”进大量键把节点个数降下来树高降到个位数甚至三层四层一次查找的磁盘IO次数也随之骤减。可以做个直观对比数据结构1亿数据时的大致树高/访问次数每次访问是否友好有序数组二分约 27 次访问只有内存中友好磁盘上并不友好AVL/红黑树约 27 层每层可能一次IO量级相同B树(m≈200)约 4 层每层一次IO量级大幅缩小这也是为什么 B树 能统治磁盘场景。教材里那一章开头就点出这个动机我建议你读的时候把“减少磁盘IO”当作一条主线后面所有分裂、合并、借位操作根源都在这里。2. 阶数定义对齐m阶B树到底能存多少个键2.1 用“子树数量”还是“键数量”描述节点容量其实一回事教材里对 B树 的第一组定义通常这样写一棵 m 阶B树满足——根节点要么是叶子要么至少有两棵子树除根节点外每个内部节点至少有 ⌈m/2⌉ 棵子树每个节点至多有 m 棵子树所有叶子节点位于同一层一个有 k 棵子树的节点恰好有 k-1 个键键按递增顺序排列。这里有个容易混淆的地方有的资料说“每个节点最多 m-1 个键”有的说“最多 m 棵子树”。听起来不一样其实是同一件事的两种表述因为键与子树的对应关系就是 k-1。我习惯把“子树数量”当作主定义因为实现时指针数组和键数组天然就有对应关系写代码不容易错。顺手整理一份常见的阶数对照表方便查阅阶数 m子树数量范围非根节点键数量范围非根节点常见的叫法323122-3树424132-3-4树53524无特别叫法20010020099199数据库索引的典型规模如果你之前看的是另一本算法教材可能遇到过“最小度数 t”的说法每个非根节点至少 t-1 个键、至多 2t-1 个键。它与“m 阶”可以互相换算本质就是每种资料对同一种结构选用了不同参数。后面我会专门讲这组换算别慌。2.2 节点键数量的上下限从 ⌈m/2⌉-1 到 m-1非根节点的键数量必须落在 [⌈m/2⌉-1, m-1] 区间内这一点是实现时最重要的不变量。低于下限叫“下溢”高于上限叫“溢出”。溢出靠分裂解决下溢靠借位或合并解决。根节点是例外它至少可以只有 1 个键当树里只有一个键时连子树都没有至多同样不能超过 m-1 个键。这就是为什么删除操作只有在根节点键数变成 0 时才会触发“树变矮”。记住这个例外写删除逻辑时能少走很多弯路。我刚实现B树时犯过一个典型错误把“根节点最少键数”也按普通节点的 ⌈m/2⌉-1 处理结果删除最后一个键时直接崩了。后来想明白根节点是整棵树的入口它空掉不代表非法而是代表整棵树可以降低一层。2.3 “所有叶子同一层”这条规则的真正作用第 4 条规则往往被一眼带过但它极其重要。叶子全部在同一层意味着从根到任意叶子的路径长度完全相等任何查找经历节点数量相同复杂度稳定。这背后是插入时只长高根、删除时只矮化根的设计——这两种操作保证了树的“全局平衡”而不是像 AVL 树那样靠局部旋转维持高度差。你可以这么理解B树 的平衡不是“尽量平衡”而是“强制所有叶子站在同一条水平线上”。树高只会在根节点分裂时加一只会在根节点被删空时减一。这个特性使得 B树 的查找性能在有大量写入时依然可预测对数据库这种读多写多、延迟敏感的组件相当重要。3. 插入操作逐层拆解分裂是B树变高的唯一方式3.1 插入永远发生在叶子层往上一层层回流B树 插入的第一步是沿着根向下做一次查找确定新键应该落在哪个叶子节点。然后直接把新键插入这个叶子保持键有序。这里很多人会问为什么不能在中间节点插入因为B树 的每个节点键数有上限如果允许在中间插入很难维持键与子树指针的位置关系而在叶子插入后如果溢出可以通过“中位数上移”把影响一层层向上传递这让整个修复路径非常规整。教科书里的操作就是一句话“只往叶子插满了就分裂。”但实际写代码时真正的难点是分裂时的指针迁移和父节点更新。搞清楚这一点再去看教材里的伪代码就不会觉得晦涩。3.2 满节点分裂的标准动作中位数上移左右各成一子叶子节点满了也就是键数达到 m-1 时需要分裂。设节点内有 m-1 个有序键取第 ⌈m/2⌉ 个键作为中位数。具体动作分三步中位数左边所有键保留在原节点中右边所有键复制到一个新创建的节点中中位数本身从原节点移除上移到父节点的正确位置父节点新增一个子树指针指向刚才创建的新节点。如果被分裂的是内部节点还需要把中位数左右的子树指针一并重新分配左半部分挂在左边右半部分挂在新节点上。这一步最容易漏因为键的搬移肉眼可见而指针的搬移需要仔细对着“键数量 子树数量-1”这个关系推。这里有个值得注意的边界情况节点键数为偶数个时“中间偏右”和“中间偏左”两种取法都可以作为分裂基准只要保证分裂后的两侧都不低于最小键数即可。不同教材可能用 ⌈m/2⌉ 或 ⌊m/2⌋含义差之毫厘。我建议统一用第 ⌈m/2⌉ 个键。3.3 根分裂全树长高的唯一入口常规插入时中位数上移给父节点如果父节点也满了就继续向上分裂。最极端的情况是根节点也满了此时不能再往上找父节点而是新建一个空根节点把满根节点分裂成左右两个节点中位数放进取新建的根。树的层数因此加一。根分裂是 B树 高度增加的唯一途径。这解释了第 2.3 节说的“所有叶子同一层”——因为除了根分裂没有任何操作会增加一层而根分裂会让所有老叶子整体下降一层它们依然在同一层。对比一下 AVL 树靠旋转维持平衡的做法B树 这种“只在根部整体生长”的方式非常优雅也极大简化了并发控制的设计。3.4 用2-3树完整走一遍1到7的插入理论说多了容易飘我用一棵空的 3 阶B树也就是2-3树依次插入 1 到 7把整个过程走完。3阶B树每个节点最多 2 个键、最多 3 个子树非根节点最少 1 个键。删除、合并操作非常直观。插入 1、2根节点作为叶子依次为 [1]、[1,2]。插入 3根节点 [1,2] 溢出第 2 个键 2 是中位数。分裂出左节点 [1] 和右节点 [3]中位数 2 上移成为新根。此时树高从 0 变成 1结构为根 [2]左子树 [1]右子树 [3]。插入 4查找发现 4 应去右子树落到叶子 [3]得到 [3,4]。插入 5右叶子 [3,4] 溢出中位数 4 上移动到根。根 [2] 变成 [2,4]左子树 [1]、中间子树 [3]、右子树 [5]。插入 6落入右子树 [5]变成 [5,6]。插入 7右子树 [5,6] 溢出中位数 6 上移。此时根 [2,4] 如果直接接收 6 会变成 [2,4,6]超过 2 个键的上限所以根也要分裂。以 [2,4,6] 的第 2 个键 4 为中位数建新根左节点 [2] 挂 [1] 和 [3] 两棵子树右节点 [6] 挂 [5] 和 [7] 两棵子树。最终结构是两层根 [4]下层左侧内部节点 [2]右侧内部节点 [6]再往下是四个叶子。整个过程里叶子始终在同一层树高只在第三次插入根第一次分裂和第七次插入根第二次分裂时发生变化。把这个过程画出来B树 插入的所有规则都在一张图里了。4. 删除操作比插入难十倍借位、合并与树高回退4.1 删除内部节点找前驱或后继当替身把问题推给叶子删除是 B树 最复杂的操作原因在于它必须同时维护“键数量下限”和“叶子同层”两条不变量。如果待删除的键在内部节点我们不能直接把它拔掉否则该节点的子树指针数量会和键数量对不上。标准做法和二叉搜索树很像找到这个键的左子树里的最大键前驱或者右子树里的最小键后继用替身覆盖当前键然后去叶子层删除那个替身。这样一来真正的删除动作永远发生在叶子节点问题被统一收口。为什么取前驱或后继因为替身和原键在排序上相邻替换后节点内部依然有序而且替身位于叶子删除它不会破坏内部节点的子树结构。换成其他位置的键整个节点的相对顺序就会乱套。4.2 下溢后的第一步先看兄弟节点能不能借删除叶子键后如果叶子剩余键数低于 ⌈m/2⌉-1就触发下溢。修复的第一步是检查相邻的左兄弟或右兄弟如果兄弟节点还有富余键就从兄弟“借”一个。借位不是直接把兄弟的一个键塞到当前节点而是借助父节点完成一次旋转。以向右兄弟借为例把父节点中分隔这两个子节点的键下移到当前节点把兄弟节点的最小键上移到父节点原位置如果兄弟是内部节点还要把对应的子树指针一并移交。这个动作保证两侧节点都保持在合法键数区间同时父节点键数不变。我把这个操作比作“过桥”父节点是桥左岸缺货右岸有多余就从桥上转运一次。比直接越过桥去搬运要稳妥得多。处理下溢时的顺序很重要必须先确认兄弟“有余粮”才借因为如果兄弟也处于最小状态借一个键会让兄弟自己也下溢等于问题没解决还制造了新问题。4.3 兄弟也不够时拉下父节点分隔键做合并如果左右兄弟都处于最小键数无法借出那就只能合并。合并动作是借位的逆操作把当前节点、一个兄弟节点、以及父节点中分隔两者之间的那个键三者拼成一个节点父节点删除一个键和一个子树指针新节点键数刚好合法等于两个非根节点最小键数之和再加 1。合并会让父节点少一个键。如果父节点因此低于最小键数它又会变成“下溢节点”需要继续向它的兄弟借位或合并。于是修复过程沿着树向上传播就像插入时分裂向上传播一样。这里我建议画张状态图帮助理解把“下溢节点”“兄弟余粮”“兄弟刚够”这三个状态分别列出分支你会发现删除操作的递归逻辑和插入操作几乎是对称的只是方向相反。4.4 一路合并到根变空B树怎样少一层级联合并的终点通常是根节点恰好减少一个键一切恢复合法。但还有一种边界情况根节点键数从 1 减到 0并且它只剩一个子树指针。此时根节点没有存在的必要整棵树直接把这个空根删掉让唯一的子树成为新根树高减一。用前面 2-3 树里生成的结构演示根 [4]左内部节点 [2]孩子 [1] 和 [3]右内部节点 [6]孩子 [5] 和 [7]。依次删除后如果某个内层节点连续触发合并最终可能让根 [4] 和下层的节点合并成新根。这个过程比较长我当初调试时对着输出日志愣是看了半天才确认逻辑没错。注意一个细节根变空和节点下溢不是一回事。普通节点下溢可以用借位解决而根节点键数为 0 时不意味着它“下溢”因为根节点本来就可以只有很少的键。只有当根节点键数变成 0 且只剩下一个孩子时才允许删除该根并降低树高。这个例外写代码时一定要单独判断。5. 查找与顺序遍历B树最朴素的索引优势5.1 节点内二分查找节点间单路下降B树 的查找流程非常直接从根开始在节点内部用线性扫描或二分查找定位“下一个子树”的入口然后下降到子节点重复这个过程直到叶子。如果中间某个节点里命中目标键查找提前结束如果到叶子都没命中说明键不存在。节点内部的查找发生在内存中可以很快节点间的下降才是真正昂贵的部分因为它意味着一次磁盘IO。这就是为什么“树高”而不是“比较次数”才是衡量 B树 性能的核心指标。把节点尽量塞满、把阶数尽量调大都是为了压低树高。我在数据库场景里衡量一条查询“走索引”的代价基本就是在估算它会从根到叶子经过多少层。常见规模下 B树 索引只有三四层所以大部分索引查询都能在个位数的磁盘IO内完成。5.2 中序遍历天然有序叶子链表是B树的伏笔B树 的中序遍历会输出有序序列。因为每个节点内部键有序而子树指针夹在键之间先遍历第一个子树再读第一个键再遍历第二个子树再读第二个键以此类推。这个顺序性质是索引支持范围查询的基础。但教材接着就会提到一个工程优化如果查询要扫描很大范围的数据逐层回溯效率很差。更常见的做法是 B树把叶子节点用链表串起来范围查询时先定位到左端点然后沿着链表一路向后扫。我看到这一节时脑子里冒出的第一个念头是“这不就是把树形结构和中序遍历的线性特点合在一起了嘛”。如果你后面去读 Handbook 里关于 B树 的变体章节会发现所有改进几乎都围绕同一件事减少IO次数。叶子链表、内部节点只存键、数据只在叶子出现全部是在让“顺序访问”和“点查询”都变得更快。6. 教材没明说的三个坑调试B树遇到的真实问题6.1 阶数、度数、最小填充率不同资料里的定义换算我最早看教材时被“m 阶”的表述绕了挺久因为另一本经典书用的是“最小度数 t”。两套体系下一个节点最多有多少键这个数字对不上。后来我把所有资料里的参数都换算成两个区间键数量上限和键数量下限问题一下就清楚了。这里给你一个实用的换算模板以“m 阶”定义上限 m-1下限 ⌈m/2⌉-1以“t 最小度数”定义上限 2t-1下限 t-1两者等价时m 2t或 t ⌈m/2⌉。查资料时先把这些参数写好贴在屏幕上再动手写节点类能省很多调试时间。我见过有人把 2-3-4树 当成 3 阶B树去实现结果分裂逻辑和键数范围全错最后整棵树乱成一锅粥。6.2 分裂和合并时父子指针的更新是最容易漏的一步新手实现 B树往往会先写出一个叶子场景下能跑的版本然后栽在内部节点分裂上。最常见的问题是创建新节点时只拷了键忘了把右半部分的子树指针一起拷过去或者拷了指针但忘了把那些子节点的父指针指到新节点。修复的办法只有一个把“键数组的长度”和“子树数组的长度”始终绑定在一起检查。每次分列或合并后立刻用这两个长度自检如果不符合“键数 子树数-1”一定有问题。我在开发时还习惯给每个节点加一个字段记录父节点指针。虽然 B树 查找不需要父指针但分裂和合并都离不开它有了父指针向上传播时就不用沿途维护路径栈代码会简单很多。6.3 调试B树的两件法宝中序遍历校验与键数量巡检写 B树 时不能只看“插入删除了多少键”来判断对错。我用来抓 bug 的两个方法是中序遍历输出和逐层键数巡检。中序遍历输出序列应该总是严格递增的。一旦出现乱序大概是分裂时中位数的位置取错或者合并时键的拼接顺序不对。键数量巡检则是递归检查所有节点键数量是否落在合法区间、根节点是否是叶子或至少两个子树、所有叶子深度是否相同。把这两条写在测试函数里每次插入删除后跑一遍能发现绝大多数隐藏问题。这个方法比你在纸上画半天图高效得多。因为树结构会随着操作动态变化人眼根本跟不上每一步的状态但程序可以一遍遍把不变量跑完。7. 从纸面到磁盘B树与一个最小可运行的Python骨架7.1 B树把索引与数据彻底分开顺序访问更友好教材里的基础 B树 章节通常会留一点页面给 B树 变体。与 B树 最大的不同是B树 的内部节点只保存索引键不再持有数据记录所有数据都集中在叶子节点并且叶子节点之间用双向链表连接。这意味着非叶子节点能容纳更多键树更矮磁盘IO更少。同时由于数据只在叶子出现范围查询可以直接顺着叶子链表走不需要在内部节点间来回跳跃。关系数据库的索引几乎都采用这种结构包括聚簇索引和二级索引。为什么不是所有地方都用 B树因为 B树 在点查询上其实可以直接在内部节点命中并结束而 B树 必须走到叶子才能拿到数据。但在磁盘场景下B树 的树高更低、叶子链表带来的范围查询优势太明显这点点查询的额外开销微不足道。7.2 数据库、文件系统与系统软件中的B树家族学完教材这一章你会发现 B树 家族是很多底层系统的基石关系数据库的聚簇索引、二级索引内部节点存键值叶子存行位置或主键。文件系统目录索引和磁盘块管理需要在有限的元数据空间里维护海量文件名和块映射。内存分配器某些高性能分配器使用 B树 管理空闲区间能快速按地址查找相邻块。日志结构存储引擎中的内存索引与磁盘索引合并其中也用到了大量树形索引思想。这些场景的共性是“数据量远超内存可连续加载的量”和“顺序访问与随机访问都需要兼顾”。理解了基础 B树 后你看这些系统的文档会顺畅很多至少别人讨论索引页分裂、页合并时你不会一脸茫然。7.3 一个最小可运行B树骨架插入与查找只写核心逻辑纸上谈兵告一段落我给出一个只包含插入和查找的 Python 骨架删除逻辑比较复杂作为练习留给你补齐。它使用的结构定义严格遵循本文档前文的“m 阶”参数。import math class BTreeNode: def __init__(self, leafFalse): self.keys [] self.children [] self.leaf leaf class BTree: def __init__(self, m3): self.m m self.min_keys math.ceil(m / 2) - 1 self.max_keys m - 1 self.root BTreeNode(leafTrue) def search(self, node, key): i 0 while i len(node.keys) and key node.keys[i]: i 1 if i len(node.keys) and key node.keys[i]: return (node, i) if node.leaf: return None return self.search(node.children[i], key) def insert(self, key): root self.root if len(root.keys) self.max_keys: new_root BTreeNode(leafFalse) new_root.children.append(root) self._split_child(new_root, 0) self.root new_root self._insert_non_full(self.root, key) def _insert_non_full(self, node, key): i len(node.keys) - 1 if node.leaf: node.keys.append(None) while i 0 and key node.keys[i]: node.keys[i 1] node.keys[i] i - 1 node.keys[i 1] key return while i 0 and key node.keys[i]: i - 1 i 1 if len(node.children[i].keys) self.max_keys: self._split_child(node, i) if key node.keys[i]: i 1 self._insert_non_full(node.children[i], key) def _split_child(self, parent, i): child parent.children[i] mid self.max_keys // 2 new_node BTreeNode(leafchild.leaf) new_node.keys child.keys[mid 1:] child.keys child.keys[:mid] if not child.leaf: new_node.children child.children[mid 1:] child.children child.children[:mid 1] parent.keys.insert(i, child.keys[mid]) parent.children.insert(i 1, new_node) def inorder(self, nodeNone): if node is None: node self.root if node.leaf: return node.keys result [] for i, key in enumerate(node.keys): result.extend(self.inorder(node.children[i])) result.append(key) result.extend(self.inorder(node.children[-1])) return result # 使用示例 bt BTree(m3) for k in [1, 2, 3, 4, 5, 6, 7]: bt.insert(k) print(bt.inorder()) print(bt.search(bt.root, 5))这段代码省略了删除、借位和合并但插入与查找已经能跑通。你可以在自己机器上运行然后试着在_split_child里加断言验证分裂后每个节点的键数都在合法区间。等你把删除补上再配合前面讲的中序遍历自检基本就把这一章的工程量吃透了。我个人在实际学习中还有一个体会学 B树 不能满足于“能写代码”最好反向给自己出几道题。比如“为什么删除时优先借而不是合并”“为什么插入时只分裂叶子”这些问题教材里往往不会直接写答案但一旦你自己想明白B树 的整套机制就真正长在脑子里了。
返回列表