☰
数据库索引核心:B树与B+树的差异及底层原理详解
2026/10/4 8:04:31 网站建设 项目流程

市面上写B树和B+树的文章实在太多了,但大多数都在贴教科书定义,什么“m阶”“半满约束”“叶子同层”——背完就忘。我当年准备面试时也是这个状态,直到自己动手实现了一遍、又去看了InnoDB和MongoDB的底层索引代码,才算真正把它俩的区别想明白。这篇文章我不按八股文来,直接从“数据库为什么选B+树”这个真问题出发,把B树和B+树的差异、代价、适用场景一次讲透。适合正在学数据结构的同学、准备数据库面试的工程师,以及想搞清楚MySQL索引原理的开发人员。全文一个图都不用,全凭文字拆解和表格,但保证你读完能自己画出这两棵树。

1. 先搞明白:B树到底解决了什么问题

1.1 从二叉搜索树到树高失控

想理解B树,得先回到二叉搜索树。二叉搜索树每个节点最多两个子节点,查找时每向下走一层,数据范围缩小一半。这个特性在纯内存场景下非常漂亮,100万条数据,平衡后的二叉树高度大约20层,内存访问一次只要几十纳秒,20次完全不痛不痒。

但数据库的索引不是为了“内存友好”设计的,而是为了“磁盘友好”。磁盘一次IO(读取一页数据)的耗时大约在0.1毫秒到10毫秒之间,比内存访问慢了好几个数量级。如果一棵树需要20次IO才能查到目标数据,单次查询就要耗费几十毫秒,这个延迟在线上是不可接受的。

那怎么把20次IO降到4次?答案只有一个:让树更矮。

同一棵100万节点的树,如果每个节点不是2个孩子,而是100个孩子,树高立刻从20层掉到3层左右。这就是B树最核心的设计动机——提升单节点的孩子数量,用“分叉多”换来“高度低”。这个思想就叫多路平衡查找树,B树的具体定义可以理解成:一棵高度平衡、每个节点最多容纳m个子节点的多叉树。

1.2 磁盘IO决定了“节点越大越好”

很多教程一上来就讲B树的阶数、分裂规则,容易让人忽略一个根本性问题:为什么要把多个键打包进一个节点?直接设计成二叉树,每个节点存一个数据,不也能用平衡算法保证稳定吗?

原因在于磁盘的读取单位是“页”。数据库引擎从磁盘读数据时,最小单位一般是一页,MySQL InnoDB默认页大小是16KB。索引节点越大,一次IO带回来的有效信息越多,查找效率越高。假设一个页能存1000个索引键,那么读取一页后,内存里就有了1000个键可用来做二分比较,再决定下一层读哪个页。这个“一页多键”的设计,让树的高度被压缩到极致。

实际估算一下:MySQL 3层B+树,非叶子节点每个键大约8到16字节,加上指针,一页16KB大概能容纳1000到2000个键。第一层1000个键,第二层就是100万,第三层理论上能覆盖10亿以上叶子记录。也就是说,十亿行级别的表,走主键查询也只需要3次磁盘IO。这个效率是二叉搜索树完全做不到的。

1.3 同层节点保持有序是平衡的灵魂

B树还有一个容易被忽略的规则:所有叶子节点都在同一层。这在数据结构里叫“完美平衡”。为什么必须这样?因为数据库查询耗时对树高极其敏感,如果某条数据在第三层就能找到,另一条却要跑到第五层,那查询延迟就不可控了。做服务端开发的人都懂,性能不稳定比性能差更可怕,因为无法预测、无法优化。

为了维持这个“叶子同层”,B树在插入和删除时有一套严格的约束:每个非根节点的关键字数量必须在ceil(m/2)-1到m-1之间。这个“至少半满”的约束,就是为了防止树出现“细长条”结构。细长条的树本质上就是链表,高度失控,失去了多路的意义。

到这里你应该明白:B树不是算法的花样翻新,它是拿扇出换IO次数的工程产物。搞清楚这一点,再看接下来B+树对B树做的改造,就能知道每一步改动背后的逻辑是什么。

2. B树的结构拆解:一个节点能装很多孩子

2.1 一个B树节点里到底有什么

B树的节点结构可以拆成三部分:一组有序的关键字、对应的一组子节点指针、以及数据指针。关键点在于,B树的每个节点上既存索引键,也直接保存这条数据对应的记录内容或记录指针。换句话说,数据是“遍布全树”的。

查一下查找过程你就懂了。比如在3阶B树里查数据47,根节点有[40, 80]两个键,47介于两者之间,于是进入第二个子节点;这个子节点里恰恰存着键47和数据指针,直接返回,连叶子节点都不用碰。这带来一个直观的好处:热点数据如果恰好落在上层节点,查询IO次数比“必须走到底”的结构更少。

但代价也随之而来。因为每个节点都存数据,节点的体积比“只存索引”要大得多。同一页磁盘空间,能容纳的索引键数量变少,扇出降低,树高自然增加。这就为B+树的出现埋下了伏笔。

2.2 节点分裂与合并的最少占用原则

B树的插入和删除规则很机械化,但细节极多。插入时,如果某个节点的关键字满了(达到m-1个),就要执行分裂:把节点一分为二,把中间那个键上提到父节点,剩下半个留在当前节点,半个进新节点。这个过程可能递归地向上传递,如果父节点也满了,就再分裂,直到根节点为止。根节点分裂时树才长高一层。

节点分裂时有个最容易被忽略的细节:保证分裂后左右两个节点都满足“半满”约束。如果一个节点要分裂,必须从中间位置切开,让两侧都至少持有ceil(m/2)-1个关键字。删除时则相反,如果节点关键字数低于半满,先从兄弟节点借,借不到就把当前节点和兄弟节点合并,并降下父节点中的一个键。这一系列操作的目的只有一个:维持树高不失控,保证叶子同层。

这里有个动态平衡的规则值得单独说:向兄弟借关键字时,不是直接把兄弟节点里的键拿过来,而是通过父节点中转。这很像AVL树旋转时“绕过根节点”的思路——直接搬兄弟节点的键会破坏排序关系,只有经过父节点的中间值过渡,才能保持整棵子树有序。

2.3 我手写B树时踩过的三个坑

光看理论很容易觉得自己懂了,实际手写一遍B树实现,99%的人会栽在几个细节上。

第一个坑:分裂递归忘上提。插入导致叶子节点分裂后,中间键要上提到父节点。很多初学者只处理了当前层,忘了父节点可能因此也超载,结果树结构一团糟。正确做法是:插入函数的返回值设计成“是否需要上提”,逐层传上去,直到某一层没分裂为止。

第二个坑:删除时的借位替换。删除关键字后如果节点不足半满,向左兄弟借时,需要用父节点里的分隔键替换借出的键,再把兄弟的最大键推到父节点。这个逻辑不画图硬写代码,十有八九会把父节点的键弄错位置。

第三个坑:中间键的选取规则。m为偶数时,满节点有m-1个键,分裂时取中间偏左还是中间偏右会得到不同形态。两种做法都能满足B树约束,但整个代码里必须始终一致,否则同一棵树会出现不稳定的旋转状态。我建议实现前先定下规则:统一取下位中位数(左中间),减少边界判断。

这些坑在B+树里同样存在,而且因为叶子链表的存在变得更微妙。所以别急着跳台阶,先把B树的插入删除跑通,再去看B+树会轻松非常多。

3. B+树比B树多出来的两样东西:链表和瘦身

3.1 非叶子节点只存索引,不存数据

B+树和B树长得几乎一样,唯一的本质差异就是数据存储的位置。B+树的内部节点(非叶子节点)里只存索引键和子指针,所有真正的内容都下沉到叶子节点。

这个“瘦身”带来的收益极其明显。同样是16KB的磁盘页,B+树的非叶节点能容纳的键数量比B树多得多。假设每个键加指针占用12字节,16KB一页能放约1300个键;如果这个节点同时兼任“存数据”的职责,每行数据动辄几百字节,那一个页可能连50个键都塞不下。扇出直接差出一个数量级。

扇出高了,树高就降了。最直观的对比:1000万条记录的索引,用B树可能要走3到4层,用B+树可以稳定压到2到3层。可别小看这1层差距——千万级数据量下,每次查询少1次IO,对高并发系统来说就是巨大的性能差异。

还有连带好处:因为非叶节点不存数据,索引结构里不会因为数据记录大小不一而出现“节点空间浪费不均”的问题。数据库做索引页缓存的时候,可以在有限的内存里缓存更多索引页,命中率也提高了。

3.2 叶子节点通过链表串联:范围查询神器

B+树第二个关键改动,是所有叶子节点之间用指针串成一个链表,在InnoDB里实际是双向链表。这个设计直接解决了B树最头疼的问题:范围查询。

B树查某个范围的数据,比如查主键大于100且小于200的记录,查到第一条100以后,接下来每取一条都得不断回溯父节点、重新走路径,中序遍历的跳转成本非常高。数据量一大,这类查询就废了。

B+树呢?因为叶子节点已经有了链表,查到100这个位置之后,顺着叶子指针往后扫就行,一路都是顺序IO。数据库可以利用这一点做“顺序预取”,一次读入连续多个页,吞吐量完全碾压B树的随机跳转。这也是为什么关系型数据库里WHERE id BETWEEN这种范围条件能保持高效执行的底层原因。

叶子链表还让“遍历全表”变得极其优雅。想扫全量数据,只需要从最左叶子节点开始,顺着链表走到最右,不需要维护任何栈或回溯状态。这也是InnoDB全表扫描能保持稳定吞吐量的基础。

3.3 所有查询路径等长:查询次数被锁死

B树允许“在中间命中原路返回”,B+树不行,所有查询都必须走到叶子节点。有人觉得这是缺点,多了一步IO。但从工程视角看,这恰恰是优点:查询时间变得完全可预测。

试想一个数据库查询引擎,同一张表的查询,有时1次IO就返回,有时3次IO才返回,执行计划怎么估算成本?查询耗时怎么预估?连接池、超时时间怎么设置?全都变得不确定。B+树把所有查询路径长度统一之后,每个查询的IO次数就是一个确定的树高,执行计划和成本评估就有了稳定基础。

这个特性在关系型数据库里尤为重要:因为业务查询大多不是单点查询,而是复杂的连表、过滤、排序,任何一步的不确定性都会被放大。稳定的IO次数,意味着稳定的性能表现,这在服务端是一个非常重要的工程属性。

4. 一张表看清B树和B+树的七个维度差异

4.1 核心差异对照表

说了这么多,不如一张表格来得清楚。我把两棵树的差异按7个维度做了对比:

对比维度B树B+树
数据结构节点既存索引键又能存数据非叶子节点只存索引键,数据全在叶子
查找路径可能在非叶节点直接命中,IO次数不固定必须走到叶子,IO次数固定等于树高
范围查询需要中序遍历,回溯节点,开销大叶子链表顺序扫描,天然支持范围查询
非叶节点容量数据占空间,扇出低,树偏高索引键小,扇出高,树矮
写操作代价更新数据可能要移动节点内数据记录非叶只更新索引键,叶子集中放数据,写成本更可控
查询稳定性热数据可能快,冷数据可能慢所有查询同耗时,稳定可预测
典型场景文档型数据库、小规模内存索引关系型数据库聚簇索引、二级索引

4.2 空间与写入的代价要算明白

很多资料只说B+树优势,不敢提代价。实际上B+树有一个明显的空间浪费:非叶子节点里存了冗余的分隔键,这些键在叶子节点里也会存在。B树反而每个键只存一遍,空间利用率理论上更高。那为什么工程上还是选B+树?因为空间省下来的量,远不及扇出提升带来的收益。数据库里磁盘页是按16KB一次读取的,页内存10个键或存100个键,IO成本相同,但后者能把树高降1层。这个账划算得多。

写入代价也要重新算。B树更新一条数据,如果数据记录就存在非叶节点里,记录变大时需要移动节点内所有后续数据,甚至可能触发节点分裂,成本很高。B+树的数据全部集中在叶子页,非叶节点的键基本不动,更新的主要成本集中在叶子页本身。叶子页写满后触发页分裂,由于数据按主键有序排列,分裂通常只影响相邻叶子,影响面小且可预测。

4.3 顺序访问的缓存命中率

还有一个容易被忽略的差异:缓存命中率。B+树的叶子节点按主键顺序排列,物理页在磁盘上大致连续,范围扫描时数据库能用预读机制连续加载多个页,一次IO带回多页有用数据。B树节点分散,数据随机分散在各层,即使扫描同一范围,也要不断在不同深度的节点间跳跃,无法高效预读。

内存缓存的角度更明显。数据量巨大时,索引缓存只能容纳一部分页,B+树因为非叶节点瘦,缓存里能放更多上层索引页,路径查询时命中缓存的概率更高。B树因为每个节点都“胖”,同样的缓存空间只能覆盖更少的索引分支,冷数据命中的概率上升,查询变慢的概率随之增加。

5. 真实场景:InnoDB为什么坚持B+树,MongoDB为什么用B树

5.1 InnoDB页结构与聚簇索引的配合

MySQL的InnoDB引擎,聚簇索引的底层就是B+树。聚簇索引的意思是,整行数据直接存在B+树叶子节点里,主键就是这颗树的排序键。拿到主键,一次查询就能取回整行记录。

InnoDB选择B+树,最直接的原因就是前面提到的范围查询。业务系统里大量SQL是范围条件:按时间查订单、按区间查价格、按ID批量查详情。如果底层是B树,这些查询全要退化成为回溯式的多点查找,性能根本无法支撑。B+树叶子链表让这种查询变成纯粹的线性扫描,配合页预读,速度可以接近顺序读磁盘。

非叶节点瘦身对InnoDB还有额外意义。二级索引(非主键索引)在InnoDB里叶子节点存的是主键值,一个表可能有多个二级索引。B+树让每个索引都能用最小的非叶空间支撑最大的数据量,索引内存占用和磁盘占用都被压缩到极致。同一块Buffer Pool缓存,能容纳更多索引页,这对数据库的整体命中率至关重要。

5.2 MongoDB的场景里B树反而更合适

很多人不知道,MongoDB的默认索引底层用的是B树(WiredTiger引擎)。这就引出一个问题:B树既然被关系型数据库淘汰了,为什么文档型数据库还在用?

原因是MongoDB面对的场景和MySQL完全不同。文档型数据库的核心操作是“单文档定位”:根据_id直接取文档,这种点查询占绝大多数。B树允许在非叶节点命中即返回,少走一层就少一次磁盘IO,对点查询占优。范围查询MongoDB也支持,但没有关系型数据库那么重的联表、聚合压力,B树的性能短板被弱化了。

更关键的是文档更新机制。MongoDB的文档允许动态增减字段,文档体积会变化。文档增大后可能要挪到新的磁盘位置,索引节点里存的其实是指向文档的指针。B树把数据指针存在所有节点层,文档搬移后只需更新指针值;如果数据体积超过一个阈值,B树的节点还能通过分裂来腾出空间,保证索引结构不受拖累。B+树把所有数据拘在叶子,文档变大导致叶子页写满、频繁分裂,反而更容易产生碎片。

所以在MongoDB的语境下,B树更贴近实际工作负载。这告诉我们一个道理:数据结构没有绝对优劣,只有适不适合当前访问模式。

5.3 SQLite对B+树的微小改动带来的启示

SQLite的索引底层也是B+树,但它的实现在论文基础上做了微调:内部节点额外保存一个“最右指针”,可以直接指向子树内最大的键。这样在某些边界查询场景下,不需要递归到最深层的右子树,省了一次IO。类似这种细节,每个数据库引擎都有,不是把教科书里的B+树抄一遍就算完。

这给了我们一个启示:面向面试学B+树,背结论就行;面向工程用B+树,必须理解它跟页存储、页分裂、磁盘预读之间的耦合关系。不同引擎对B+树的改造成度不同,但核心思想一致:围绕磁盘页大小设计节点、用链表优化顺序访问、用数据下沉降低树高。理解了这一层,无论数据库换成PostgreSQL、SQLite还是OceanBase,你都能快速看懂它的索引结构。

6. 别把B+树认成红黑树,以及如何通过动画真正看懂它

6.1 B+树不是红黑树,也不是二叉树的变相实现

关于“B+树是红黑树吗”这个问题,我见到不止一次出现在技术论坛里。答案很明确:不是。红黑树是一棵二叉搜索树,每个节点最多两个孩子,通过红黑染色保证最长路径不会超过最短路径的2倍,依靠左旋右旋维持平衡。典型应用是Java的TreeMap、Linux内核的调度队列,不适合做磁盘索引,因为树高随数据量线性增长,IO次数无法接受。

B+树是名副其实的多路搜索树,每个节点可以有几十到上千个孩子,平衡约束是“所有叶子在同一层”。它和红黑树唯一的共同点是“都叫平衡树”,但一个是内存算法,一个是磁盘结构,机制完全不同。

之所以有人混淆,可能是因为听说过“B-树”。B树在英文文献里常写作B-tree,读作“B树”,和减号无关。B+树则是B-tree的改良版,加号表示“在B树的基础上增加了新特性”。它们是同一家族的迭代关系,不是二叉化改造。

6.2 怎么跟面试官把这个问题讲清楚

如果面试被问到B树和B+树的区别,我建议不要上来就背概念,而是按“同源-差异-工程结果”三步走。

先说同源:B树和B+树都是m阶多路平衡查找树,都有节点半满约束,都要求叶子同层。讲这一点就能让面试官知道你理解的是本质,不是只会背公式。

再讲结构差异:B树的数据遍布所有节点段,非叶节点既能做索引也能直接返回数据;B+树的数据只在叶子节点,非叶节点只存索引键,且叶子节点之间通过双向链表串联。

最后落到工程影响:非叶节点瘦身导致扇出变大、树高降低;所有查询路径等长导致IO次数可预测;叶子链表让范围查询变成顺序扫描。正因为这些特性,InnoDB选B+树做聚簇索引。这一套讲完,基本没人觉得你是在背八股。

6.3 推荐的可视化与自测方法

很多人想找B+树的动画实现来帮助理解。网上确实有不少现成的可视化项目,把插入分裂、删除合并的过程一帧一帧展示出来。但我个人体会是,看别人动画一百遍,不如自己写一个。

我建议用Svelte或Vue写一个B+树插入过程的可视化:把树的当前状态存成普通数组加引用关系的对象,每次插入/删除后把快照推进一个队列,页面用递归渲染组件逐帧播放。写这个项目的过程中你会被迫处理“分裂后父节点怎么连”“叶子插入后链表的prev/next怎么更新”这些只靠眼睛看不出细节的问题。

我自己写的时候踩过一个大坑:节点对象在分裂合并过程中会被销毁和新建,如果动画状态里保存的是对象引用,旧帧全部跟着变了。正确做法是给节点加唯一id,动画过程只按id追踪节点的位置变化。这个经验,不亲手跑一遍可视化根本学不到。

真正理解B+树之后,你会发现一个很奇妙的事实:B+树的全部优势,其实都可以从“把数据移到叶子节点”这一个决定推导出来——扇出提高所以树矮,树矮所以IO减少;数据下沉所以非叶节点可以容纳巨量索引;叶子链表是为范围查询服务的,而因为数据全在叶子,链表串联的恰好是完整数据集。明白了这一点,你就不需要背区别清单了。

最后分享一个我私藏的自测方法:随机生成100万条整数,分别用B树和B+树实现“插入后按范围查询”,打印每次查询的IO次数和最终树高。亲测跑完之后,你对这两种结构的理解深度会超过绝大多数只刷题的人。数据结构这种东西,只有亲手写一遍、踩一遍坑,才能变成你自己的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询