☰
深入理解B-Tree与B+Tree:数据库索引性能优化的核心
2026/10/6 16:46:48 网站建设 项目流程

做后端开发这些年,我有个特别深的体会:凡是和数据存储格式有关的性能难题,最后几乎都能绕到 B-Tree 头上。早几年排查一个线上慢查询,表里才几百万行,索引也建了,EXPLAIN 看着也走了索引,可查询就是被拖到几百毫秒。后来把 InnoDB 的表空间拆开,一层层翻到索引页的二进制内容,才算真正看清 B-Tree 在磁盘上是怎么组织数据的。那次之后我意识到,不懂 B-Tree,谈数据库性能优化基本就是隔靴搔痒。

数据量和查询速度的关系并不是线性增长的,而是存在一个“断崖”:数据量到了一定规模,一个糟糕的数据存储格式会让一切都慢下来。B-Tree 这个结构,正是存储引擎与磁盘之间那层最关键的缓冲。它解决的不只是“能不能找到”的问题,而是“用最少的磁盘 IO 找到”的问题。这篇文章我不打算堆公式,尽量用大白话把 B-Tree 讲透,给你一份能直接跑起来的最小实现,再把 InnoDB 里那些真实落地的细节一并聊清楚。如果你正在看 MySQL 的索引原理、准备系统学习存储引擎,或者单纯想搞懂数据到底是怎么被组织在磁盘上的,这篇应该能帮到你。

1. 为什么存储引擎都绕不开 B-Tree:核心设计思路拆解

1.1 从二叉搜索树说起:内存里的强者,磁盘上的弱者

很多教材讲索引时喜欢从二叉搜索树讲起,因为它的查找复杂度是 O(log n),听起来已经很快了。我们算一笔账:如果表里有 4 亿条记录,一棵完全平衡的二叉搜索树,查找一个 key 最多需要比较 28 次左右。28 次是比较少,但如果比较一次就要读一次磁盘节点呢?

机械硬盘一次随机 IO 的寻道时间大约是 10 毫秒,28 次就是 280 毫秒。这还没算磁盘旋转延迟和传输时间,一个查询 300 毫秒,用户早就点走了。即使换成 SSD,每次随机读做一次 IO 也要几十微秒,乘以树的高度依然是笔不小的开销。问题不在于比较次数,而在于每一次比较都可能触发一次随机磁盘访问。

二叉搜索树的问题就是“太瘦高”。每个节点只存一个 key,最多两个孩子,树高随数据量线性增长。4 亿条数据就要 28 层,你再怎么倒腾,也逃不掉 28 次磁盘 IO 的宿命。内存里跑没问题,磁盘上就不合适了。

1.2 磁盘 IO 的脾气:随机读远比你想象中贵

磁盘读写有一个基本单位叫“页”,传统机械盘一般 4KB,数据库引擎为了效率会用更大的页,比如 MySQL InnoDB 默认 16KB。操作系统和存储引擎做 IO 的时候,不是按字节读,而是按页读。哪怕你只想取一个 int,磁盘也会把整页数据拉到内存。

这里就有一个关键矛盾:我们明明花了 10ms 换来一整页数据,二叉搜索树却只用 1 个 key,剩余空间全浪费了。这就像你打车去超市只买一瓶水,成本全花在路上,商品本身反而无所谓。

还有一个隐藏点:顺序读写和随机读写的差距非常大。顺序读一整块连续的数据,机械盘能跑到一两百 MB/s;随机读一个 16KB 的页,每页都要重新寻道,吞吐量会跌到惨不忍睹。所以存储结构设计的第一原则是:能顺序读就别随机读,能一次多拿点就别一次拿一点。

1.3 B-Tree 用“胖”换“矮”,把每次磁盘 IO 榨干

B-Tree 的思路非常直接:既然一次 IO 能拿回一整页,那我就让一个节点占一页,一个节点里塞几百个 key,同时派生出几百个分支。节点变胖,树自然变矮。

一棵典型的三层 B+Tree,根节点能管几百万个 key;如果是四层,就能管到几十亿条记录。也就是说,绝大多数 OLTP 场景下,从根到叶子最多 3~4 次磁盘 IO。第一次读根节点可能还在内存缓存里,实际真正落盘的 IO 次数往往只有 2~3 次。

这背后其实是一场“空间换时间”的交易:我们用单页里的大量 key 空间,换取树的高度大幅降低,进而把每次磁盘 IO 的收益最大化。所有数据库索引相关的核心优化,归根结底都在围绕这件事做文章:让树更矮,让每个节点装更多 key,让叶子节点的数据尽可能连续。

2. B-Tree 结构与核心操作细节解析

2.1 一颗 B-Tree 长什么样:节点、阶数与平衡规则

先约定几个术语。B-Tree 有一个参数叫“阶数 m”,它约束了每个节点最多能有多少个子节点。一颗 m 阶 B-Tree 需要满足:

  • 每个节点最多有 m 个子节点,最多 m-1 个 key。
  • 非叶子节点如果它不是根,至少有 ⌈m/2⌉ 个子节点。
  • 根节点要么是叶子,要么至少有 2 个子节点。
  • 所有叶子节点在同一层。

类比我日常整理抽屉的习惯:一个抽屉放不下就把中间的物品提出来,放到上一层的新抽屉里,下层抽屉保持均分。这个动作在 B-Tree 里就是分裂。规则里“所有叶子在同一层”是最硬的不变量,它保证了查询路径最坏也不会超过树的高度,不会出现二叉搜索树那种退化成链表的情况。

每个节点内部其实就是一个有序数组加一个子节点指针数组。数组的好处是局部性好,磁盘页加载进来之后,页内可以用二分查找快速定位。

2.2 查找:从根一路下沉,最多走树高那么多次

B-Tree 查找很简单,和二叉搜索树很像,只是每个节点内部要处理多个 key。伪逻辑是这样的:

从根节点开始,在当前节点的 key 数组里做二分。如果命中目标 key,直接返回;如果 key 小于第一个 key,就往第一个子节点走;如果 key 大于最后一个 key,就往最后一个子节点走;如果落在两个 key 之间,就往中间那个子节点走。直到走到叶子节点为止。

由于每个节点都存了真实数据,B-Tree 的查找并不强制要求走到叶子节点,这就是它和 B+Tree 的一个关键区别。查找代价的上限就是树高,每一层最多一次磁盘 IO。如果根节点在 Buffer Pool 里常驻,实际 IO 次数往往等于树高减一。

2.3 插入与分裂:为什么说页面分裂是性能守恒的代价

插入操作的难点在于维护“不变量”。如果直接往一个满节点里塞 key,节点就会超过 m-1 的上限,所以必须先分裂。

分裂发生在节点已满的情况下。以 m=5 为例,一个节点最多装 4 个 key。如果插入了第 5 个 key,就取中间的那个 key 提升到父节点,原节点被拆成左右两个各装 2 个 key 的节点。注意,如果父节点也满了,就会继续向上分裂,最坏情况是一路分裂到根,导致树的高度增加一层。

我当年第一次手写 B-Tree 时,最容易被绕晕的就是分裂时机。实际实现里有一个经典的处理方法:插入路径上如果遇到满节点,就先分裂,再继续向下插入。这种“先分裂下沉”的策略叫自顶向下分裂,能保证真正要插入叶子节点时,它的父节点一定有空间容纳提升上来的中间 key。

页面分裂的代价不止是内存操作,它会把原本连续的数据页拆开,导致页碎片和随机写入。这也是很多数据库在批量插入时,写入性能反而波动很大的原因之一。

2.4 删除与合并:维持不变量才是 B-Tree 的命根子

删除比插入更麻烦,因为删完之后节点可能“太瘦”。如果一个节点的 key 数量跌到下限以下,就需要从兄弟节点借一个 key,或者把两个瘦节点合并成一个。

借 key 的处理比较微妙:不能直接挪兄弟节点的 key,因为这可能会破坏父节点中的 key 作为分隔符的语义。正确做法是把父节点中夹在中间的那个 key 拉下来,再从兄弟节点提一个上去。合并则相反:把父节点的中间 key 拉下来,连同一个兄弟节点一起合并成一个节点。如果父节点因此又变瘦,就继续向上处理,直到根节点被合并、树高降低。

理解删除复杂性的关键,是想明白 B-Tree 的一切操作都在守卫那个“所有叶子同层”的不变量。只要这个不变量没被破坏,查询性能就始终有保障。

3. 从 B-Tree 到 B+Tree:主流数据库的真实选择

3.1 B+Tree 到底改了什么

大多数数据库实际用的不是经典 B-Tree,而是它的变体 B+Tree。B+Tree 和 B-Tree 的区别只有两点,但这两点都非常关键:

第一,非叶子节点不再保存数据,只保存 key 和子节点指针。这样一来,同样一个 16KB 的页,B+Tree 的非叶子节点能容纳的 key 数量比 B-Tree 多很多。如果 B-Tree 需要三层才能覆盖 1000 万条记录,B+Tree 可能两层就够,根节点到叶子的路径更短,查询 IO 次数更少。

第二,所有数据都保存在叶子节点,并且叶子节点之间用链表串联。这个设计带来一个巨大的优势:范围查询。如果你要查“age 在 20 到 30 之间的所有人”,B+Tree 可以定位到 20 这个 key,然后沿着叶子链表顺序往后扫就行。经典 B-Tree 要做中序遍历,需要回溯父节点,就麻烦多了。

对比项经典 B-TreeB+Tree
非叶子节点内容key + 数据仅 key
叶子节点存储数据随节点分布所有数据
叶子节点链表无有
范围查询中序回溯,复杂叶子顺序扫,高效
单页容纳 key 的能力较差更好
典型使用场景文件系统、内存数据库关系型数据库、LSM 辅助索引

我个人的看法是,B+Tree 的两个改动都是围绕“磁盘 IO 更少、顺序访问更多”这两个目标服务的,不是单纯为了炫技。

3.2 InnoDB 中的 B+Tree 落地格式

InnoDB 的索引页是 16KB,一个页内部有着严格的物理结构。简单说,一个索引页主要由这些部分组成:文件头、页头、系统记录、用户记录、空闲空间、页目录和文件尾。

用户记录并不是整齐排列的,而是按照“记录头 + 数据列”的格式,通过记录头里的 next_record 字段串成单向链表。页目录则维护了一组槽位,每个槽位指向一组记录的起始位置,这样页内查找可以先在目录里做二分,再在链表里线性扫描。这个设计和 B+Tree 本身的查找算法是套在一起的:先沿树下沉到叶子页,再在页内通过目录定位记录。

聚簇索引的叶子节点直接保存整行数据,主键就是 B+Tree 的 key。二级索引的叶子节点保存的是主键值,所以用二级索引查询时,如果索引不能覆盖所需列,还要拿主键回表再查一次聚簇索引。这就是为什么覆盖索引能节省大量 IO:它让二级索引叶子里的数据足够回答问题,省掉了回表的路径。

InnoDB 还有一个细节值得注意:每张表都有一个主键,如果你没有显式定义,InnoDB 会选一个非空唯一索引当主键,都没有的话就生成一个隐藏的 rowid 作为主键。因为聚簇索引的 key 必须要存在,这直接导致了后面要聊的主键设计问题。

3.3 为什么不是哈希表或跳表

B+Tree 并不是唯一的索引结构,但它特别适合磁盘场景。哈希表能做到 O(1) 查找,可是哈希没有顺序性,范围查询、排序、最左前缀匹配全都没法利用索引。跳表在内存里很优秀,Redis 的 zset 就用它,但跳表的指针存在节点里,随机跨度大,磁盘加载一页很难凑到一条连续链路,局部性远不如 B+Tree 强。

还有一个很现实的点:B+Tree 天然支持范围扫描、ORDER BY、分组统计这些 SQL 操作,而哈希索引只适合等值查询。数据库引擎追求的是多种查询模式下的稳定表现,B+Tree 是综合评分最高的那个。

4. 实操:手写一个最小可用的 B-Tree

4.1 数据结构怎么设计

纸上谈兵再多,不如自己写一遍。我用 Python 实现了一个最小版 B-Tree,方便你直接跑。它不做磁盘持久化,只负责把插入、查找和分裂逻辑演示清楚。

树的度数 t 表示每个节点至少 t-1 个 key、最多 2t-1 个 key。我取 t=2,这样每个节点最多 3 个 key,方便观察分裂过程。

4.2 实现查找与插入

class BTreeNode: def __init__(self, leaf=False): self.leaf = leaf self.keys = [] self.children = [] class BTree: def __init__(self, t): self.t = t self.root = BTreeNode(leaf=True) 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 node.keys[i] == key: return (node, i) if node.leaf: return None return self.search(node.children[i], key) def split_child(self, parent, i): t = self.t child = parent.children[i] mid = child.keys[t - 1] right = BTreeNode(leaf=child.leaf) right.keys = child.keys[t:] child.keys = child.keys[:t - 1] if not child.leaf: right.children = child.children[t:] child.children = child.children[:t] parent.keys.insert(i, mid) parent.children.insert(i + 1, right) 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 else: while i >= 0 and key < node.keys[i]: i -= 1 i += 1 if len(node.children[i].keys) == 2 * self.t - 1: self.split_child(node, i) if key > node.keys[i]: i += 1 self.insert_non_full(node.children[i], key) def insert(self, key): root = self.root if len(root.keys) == 2 * self.t - 1: new_root = BTreeNode(leaf=False) new_root.children.append(root) self.root = new_root self.split_child(new_root, 0) self.insert_non_full(new_root, key) else: self.insert_non_full(root, key)

search 和 insert 是最核心的两段逻辑。insert 里我采用的正是前面说的“自顶向下分裂”:当路径上的节点已满时,立刻分裂,再继续下沉。这种写法的好处是递归处理简单,不用在回溯时再判断父节点是否还有空间。

4.3 验证正确性:插入 20 条数据后中序遍历

def inorder(self, node): result = [] if node: for i in range(len(node.keys)): if not node.leaf: result.extend(self.inorder(node.children[i])) result.append(node.keys[i]) if not node.leaf: result.extend(self.inorder(node.children[-1])) return result btree = BTree(2) for i in range(1, 21): btree.insert(i) print(btree.inorder(btree.root))

如果没有 B-Tree 的平衡约束,光往二叉搜索树里插 1 到 20,最后会退化成一条链表;而这里插入 1 到 20 之后,中序遍历输出应该是[1, 2, 3, ..., 20]。同时我们可以检查根节点的 key 数量和小树的高度,会发现整棵树始终保持在两到三层。这就是 B-Tree 的平衡能力:无论插入顺序如何,树的高度都不会失控。

我建议你把 t 换成 3、4 再跑一遍,观察每个节点的容量变化,这比看十遍文档都管用。

5. 实际运维中的典型问题与排查技巧实录

5.1 页面分裂:写入抖动和碎片化的元凶

线上 MySQL 出现周期性写入变慢,很多时候不是 SQL 本身有问题,而是页面分裂在作祟。当大量插入操作落在同一个索引页上,页满了就触发分裂,InnoDB 不仅要写入新数据,还要把原页面重写、更新父节点的指针,这会产生额外的随机 IO。

实践中,我见过最典型的场景就是使用 UUID 主键。UUID 完全随机,新插入的数据会落在树的不同位置,导致频繁的页分裂和页空洞。改用自增主键或雪花 ID 之后,新数据基本顺序追加到最右侧叶子,分裂次数大幅减少,写入性能能提升好几倍。

注意:这里说的不是“主键必须自增”,而是说“主键值的分布要和插入顺序尽量一致”,能显著减少索引维护成本。

5.2 主键设计:为什么 UUID 主键会让索引膨胀

UUID 主键的问题不仅是随机写入,还有二级索引膨胀。InnoDB 的主键是聚簇索引的 key,二级索引的叶子节点存的是主键值。如果主键是 16 字节的 UUID,每个二级索引记录都要多带 16 字节主键。一张表如果有五六个二级索引,这个空间的浪费是成倍的。

更重要的是,随机 UUID 会让 B+Tree 频繁分裂,页内部碎片也多。数据页面紧凑程度下降,同样 16KB 的页能装的记录减少,索引体积膨胀,查询时需要读取的页数就会增加,IO 次数随之上升。这是我实际优化过的一个真实案例:把主键从 UUID 改成自增 bigint 后,索引体积直接缩小了约三分之一,查询耗时也跟着降了下来。

5.3 覆盖索引与最左前缀:让 B+Tree 少跑几趟

覆盖索引是被低估的优化手段。二级索引叶子节点只存索引列加主键,如果查询的列全在索引里,引擎就完全不需要回表,直接在二级索引的 B+Tree 上完成扫描。

举一个例子,SELECT id, name FROM user WHERE name = '张三',如果name和id都在联合索引(name, id)里,查询就无需回表。因为 InnoDB 的二级索引本来就以主键 id 作为叶子节点的附加列,联合索引把 id 放进去之后,索引就是查询的完整答案。

最左前缀原则也是由 B+Tree 的 key 有序性决定的。联合索引(a, b, c)本质上先按 a 排序,再按 b 排序,再按 c 排序。跳过了 b 直接查询 c,B+Tree 就没法利用上一层的顺序性,只能扫描 a 相同的所有区间。这个不是缺点,而是有序 key 的天然约束。

5.4 数据量翻倍,查询没有指数变慢?这才正常

最后提醒一个认知点:B+Tree 的查询开销是 O(log n) 的,数据量翻一倍,树高通常只增加很少。比如 100 万条记录树高 2,到 1 亿条记录树高可能还是 3,查询的磁盘 IO 次数没怎么变化。这在数据库层面是一个非常可贵的特性。

所以当线上表从几百万涨到几千万,查询耗时从几十微秒跳到几十毫秒时,问题往往不在 B+Tree 本身,而可能是缓冲池命中率下降、索引碎片变多、或者某些页被淘汰出 Buffer Pool 后产生了大量物理读。先看 Buffer Pool 命中率,再看页碎片率,最后才去优化 SQL,这个顺序能帮你少走很多弯路。

现象常见原因排查方向
写入周期性变慢页分裂过多检查主键是否随机
索引体积异常膨胀UUID 主键、碎片分析页密度与主键类型
查询突然变慢Buffer Pool 命中率下降监控物理读次数
范围查询慢索引顺序性未利用检查联合索引设计
二级索引查询慢回表次数多考虑覆盖索引

我后来做性能优化时,养成一个习惯:拿到一个慢查询,第一件事不是急着加索引,而是先想清楚当前的数据存储格式能不能用最少的 IO 满足这个查询。B-Tree 和 B+Tree 真正教会我的不是“有索引就快”,而是“每一次读页都是有成本的,设计数据结构本质上就是在做 IO 预算”。

如果你也想彻底吃透这块,建议照着上面的代码敲一遍,再插入几万条随机数,观察分裂次数和树高变化。数据存储这个底子一旦打通,后面再去理解 LSM-Tree、倒排索引、HNSW 这些结构,都会顺畅很多。哪怕最后你发现手写 B-Tree 的场景不多,但那个“以磁盘 IO 为中心想问题”的思维方式,对接手任何数据密集型的应用都是受用的。

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

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

立即咨询