☰
B+树如何压到极限?从页结构拆解MySQL索引的磁盘读取次数
2026/10/5 7:46:15 网站建设 项目流程

之前帮朋友排查一个线上慢查询,订单表只有几百万行,按主键查单条记录,理论上应该毫秒级,实际却拖到几百毫秒。排查一圈下来,问题不在 SQL 写法,不在表结构设计,而在一个很多开发者根本没当回事的指标——磁盘读取次数。MySQL 把 B+ 树作为默认索引结构,核心原因只有一个:它能把"定位一行数据所需的磁盘读取次数"压到理论极限。这篇文章我想像庖丁解牛一样,把 B+ 树从根到叶逐层拆开——每一层的页里到底放了什么,一次查询沿着索引一次次路由、最终落到叶子节点定位到那条记录,一共要读几次磁盘,以及实战中哪些使用习惯会让这些读取次数悄悄失控。不管你是刚接触索引原理的新手,还是每天和慢 SQL 打交道的开发,这篇应该都能提供一个新的视角。

1. 磁盘I/O的账本:为什么索引结构要为"读取次数"服务

1.1 存储层级里最贵的一跳:随机读的物理代价

从 CPU 的视角看,L1 缓存命中只需 1 纳秒左右,内存访问大约 100 纳秒,而一块普通机械硬盘的随机读需要 5 到 10 毫秒。固态硬盘快一些,但一次随机读也要几十到几百微秒。换算下来,一次机械硬盘随机读的时间,足够 CPU 执行几百万条指令。所以数据库所有结构设计的首要目标,不是让 CPU 少算几步,而是让磁盘少读几页。

这里有个常见误区:很多人在分析查询慢的时候,喜欢数"索引比较了几次""内存循环了几轮",但真正的瓶颈几乎都在 I/O 上。类比查字典:你在纸面字典里翻一次页的成本,远高于在翻到的那一页上扫几眼。B+ 树设计的第一性原理,就是把这"翻页次数"压到最少。

存储设备还有一个关键特性:随机读和顺序读的成本可以差两个数量级。一次顺序读一页和一次随机读一页,耗时完全不同。这个特性会在后面讲范围查询和预读时反复出现,先留个印象:B+ 树的叶子节点用链表串起来,本质上就是为"把随机读尽量变成顺序读"服务的。

1.2 红黑树、B树、B+树的逐项对比:MySQL为什么坚持B+树

先回答一个被问烂了的问题:B+ 树是红黑树吗?不是。红黑树是内存里的二叉平衡树,Java 的 TreeMap、C++ 的 std::map 都用它;它的每个节点只存一个键、最多两个孩子。把 1000 万行数据塞进红黑树,树高大约是 log2(1000万)≈23 层。哪怕每个节点恰好是一个页,查一次也要 20 多次磁盘随机读,机械硬盘下轻松几百毫秒。所以它在磁盘索引这个场景里基本没戏。

B 树(B-Tree,多路平衡树)比红黑树更适合磁盘,每个节点可以存很多键。但经典的 B 树有个问题:非叶子节点也存储完整的数据记录或键值,这样每个非叶子节点能索引的孩子数量就会变小,树容易长高;而且做范围查询时,需要在父子节点之间来回回溯,顺序读特性很差。

B+ 树把两者都改了:非叶子节点只存"索引键 + 下一层页号",不存数据,扇出成倍变大;所有数据记录都集中在叶子节点,并且叶子节点之间用指针串成双向链表;任何一次查询都需要从根走到叶子,路径长度完全一致,不会出现"有时快有时慢"。

对比项红黑树B树B+树
单节点容量1个键/2个孩子多个键+数据多个键+子页指针
1000万行的树高约23层约4~6层约3~4层
范围查询需要中序遍历回溯需要中序遍历回溯叶子链表顺序扫描
适合磁盘索引不适合适合度一般极适合

MySQL 的 InnoDB 引擎最终选择 B+ 树,结论其实非常工程化:要最小化磁盘读取次数,就要最大化扇出、压低树高;要支持范围查询和排序,就要让叶子节点天然有序并容易顺序遍历。B+ 树在这两点上做到了极致。

1.3 B+树的总体设计目标:以最小读取次数换取有序遍历

到这里可以把 B+ 树的设计目标总结成一句话:用页内空间换树的高度,把"定位任意一行数据"的磁盘读取次数收敛到 2~4 次。

InnoDB 里,磁盘读写的最小单位不是行,而是页。默认一个页 16KB。你要读一行数据,无论多大,InnoDB 都必须先把整页从磁盘加载到内存。所以"读取次数"的本质是"页访问次数":每访问一个不在内存的页,就是一次磁盘随机读。B+ 树通过让非叶子页尽可能多地塞路由项,把从根到叶子的页访问次数压到树高 h,其中根页几乎常驻内存,因此物理随机读次数通常只有 h-1。

这个"h-1"就是全篇的核心数字。后面第 3 章会详细算一次查询的完整路线图,第 4 章再解释为什么 h 在千万行级别也涨不上去。先记住这个结论,后面的内容都是围绕它展开。

2. InnoDB页的内部布局:叶子节点里数据到底怎么排

2.1 一个16KB的页:头部、记录区、目录区的分工

庖丁解牛的第一刀,应该切在"页"上。InnoDB 的每个页默认 16KB,物理结构大体可以分成四块:文件头/页头(保存页号、上一页/下一页指针、LSN 等信息)、系统虚拟记录 Infimum 和 Supremum、用户记录区、页目录。

用户记录区是从页头之后的某个偏移开始,向页中间方向生长的。页目录则从页末尾往前生长。记录并不要求物理连续,每一条记录头部都有一个 next_record 字段,记录"下一条记录的相对偏移",通过它把页内记录串成一个单向链表。这么做的好处是,删除记录时不用挪动其他记录,只需要改指针,插入时也可以尽量复用碎片空间。

页尾还有一个 File Trailer,里面存放校验信息。崩溃恢复时 InnoDB 会用它检查页是否在写入过程中损坏。这块平时大家不太关注,但做数据库内核或数据恢复的人会天天和它打交道。理解页的结构有个额外好处:以后看到"页分裂""页利用率""碎片整理"这些词,脑子里会有一个具体的物理画面,而不是抽象概念。

2.2 叶子节点与非叶子节点:一句话区分两种"页"

B+ 树里有两种页,但它们的物理格式完全一样,都是上面那个 16KB 结构,区别只在于"用户记录区里存的是什么"。

叶子页:聚簇索引的叶子页存的是完整的每一行数据;二级索引的叶子页存的是"索引键 + 主键"。非叶子页:不管哪一层,存的全是路由项——"子页中的最小索引键 + 指向子页的页号"。每个路由项对应一个孩子页。

这一点特别容易让人绕晕。很多入门文章把 B+ 树画成"上面是键、下面是数据",但没说明"上面每一层页里面其实放着一张小的路由表"。理解了这个,后面就能明白扇出系数是怎么算的,也知道为什么索引键越小、树越矮。我见过不少同学背下了"B+树非叶子节点不存数据"这句话,却不知道非叶子页里存的到底是什么,也无法解释为什么主键用 varchar 会让索引变慢,都属于没拆到这一层。

2.3 页目录与槽:页内定位不是线性扫描,而是二分

到达叶子页之后,还要在页内找到具体那条记录。InnoDB 没有蠢到去线性扫描一页里几百条记录,而是给页内记录建立了一套"页目录"(Page Directory)。

页目录的原理是把页内记录按顺序分成若干组,每组最多 8 条记录(最后一组可以更少),每个组在目录区有一个"槽",槽里记录的是组内最大记录在页内的偏移量。查找记录时,先用二分法在槽数组里定位到"目标属于哪个组",然后在组内顺着单向链表最多遍历 8 条记录,就能命中目标。

页内还有两个虚拟记录要注意:Infimum 代表"比页内任何记录都小",Supremum 代表"比页内任何记录都大",它们夹在所有真实记录链表的头和尾。查找不存在的记录时,最终会落到 Supremum 前面的位置结束。这两个虚拟记录没有实际业务数据,纯粹是工程上的哨兵设计,避免了链表边界的各种特殊判断。

2.4 双向链表与下一页:为什么范围查询能"顺着走"

页和页之间,InnoDB 通过页头里的 PAGE_NEXT、PAGE_PREV 字段串成一个双向链表。数据页里这个链表连接的是同一层的兄弟叶子页,所以从任意叶子页出发,既能往前扫也能往后扫。

这个双向链表对"最少磁盘读取次数"的贡献,体现在范围查询上。点查只关心单条记录,范围查询却要连续读多个叶子页。B+ 树叶子页间用指针串好之后,扫描完当前页的最后一条记录,直接拿页头里的下一个指针去读下一页,不需要再回到父节点查"下一个孩子是谁"。加上 InnoDB 的预读机制,连续扫描多个页时经常一次把整个区段读进内存,实际成本远低于"一页一随机读"。这也是 B+ 树比 B 树更适合做范围索引的根本原因。

3. 从根到叶的一次旅行:定位记录的磁盘读取路线图

3.1 起点:根页常驻内存,这一步零成本

现在开始沿着索引找一条记录。先明确一个前提:InnoDB 的 B+ 树根页几乎总是驻留在 Buffer Pool 里,不会被 LRU 淘汰。为什么?因为每次查询都从根进入,根页是全局最高频访问的页,Buffer Pool 的淘汰策略天然会保留它,工程实现上也有意把根页当作特殊页对待。

所以计算磁盘读取次数时,根页这一层访问可以不算物理 I/O。这个细节很关键,面试里讲"为什么树高 3 层的查询只要 2 次随机读",答案就在这里:3 层树从根到叶要访问 3 个页,但根页在内存里,真正从磁盘读取的只有中间层和叶子层那 2 个页。

3.2 每下降一层消耗一次随机读:树高即读取次数

接下来:在根页的页目录里二分,比较目标键和路由项的大小,确定该去哪个孩子页。孩子页如果不在内存,就产生一次随机磁盘读,把整个页加载进 Buffer Pool。然后在这个孩子页里继续二分,找下一层的页号……直到最终落到叶子页。

这个过程每下降一层只访问一个页,不访问多个候选页。因为路由信息就在页内,二分下来路径是唯一的。所以一次主键点查的磁盘随机读次数,就是树高减 1。三层树约 2 次,四层树约 3 次。页内二分和记录遍历全在内存里完成,几乎不耗时间。

值得强调的是,如果某个中间层页刚好还在 Buffer Pool 里(比如它被其他查询刚用过),那连这 2 次都可能省掉。所以"最少读取次数"在日常里经常比理论值更少。这里也是很多人理解偏差的地方:他们以为"走索引"就一定有磁盘 I/O,实际上热页命中时,整个查询可能连一次磁盘都不碰。

3.3 叶子页内二分:真正定位到具体记录的那一步

落到叶子页之后,进入第 2 章讲过的页目录二分流程:先二分到目标组,再沿组内单向链表找到那条记录。

如果查的是聚簇索引,叶子记录就是完整行数据,到这里直接返回,查询结束。整条链路的页访问次数就是从根到叶的 h 次(逻辑页访问),其中物理随机读约 h-1 次。整个过程可以用一句话概括:树有多高,路就有多长;页内再多花哨操作,都不额外读磁盘。

这里有个容易混淆的点:从根到叶的每一层都读了一个页,但页在内存中可能被重复使用(比如所有查询都经过同一个根页)。所以在描述时最好把"逻辑页访问"和"物理磁盘读取"分开,前者是 h,后者是 h-1。做性能分析时,真正卡时间的只有后者。

3.4 二级索引的追加成本:回表为什么是额外的一次读取

绝大部分慢查询的根源,来自二级索引。二级索引的叶子节点存的是"(索引列, 主键)",没有完整行。查询过程变成两段:先在二级索引 B+ 树里从根走到叶子,拿到主键;再拿主键回聚簇索引 B+ 树走一次,从根走到叶子,取出整行。

这个"回表"就是额外的一次完整树路径。如果二级索引树高 h1,聚簇索引树高 h2,那么一次"命中一行"的点查,磁盘随机读次数大约等于 (h1-1) + (h2-1)。命中的行数越多,回表次数越多,而且每次回表要访问的聚簇索引页可能都不同。

举个例子:一张 500 万行的大表,聚簇索引树高 3,二级索引树高 2(因为二级索引页更紧凑,通常比聚簇索引矮)。select * from t where name='张伟'假如命中 800 行,光回表就是 800×(3-1)=1600 次随机读。机械硬盘下这就是 10 秒级别,即使 SSD 也要一两秒。这就是为什么低区分度的列建索引后,优化器经常直接放弃索引改走全表扫描——全表顺序读反而更快。

3.5 量化演示:一千万行数据到底读几次磁盘

这里用一组贴近现实的数字估算。假设一张表:主键 bigint,单行数据约 1KB,叶子页 16KB 能放约 15 行,非叶子页大概能放约 1000 个路由项。

1000 万行数据需要叶子页约 1000万/15 ≈ 67 万个。上一层需要约 67万/1000 = 670 个非叶子页,外层再一层就是 1 个根页。这样刚好组成三层 B+ 树。也就意味着,按主键查任意一行,磁盘随机读次数只有 2 次。如果统计信息更新及时、页利用率理想,四层树要支撑到 1000万×1000,也就是 100 亿行级别。

所以结论很反直觉:数据从 1 万涨到 1000 万,单行点查的磁盘读取次数几乎没有变化,都是 2~3 次。真正让查询变慢的,从来不是数据总量,而是"你用的是不是索引路径""回表了多少次""扫了多少个叶子页"。理解这一点,很多"数据量大了查询就慢"的说法就需要修正:数据量变大不是问题,访问路径变差才是问题。

4. 树高为什么长不快:扇出系数与容量上限的数学账

4.1 一个非叶子页能塞多少路由项:16KB / 索引键+页号

树高的物理上限,取决于非叶子页的扇出。一个非叶子页只有 16KB,减去页头、页尾、页目录等开销,能放的路由项数量就决定了它能指向多少个孩子。

每个路由项由三部分构成:索引键值、子页页号(4 字节)、记录头(约 5 字节)。以 bigint 主键为例,每项约 8+4+5=17 字节,那么一页大约能放 (16KB-200B)/17B ≈ 950 项,四舍五入可以按 1000 估算。如果主键改成 varchar(36) 的 UUID,每项直接涨到约 45 字节,一页只能放约 360 项,扇出缩水近三分之二。

这就是为什么主键最好用短整型。扇出不仅影响树高,还影响整棵树的页总数和内存占用效率。一个容易被忽略的连锁反应是:主键越长,二级索引叶子页里要存的"主键副本"也越长,二级索引页能放的行数变少,二级索引树同样变高。主键长度的影响是全局性的,不只是主键树自己的事。

4.2 从三层到四层:容量天花板逐级放大

扇出是 1000 时,树的容量增长是几何级数。按单行 1KB、每叶子页 15 行估算:

  • 一层树:只有根页,叶子页 1 个,约 15 行;
  • 两层树:第二层最多 1000 个叶子页,约 1.5 万行;
  • 三层树:叶子页最多 100 万个,约 1500 万行;
  • 四层树:叶子页最多 10 亿个,约 150 亿行。

注意这里有个容易出错的地方:容量是按"最满"算的,实际页利用率通常 70%~90%,但数量级完全够用。所以千万级业务表几乎全部落在三层树,只有真正百亿级别的大厂核心表才会到四层以上。树高每增加一层,定位一次查询只多一次随机读,但可承载的数据量放大了 1000 倍,这笔账非常划算。

这也从侧面解释了为什么 B+ 树能成为工业级数据库索引的事实标准:不管业务怎么膨胀,查询成本都维持在"2~4 次随机读"的常量级别。很多其他方案(比如在链表上建哈希索引)遇到大数据量时性能会退化,B+ 树却几乎不感知数据量增长。

4.3 主键类型如何影响树高:bigint与uuid的扇出差异

把上面的公式反过来用,就能解释很多线上事故。如果主键是 UUID 那样的 36 字符字符串,每个路由项约 45 字节,一页只能放约 360 项。在千万行级别,三层树可能刚好够,但如果行数据更宽或者页利用率低,就很容易顶到四层。更麻烦的是二级索引页也会因为主键副本变长而膨胀。

我在优化线上表时,遇到过一张主键用 varchar(32) 流水号的表,二级索引页数量比换成 bigint 后多了约 30%,整个索引树的深度也更不稳定。所以我对表设计的一贯建议是:业务无特殊要求时,用自增 bigint 做主键;如果必须用 UUID,尽量改用 UUID 的二进制紧凑格式,或者用雪花算法生成的 64 位整数。这一个改动,可能同时影响主键树和所有二级索引树的深度与页数量,是性价比最高的优化。

5. 实战中最容易让读取次数失控的五个场景

5.1 回表失控:二级索引命中很多行却要逐行回表

最常见的"有索引还是慢",就是回表次数失控。比如:

SELECT * FROM orders WHERE user_id = 12345;

orders 表 1000 万行,user_id 有普通二级索引。这条 SQL 如果恰好命中 3000 笔订单,那么流程是:二级索引树拿到 3000 个主键,然后逐个回聚簇索引取整行。每次回表都是一次随机读,3000 次随机读在机械硬盘上就是 15 秒以上,SSD 也得一二百毫秒。索引确实用上了,但没有解决本质问题。

解决思路一般有三个:把 select 的列收窄到能被索引覆盖;把高频查询条件设计成联合索引并尽量覆盖 SELECT 列;或者改成分页查询,避免一次拉回几千行。很多团队过度依赖"加索引",却不看回表次数,这是我在实际排查里见到最多的误区。加了索引不等于快,要看到底省掉了哪几次磁盘读取。

5.2 随机主键引发的页分裂:碎片让"应有的读取次数"失真

就算索引设计完全合理,页的物理状态也会让"应读取的页数"悄悄变大。最大的元凶是页分裂。

InnoDB 的页一旦写满,再插入新记录就得申请一个新页,并把原页约一半的记录搬过去,这叫页分裂。自增主键插入时,新记录基本都追加在当前已打开的叶子页末尾,页分裂很少发生。但 UUID 主键是随机值,新记录可能落在任意位置,于是表里大量叶子页处于半满状态,页利用率从理想 100% 掉到 60%~70%。同样 1000 万行,实际占用的叶子页比理论多 40%,范围查询要扫的页数随之变多。

更隐蔽的连锁反应是:页分裂会让新页的物理位置离兄弟页很远,原本"叶子链表的下一页"可能落在磁盘的不同位置,顺序扫描退化成随机扫描,预读也失效了。我见过一个订单表,数据在页里碎成渣,单条主键点查倒没事,按时间范围扫描却慢得离谱。解决方式就是换自增主键,或者定期重建表整理碎片。

5.3 联合索引的最左前缀:用错前缀等于让树白爬

联合索引的排序规则是"从左到右逐列比较"。索引 (a, b, c) 在 B+ 树里,本质上按 (a, b, c) 的字典序排序。查询如果跳过 a 直接按 b 或 c 过滤,索引的有序性就完全用不上,优化器只能退化成全索引扫描甚至全表扫描。

反过来讲,联合索引还承担了排序任务。where a=1 order by b能走 (a, b) 索引,不需要 filesort;但where b=1 order by a走不上,因为树的全局顺序首先按 a 排,b 相同的数据块里 a 不一定有序。很多人问"为什么我已经建了联合索引,order by 还是慢",答案通常都在最左前缀上。

设计联合索引时我的习惯是:先放下所有等值过滤列,再放范围过滤或排序列,最后才考虑把 SELECT 需要的列往里塞做覆盖。顺序反了,索引就是摆设,甚至比没有索引更糟糕——优化器还得花时间去评估它然后放弃它。

5.4 覆盖索引与索引下推:两条减少读取次数的官方捷径

覆盖索引是最省事的高速路。如果 SELECT 的列恰好都在某个二级索引里,那么查询在二级索引叶子层拿到数据后直接返回,一次回表都不需要。比如:

SELECT id, name FROM user WHERE name = '张三';

只要存在 (name, id) 联合索引,name 的二级索引叶子本身就存了 id,查询完这个索引就拿齐了数据,Extra 里会显示 Using index。审视线上 SQL 时,我会刻意把高频查询的 SELECT 列表往索引里塞,很多时候能让核心接口的延迟直接减半。

索引下推(ICP)是另一条被低估的机制。MySQL 5.6 之后默认开启,它允许把部分 WHERE 条件下推到存储引擎,在二级索引叶子层先过滤一部分记录,再决定要不要回表。典型例子:

SELECT * FROM user WHERE name LIKE '张%' AND age > 20;

有索引 (name, age) 时,没有 ICP 会先把所有"张%"的主键回表取行,再在 Server 层过滤 age;开启 ICP 后,age > 20 在索引叶子层就被过滤掉了,回表次数大幅减少。EXPLAIN 的 Extra 列出现 Using index condition,就说明 ICP 生效了。这条机制对宽表、高回表成本的场景帮助特别明显。

5.5 "索引越多越好"的代价:写入放大的另一本账

优化读完再讲写的账。每建一个二级索引,就等于给表多挂了一棵 B+ 树。INSERT 时所有索引树都要写入,UPDATE 如果动了索引列,涉及的索引树都要更新;每棵树都可能触发页分裂、路由调整。如果你为了压查询延迟一口气加了 6 个二级索引,写入放大可能让插入性能下降一半以上。

所以在实际项目里,索引不是越多越好。我的做法是:先用慢查询日志和 EXPLAIN 找出真正高频慢路径,再针对性地建组合索引;索引数量尽量控制在 5 个以内;每个索引都要能回答"它服务了哪条高频查询"这个问题,答不上来就删。读多写少的报表库可以放宽,交易类核心库必须严格。

6. Buffer Pool与预读:物理读取之外的第二层"免读"机制

6.1 Buffer Pool命中:热数据让磁盘读取次数直接归零

前面算了这么多树高和读取次数,但真正落到物理磁盘上的次数,还要再看一层屏障:Buffer Pool。

InnoDB 的所有页访问都先走 Buffer Pool。页在内存里命中,就没有任何物理磁盘 I/O;只有不在内存时才算一次物理读。所以同样一条 SQL,冷数据第一次跑可能要读 3 次磁盘,热数据第二次跑可能就是 0 次磁盘读。这也是为什么有些慢查询"重启服务后变得更慢",因为 Buffer Pool 被清空了,热数据要从磁盘重新加载。

日常运维里,我会用这两个状态值评估:Innodb_buffer_pool_read_requests(请求读次数)和 Innodb_buffer_pool_reads(真正从磁盘读次数),命中率通常要求 99.9% 以上。如果命中率长期偏低,第一件事不是优化 SQL,而是看 innodb_buffer_pool_size 是否太小。内存里多放几个 G 的页,磁盘读取次数可以直接清零,比任何索引优化都立竿见影。

6.2 InnoDB预读:顺序扫描为什么越读越快

另一个容易被忽视的机制是预读。InnoDB 在顺序扫描一个区段(extent,一般 64 个页)里的页时,如果读到的页数超过阈值,就会把下一个区段整个异步读进 Buffer Pool。也就是说,范围查询在 B+ 树叶子链上连续读页时,实际物理 I/O 经常是"一次读一批",而不是"一次读一页"。

这就是为什么大范围扫描的表在 SSD 上也能做到几百 MB/s 的顺序读吞吐。配合第 2 章讲的叶子页双向链表,B+ 树不仅点查次数少,范围查询的顺序读特性也远优于 B 树和红黑树。如果页碎片严重(见 5.2),预读的批量读取就会失效,因为物理上"下一页"不在磁盘相邻位置,预读机制派不上用场。

6.3 优化器选错索引:rows估算与回表成本失衡

索引设计再好,最终走哪条路径也要过优化器这一关。优化器根据统计信息估算各方案要读多少个页、回表多少次,然后选它认为成本最低的。统计信息不准时,就可能出现"明明有更优索引,却走了全表扫描"的怪事。

一个典型场景:大表的某个二级索引区分度一般,优化器估算命中行数很高,认为回表成本爆炸,于是改走全表扫描。但实际数据分布可能没那么差,只是统计信息过期了。处理办法很简单:先ANALYZE TABLE刷新统计信息;还不行就用 index hint 临时指定索引试点;再不行就得考虑用覆盖索引降低估算成本,或者调整 SQL 过滤条件让选择性更好。我在线上遇到过几次这类问题,基本都是统计信息滞后,analyze 之后立刻恢复正常。

6.4 排查时我必看的三列:type、key_len、rows

最后给一套可以直接上手的排查姿势。EXPLAIN 输出里,我几乎总是先看这三列:

列含义我的判断标准
type访问类型const > ref > range > index > ALL,低于 range 就要警惕
key_len实际使用的索引字节长度算上字符集和可变长度,判断联合索引用到了哪几列
rows优化器估算的扫描行数数量级准,和实际差别过大说明统计信息有问题

举个实际例子:varchar(20) 的 name 列,utf8mb4 字符集,允许 NULL,那么 key_len = 20×4 + 2(变长长度) + 1(NULL 标志) = 83。如果你建了联合索引 (name, age),EXPLAIN 显示 key_len=83 而不是更大,说明 age 这一列根本没被用上,SQL 对不起这个索引。

MySQL 8.0.18 之后还可以用 EXPLAIN ANALYZE 直接看到真实执行时间和实际读取行数,把它和 rows 对比,能很快定位到统计信息偏差。我现在的习惯是:先看 type 确认访问形态,再看 key_len 确认索引使用深度,最后看 rows 判断回表规模。三列看完,一条慢 SQL 的病因基本就浮出水面了。

把 B+ 树的每一层页结构、路由方式、回表成本、扇出数学都拆开之后,再回头看慢查询,会发现很多"玄学"其实都是算术题。比如"为什么 UUID 主键的表越用越慢"——因为扇出变小、页利用率下降、二级索引变胖;"为什么加了联合索引还是慢"——因为可能只用了前缀列,或者回表次数太多;"为什么大范围查询反而没那么慢"——因为叶子链表加预读把随机读变成了顺序读。我自己的体会是,真正理解 B+ 树之后,优化 SQL 就不再是碰运气试索引,而是先在心里画一条访问路径,算清楚要读几个页、回表几次,再动手改。如果你也被"有索引还是慢"折腾过,不妨按这篇文章的路线,把页结构和树高公式亲手推一遍,推完你会觉得 InnoDB 的一切设计都顺理成章。

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

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

立即咨询