
如果有人在我刚做开发的时候告诉我搞懂B树的定义能省下无数个排查慢查询的夜晚我一定不会信。直到一次线上数据库在数据量翻了两倍之后一条明明走了索引的查询突然慢得离谱我才老老实实把索引背后的数据结构翻了个底朝天。B树这个名字听起来像教科书里的古董但MySQL、PostgreSQL、文件系统、各类KV存储全都绕不开它。这篇文章我要做的事很简单把“B树定义”讲透再把它和常被拿来对比的B树放在一起拆开揉碎地聊最后给出一份能直接拿去用、拿去背、拿去debug的实操笔记。适合刚接触数据结构的同学也适合面试前想系统过一遍索引原理的后端和数据库工程师。1. B树的定义到底在说什么1.1 从“二叉树不够用”说起你可能会问平衡二叉树已经很完美了为什么还要B树原因就一句话二叉树在内存里很香在磁盘上很贵。传统二叉搜索树每个节点只有一个关键字、两个孩子数据量一大树的高度就会很高。假设一千万条数据理想平衡二叉树的高度大约是 log2(1000万)也就是24层左右。如果每访问一个节点对应一次磁盘IO一次查询要碰二十多次磁盘这个代价在机械硬盘时代几乎是灾难。B树的思路很直接让一个节点多存几个关键字、多长几个孩子把树压扁。同样一千万条数据如果每个节点能存几百个关键字整棵树的高度可能只要三四层。也就是说一次查询从二十多次磁盘访问降到三四次这个差距直接决定了数据库能不能撑住大流量。这里要澄清一个常见的误解B树不是“叉很多的二叉树”它是一种自平衡的多路搜索树。所谓多路指的是每个节点可以有多个孩子所谓平衡指的是所有叶子节点都在同一层树不会长成一边高一边低。它的目标不是减少内存比较次数而是减少磁盘IO次数。1.2 B树的准确定义与阶数B树的定义通常用“阶数”m来表达。一棵m阶B树需要满足以下条件每个节点最多有m个孩子即最多有m-1个关键字。除根节点和叶子节点外每个节点至少有ceil(m/2)个孩子。根节点至少有两个孩子除非整棵树只有一个节点。所有叶子节点都在同一层不能出现有的叶子在第3层、有的在第5层的情况。每个节点的关键字按升序排列并且关键字之间夹着指向子树的指针。这里有一个容易混淆的点教科书里对“叶子节点”有两种说法。一种说法是叶子节点是查找失败时到达的外部空节点不存储数据另一种说法是叶子节点就是最底层存储关键字的节点。这两种定义在不同资料里都出现过。我建议在面试和看代码时统一用第二种理解最底层有关键字的节点就是叶子节点否则讨论插入删除会很别扭。只要自己坚持一套定义去推演就不会乱。阶数m到底是越大越好吗并不是。m的大小受限于磁盘页大小和单个关键字占用的空间。一个节点如果装得太满虽然树更矮了但节点内部的二分查找和内存拷贝成本也会上升而且一个节点填不满时浪费的空间更多。实践上数据库通常以页为IO单位一个节点对应一个页比如InnoDB默认页大小16KB存主键long型的话一个节点大概可以容纳一千多个关键字。1.3 三条关键性质背后的直觉B树的所有性质都可以用三个词记矮、满、平。“矮”是因为多路树高最多为log_ceil(m/2)(N)远小于二叉树。“满”是要求除根和叶子外每个节点至少有ceil(m/2)个孩子防止节点空闲空间太多也保证删除后需要合并的下界。“平”是叶子同层保证每次查找的磁盘访问次数稳定不会出现一个查询快一个查询慢的情况。这三条性质是互相约束的。如果你允许节点只有两个关键字就分裂一次树会太瘦接近二叉树失去多路优势如果你允许节点一直攒到满才分裂对插入很友好但删除时容易频繁合并。B树用“半满”作为平衡点实际上是在空间利用率和操作复杂度之间做了一个工程取舍。教科书上的定义看起来像数学公理本质上是磁盘IO模型下最优解的近似表达。2. B树的查找、插入与删除是怎么落地的2.1 查找从根开始的多路二分B树的查找逻辑和二叉搜索树很像只不过每个节点内部是一个有序数组。从根节点开始拿着要查找的key在节点内找它的位置。如果命中某个关键字直接返回数据如果落在某两个关键字之间就沿着它们对应的孩子指针继续往下走。举个例子一个3阶B树的根节点有两个关键字[30, 70]要查找key55。先看30太大再看70比55大于是key应该落在30和70之间走中间的子树。进入子节点后重复这个过程直到叶子节点。如果叶子节点里也没有就说明数据不存在查找失败。工程实现里节点内部不会用顺序遍历去比较通常用二分查找先定位区间再决定走哪个孩子。这里有个很实用的细节定位“落在哪个区间”一般用二分查找返回第一个大于等于key的位置。如果这个位置的值等于key那就是命中如果大于key则这个位置对应的孩子指针是下一层入口如果返回的位置是节点长度说明key比节点内所有关键字都大走最后一个孩子。查找的时间复杂度是O(log_m N)每次只访问路径上的节点。因为树高度低B树更适用于“一次访问成本极高、但路径访问次数极少”的场景。在内存数据结构里二分查找可能比B树更快但那是另一个赛道B树从来不是为纯内存查询设计的。2.2 插入先插后裂的分裂机制插入第一步永远是先做一次查找找到目标叶子节点。如果这个叶子节点没满直接原地把key插进去保持有序数组即可。真正麻烦的是节点已经满了的情况这时候要执行分裂。假设一个m阶B树一个节点最多有m-1个关键字。当第m个关键字插入后节点已经超过了上限需要把节点中的m个关键字分成两半。具体操作是选中间位置ceil(m/2)的关键字作为“上提关键字”左边的关键字留在原节点右边的关键字放入新兄弟节点然后把上提关键字插到父节点里新兄弟节点作为父节点的新孩子。分裂带来的连锁反应是父节点可能也满了。如果父节点满了继续用同样的规则向上分裂直到根节点。如果根节点也满了就创建一个新的根节点把上提关键字放进去树的高度增加一层。这是B树高度增长的唯一方式而且增长总是发生在根节点而不是某个子树所以所有叶子依然保持同一层。这里有一个非常容易被忽略的边界分裂时选中间关键字不能简单用“数组中间”那个值。因为节点关键字数量为m时中间位置应该是ceil(m/2)这个位置。选错中间位置会导致左右节点数量不合法要么左边少于min children要么右边少于min children。很多初版实现都会在这里写出隐蔽的bug。插入操作的整体代价是O(log_m N)因为向上分裂最多传播到根节点路径长度就是树高。实际工程中一次插入可能要写多个磁盘页所以数据库会靠预写日志、缓冲池延迟刷盘来降低随机IO。2.3 删除借位、合并与根下降删除是B树操作里最复杂的部分因为它要维持“每个节点至少有ceil(m/2)-1个关键字”的下界。删除分两种情况。第一种情况要删除的关键字在内部节点。通常做法是找它的前驱或后继把前驱/后继的值覆盖到当前节点上然后问题就转化成删除叶子节点里的那个前驱/后继关键字。这种替换技巧绕开了“从内部节点直接删”的麻烦和二叉搜索树删除的思路一脉相承。第二种情况要删除的关键字在叶子节点。直接删掉后如果节点关键字数还满足下界皆大欢喜。如果不够了先看左兄弟或右兄弟能否借一个关键字过来——兄弟节点关键字数大于min则通过父节点作为中转借一个key过来完成“旋转”。如果兄弟也不够借就把当前节点和一个兄弟节点合并同时要把父节点里的分隔关键字也拉下来一起并入新节点。合并可能让父节点也低于下界于是继续向上递归合并直到根节点。如果根节点的孩子因为合并少到只剩一个那么根节点被删除它的唯一孩子成为新根树高减一。删除的“借位”细节最容易写错借位不是直接拿兄弟的一个key塞过来而是父节点作为桥梁。比如左兄弟借一个最大key给父节点父节点把原来的分隔key移下来补到当前节点。逻辑上一共动了三个节点少了任何一个步骤顺序都会乱。很多教材把这一步画成箭头图看起来简单自己写一遍就知道坑在哪儿了。作为使用者你不需要手写完整的删除逻辑但理解删除对理解数据库页回收、空白页整理很有帮助。比如MySQL InnoDB中删除一行数据并不一定立刻物理删除而是标记删除后台由purge线程清理。这和B树合并的成本考量有关延迟清理可以降低随机IO频率。3. B树和B树一字之差工程上却是两套取舍3.1 数据存储位置带来的根本差异B树和B树最大的区别只有一句话B树把所有真实数据都放在叶子节点内部节点只存关键字和指向下一层的指针而B树每个节点都可以存数据或存指向数据的指针。这个差异看起来只是“数据放哪”的问题实际上把两种结构的性格完全改掉了。在经典B树中查找一个key可能在根节点就直接命中不用继续下探也可能到第三层才命中所以一次查找的访问次数不稳定。B树因为内部节点不存数据无论查找哪个key都必须从根一路走到叶子每次查找路径长度完全一样。表面上看B树更“死板”但这个“稳定”在并发控制和性能预测里很重要。系统可以准确估计一次查询最多几次IO从而合理设计缓存和连接池。另一个附带影响是节点存储量。B树的内部节点由于只存key和指针一个节点能容纳的关键字个数比B树多得多。同样16KB的页B树内部节点可能装几千个key而B树因为要携带数据或数据指针装的数量就少。内部节点能装更多key意味着树更矮根节点到叶子的路径更短。这也是为什么很多数据库索引最终选择了B树。3.2 范围查询与遍历性能的差别范围查询是B树最能打的场景。比如执行“select * from t where id between 100 and 200”B树的叶子节点会通过双向链表串联起来找到第一个满足条件的key后直接沿着链表向后扫直到超过范围为止。整个过程中几乎不需要回溯到父节点顺序IO友好效率极高。B树在这类场景下就比较头疼。关键字分散在很多节点中范围查询时你无法保证目标范围内相邻的key在物理上也相邻。你经常需要从一个节点跳到父节点再跳到另一个子树产生大量随机IO而且同一个节点可能被重复访问。虽然B树也可以做范围查询但实现要复杂得多性能通常不如B树。遍历全体数据的场景也是这样。B树的叶子链表让全表扫描变成了顺序读B树你得做中序遍历相当于一次又一次在父子节点之间跳跃。数据库的存储引擎几乎是压着“顺序IO比随机IO快几个数量级”这个事实做优化的所以B树在OLTP范围查询场景下成为明显更优的选择。3.3 MySQL InnoDB为什么选了B树而不是B树这里要区分两层MySQL的索引默认数据结构是B树这是InnoDB引擎层的选择。为什么不是B树原因主要有三个。第一InnoDB的数据本身以聚簇索引形式组织叶子节点直接存整行数据那么内部节点越省空间越好B树内部只存主键所以能最大程度降低树高。第二InnoDB需要高效支持范围查询、排序、分页B树叶子链表天然适配。第三B树由于数据全部在叶子叶子节点可以做成双向链表配合预读机制顺序扫描性能非常稳定。但B树并不是全场景最优。比如Redis这种纯内存KV数据全在内存IO模型完全不同反而更适合跳表因为跳表实现简单、范围查询也很方便。LevelDB/RocksDB用的是LSM-Tree优化写放大而不是读放大。所以别拿到“B树最好”的结论理解为“在磁盘数据库OLTP场景下B树最划算”就够了。4. 手写一个迷你B树以及我踩过的坑4.1 简化实现定义节点结构与搜索逻辑纸上谈兵没用我建议你至少手写一个支持查找和插入的迷你B树阶数取3或者4都行。用数组存关键字、用数组存孩子指针别用链表真实实现里都是用连续内存配合二分查找的。节点结构大致如下class BTreeNode { int[] keys; int degree; // 阶数 m BTreeNode[] children; int keyCount; // 当前关键字数量 boolean leaf; // 是否是叶子节点 }搜索逻辑的核心是“定位孩子下标”。拿到一个key先二分找到第一个大于等于key的位置pos。如果pos keyCount 且 keys[pos] key说明找到了如果当前节点是叶子说明不在树里否则递归搜索 children[pos]。这个pos同时表示要下降的孩子指针下标真实代码里很多人会把边界情况弄错比如key大于所有关键字时pos会等于keyCount这时候要走children[keyCount]。写节点分配时还有一个性能细节keys和children数组长度都开成degree和degree1因为分裂时需要临时容纳一个额外的key。直接开上限减少频繁扩容。数据结构里数组固定长度虽然浪费一点内存但换来的是缓存命中和GC压力降低。4.2 插入分裂的代码级要点插入先递归到叶子然后在本地数组里插入key。这一步简单。难的是分裂函数。我的建议是单独写一个dawaSplit的方法别把分裂逻辑内联到插入方法里。分裂时要把当前节点从中间位置mid degree / 2劈开。注意阶数是奇数偶数时最好先统一用ceil(degree/2)作为上提位置否则边界麻烦。新建一个right节点把mid后面的key和孩子搬过去更新keyCount然后把mid位置的key从当前节点里取出来插到父节点。如果当前节点是根新建父节点然后把这个父节点赋值给根。实际操作中我踩过最隐蔽的坑是父节点的孩子指针数组需要扩容一个位置但是原来的children数组可能没预留空间。如果你在节点定义时孩子数组长度是degree插入父节点后很可能越界。解决办法是把孩子数组长度设为degree 1因为分裂时父节点最多多一个孩子。这个看似多余的空间在代码里救了我好几次。另外分裂时孩子指针也要跟着搬。不止是keys要分给右兄弟当前节点的孩子数组也要从mid1开始搬到right节点。只搬key不搬孩子是新人最容易犯的错误查出来的时候可能已经导致内存错乱。4.3 实操中的典型BUG与排查思路我总结了一下平时在代码Review和Debug中最多见的几类问题说给你参考第一类是“丢失父指针”。很多学生实现B树时节点里根本没有parent字段删除和借位时找不到父节点只能用递归返回值一层层回传一旦递归结构写复杂就出错。我的建议是初学实现可以先加parent字段方便调试性能优化时再去掉。第二类是“根节点分裂后忘了更新全局根引用”。Java实现中如果你把根节点存在外部变量里分裂后必须重新赋值。很多人只改了局部变量看起来数据没丢但下一次查找还是从旧根开始结果一切正常就是慢然后找半天找不到bug。第三类是“删除合并时兄弟节点选择错误”。有的实现只和右兄弟合并不考虑左兄弟这会导致在右兄弟不存在或右兄弟不够借时出错。稳妥做法是左右兄弟都看一下优先选能借的都不够借才合并。排查时最有效的工具就是把B树结构打印出来。写一个toString或者draw方法把每一层的节点都打印出来对照定义检查每层是否符合关键字数量范围是否所有叶子同层只要把这两条check一下大部分问题都能快速定位。4.4 面试与实战高频问题速查我把面试里高频的几个问题整理成了一张速查表你也可以当作复习大纲问题参考答案B树和二叉搜索树的区别多路、所有叶子同层、每个节点可存多个key降低树高减少磁盘IOm阶B树每个节点最多几个孩子m个关键字最多m-1个m阶B树非根节点最少几个关键字ceil(m/2)-1个B树为什么高矮节点大、多路所以同样数据量下路径短B树和B树的根本区别数据只在叶子内部节点只存索引为什么InnoDB用B树范围查询、顺序扫描、内部节点更省空间、查询路径稳定什么时候用B树而不是B树如果你非常依赖单点查询B树可能在更高层命中但实际收益很小工程上B树仍是更稳妥选择最后聊一个我在实际项目中积累的体会B树定义不难背难的是理解它每个数字背后的IO动机。你真正动手实现一次插入和分裂之后再去看MySQL的索引原理、Page结构、页分裂策略会发现很多东西是相通的。那些看起来高深的数据库参数本质都是在B树这套规则上做工程调优。如果你现在正准备面试与其死记硬背“B树是平衡多路搜索树”不如打开代码编辑器写一遍分裂过程面试官问起来时你能讲出“为什么中间key要上提”“为什么小孩数组要多留一位”那才是真正吃透。