Interview Lab / MySQL 索引三部曲 · 1/3

B+Tree 索引结构:从磁盘页到点查与范围扫描

B+Tree(B Plus Tree,B 加树)是一种把有序数据分层存放的多叉树;页是数据库成块读写和缓存数据的存储单位。B+Tree 用较少的页访问缩小候选范围,并把真正的索引记录留在有序叶子页。

本文以 InnoDB 为边界:它是 MySQL 默认的事务型存储引擎,也就是实际负责保存行与索引的组件。本文只讲“数据怎样组织、一次查找怎样走”;至于索引列怎样组合、MySQL 最终是否选择它,分别放到后两篇。

MySQL 8.0 / 8.4InnoDB内部页叶子页点查与范围扫描
版本边界:本文面向 MySQL 8.0 / 8.4 的 InnoDB。官方文档常把普通索引统称为 B-tree;本文用工程里常说的 B+Tree 模型解释“内部页导航、记录位于叶子页”。默认页通常为 16KB(kilobytes,千字节),但可以配置,因此树高不是固定常数。
一、先解决问题:为什么不能每次从第一行找到最后一行

想象一本没有目录、单词也没有排序的词典。找 database 时只能从第一页逐项检查。数据库的全表扫描也类似:候选范围接近整张表,最后只返回 3 行,也可能先看过几十万行。

现在把单词排序,并在目录写上“A–F 去第 20 页,G–M 去第 80 页”。一次选择就能排除大批候选。B+Tree 把这种“按范围分路”重复多层。

目标

尽快排除不可能的数据,而不是让每次比较本身变得花哨。

办法

内部页负责导航,叶子页保存有序记录,相邻叶子可继续扫描。

边界

找到起点快,不代表返回几十万行也快;范围越宽,叶子扫描仍越多。

B+Tree 的核心价值:用少量导航页找到起点,再按顺序读取需要的叶子记录。
二、三个部件:页、多叉、叶子链分别负责什么

Page(页)是 InnoDB 读写和缓存数据的重要块单位;一页里能放很多索引记录。I/O(Input/Output,输入/输出)在这里指内存与存储设备之间的读写。

部件里面放什么解决什么问题代价
内部页分隔键与子页位置一次页访问排除大量候选键变化可能调整父层
多叉一个页指向许多子页比每个节点只有两个孩子的树更矮索引键越宽,单页能放的分支越少
叶子页真正的索引记录,按键有序定位下界后可连续完成范围扫描写入要维护顺序,可能发生页分裂
相邻叶子关系前后页位置跨页继续扫描,不必为每条结果重走根页范围很大时仍要经过很多页
不要把三件事混为一谈:页是存储块;树节点是逻辑位置,通常由索引页承载;业务行是表中的记录。访问一层通常意味着访问一个页,但页已在 Buffer Pool(缓冲池,用内存缓存常用页的区域)时,不一定真的读取磁盘。
三、完整走一遍:点查与范围扫描到底读了哪些位置

下面共有 12 条主键记录,分在 L0~L3 四个叶子页中;页编号从 0 开始。查询 id BETWEEN 29 AND 53 时,蓝线先找下界 29,绿线再沿有序叶子扫描,读到第一条超过上界的 61 后停止。

B+Tree 范围查询从全部候选到命中记录的完整过程根页先在三个编号从零开始的候选范围中选择零号范围,再进入内部页选择一号叶子;四个叶子页和十二条记录全部画出,随后从二十九扫描到五十三并在六十一停止。 id BETWEEN 29 AND 53:先定位一次,再连续扫描蓝色=定位下界;绿色=返回记录;橙色=排除或停止。页与候选编号均从 0 开始。 根页 R0:候选集合共 3 个候选 0:<40候选 1:40–60候选 2:≥6129<40 → 选候选 0 内部页 I0:候选 L0、L129≥20 → 选择 L1候选 1 → L2找下界时不走;范围扫描会进入候选 2 → L361 超过上界,成为停止出口 全部叶子候选:4 页、12 条记录L0:5 · 11 · 18记录序号 1 · 2 · 3低于下界,排除L1:20 · 29 · 37记录序号 4 · 5 · 6跳过 20,从 29 开始L2:41 · 48 · 53记录序号 7 · 8 · 9继续返回L3:61 · 72 · 88记录序号 10 · 11 · 12检查 61 后停止 实际返回:29、37、41、48、53,共 5 条;定位经过 R0、I0、L1,随后跨到 L2。可见代价:跨 1 个叶子页,并额外检查停止点 61;不是“用了索引就只读一行”。
最应记住:范围查询不会为每条结果重走整棵树,而是先找下界,再沿叶子顺序扫描;常见误读是“命中索引就只访问一条记录”。

精确查询 id=48 只保留“逐层定位”部分,到叶子命中后即可返回。范围越宽,后半段经过的叶子页越多,所以 B+Tree 只能让起点定位更快,不能消除大结果集本身的成本。

四、两类索引:叶子里放的是整行,还是主键

Clustered Index(聚簇索引)是叶子记录直接包含完整业务行的索引;InnoDB 通常用主键组织它。Secondary Index(二级索引)是其他索引;叶子保存自己的索引列和主键值。

下面继续使用订单例子:tenant_id 表示商户编号,created_at 表示创建时间,amount 表示订单金额,note 表示订单备注。

索引叶子记录查询得到什么
主键对应的聚簇索引主键 + 完整行定位到叶子后已取得业务行
二级索引 (tenant_id,created_at)商户、时间 + 主键 id先得到 id;若还需其他列,再按 id 查聚簇索引

“从二级索引拿到主键,再去聚簇索引取得完整行”叫回表。它解释的是两棵树之间的数据路径,不等于一定发生两次磁盘读取:相关页可能已在缓冲池。

三个二级索引命中如何连接到三条完整行二级索引页的三个连续记录分别携带主键二百零五、三百零九和四百一十二,三条箭头逐一连接聚簇索引中的完整行业务对象;只需要索引已有列时直接返回而不走箭头。二级索引命中 3 条:主键就是通往完整行的连接同一列从上向下追踪;每条箭头表示一次逻辑回表。(tenant=7, 09:20, id=205)二级索引记录 · 下标 0(tenant=7, 10:05, id=309)二级索引记录 · 下标 1(tenant=7, 11:40, id=412)二级索引记录 · 下标 2完整行 id=205amount=88 · note=A完整行 id=309amount=39 · note=B完整行 id=412amount=120 · note=C若查询只需要二级索引已有的列:直接返回,三条回表连接均不发生。
最应记住:二级索引叶子并不默认包含完整行;常见误读是把“使用二级索引”理解成“已经取到所有列”。
主键宽度会被放大:每个二级索引记录都携带主键。过长的主键会让所有二级索引一起变宽,使单页容纳更少记录。
五、写入时发生什么:有序结构不是免费的

插入新记录时,InnoDB 先定位它所属的叶子页,再把它放进正确顺序。如果目标页空间不足,可能把记录拆到新页,这叫页分裂;删除后空间长期不足时,也可能发生合并或重组。

  1. 定位目标叶子页。
  2. 页有空间:在页内维护有序记录。
  3. 页没有足够空间:申请新页并重新分配记录,再调整上层导航。
  4. 表上的每棵相关索引都要同步维护,所以索引越多,写入成本通常越高。
插入记录导致叶子页从未满到分裂的完整演化先展示叶子页依次插入二十、二十九和三十五形成三条记录;插入三十七时分为空间足够与空间不足两条分支,本例走空间不足分支,把记录分到原页和新页,并把三十五写入父页作为新分隔键。本例页容量为 3 条:插入 37 时怎样发生页分裂容量 3 只是便于数清过程的缩小模型,不代表真实页只能放 3 条。状态来历:先后插入20 → 29 → 35L1=[20,29,35],3/3 已满现在插入 37先定位到 L1判断:是否还有 1 个空槽?有:页内插入后结束(本例不走)没有:本例走分裂原页 L1 · 旧对象继续存在下标 0=20 下标 1=29代价:搬出 35,并修改页内容新页 L4 · 新申请的实体下标 0=35 下标 1=37代价:申请 1 页并搬入 2 条L1 指向后继页 L4父页新增分隔键 35 → 指向新页 L4
最应记住:页分裂不是“表满了”,而是目标叶子页空间不足;常见误读是只看到分裂结果,却忽略新页申请、记录搬迁和父页更新三份成本。
结构篇的边界到这里:现在已经能解释“怎样找到记录”和“为什么写入有代价”。下一篇再用这些事实推导联合索引列序、覆盖索引与排序分页,不在本篇提前塞入设计口诀。
六、面试追问:只回答结构层的问题
为什么常用 B+Tree,而不是普通二叉树?
一次页访问能取得很多分隔键,多叉让一层排除大量候选,通常以更少层数到达叶子;叶子有序又适合范围扫描。关键不是“比较次数最少”,而是减少昂贵的页访问。
范围查询为什么快?
先沿内部页定位下界,只做一次树导航;之后沿有序叶子继续读,遇到上界就停。但范围本身很大时,仍会扫描很多叶子记录。
聚簇索引与二级索引最关键的差别是什么?
叶子承载的内容不同:聚簇索引叶子是完整行,二级索引叶子是索引列加主键。如果需要的列不在二级索引中,命中后还要按主键回表。
树高是不是固定三层?
不是。它取决于记录数、页大小、键与指针宽度、页填充程度等;“三层能放多少行”只能做特定假设下的估算。
七、30 秒总结与下一篇
内部页缩小候选,叶子页保存有序记录;点查到命中即停,范围查询定位下界后连续扫描。

面试时可以这样答:“InnoDB 索引按页组织。内部页保存分隔键和子页位置,一个页能指向许多子页,所以树通常较矮;真正的索引记录在有序叶子页。点查逐层排除候选,范围查询只定位一次下界,随后沿叶子顺序扫描。聚簇索引叶子包含完整行,二级索引叶子包含索引列和主键,因此非覆盖查询可能回表。结构的代价是写入要维护顺序,页满时还可能分裂。”

下一步:进入《MySQL 索引设计:从一条 SQL 推导联合索引》,学习怎样把本篇的“有序键”用于真实查询。

八、应用场景:什么时候依赖 B+Tree,什么时候别硬用

主键点查

什么时候用:按订单 id、用户 id 等唯一编号取一行。为什么适合:逐层排除候选后立即停止。注意:主键过宽会放大所有二级索引。

连续范围

什么时候用:读取一段时间或编号区间。为什么适合:定位下界后沿叶子继续扫描。注意:范围过宽仍会读取很多页。

不该硬用

前置通配符、复杂全文相关性和大范围分析聚合通常不是普通 B+Tree 的强项,应评估全文索引、搜索系统或分析存储。

九、资料依据