☰
MySQL索引底层原理:B+树、回表与聚簇索引实战解析
2026/10/6 3:40:00 网站建设 项目流程

做后端开发这些年,MySQL 索引相关的坑我踩过不少,也帮同事排查过不少慢查询问题。一个很典型的场景是:明明建了索引,执行计划出来却全表扫描;明明同一个 SQL,线上跑得好好的,数据一多突然就慢了。这些问题如果只停留在“索引能让查询变快”这个层面,根本没法解释。想要真正看懂索引,必须回到底层,理解 MySQL 索引底层的数据结构是什么、用了哪些算法、为什么偏偏选了这个结构。

这篇文章我会从数据页、磁盘 IO 说起,把 B+树、聚簇索引、二级索引、回表、最左前缀这些概念串成一条线,再结合常见的 where a and b 建索引、order by 排序、索引失效这类真实场景,把底层原理和实操方法一起讲清楚。内容偏底层但尽量说人话,适合被慢查询折磨的后端开发、刚接触索引的初学者,以及想系统梳理索引知识的朋友。

1. 索引的本质:没有索引时,MySQL 到底在做什么

1.1 全表扫描的真实代价

你执行一条 select 语句,InnoDB 引擎首先要搞清楚数据存在哪。表在物理上是由一个个数据页组成的,默认情况下一个数据页的大小是 16KB。在没有索引的时候,MySQL 只能从第一个数据页开始,把整张表的所有页全部读一遍,逐行判断条件是否满足,这个过程就是全表扫描。

全表扫描的代价有多大?关键在于磁盘 IO。机械硬盘一次随机读大概要 10ms,就算用 SSD,一次 IO 也有几十到几百微秒。如果一张表有 1 万个数据页,全表扫描最少要读 1 万个页,这个量级的开销对 OLTP 系统来说是灾难性的。你想想查新华字典,如果字典没有目录,要从第一页翻到最后一页找某个字,那得多痛苦。索引就是字典的目录,但索引能做的远不止“目录”这么简单,它本身就是一种精心设计的数据组织方式。

1.2 为什么最终选了树而不是数组或链表

刚开始学数据结构时我就有个疑问:要加速查找,用有序数组二分查找不是很好吗?一次就能排除一半的数据,时间复杂度 O(logN)。但问题是,数据库不是只读的,插入、删除太频繁了。有序数组中间插一条数据,后续所有元素都要往后挪,代价是 O(N)。

链表倒是解决了插入删除的问题,但链表没法二分查找,查一个节点只能从头往后遍历,O(N) 的时间复杂度在高并发场景下根本扛不住。于是树结构成了最优解:二叉查找树查找、插入、删除平均都是 O(logN),看起来完美。但二叉查找树有个致命弱点,如果插入的数据是有序的,树会退化成一条链表,查找又变成 O(N)。后来有了 AVL 树、红黑树,通过旋转保证树的平衡,查询效率稳定在 O(logN)。

但你以为 MySQL 用红黑树就完了?并没有。MySQL 最终的答案是 B+树,一种多路的平衡查找树。为什么要多路?因为数据库的瓶颈在磁盘 IO,树越矮,查询时访问的节点越少,磁盘 IO 次数越少。二叉树的层高是 log₂N,B+树的层高是 logᵐN(m 通常上千),在千万级数据量下,树的高度能压到 3 到 4 层,这就是根本差异。后面我会专门算这笔账。

1.3 哈希索引为什么救不了日常 OLTP

既然要追求快,哈希表查找 O(1) 不更快吗?哈希索引确实快,但只适合等值查询。select * from user where id = 100这种,哈希可以一次命中。但面对where age > 18、where name like '张%'、order by create_time这类范围查询,哈希彻底抓瞎,因为哈希表里数据是无序的,无法支持范围扫描。

InnoDB 其实有自适应哈希索引,默认开启,当检测到某些热点等值查询时,会自动在 B+树索引上建立哈希索引来加速。但它只是个内部加速机制,由存储引擎自己决定,开发人员无法直接创建哈希索引。日常业务里,范围查询、排序、分组这些场景太常见了,所以真正的明星还是 B+树。哈希表不是不好,只是不适合做数据库的通用索引。

2. B+树底层结构拆解:从数据页到层高计算

2.1 数据页:树的一个节点就是 16KB 的页

理解 B+树之前必须先理解数据页。InnoDB 的最小读写单位不是行,而是数据页,默认 16KB。整个表空间被划分成一个个页,页内部又包含页头、页尾、用户记录、页目录这些部分。你每次查询,InnoDB 最少读一个页到内存,而不是只读一行。

B+树的一个节点,本质上就是一个数据页。这意味着,树的一个“格子”里能塞多少索引项,取决于一个页能存多少键值对。普通二叉树一个节点只能存一个键,而 B+树的一个节点可以存上千个键,这就是“多路”的由来。树的高度从 log₂N 降到了 log₁₀₀₀N,层级少了,查询时的磁盘 IO 自然就少了。

2.2 B+树的形态和三层的容量

B+树有两个鲜明特点:非叶子节点只存索引键和指针,不存数据;叶子节点存数据,并且叶子节点之间用链表串起来。你可以把 B+树想象成一棵倒着的树,从上往下搜索,先走根节点,再走中间层,最后到达叶子节点。

非叶子节点一个页能存多少索引项?以 8 字节的 bigint 主键为例,加上 6 字节的指针,一行索引项大概占 14 字节,一个 16KB 的页能存 16384 / 14,约 1170 个键。假设一行数据大小是 1KB,一个叶子页能存 16 行数据。那么一棵三层高的 B+树能存多少数据?1170 × 1170 × 16,大约是 2190 万行。什么概念?两千万行的表,只要走主键索引,三层树就够用了。

关键是,根节点通常常驻内存,所以从根到叶子往往只需要 2 次磁盘 IO。第一层和第二层索引项不存数据,占空间小,也更容易被缓存在内存里。所以大多数 OLTP 查询都能在毫秒级完成,这就是 B+树对磁盘 IO 友好的真相。

2.3 页分裂与页合并:索引背后的自动维护

B+树不是静态的,数据不断插入删除,树也会自我调整。最容易引发问题的操作是插入。当插入一条记录时,如果对应叶子页已经满了,InnoDB 必须申请一个新的页,把原来一半的数据搬过去,这个过程叫页分裂。页分裂会产生页碎片,让物理空间不连续,还会增加额外 IO。删除数据如果导致相邻两个页利用率很低,InnoDB 又会触发页合并,把两个页的数据并到一个页里。

理解了页分裂,就能理解为什么业内一直推荐用自增主键。自增主键新插入的数据永远追加在当前最大主键位置,不会随机往中间插,最大程度避免频繁的页分裂。反过来,如果用 UUID 做聚簇索引的键,主键完全无序,每次插入都可能插到已有数据的中间某个位置,触发页分裂的概率大大增加,时间长了表碎片会很严重。这个结论不是玄学,是 B+树物理结构决定的。

2.4 B树 vs B+树:为什么偏偏是 B+树赢了

面试时常被问“为什么是 B+树而不是 B树”。两者名字很像,区别却致命。B树的所有节点都存放数据,包括非叶子节点,而 B+树的非叶子节点只存索引键,数据全在叶子。这个区别带来三个直接后果。

第一,B+树的非叶子节点能存更多索引项,树更矮,查询 IO 更少。B树每个节点都要放数据,一层能存的索引项就少得多。第二,B+树的查询效率是稳定的。无论查哪条数据,都必须从根走到叶子,路径长度固定;B树可能在非叶子节点就命中数据,运气好一次 IO 就查完,运气差要多走几层,不稳定。第三,B+树的叶子节点用链表串联,范围查询非常自然,从头节点顺着链表往后扫就行。B树的范围查询需要在各个子树之间来回回溯,慢得多。日常 SQL 里范围查询、排序、分组一大堆,B+树能把这些场景全部覆盖,这是它能胜出的根本原因。

3. InnoDB 索引的两种形态:聚簇索引与二级索引

3.1 聚簇索引:主键决定了数据行的物理组织

InnoDB 里,每张表都有一个聚簇索引,也叫主键索引。聚簇索引的叶子节点存的是完整的数据行。换句话说,表数据本身就是按主键顺序排列的,主键的顺序决定了数据页里记录的排布顺序,再具体点,InnoDB 会按照主键大小把数据行串起来。

这也解释了为什么建表建议必须有主键。如果你建表时没指定主键,InnoDB 会先找有没有非空唯一索引,有就把它当聚簇索引,没有就会生成一个隐藏的 rowid 作为聚簇索引。这个隐藏 rowid 对应用层不可见,你还控制不了它,后续所有二级索引回表都得靠它,排查问题很不方便。所以建表的时候老老实实给一个主键,最好是个整型的、自增的、和业务无关的字段。聚簇索引在 InnoDB 里只有一个,因为数据行没法同时按两种顺序物理组织。

3.2 二级索引与回表:理解覆盖索引的威力

聚簇索引之外的索引都叫二级索引,也叫普通索引。二级索引的叶子节点不存完整数据行,而是存索引键加上主键值。比如你在 name 字段建了索引,索引叶子节点存的是 name 和主键 id。有了这个结构,select * from user where name = '张三'的查询流程是:先走 name 的二级索引,找到所有符合条件的叶子,取出主键 id,再用这些 id 去聚簇索引里查完整数据行。这第二步,就叫回表。

回表不是免费的。尤其当二级索引命中大量数据行时,每一条都要去聚簇索引再查一次,等于查询次数翻倍。怎么避免回表?答案是覆盖索引。如果查询列都包含在索引里,那从二级索引的叶子节点就能直接拿到所有需要的字段,压根不用回表。比如你只查select id, name from user where name = '张三',而 (name) 索引里就包含 name 和 id,那 InnoDB 就是覆盖索引扫描,不用回表。这也是为什么大家常说别随便 select *,多用覆盖索引,核心目的就是少一次回表 IO。

3.3 联合索引与最左前缀:底层是怎么工作的

联合索引是一个索引包含多个列,比如 (a, b)。它的排序规则是:先按 a 排序,a 相同再按 b 排序。你可以想象一本电话本,先按姓排序,同姓的人再按名字排序。这就引出最左前缀原则:查询条件里必须包含联合索引最左边的列,这个索引才可能被用到。

具体来说,where a = 1能用到 (a, b) 索引,where a = 1 and b = 2也能用到,因为可以通过 a 定位到目标范围,再在范围内用 b 精确过滤。但where b = 2用不到,因为 b 在联合索引里是二级排序字段,没有 a 的限制,索引的整体顺序对 b 来说没有全局意义。这条规则不是 InnoDB 故意限制,而是 B+树的排序方式决定的:一个节点的下一层节点只能按最左列有序组织,跳过了第一列,后面的列等于被锁在抽屉里。

4. 索引设计与优化实战:怎么建索引,怎么验证

4.1 where a and b 到底怎么建联合索引

很多人建联合索引喜欢一上来把条件的列都丢进去,其实顺序很讲究。假设有一个订单表,高频查询是where user_id = 100 and status = 1,两个都是等值条件,那联合索引竖着建就行,但谁放前面?经验做法是:区分度更高的列放前面。user_id 区分度通常比 status 高得多,status 无非几个值,如果 status 放前面,索引先按 status 分组,每组里再按 user_id 排,一次扫描要过掉大量无关分组,效率差。把 user_id 放前面,索引会先精确定位到某个用户的数据块,再在块内过滤 status,数据量瞬间小很多。

如果条件是where user_id = 100 and create_time > '2024-01-01',前者是等值,后者是范围,那联合索引应该建 (user_id, create_time),而不是反过来。因为范围条件的 create_time 放到前面,user_id 的等值条件就没法精确过滤了。反过来,索引 (user_id, create_time) 可以先用 user_id 精确定位,然后 create_time 在有序区间里扫描,非常顺畅。记住这个口诀:等值条件放前面,范围条件放后面,排序字段最后按需考虑。

4.2 order by 排序是怎么吃索引的

排序是另一个重灾区。select * from order where user_id = 100 order by create_time desc这条 SQL,如果只看 where 条件建了 (user_id) 索引,那么 order by create_time 就没法用索引排序,MySQL 只能先把符合条件的数据捞出来,再做 filesort。filesort 如果数据量小还能在内存里排,数据量一大就得用到临时文件,磁盘排序那个速度,慢得让人抓狂。

正确做法是建联合索引 (user_id, create_time)。这样索引内部先按 user_id 分好区,每个分区内又按 create_time 有序,查询时顺着索引顺序往下扫,天然就是排好的,不需要额外排序。这就是索引排序和文件排序的本质区别。注意一个细节:如果排序方向不一致,比如order by user_id asc, create_time desc,MySQL 8.0 之前索引没法同时支持两个方向的扫描,可能需要 filesort。MySQL 8.0 支持降序索引,建索引时可以直接(user_id asc, create_time desc),从底层解决这个问题。

4.3 索引失效的底层原因,不只是“规则”两字

网上列了一堆索引失效规则,但如果不理解失效原因,换个场景照样懵。所有失效场景都能归结为一句话:索引列被“加工”了,原始排序被破坏。

像where age + 1 > 20就是在索引列上做了运算,B+树里存的是 age 的原始值,没法直接二分查找 age+1,优化器只能放弃索引。where DATE(create_time) = '2024-01-01'同理,应该改成create_time >= '2024-01-01 00:00:00' and create_time < '2024-01-02 00:00:00',保证查询用得上索引。隐式类型转换也很阴险,varchar 列和数字比较,MySQL 会把字符串列转成数字,等于在列上干了隐形函数,索引就失效了。最典型的就是手机号字段存的是 varchar,查询时where phone = 13800000000,看起来没问题,实际索引已经废了,必须把参数写成字符串形式。

前导通配符也一样,like '%关键字'没法从索引开头顺序匹配,只能用全表扫描;like '关键字%'则能用上索引范围扫描。还有or连接的非索引条件,比如where name = 'a' or status = 0,status 没索引,MySQL 没法直接只用 name 索引定位,大概率全表扫描。is null/is not null也要注意,视查询计划和代价而定。理解这些规则背后的“原始顺序被破坏”这一底层逻辑,比死记硬背可靠得多。

4.4 用 explain 和 trace 验证索引是否真的用了

建完索引心里没底?直接看执行计划。explain select ... from t where user_id = 100,重点看几个字段:type 表示访问类型,从好到差大概是 const、eq_ref、ref、range、index、ALL。ALL 是全表扫描,最差。range 是范围扫描,比如 where user_id > 100。ref 是等值匹配,属于常见优秀级别。key 字段显示实际命中的索引,如果显示 NULL 就是没走索引。rows 是估计扫描行数,越大越危险。

还有一个更细的工具叫 optimizer trace。先打开set optimizer_trace="enabled=on";,再执行目标 SQL,然后查select * from information_schema.optimizer_trace;里的 trace 字段,能看到优化器是为什么选这条路,比如评估走索引的代价 vs 全表扫描的代价后选择了全表扫描。这个工具排索引失效时特别好用,能告诉你优化器心里的小算盘,而不只是猜。

5. 常见问题排查与经验速查

5.1 索引失效场景与排查思路速查表

我在实际项目中把这些年高频遇到的索引问题整理成了一张表格,排查慢查询时先对着表过一遍,能省很多时间。

场景现象根因处理方式
索引列做运算或函数明明有索引,key 显示 NULL索引原始顺序被破坏,无法二分定位改写 SQL,把运算移到查询条件右侧
varchar 列与数字比较手机号索引不生效隐式类型转换,字符串列被转换参数写成字符串,如 '13800000000'
like 前导通配符前缀索引被放弃无法从头匹配有序结构业务上改为后缀匹配或用全文索引兜底
or 连接非索引列执行计划走全表扫描优化器无法组合多种索引路径拆 SQL 或用 union 分别查询
联合索引范围列后加条件后列过滤未生效范围条件后的列在索引树中不可精确检索调整索引列顺序,范围条件靠后
数据量很小有索引但不走,走 ALL优化器认为全表扫比来回 IO 更快不强行干预,必要时用 force index 或调整成本参数
字符集不一致 joinjoin 关联字段索引失效字符集不同,隐式转换导致无法匹配统一表字段字符集为 utf8mb4

5.2 真实排障经验:一次 dba 级别的定位过程

之前遇到一个线上慢查询,用户表两千万数据,where nickname = 'xxx'已经建了索引,但响应始终要跑三秒多。一开始我以为索引没建上,explain 一看 key 有值,rows 却显示 80 万,type 还是 ref,明摆着不是全表扫描但扫描量很大。后来用 optimizer trace 一查才发现,nickname 的区分度太低,大量用户用的默认昵称,比如“微信用户”这种,同一个值能命中几万行,就算走索引也要回表几万次,代价比全表扫描还高。

这给我们一个教训:不是建了索引就万事大吉,索引的价值建立在区分度上。排查这类问题,除了看 explain,还要算一下区分度,select count(distinct column) / count(*)如果低于某个阈值,这个字段单独建索引意义不大。这时候可以考虑前缀索引,或者把查询条件设计得更精细,比如加上用户等级、地区等联动条件。区分度低的场景,索引反而成为负担。

5.3 索引不是越多越好:写入性能的隐形代价

最后特别想提醒一句,索引不是免费的午餐。每建一个二级索引,就意味着每次 insert、update、delete 都要额外维护一棵 B+树。插入一条数据,主键索引写入一次,二级索引每建一个就要多写一次,索引多了,写入放大非常明显。某个表我曾经加了 6 个索引,批量导入 100 万行数据,速度直接慢了一倍。删除索引后立刻恢复。

所以建索引之前先问自己三个问题:这个查询是不是高频?这个 SQL 是不是真的慢?这个字段的区分度够不够?不是所有 where 条件里的列都需要单独建索引。联合索引能覆盖的尽量复用,能用覆盖索引避免回表的优先考虑,能不建的坚决不建。业务初期数据量小可能根本感受不到差距,一旦数据量上来,这些设计欠账都会加倍还回来。

写到这里,我想起上个月帮一个朋友排查订单查询慢的问题。他建表时明明给 order_no 建了唯一索引,但用 order_no 查还是慢。查了一圈发现,他的 order_no 是 varchar 类型,但代码里传的是 Long 类型,MyBatis 拼接 SQL 后触发了隐式类型转换,索引活活被“烧”掉。那一刻我对“索引底层算法决定上层写法”这句话体会特别深。数据库不会骗你,它只是严格按底层数据结构来执行你给的 SQL,你觉得它笨的时候,往往是你的 SQL 或者表结构与它的底层机制拧着来。多花点时间吃透索引底层的 B+树、回表、最左前缀这些机制,比背一百条优化规则都有用。

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

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

立即咨询