☰
MySQL索引优化实战:从B+ Tree到慢查询排查
2026/10/8 2:59:53 网站建设 项目流程

1. 索引优化,先搞清楚 MySQL 到底在背后做了什么

先聊点实在的。不管你是刚接手一个线上业务库,还是自己写着玩的小项目,只要数据量过了百万级,早晚会遇到同一个问题:SQL 越来越慢,系统越来越卡,老板越来越急。很多人第一反应就是“加索引”,但加了索引之后呢?有时快如闪电,有时纹丝不动,甚至更慢。这时候你就得停下来想一想:MySQL 的索引到底是怎么工作的?为什么同样是走索引,有的查询能到毫秒级,有的却依然全表扫描?

如果你去翻 MySQL 的官方文档,会看到索引被描述为“帮助 MySQL 高效获取数据的数据结构”。这句话听起来很像废话,但它的分量很重——索引的本质,就是把你原本需要“从头翻到尾”的数据查找,变成“按目录直接翻到那一页”。在关系型数据库里,这个“目录”最常见的物理载体就是 B+ Tree。理解了 B+ Tree 的查找逻辑,你基本就理解了 MySQL 索引优化的底层逻辑。

这篇文章不是什么高深论文,也不是把官方文档抄一遍。我打算从原理讲到实操,从慢查询分析讲到索引设计,中间穿插我自己在真实业务里踩过的坑,以及最终总结出来的、可以直接拿去用的优化套路。适合谁看?刚接触索引优化、想知道为什么加了索引还是不快的初级开发;也包括写过不少 SQL、想系统梳理索引知识的后端工程师。看完这篇文章,你至少能回答这几个高频面试题:主键索引和唯一索引有什么区别?哪些场景会导致索引失效?以及,当你的 SQL 慢到不能忍的时候,第一步应该做什么。

2. 索引为何能让查询“快如闪电”

2.1 从 B+ Tree 说起:为什么不是二叉树也不是哈希表

先说一个结论:MySQL InnoDB 引擎的索引,默认都是 B+ Tree 结构。很多人背过这个结论,但不理解为什么。

你可以把 B+ Tree 想象成一棵“扁而宽”的树。它的特点是:所有数据都存储在叶子节点,非叶子节点只存索引键值;叶子节点之间通过指针串联成一个有序链表。这意味着两件事。第一,无论你要找的数据在树的第几层,从根节点走到叶子节点的路径长度几乎相同,也就是说查询耗时非常稳定。第二,因为叶子节点是排好序且相连的,做范围查询(比如WHERE age BETWEEN 20 AND 30)的时候,只要找到起点,顺着链表一路往后扫就行,不需要反复回树里找。

那为什么不用二叉树?二叉树在数据量大的时候会变得很深,比如 1000 万条数据,二叉树可能要 20 多层,每层一次磁盘 IO,20 多次 IO 就很要命了。而 B+ Tree 因为每个节点可以存储多个键值,树的层数通常只有 3 到 4 层,也就是说,一次查询最多只需要 3 到 4 次磁盘 IO,这个效率完全不在一个量级。

也有同学问,为什么不用哈希索引?哈希索引的查找速度更快,理论上是 O(1)。但哈希索引最大的问题是:它只支持等值查询,不支持范围查询,也不支持排序。你的业务里只要有>、<、BETWEEN、ORDER BY这类操作,哈希索引就废了。所以 InnoDB 默认用 B+ Tree,本质上是在“查找效率”和“功能覆盖范围”之间做了最优权衡。

2.2 聚簇索引与二级索引:回表到底是什么

InnoDB 的索引还有两个必须分清楚的概念:聚簇索引(clustered index)和二级索引(secondary index)。

聚簇索引就是主键索引。InnoDB 的表数据本身就是按主键聚集存储的,也就是说,表的每一行数据就挂在主键索引的叶子节点上。你通过主键查询,直接就能拿到整行数据,不需要额外操作。如果你建表时没有定义主键,InnoDB 会找一个非空的唯一索引代替;连唯一索引都没有,它就会生成一个隐藏的 row_id 作为聚簇索引。

二级索引则是你额外创建的普通索引,比如CREATE INDEX idx_name ON users(name)。二级索引的叶子节点存储的并不是整行数据,而是“索引列的值 + 主键值”。所以当你通过二级索引查询时,第一步是通过 B+ Tree 找到对应的主键值,第二步还得再用这个主键值去聚簇索引里查一次完整行数据,这个过程就叫“回表”。

这里就引出了一个非常核心的优化思路:如果 SQL 需要的所有列都已经包含在二级索引里,那就不需要回表了,这种索引叫“覆盖索引”。比如你有SELECT name, age FROM users WHERE name = '张三',如果你建的是(name, age)联合索引,那么 MySQL 在二级索引的叶子节点上就已经拿到了 name 和 age,根本不用回表。这在高频查询场景下是巨大的性能提升。

3. 索引设计:不是越多越好,而是刚刚好

3.1 常规索引、唯一索引与主键索引的真实区别

很多人搞不清这三者的关系,我这里直接说结论。

主键索引是聚簇索引,一张表只能有一个,通常不允许为 NULL,它的叶子节点存的是完整行数据。唯一索引是二级索引,一张表可以有多个,它允许有一个 NULL 值(在 InnoDB 里,多个 NULL 值其实也是允许的,因为 NULL 被视为互不相等),它的叶子节点存的是“唯一索引列 + 主键值”。普通索引没有任何限制,叶子节点同样是“索引列 + 主键值”。

所以面试题“主键索引和唯一索引的区别”答案很清晰:物理结构不同、数量限制不同、叶子节点存储内容不同,以及主键索引不需要回表,唯一索引在绝大多数情况下需要回表。

这里有一个实操经验要分享:不要把唯一约束和业务逻辑混在一起。比如用户表里手机号要求唯一,很多人直接就把手机号设成唯一索引,这没问题。但如果你在多个业务表里都为了“防重复”去加唯一索引,就要仔细想一想:插入数据时,MySQL 做唯一性检查是有额外开销的。如果这个表写入量极大,唯一索引会明显拉低写入性能,因为每次插入都要先查一遍、再判断冲突。不如把唯一性校验放到应用层去处理,数据库层只保留必要的唯一索引。

3.2 联合索引的“最左前缀原则”

联合索引是工作中用得最多、也最容易踩坑的索引类型。它遵循一个核心规则:最左前缀原则。

假设你建立了一个联合索引(a, b, c),实际上 MySQL 会按 a、b、c 的顺序,先按 a 排序,a 相同再按 b 排序,b 相同再按 c 排序。所以它能高效命中的查询条件组合只有三种:a、a,b、a,b,c。如果你直接查b或者c,这个索引基本帮不上忙,因为索引的整体顺序已经被 a 决定了,没有 a 作为前缀,b 和 c 就是无序的。

这个原则直接决定了你设计联合索引时的字段顺序。一个常见的经验法则是:把“区分度高”的字段放在前面,把“经常用于等值查询”的字段放在前面,把“范围查询”的字段尽量往后放。举个例子,WHERE status = 1 AND created_at > '2024-01-01',如果 status 只有 0 和 1 两种取值,区分度很低,把它放前面意义不大。但如果查询模式固定是“先按 status 过滤,再按时间排序”,那(status, created_at)反而更合适,因为 status 的等值过滤能迅速缩小数据范围,created_at 则负责排序和范围筛选。

说句实话,没有完美的索引设计,只有适合当前业务查询模式的索引设计。你在建索引之前,先把自己业务里最频繁执行的 10 条 SQL 拿出来,看看它们的 WHERE 条件、排序字段、关联字段,再决定怎么建。这才是索引设计的正确起点。

3.3 索引下推:MySQL 帮我们做的额外优化

讲联合索引时,经常有人忽略一个内置优化——索引下推(Index Condition Pushdown,简称 ICP)。这个功能默认开启,它做的事情是:在索引遍历过程中,提前对索引包含的字段进行条件过滤,减少回表次数。

举个例子,有联合索引(name, age),执行SELECT * FROM users WHERE name LIKE '张%' AND age = 20。在 MySQL 5.6 之前,InnoDB 只能先根据name LIKE '张%'找到一批主键,然后回表查出完整行,再在服务层过滤 age。有了索引下推之后,InnoDB 在索引遍历阶段就会判断 age = 20 这个条件,age 不匹配的直接跳过,回表次数大大减少。

这个优化我实测过,在数据量百万级、name 前缀匹配出的结果集很大的场景下,开启和关闭 ICP 的耗时差距能接近一倍。所以有些时候你写 SQL 觉得“已经走索引了,怎么还是慢”,不妨看看 EXPLAIN 里有没有Using index condition,这个标记就表示走了索引下推,但还能继续优化。

4. 索引失效的经典场景与排查实操

4.1 那些年我们一起踩过的失效坑

网上流传的“索引失效”清单很多,但很多人只会背,不会排查。我把自己在真实业务里踩过、也帮别人排查过的场景列出来,每条都带案例说明,这样你遇到类似问题时能一眼识别。

第一类:对索引列使用了函数或计算。比如WHERE DATE(created_at) = '2024-01-01',哪怕 created_at 上有索引,这个查询也不会走索引,因为 MySQL 必须先对每一行的 created_at 做 DATE() 运算,才能和常量比较。正确的写法是范围查询:WHERE created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00'。这是一个极其常见的慢查询原因。

第二类:隐式类型转换。如果索引列是 varchar 类型,你查的时候却用了数字:WHERE phone = 13800138000,MySQL 会把字符串列转换为数字再比较,导致索引失效。反过来,数字列用字符串查,一般没问题,但为了统一规范,条件值的类型最好和列类型完全一致。

第三类:模糊查询的左侧通配符。WHERE name LIKE '%张三%'无法使用索引,因为 B+ Tree 是按前缀排序的,你要从中间截一段去匹配,顺序就丢了。如果你的业务确实需要这种模糊搜索,建议考虑全文索引或者 Elasticsearch,而不是死磕 MySQL。

第四类:联合索引不满足最左前缀。刚才讲过,(a, b, c)索引,你只查 b 或 c,索引直接失效。还有一点要注意,如果最左字段是范围查询,后面的字段也无法用于索引过滤,但前面的等值字段可以。这个细节很多人容易忽略。

第五类:OR条件连接。比如WHERE a = 1 OR b = 2,如果 a 和 b 不是都建了索引,MySQL 可能放弃索引,改成全表扫描。因为 OR 意味着只要任意一个条件满足就返回,如果某个字段没有索引,MySQL 必须先全表扫一遍才能准确返回。解决办法是给 OR 两端涉及的字段都建索引,或者拆成两个查询用 UNION ALL 合并。

4.2 用 EXPLAIN 定位慢查询

排查索引失效和慢查询,EXPLAIN 是绕不开的工具。你不需要背所有输出列,但必须会看几个重点字段。

type 列是最直观的访问类型指示器。常见的值从好到差依次是:system > const > eq_ref > ref > range > index > ALL。ALL就是全表扫描,通常是要尽量避免的;index是扫描了整个索引树,虽然没有全表扫,但也不理想;range是范围扫描,可以接受;ref和eq_ref是使用了非唯一索引或唯一索引等值匹配,表现不错;const是主键或唯一索引等值查询,理论上是最优。

key 列表示实际用到的索引,ken_len 列表示索引字段的最大字节长度,rows 列是 MySQL 预估需要扫描的行数,filtered 列是经过条件过滤后剩余行的百分比。我通常的习惯是:先看 type 是否到了 range 或更好,然后看 key 是否命中了预期索引,最后看 rows 是否过大。如果你发现 type 是 ALL 或者 rows 数值大得离谱,就说明这条 SQL 的索引利用情况很差,需要回头检查条件写法或索引设计。

实操时有个小技巧:在 EXPLAIN 后面加FORMAT=JSON,可以看到更详细的成本估算信息。当索引选择器在多条候选索引之间纠结时,JSON 输出里会包含considered_execution_plans,你能直接看到每一条可选索引的成本,方便判断 MySQL 为什么会选那个“看起来不合理”的索引。

4.3 实战案例:一条从 3 秒到 50 毫秒的查询

我之前在某个电商项目里处理过一个典型慢查询,SQL 大概是这样的:

SELECT order_id, user_id, amount, status FROM orders WHERE user_id = 'U123456' AND create_time BETWEEN '2024-03-01 00:00:00' AND '2024-03-31 23:59:59' ORDER BY create_time DESC LIMIT 20;

orders 表当时有三千多万行,这条 SQL 跑了将近 3 秒。EXPLAIN 一看,type 是 ALL,rows 预估扫描了三百多万行,key 是 NULL。问题的根源是 orders 表只有一个主键,查询里使用的 user_id 和 create_time 完全靠全表扫描过滤。

后面我做了两件事。第一,加了联合索引(user_id, create_time),正好覆盖这个 SQL 的 WHERE 条件和 ORDER BY 字段。第二,顺手把查询字段调整为SELECT order_id, user_id, amount, status,由于这些字段中没有 create_time,无法完全覆盖,所以还是需要回表查字段,但回表量已经控制在 20 条以内,成本低到可以忽略。最终这条 SQL 从 3 秒降到了 50 毫秒以内。

这个例子说明一个道理:慢查询优化不是每次都要上缓存、上分库分表,很多问题就是一条联合索引的事。很多团队一慢就上 Redis,上消息队列,最后还是没解决根本问题。先看索引,再看 SQL 写法,最后才考虑架构层面的改造,这是成本最低、效果最明显的优化路径。

5. 索引优化做到位的几个额外关键点

5.1 覆盖索引:让回表不再发生

前面提到覆盖索引,我再展开说说。覆盖索引的定义是:查询所需要的所有列,都能在二级索引的叶子节点上直接找到。查询不需要回表访问主键索引里的完整行。

这个优化在统计类 SQL 里效果尤其明显。比如SELECT COUNT(*) FROM orders WHERE status = 1,如果你有(status)索引,MySQL 直接扫索引树上的 status 列就能数出来,不需要碰数据行。如果查询里还有其他字段,比如SELECT status, COUNT(*) FROM orders GROUP BY status,那么(status)索引也足够用了。

我在实践中还发现一个细节:覆盖索引可以显著减少磁盘 IO,但它会占用更多存储空间,因为索引里存的列更多了。所以不要为了覆盖而无脑把表里所有字段塞进一个索引。一般只对高频查询中需要返回的少量字段做覆盖,比如用户列表页需要查 id、name、avatar 这 3 个字段,那就建一个(name(长度), id, avatar)之类的索引,前提是你的查询条件能命中这个索引。

5.2 索引基数与区分度的取舍

索引的“性价比”怎么衡量?一个很重要的指标是基数(cardinality),也就是索引列中不同值的数量。基数越高,说明这个列的区分度越好。比如一个用户表,性别列的基数只有 2,几乎区分不了数据;手机号列的基数接近表行数,区分度极高。

MySQL 的优化器在决定走不走某个索引时,会参考索引的基数来估算扫描行数。如果区分度太低,优化器认为即使走索引,也要扫描大量行,不如直接全表扫。所以你在选择索引列时,尽量选基数高、业务查询频繁的字段。如果某列的区分度确实很低,但又经常作为查询条件,那可以考虑把它放在联合索引里,专门用于过滤和减少后续计算量,但别指望它能单独成为高性能索引。

这里分享一个教训:我曾经在一个活动表里给is_deleted(只有 0 和 1 两种值)单独建过索引,结果 EXPLAIN 显示 MySQL 完全不走,直接全表扫。因为 90% 的行都是 0,索引扫描一大半数据还带回表,优化器一算账就知道不划算。后来我把is_deleted和其他查询字段组合成联合索引,才真正发挥价值。低区分度字段单独建索引,基本是浪费空间。

5.3 排序与分组:避免 filesort 和临时表

ORDER BY 和 GROUP BY 也是索引优化的重点。如果排序字段没有索引,MySQL 就需要把结果集先放进内存(或磁盘临时文件)里做排序,EXPLAIN 里会看到Using filesort。同理,GROUP BY 没有索引时也容易使用临时表。

要避免 filesort,最好的办法就是让排序字段出现在某个索引中,并且保证排序字段的顺序和索引顺序一致。比如索引(user_id, create_time),执行WHERE user_id = 'U123' ORDER BY create_time DESC时,MySQL 可以直接利用索引的顺序完成排序,不需要额外的排序操作。但如果是ORDER BY create_time ASC, user_id DESC,排序方向不一致,也可能导致额外的排序。

GROUP BY 稍微复杂一点,如果只关心分组后的统计值,可以在索引里包含分组字段和聚合字段,比如(status, amount),这样GROUP BY status就不用生成临时表。不过分组查询的优化空间通常有限,业务层能用内存聚合解决的,也值得考虑。

5.4 索引维护成本:写入变慢的代价必须心里有数

很多人忽略一个事实:索引不是免费的。每一次 INSERT、UPDATE、DELETE,InnoDB 都要同步维护索引树。索引建得越多,写入路径越长,性能损耗越大。尤其是在高并发写入场景下,一个表上有五六个索引,每次写入都会拖垮性能。

我处理过的一个真实事故:某个日志表为了支持多种查询组合,建了 4 个联合索引,结果数据写入从单条毫秒级变成了几十毫秒级,最终拖慢了整个业务的写入链路。后面我们把不常用的索引删掉,保留两个核心查询索引,写入时延才恢复过来。

所以,一个健康的索引策略应该是:优先保证核心高频查询,严格控制索引数量。一个表最多建议 5 到 6 个索引就差不多了,再多就要警惕。定期用sys.schema_unused_indexes或者慢查询日志来识别从未被使用的索引,然后果断删除,这是降低维护成本最直接的手段。

6. 写 SQL 本身也是一种优化

6.1 SELECT 只取需要的字段

这个点听起来很基础,但很多人真的做不到。SELECT *在数据量大、字段多的情况下会带来两个问题:传输数据量变大,IO 和网络开销增加;更重要的是,它几乎不可能命中覆盖索引,因为表的新增字段不在索引里时,MySQL 只能回表取完整行。

我自己比较习惯的做法是:业务需要哪些字段,SQL 就写哪些字段,不多不少。尤其在使用二级索引的查询里,把字段控制在索引包含的列内,可以直接避免回表,好处谁用谁知道。

6.2 分页查询深翻页的坑:LIMIT 1000000, 20

深分页是另一个经典性能杀手。LIMIT 1000000, 20的意思是:MySQL 要先找到前面 1000020 行,然后丢掉前 1000000 行,只返回最后 20 行。扫描行数并不会因为 LIMIT 的偏移量变大而变小,而是随着偏移量线性增长。

优化方案有两个。第一个是延迟关联:先通过覆盖索引找到目标主键集合,再用主键关联原表取完整行。例如:

SELECT t.* FROM orders t INNER JOIN ( SELECT id FROM orders WHERE user_id = 'U123' ORDER BY create_time DESC LIMIT 1000000, 20 ) tmp ON t.id = tmp.id;

这个写法让内层查询只走索引,不碰数据行,外层再根据 20 个主键精准回表。第二个方案是记录上次查询的最后一条 ID,也就是“键集分页”:WHERE id < last_id ORDER BY id DESC LIMIT 20,然后无限往下翻。这种方案更适合 App 列表场景,但不适合任意跳页。

6.3 批量插入与 UPDATE:小步快跑更安全

在业务高峰期做大批量 UPDATE 或 DELETE,也是 DBA 最怕的事之一。一次 UPDATE 几百万行,会持有大量行锁,甚至可能升级为表锁,严重影响在线业务。实操中我常用的方法是分批处理:每次只更新一万行,用LIMIT控制批次,加一点 SLEEP 时间再继续下一批。虽然总耗时变长了,但对线上影响能控制在非常小的范围。

7. 从理论到实战:一次完整索引优化的流程演示

为了让你能把前面所有内容串起来,我干脆模拟一个完整的优化流程,你之后照着做就行。

7.1 第一步:定位慢 SQL

打开 MySQL 慢查询日志,配置如下:

slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1 log_queries_not_using_indexes = ON

long_query_time = 1表示执行超过 1 秒的 SQL 都会被记录。log_queries_not_using_indexes = ON可以顺便把不走索引的查询也记下来,虽然有时会误伤一些本来就该全表扫的小表,但对发现问题很有帮助。

7.2 第二步:EXPLAIN 分析执行计划

拿一条慢 SQL,例如:

EXPLAIN SELECT * FROM payment_records WHERE uid = 'A100' AND pay_time > '2024-05-01 00:00:00' ORDER BY pay_time DESC LIMIT 10;

输出结果里如果 type 是 ALL,rows 很大,并且 key 是 NULL,基本可以断定没有合适索引。接下来看一眼表的结构和现有索引,结合业务判断需要创建什么索引。

7.3 第三步:设计索引并验证

根据 SQL 的查询条件,最合理是创建一个(uid, pay_time)联合索引。uid 是等值条件,放前面;pay_time 是范围条件和排序字段,放后面。建好索引后再跑一次 EXPLAIN:

EXPLAIN SELECT * FROM payment_records WHERE uid = 'A100' AND pay_time > '2024-05-01 00:00:00' ORDER BY pay_time DESC LIMIT 10;

这时候 type 应该变成 range,key 显示新索引,rows 会大幅下降,Extra 里也不再出现Using filesort。如果查询只需要 uid、pay_time、amount 这几个字段,还可以把 amount 加进联合索引变成(uid, pay_time, amount),直接实现覆盖索引,连回表都省了。

7.4 第四步:监控和回滚

索引上线后,继续观察慢查询日志,看这条 SQL 是否还在慢日志里出现。如果出现,再对比优化前后平均耗时。如果效果不理想或引入了写入性能问题,可以用ALTER TABLE ... DROP INDEX快速回滚。线上操作尽量在低峰期执行,并且先用SHOW PROCESSLIST确认当前没有大事务在跑。

8. 常见问题速查与独家避坑清单

结合我自己的经验,把常见问题和排查方式整理成一个速查表,你后面遇到症状时可以按图索骥。

现象常见原因排查方法解决建议
type 是 ALL 全表扫描查询条件列无索引或索引失效EXPLAIN 看 key 和 rows结合 WHERE 条件建联合索引
有索引但 type 仍是 index索引无法高效过滤,扫了整个索引树看查询条件是否满足最左前缀调整索引字段顺序或新建索引
出现 Using filesortORDER BY 字段不在索引中观察索引是否包含排序字段设计联合索引包含排序字段
出现 Using temporaryGROUP BY 或 DISTINCT 未走索引检查分组字段索引情况调整索引,减少临时表
回表过多导致慢查询二级索引查出的主键太多观察 rows 和回表次数使用覆盖索引或改写 SQL
写入变慢索引过多或大事务查看写入期间 IO 和锁等待控制索引数量,分批写入
深翻页慢LIMIT 偏移量过大查看扫描行数延迟关联或键集分页

还要提醒几个容易被忽略的细节。第一,字符串类型的索引列,在建表时最好设置合适的长度,比如VARCHAR(64),不要图省事全用VARCHAR(255)。太长的列会让索引体积膨胀,IO 开销变大。如果不得不索引很长的字符串,可以考虑使用前缀索引,比如INDEX(name(20)),牺牲一点区分度换回性能。第二,NULL值在索引中的处理方式容易让人迷惑:IS NULL和IS NOT NULL在某些条件下可以走索引,但查询条件写得不好照样全表扫。如果业务允许,尽量给字段加NOT NULL DEFAULT默认值,减少 MySQL 的判断负担。第三,频繁更新的字段不太适合做索引,因为每次更新都要改索引树结构。尤其在高并发场景,更新热点字段加索引会放大锁竞争。

9. 我个人的一点体会

做索引优化这些年,我最深的感觉是:很多慢查询问题根本不需要多高深的技术,缺的只是“回头看索引”的习惯。一条 SQL 跑慢了,先去 EXPLAIN,看看 MySQL 到底在干嘛,再对症下药。比起动不动就上各种重量级中间件,先把索引设计合理,成本低、见效快、也不容易留后患。

最后分享一个小技巧:我每次改完索引或者优化完慢 SQL,都会顺手记录到一个本地文档里,内容包括原 SQL、EXPLAIN 结果、优化的索引设计和最终耗时对比。三个月后回头看,这些记录能帮你发现很多业务模式的变化——比如某条 SQL 曾经优化得很好,但随着数据增长或查询条件变化,又开始变慢了。索引优化不是一劳永逸的,它是个持续迭代的过程,而记录恰恰是这个过程中最容易被忽略但最有价值的一环。

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

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

立即咨询