做后端开发这些年,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 或调整成本参数 |
| 字符集不一致 join | join 关联字段索引失效 | 字符集不同,隐式转换导致无法匹配 | 统一表字段字符集为 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+树、回表、最左前缀这些机制,比背一百条优化规则都有用。