MySQL 用没用索引这件事,大概是开发同学问得最多的问题之一。很多人上线前建了一堆索引,结果一条慢 SQL 把数据库拖到报警,一查才发现索引压根没走。更常见的是,在测试环境数据量小,SQL 跑得飞快,没人意识到索引没生效;一上生产,几百万行数据压过来,问题立刻爆出来。这篇文章我就把“如何判断 SQL 是否用到了索引”这件事讲透,核心工具就是 EXPLAIN。读完你能看懂执行计划的每一列,能自己判断索引有没有生效、失效原因是什么,以及遇到慢查询时该怎么一步步排查。
这篇文章适合所有写 SQL 的开发、DBA 和运维同学,尤其适合那些建了索引却不确定查询是否真正用它的人。我尽量用实际案例说话,所有示例 SQL 你都可以直接在自己环境里跑一遍验证。
1. 判断 SQL 是否使用索引的第一步:EXPLAIN 基础用法
1.1 为什么看索引不能只看表结构,要看执行计划
很多人有个误区:以为给字段建了索引,查询就一定会走索引。实际情况要复杂得多。索引就像书的目录,目录建好了,但你是不是真的按目录去翻,需要查一下才知道。MySQL 到底怎么执行这条 SQL,是扫全表还是走索引,由优化器决定。优化器会根据表的数据量、索引的区分度、统计信息、查询条件等等因素做权衡。
我见过不少案例,明明字段上有索引,查询条件也写了,结果 EXPLAIN 一看 type 列是 ALL,全表扫描。典型的场景包括:索引列上做了函数运算、发生了隐式类型转换、使用了前置模糊匹配,再或者优化器认为小表全表扫描比走索引回表更快。这些坑后面我都会详细展开。
所以,判断一条 SQL 是否用到索引,不能靠猜,也不能只看有没有索引,必须看执行计划。执行计划是优化器给出的最终执行方案,它告诉 MySQL“我是怎么干这件事的”。
1.2 EXPLAIN 到底怎么用,输出长什么样
EXPLAIN 的使用极其简单,在任何 SELECT 语句前面加 EXPLAIN 关键字就行。它不会真的执行这条 SQL,只是让优化器计算出一个执行方案给你看。
EXPLAIN SELECT * FROM user WHERE username = 'zhangsan';我准备了一张简单的用户表,用来做接下来的所有演示:
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `age` INT DEFAULT NULL, `status` TINYINT DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, `create_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_username_age` (`username`, `age`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表里有一个主键索引 id,一个复合索引 idx_username_age(username, age)。插入几条测试数据后,执行上面的 EXPLAIN 语句,输出类似这样:
mysql> EXPLAIN SELECT * FROM user WHERE username = 'zhangsan'\G *************************** 1. row *************************** id: 1 select_type: SIMPLE table: user partitions: NULL type: ref possible_keys: idx_username_age key: idx_username_age key_len: 201 ref: const rows: 1 filtered: 100.00 Extra: NULL 1 row in set (0.00 sec)注意,我加了\G让输出竖排显示,这样字段很多的时候不会乱。这个输出里面每一行都是一个执行步骤,每个字段都有特定含义。新手第一次看这堆字段容易懵,其实核心只需要关注四列:type、key、rows、Extra。抓住这四列,90% 的问题都能看出来。
2. 看懂 EXPLAIN 输出的关键列:type、key、rows、Extra
2.1 type 列:访问类型的优劣排序,到底什么才算有效的索引利用
type 列表示 MySQL 找到所需行使用的方式,也反映了这条 SQL 的效率,强烈建议把这个优先级背下来:
system > const > eq_ref > ref > range > index > ALL
这个排序从左到右,效率从高到低。下面逐个说:
- system:表只有一行,是 const 类型的特例,基本见不到。
- const:主键或唯一索引等值查询时出现,最多返回一行。比如用主键 id 查,MySQL 能直接定位到那一行,这是最快的。
- eq_ref:连接查询中,被驱动表通过主键或唯一索引等值匹配,每一行最多匹配一条。多表 join 时看到它是好现象。
- ref:非唯一索引等值匹配,比如刚才查 username='zhangsan',username 有索引但不是唯一索引,type 就是 ref。
- range:索引范围扫描,比如
id > 100、create_time BETWEEN ... AND ...,用到了索引,但扫描的是一个范围。 - index:全索引扫描,遍历整个索引树。比全表扫描好一点,因为索引通常比数据页小,但也不算高效。
- ALL:全表扫描,这个就是最不愿意看到的了,等于把整张表从头翻到尾。
实践中的判断标准很简单:type 至少要达到 range 级别,才算是有效利用了索引。ref 是非常不错的水平,const 和 eq_ref 是理想状态。看到 ALL,基本可以断定这条 SQL 需要优化;看到 index,需要进一步确认是不是真的没有过滤条件,或者覆盖索引也救不回来。
2.2 key 和 key_len:确认列表索引名,以及索引到底覆盖到哪一列
EXPLAIN 输出有两列跟索引名相关:possible_keys 和 key。possible_keys 列出的是优化器认为可能用到的索引,key 则是优化器最终实际选择使用的索引。
这里有个常见的坑:仅看 key 列不为 NULL 还不够。比如复合索引 idx_username_age(username, age),如果 SQL 是WHERE username = 'zhangsan',key 确实用了 idx_username_age,但 key_len 的长度只包含 username 这一列。这说明只用了复合索引的左侧第一列,age 列实际上没参与索引定位。
key_len 是索引列的最大字节长度,可以用来反推索引到底用到了几个字段。不同字段类型的长度计算规则大致是:
- 字符类型:字符数乘以字符集最大字节数。utf8mb4 下,VARCHAR(50) 对应于 50 x 4 = 200 字节。
- 变长字段 VARCHAR 额外加 1~2 字节记录长度(这里 VARCHAR(50) 最大 200 字节,加 1 字节)。
- 字段允许为 NULL,额外再加 1 字节。
- 整数类型:INT 占 4 字节,BIGINT 占 8 字节,可为空同样加 1 字节。
回到我们的表,username 是 VARCHAR(50) NOT NULL,age 是 INT NULL。那么:
- 只用到 username 时:key_len = 200 + 1 = 201
- 同时用到 username 和 age 时:key_len = 200 + 1 + 4 + 1 = 206
所以看到 key_len 是 201 还是 206,就能知道复合索引是否用到了完整的两列。这是排查复合索引问题时非常实用的技巧。
2.3 Extra 里那些让你又爱又恨的关键词
Extra 这一列的信息量很大,经常藏着决定性的线索。它不像 type、key 那么直观,但往往能揭示 MySQL 具体做了什么额外操作。几个高频词我整理成了一张速查表:
| Extra 内容 | 含义 | 优化方向 |
|---|---|---|
| Using index | 覆盖索引,查询所需字段都在索引中,无需回表 | 好现象,尽量保持 |
| Using where | 存储引擎返回后,Server 层又对数据进行了过滤 | 考虑是否缺索引 |
| Using index condition | 索引条件下推(ICP),部分过滤条件下推到存储引擎 | MySQL 5.6+ 默认优化,正常现象 |
| Using filesort | 需要额外排序,不是文件排序,但很影响性能 | 检查 ORDER BY 字段能否走索引 |
| Using temporary | 使用了临时表,常见于 GROUP BY、DISTINCT、子查询 | 尽量用索引覆盖分组排序 |
| Using MRR | 使用多范围读取优化 | 好现象 |
| Backward index scan | 反向扫描索引 | MySQL 8.0 对 DESC 排序的优化 |
这里我想重点提醒两个词。第一个是 Using filesort,很多新手以为它表示性能很糟,其实它只是说排序没法利用索引,MySQL 要额外把数据复制到排序缓冲区处理。一旦看到它,就要检查 ORDER BY 的字段顺序是否和索引一致。第二个是 Using index,这是覆盖索引的标志,是多少人求之不得的优化状态。我见过一句话总结得很到位:Using index 是“查完索引就完事了”,没有它往往意味着还要回表拿其他字段。
3. 亲手演练:通过三个典型场景看索引是否生效
3.1 场景一:主键查询 vs 普通字段查询
先看主键查询,这是最简单的场景:
mysql> EXPLAIN SELECT * FROM user WHERE id = 1\G *************************** 1. row *************************** id: 1 table: user type: const possible_keys: PRIMARY key: PRIMARY key_len: 4 rows: 1 Extra: NULLtype 是 const,key 是 PRIMARY,key_len 是 4(INT 主键的长度),说明这次查询通过主键精确定位到了唯一一行,这是最优路径。
再看一个普通字段但没有索引的情况,我们把 phone 字段查一下,phone 没有建索引:
mysql> EXPLAIN SELECT * FROM user WHERE phone = '13812345678'\G *************************** 1. row *************************** id: 1 table: user type: ALL possible_keys: NULL key: NULL rows: 100 Extra: Using wheretype 是 ALL,key 是 NULL,rows 估算扫描 100 行。这足以说明这条查询是全表扫描。即使数据量小,也要明确它没走索引。
通过这个对比,你应该能体会到:索引到底有没有用,执行计划一目了然。
3.2 场景二:复合索引的最左前缀原则
这是最容易踩坑的地方。我们建立了 idx_username_age(username, age),这个索引能同时为 username 和 age 查询服务,但前提是必须遵循最左前缀原则:查询条件中必须包含最左侧的 username 列,索引才会被使用。如果跳过了 username 直接查 age,索引就用不上。
我们来实际验证一下。第一种情况,只查 username:
EXPLAIN SELECT * FROM user WHERE username = 'zhangsan';type 是 ref,key 是 idx_username_age,key_len 是 201,说明索引被使用了,但只用到了第一列。
第二种情况,同时查 username 和 age:
EXPLAIN SELECT * FROM user WHERE username = 'zhangsan' AND age = 23;key 依然是 idx_username_age,但 key_len 变成了 206,说明索引完整用到了两列。这种情况下,索引的过滤能力更强,效率更高。
第三种情况,只查 age:
EXPLAIN SELECT * FROM user WHERE age = 23;type 很可能是 ALL,key 为 NULL。虽然 age 是复合索引的第二列,但因为没有以 username 开头,索引直接失效。这就是最左前缀原则的威力,也是很多人建了复合索引却发现查询没用上的常见原因。
我建议你记住一个判断口诀:复合索引就像一本按“姓氏 + 名字”排列的通讯录,你只报名字让管理员找人,管理员只能从头翻,没任何捷径。
3.3 场景三:覆盖索引能帮你省掉多少回表成本
InnoDB 的二级索引(非主键索引)叶子节点存储的是索引列加上主键值。如果查询所需要的字段都能在索引里找到,那 MySQL 查完索引直接返回结果,根本不需要回表去数据页里取其他字段,这种情况 Extra 会显示 Using index。
举个例子:
EXPLAIN SELECT username, age FROM user WHERE username = 'zhangsan';因为 username、age 都在 idx_username_age 这个索引里,查询压根不用去主键索引里取别的列,Extra 会显示 Using index。
而如果是SELECT * FROM user WHERE username = 'zhangsan',需要查询的字段里包含 phone、status、create_time 等不在索引中的列,MySQL 就必须拿着主键 id 回表,找到完整的数据行返回。
覆盖索引的价值在于:它可以大幅减少回表次数,尤其在大数据量场景下,回表意味着随机的磁盘 I/O,是性能杀手。所以,一个非常实用的优化思路是,针对高频查询,尝试把 SELECT 的字段包含到索引中,做成覆盖索引。但也要注意,索引不是越多越好,每增加一个索引都会拖慢写入速度。
4. 索引失效的常见场景排查,这是慢 SQL 的根源
4.1 函数运算、隐式转换和模糊匹配:索引杀手三件套
如果说执行计划是看“有没有用索引”,那接下来就要讨论“为什么没用索引”。根据我长期的排查经验,索引失效的案例九成以上可以归结到下面几类。
第一类,对索引列使用函数。比如在 create_time 字段上建了索引,查询写成了:
EXPLAIN SELECT * FROM user WHERE DATE(create_time) = '2024-01-01';我们在 create_time 上套了 DATE 函数,MySQL 无法直接利用索引进行比较,只能全表扫描。正确的写法是改成范围查询:
EXPLAIN SELECT * FROM user WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';第二类,隐式类型转换。phone 字段建了索引,但它是 VARCHAR 类型,写 SQL 时给了数字:
EXPLAIN SELECT * FROM user WHERE phone = 13812345678;MySQL 会把字符串列和数字比较时,默认把字符串转换为数字,导致索引列上发生隐式函数运算,索引就失效了。解决办法是严格按照字段类型传参,写成phone = '13812345678'。
第三类,LIKE 前置模糊匹配。WHERE username LIKE '%zhang%'这种写法,因为通配符在前面,索引定位无从谈起,会全表扫描。如果业务确实需要这种模糊查询,要么接受全表扫描,要么考虑全文索引、ES 等外部方案。如果是LIKE 'zhang%',则可以利用 range 扫描。
除了上面三类,OR 连接的查询也很典型。比如:
EXPLAIN SELECT * FROM user WHERE username = 'zhangsan' OR status = 1;如果 status 没有索引,MySQL 在优化时往往只能选择全表扫描。更稳妥的做法是使用 UNION ALL 拆分,或者确保 OR 两侧的字段都有索引。
4.2 优化器为什么“有索引不用”:统计信息、数据分布和回表成本
有时候 SQL 写法没有问题,索引也存在,但 EXPLAIN 结果依然显示 ALL。这时候别急着怀疑人生,先想想优化器是“笨”,还是它觉得“没必要用索引”。
一个非常常见的情况是:表里只有几十条数据。优化器一算,全表扫描才几十行,走索引反而要额外访问索引树再回表,成本更高。它当然选择 ALL。这种情况在小表上天然会发生,不是索引失效,也不是优化器有 bug,你放一两百万行数据进去再看,逻辑可能就变了。
另一个常见原因是数据分布。比如在 status 字段上建了索引,但整张表 90% 的数据 status 都是 1。你查询WHERE status = 1,优化器评估后发现,用索引要扫 90% 的索引节点再回表,还不如直接全表扫,这样反而更快。
还有一个很容易被忽略的点:统计信息过期。如果表的数据量发生了大幅变化,但统计信息没及时更新,优化器可能基于过期的统计做了错误判断。这时候执行ANALYZE TABLE user;刷新统计信息,往往能解决问题。
如果排除了上述所有情况,你仍然认为走索引更好,可以临时尝试 FORCE INDEX 进行验证:
EXPLAIN SELECT * FROM user FORCE INDEX (idx_username_age) WHERE age > 20;这会强制优化器使用指定索引,对比一下强制前后的执行计划,能帮你理解优化器的选择依据,也可以用于临时压测。
4.3 一个可复用的排查流程和问题速查表
在实际工作中,我建议你遇到慢 SQL 时,按照下面这个流程来排查,这样最省时间:
- 先跑
EXPLAIN SELECT ...,看 type 是不是 ALL,key 是不是 NULL。 - 如果没走索引,检查 SQL 条件是不是对索引列做了函数、隐式转换、前置模糊、OR 连接。
- 检查查询条件是否满足最左前缀原则,复合索引第一列在不在 WHERE 里。
- 确认表的数据量,小表全表扫描可能是合理行为。
- 执行
ANALYZE TABLE刷新统计信息,再跑一次 EXPLAIN。 - 用 FORCE INDEX 对比确认优化器判断是否合理。
- 如果 SQL 写法正常但就是慢,考虑覆盖索引、拆分查询或者调整索引结构。
为了方便查阅,我把常见失效场景整理成了速查表:
| 失效场景 | 示例 | 处理方式 |
|---|---|---|
| 索引列使用函数 | DATE(create_time) = '2024-01-01' | 改写为范围查询 |
| 隐式类型转换 | phone = 13812345678 | 按字段类型传参 |
| LIKE 前置模糊 | username LIKE '%zhang%' | 换方案或接受全表扫描 |
| 复合索引违反最左前缀 | WHERE age = 23 | 调整索引顺序或补查第一列 |
| OR 连接非索引列 | username = 'a' OR status = 1 | 用 UNION ALL 拆分 |
| 统计信息过期 | 大量增删后估算 rows 严重偏差 | ANALYZE TABLE |
| 小表数据量太少 | 几十行全表扫描比索引走查还快 | 正常现象,无需处理 |
5. 进阶:select_type、filtered 与真实执行时间验证
5.1 select_type 和 filtered:识别子查询和连接问题的关键
前面讲到的字段可以应付大部分单体查询的场景,但如果 SQL 里有子查询、关联查询或 UNION,就需要额外关注 select_type 和 filtered 这两列。
select_type 表示查询的类型,常见的包括:
- SIMPLE:简单的 SELECT,没有子查询和 UNION。
- PRIMARY:最外层查询。
- SUBQUERY:子查询中的第一个 SELECT。
- DERIVED:派生表,即 FROM 后面的子查询。
- UNION:UNION 中第二个及之后的 SELECT。
如果 EXPLAIN 结果出现多条记录,每行代表执行计划中的一个步骤。你需要按 id 顺序,从小到大逐条看。当出现 DERIVED 时,通常意味着 MySQL 要把子查询结果物化成临时表再参与查询,这种性能开销不容忽视。很多情况下,用 JOIN 改写子查询或者用窗口函数,能有效减少这种问题。
filtered 是一个百分比,表示 InnoDB 返回给 Server 层的数据中,经过 WHERE 条件过滤后剩余行数的比例。它和 rows 列配合使用,可以估算最终返回的行数:rows x filtered / 100。如果 SQL 连接了大量数据但 filtered 只有百分之几,说明索引选择性差,或者连接条件有问题。
举个例子,如果 EXPLAIN 显示 rows 是 10000,filtered 是 1%,那说明最终可能只有 100 行是有效的,但 MySQL 为此扫描了 10000 行,这往往可以通过在过滤列上添加合适的索引来优化。
5.2 EXPLAIN ANALYZE 与 profiling:用真实数据验证索引效果
EXPLAIN 给出的 rows 是估算值,不是实际值。你可能会遇到一种尴尬:EXPLAIN 明明显示走了索引,但 SQL 实际执行还是很慢。这种情况说明问题不在“有没有用索引”,而在于索引使用之后仍然需要处理大量数据,或者某些统计因子和实际差异很大。
MySQL 8.0.18 及以上版本提供了 EXPLAIN ANALYZE,真正执行这条 SQL 并返回实际的执行时间和行数:
EXPLAIN ANALYZE SELECT * FROM user WHERE username = 'zhangsan';输出大致长这样:
-> Index lookup on user using idx_username_age (username='zhangsan') (cost=0.35 rows=1) (actual time=0.118..0.121 rows=1 loops=1)注意看 actual time 和 rows,这是真实执行后的数据。如果实际行数远大于估算 rows,那统计信息可能不准。如果 actual time 很大,就要继续往下追查回表成本或者其他开销。
对于 MySQL 8.0 之前的版本,也可以使用传统 profiling 手段:
SET profiling = 1; SELECT * FROM user WHERE username = 'zhangsan'; SHOW PROFILES; SHOW PROFILE FOR QUERY 1;这种方式能看到执行各阶段消耗的时间,帮助定位瓶颈到底是在 Sending data、Sorting result 还是其他阶段。不过说实话,这个功能在 8.0 之后已经被标记为废弃,新环境我更推荐直接用 EXPLAIN ANALYZE。
要说个人体会的话,我做 SQL 调优这些年,最大的感受是:判断是否走索引只是第一步,真正的难点在于理解优化器的决策逻辑。没有哪个索引进阶技能是看几个教程就会的,一定要在真实数据量、真实业务模型下反复验证。同一个 SQL,在数据分布不同的两张表上,执行计划可能完全不同。所以,生产环境出了问题,别指望靠经验拍脑袋,第一件事永远是跑一遍 EXPLAIN,用执行计划说话。