☰
2025 MySQL索引优化实战:从EXPLAIN到覆盖索引与索引失效排查
2026/10/22 5:27:04 网站建设 项目流程

说实话,提到 MySQL 索引,网上能搜到的东西一大把,但大部分都是老掉牙的八股文,背一堆“最左前缀”“覆盖索引”的名词就结束了。真正到了 2025 年,如果你还按十年前那套规则建索引,很容易被新版本优化器的新行为、新特性打脸。我最近重新梳理了一套 MySQL 索引使用流程,从 EXPLAIN 到在线加索引踩了不少坑,这里把最有价值的部分整理出来。

2025 版 MySQL 索引使用技巧

1. 2025 年索引设计先想清楚这几件事

1.1 B+ 树仍然在默默决定一切

不管 MySQL 的版本号涨到多少,InnoDB 引擎下索引的物理结构还是那棵 B+ 树。很多开发者对 B+ 树的理解停留在“快”这个层面,但真正影响使用技巧的,是 B+ 树的几个底层事实:

  • 叶子节点存放完整数据记录,或者说指向主键的指针,数据页之间通过双向链表连接。
  • 聚簇索引的叶子节点就是整行数据;二级索引的叶子节点存的是主键值和索引列。
  • 每一页默认 16KB,索引列越长,页里能放的键值越少,树的高度越高,扫描的路径就越长。

这意味着同一个查询,单列索引和联合索引的叶子节点布局可能差很多。索引设计的底层原则没有变:用尽量少的空间、尽量短的键值,把要查的数据尽量压缩在更少的页里。

2025 年很多人忽视一点:新硬件环境下,磁盘顺序读的速度大幅提升,但随机读的提升有限。所以“回表”仍然是个昂贵的动作。哪怕你的查询命中了二级索引,如果每个匹配行都要回主键扫描一次,性能照样上不去。这也是为什么我后面会把“覆盖索引”单独拿出来讲。

1.2 新版本优化器会让你“少操心”,但别什么都交给它

MySQL 8.0 以后,优化器的变化比很多人感知到的更大。它引入了成本模型、直方图、索引跳跃扫描等功能,也让很多以前“必须靠人肉调 SQL”的场景变成了自动行为。

但这不代表你可以乱建索引。优化器的判断依据是统计信息,统计信息不是实时的,而是采样估算出来的。如果表的基数变化剧烈,优化器可能选错索引。我见过不止一次,一个明明该走 A 索引的查询,因为某天数据量突增,统计信息还没更新,结果走了另一个效率更差的索引。

所以在 2025 年的实操里,我的习惯是这样:

  • 先看统计信息是否及时,必要时主动ANALYZE TABLE。
  • 用EXPLAIN确认执行计划,别想当然。
  • 对关键 SQL 做回归测试,不能因为这次跑得快就认为永远跑得快。

新版本还引入了更多执行策略,比如某些场景下用哈希关联替代嵌套循环,这些策略不是靠索引能直接解决的。索引的使用技巧,本质上是帮优化器降低估算成本,让它在多个可选路径里选出最快的那条。

1.3 先考虑业务场景,再谈索引技巧

很多 DBA 和开发容易犯一个毛病:不看 SQL,直接凭“感觉”建索引。看到一个WHERE字段就加索引,看到一个排序字段又加索引,最后表上的索引一大堆,写入的时候每个索引都要维护,写入性能越来越差。

索引不是免费的,每个索引都是一份额外的存储结构,插入、更新、删除时都需要同步维护。一张表上有五六个索引,写入性能下降得非常明显。2025 年做索引设计,应该先分清楚场景:

  • OLTP 系统:单次查询数据量少,追求低延迟,索引要精准,尽量覆盖高频 SQL。
  • OLAP 系统:查询范围大,扫描为主,索引的作用有限,更多依赖分区、列存储或外部数仓。
  • 混合负载:要考虑读写比例的平衡,不能只盯着慢查询优化。

我一般建议先收集半个月到一个月的高频 SQL,按照执行次数排序,再对 Top 10 的 SQL 做索引优化。为了一条一天只跑一次且耗时 3 秒的报表 SQL 去加一个冗余索引,结果影响每秒几百次写入的 OLTP 主链路,这种操作不太划算。

2. 从 EXPLAIN 开始读懂索引执行计划

2.1 先学会看 type 和 key

很多人会用EXPLAIN,但不知道重点看什么。一条 SQL 的执行计划里,最关键的字段是type和key。

type表示访问类型,从好到差大致是:

type 值含义是否走索引
system系统表,通常只有一行最优
const主键或唯一索引等值查询最优
eq_ref联表时被驱动表使用唯一索引很好
ref普通二级索引等值查询好
range索引范围扫描可以
index扫描整个索引树一般
ALL全表扫描需要避免

key表示最终选择的索引,key_len表示使用的索引长度。如果key为 NULL,基本就是没有用上索引。

实际排查时,我会先看type。如果一条高频查询的type是ALL,那第一反应就是有没有合适的索引。如果type是ref或range,再往下看有没有回表、有没有排序、有没有临时表。

举个简单的例子:

EXPLAIN SELECT order_id, user_id, amount FROM orders WHERE user_id = 1024 AND status = 'PAID';

如果结果里type=ref,key命中了idx_user_status,说明联合索引生效。如果key只命中了idx_user,key_len只有user_id的长度,那说明status没有用上索引里的第二个字段,这种情况需要检查联合索引字段顺序或 SQL 写法。

2.2 用 EXPLAIN ANALYZE 看清真实耗时

MySQL 8.0 之后提供了EXPLAIN ANALYZE,它能真实地执行 SQL,并返回每一步的耗时、行数、循环次数。这个工具比单纯看EXPLAIN直观得多,但很多人还不习惯用。

用法很简单:

EXPLAIN ANALYZE SELECT order_id, user_id FROM orders WHERE user_id = 1024 AND status = 'PAID';

返回结果里会有类似这样的执行信息:

-> Index lookup on orders using idx_user_status (user_id=1024, status='PAID') (cost=1.23 rows=3) (actual time=0.032..0.047 rows=18 loops=1)

这里最有价值的是actual time和actual rows,它们告诉你真实执行时间和真实返回行数。如果某个节点的cost很小但actual time很大,就要警惕是不是统计信息严重失真,或者有没有锁等待。

我建议把EXPLAIN ANALYZE作为排查慢查询的第二步,第一步仍然是看EXPLAIN里有没有全表扫描和索引失效。因为它会真实执行 SQL,线上数据量大的时候不要随便对全表大范围查询直接跑,可以先加过滤条件,或者放到备库上执行。

3. 索引创建的实操技巧

3.1 单列索引到底该建在哪些字段上

一个最基础的问题是:什么字段值得建单列索引?我的判断标准很简单,按优先级排序:

  • 经常出现在WHERE条件里的等值查询字段。
  • 经常用于JOIN连接的字段。
  • 经常用于ORDER BY或GROUP BY的字段。
  • 区分度高的字段,比如订单号、用户 ID、手机号,这种字段一个值能过滤掉绝大部分数据。

但要注意,区分度高并不等于一定要建索引。像“性别”这样的字段,区分度极低,加索引反而没有意义。优化器看到一个能过滤 50% 数据的索引,也不会选它,还不如全表扫描快。

实际操作里,我会先用一个简单的统计来估算区分度:

SELECT COUNT(DISTINCT column_name) / COUNT(*) AS selectivity FROM table_name;

如果选择性低于 20%,一般不建议单独建索引。如果是唯一性很强的字段,比如主键、唯一业务编号,单列索引的效果会非常明显。

还有一个容易被忽略的点:字段长度。如果对一个VARCHAR(255)的字段建索引,并且实际存储内容很长,索引页会非常大。这类字段可以通过前缀索引来减少索引空间:

CREATE INDEX idx_email_prefix ON users(email(20));

前缀索引会在索引里只存字段前 20 个字符,占用空间更小,查询速度更快。但注意,前缀索引不能用于覆盖索引和某些排序场景,所以使用时要权衡。

3.2 联合索引字段顺序的黄金法则

联合索引的顺序问题是 MySQL 索引面试题里的常客,但在真实项目里同样非常关键。联合索引的本质是多个字段共同构成一棵 B+ 树,排序规则先按第一个字段,再按第二个字段,以此类推。

因此,最左前缀原则没有失效,也不能失效。WHERE条件里如果不用第一个字段,联合索引基本帮不上忙,除非 MySQL 8.0 的跳跃扫描能够覆盖的场景,但它有严格限制,不能当作常规依赖。

我的经验是,联合索引字段顺序按以下规则排列:

  • 等值查询的字段放前面。
  • 范围查询的字段放后面。
  • 区分度高的字段不一定非要放最前面,要看它是不是等值条件。
  • 尽量让查询能用上更多索引字段,减少回表。

举例,假设有一个订单表,经常做下面这个查询:

SELECT * FROM orders WHERE status = 'PAID' AND created_at BETWEEN '2025-01-01' AND '2025-01-31';

这里status是等值条件,created_at是范围条件。联合索引应该建(status, created_at),而不是(created_at, status)。因为前者可以通过status快速定位,再在索引内按created_at范围扫描;后者只能通过created_at范围扫描,再过滤status,索引利用率反而低。

还有一种情况很常见:多个等值条件,顺序怎么排?我一般建议把区分度更高的字段放前面。比如user_id和status都是等值查询,user_id选择性更高,就放前面。这样索引树的分支可以更早收敛,扫描的叶子节点更少。

3.3 覆盖索引:一个被低估的杀手锏

覆盖索引的意思是,查询需要的所有列都包含在同一个二级索引中,查询过程不需要回表查聚簇索引。它是最能直接降低查询延迟的技巧之一。

看这个例子:

SELECT order_id, user_id, amount FROM orders WHERE user_id = 1024;

如果只用idx_user_id(user_id),执行计划会是:先在二级索引里找到user_id=1024的主键值,再回表查order_id和amount。如果改成:

CREATE INDEX idx_user_amount ON orders(user_id, order_id, amount);

同样一条查询,所有需要的字段都能从索引里拿到,完全不需要回表。执行计划里的Extra字段会显示Using index,代表覆盖索引生效。

很多开发者觉得覆盖索引会占用太多空间,所以很少用。但实际上,把高频 SQL 里 SELECT 的字段放进索引里,往往比增加更多冗余索引收益更大。尤其是在查询返回字段固定、数据量大的表上,覆盖索引能让一次范围扫描的成本大幅下降。

实操中要注意:覆盖索引不适合字段非常多的情况。如果 SELECT 里有十几列,硬塞进索引会让索引页膨胀,反而降低效率。这种情况不如减少字段,或者让查询只返回必要列。

3.4 函数索引和降序索引的 2025 年正确用法

MySQL 8.0 开始正式支持函数索引,这个特性太实用了。以前遇到WHERE DATE(created_at) = '2025-01-01'这种写法,索引基本失效,因为函数处理后和索引里的原始值无法匹配。现在可以这样建:

CREATE INDEX idx_created_date ON orders((DATE(created_at)));

这样MySQL就能在索引里存储DATE(created_at)的计算结果,查询时直接匹配,不用全表扫描。函数索引特别适合处理经常按日期、按月统计或按大小写匹配的场景。

降序索引在 MySQL 8.0 里也得到了更好的支持。以前索引默认都是升序排列,如果查询里同时需要ASC和DESC排序,排序操作可能无法直接利用索引方向。现在可以明确创建:

CREATE INDEX idx_user_created_desc ON users(user_id ASC, created_at DESC);

这样当查询是WHERE user_id = ? ORDER BY created_at DESC时,索引可以直接按倒序扫描,不需要额外排序。

不过需要注意的是,函数索引和降序索引都不是万能的。函数索引会让列上的函数计算落到索引维护层,写放大更明显;降序索引在单字段查询场景下收益不大,只有复合排序场景才值得考虑。

4. 最容易被坑的索引失效场景

4.1 隐式类型转换:最隐蔽的索引杀手

这是一个看似很小、实际非常容易踩的坑。如果表字段是VARCHAR,但 SQL 里传入的是数字,MySQL 会自动做隐式类型转换,导致字段上的索引失效。

SELECT * FROM users WHERE phone = 13800138000;

如果phone字段是VARCHAR,这个查询里13800138000是数字,MySQL 会把字段转换为数字再比较,索引上的VARCHAR值无法直接匹配,于是走全表扫描。正确做法是把参数写成字符串:

SELECT * FROM users WHERE phone = '13800138000';

反过来,如果字段是数字,查询里传了字符串,影响相对小一些,但也可能产生隐式转换。最好的方式是统一代码里传参的类型,让数据库拿到的参数永远和字段类型一致。

这个坑特别隐蔽,因为数据量小的时候性能差异不大,一旦表数据到了几千万行,一条全表扫描就能把数据库拖垮。

4.2 LIKE 前导通配符:仍然不能靠索引优化

WHERE name LIKE '%张%'这种模糊查询,即使name字段有索引,也无法利用索引快速定位,因为以%开头意味着无法确定扫描起点。MySQL 只能全索引扫描或全表扫描。

如果是WHERE name LIKE '张%',也就是前缀模糊查询,索引是可以使用的。因为 B+ 树可以按前缀定位到第一个匹配值,再顺序扫描。

业务里如果频繁需要%关键词%这种搜索,建议不要硬靠 MySQL 索引解决,而是考虑全文索引、搜索引擎或者专业的文本检索组件。MySQL 的全文索引适合简单场景,但数据量和查询复杂度上来之后,还是应该交给更合适的工具。

4.3 OR、IN 和范围条件要小心

在 MySQL 里,OR是很容易让索引失效的条件。比如:

SELECT * FROM orders WHERE user_id = 1024 OR status = 'PAID';

如果user_id和status各有单列索引,MySQL 可能用索引合并把两个结果合并,然后再回表去重。这种方式在小数据量下还可以,但在大规模数据下常常性能不稳定,而且索引合并不是总能被优化器选中。

更推荐的做法是改成 UNION 或拆分查询,让每个分支都能独立高效地走索引:

SELECT * FROM orders WHERE user_id = 1024 UNION SELECT * FROM orders WHERE status = 'PAID';

当然,如果两个字段组合频率很高,更直接的办法是建联合索引,让type=range或type=ref优雅地解决问题。

IN的情况稍微好一些,MySQL 对IN列表有优化,能走索引。但IN的列表非常长时,执行计划可能会退化成索引范围扫描,性能也能接受,只是要控制列表长度。

范围查询如<、>、BETWEEN也可能会让联合索引后续字段失效,这也解释了为什么范围字段要放在联合索引的后面。

4.4 ORDER BY 和 GROUP BY 没用好索引排序

排序操作如果不走索引,MySQL 就要额外做filesort,也就是把结果集在内存或磁盘里排序一遍。这对性能的影响很大,尤其是结果集大的时候。

想让ORDER BY走索引,排序字段必须和索引的字段顺序、排序方向匹配。比如联合索引(user_id, created_at),下面这个查询可以避免额外排序:

SELECT user_id, created_at FROM orders WHERE user_id = 1024 ORDER BY created_at;

因为索引本身就是按user_id后按created_at排序的。但如果改成:

SELECT user_id, created_at FROM orders WHERE user_id = 1024 ORDER BY created_at DESC;

升序索引需要反向扫描,部分场景下也能利用索引,但方向不一致时需要额外处理。所以建索引之前,最好把业务里最常用的排序方向也考虑进去。

GROUP BY类似,分组字段也建议包含在索引里。如果想统计每一天的订单量,GROUP BY DATE(created_at)这种写法因为对字段做了函数运算,索引不一定能直接用,建议改成函数索引或加一个生成列。

4.5 统计信息过期:优化器选错索引

索引本身存在,但优化器选择了全表扫描,这种情况很多时候和统计信息有关。InnoDB 通过采样页的方式估算基数,数据大量增删后,统计信息可能没有及时刷新。

遇到这种情况,我的处理顺序是:

  1. 先看数据量和统计信息时间,判断是否过期。
  2. 执行ANALYZE TABLE orders;更新统计信息。
  3. 重新EXPLAIN看执行计划是否恢复。

如果执行计划仍然不对,可以通过FORCE INDEX临时指定索引,但这只是应急手段,不能长期依赖。长期依赖FORCE INDEX很可能是因为 SQL 写法有问题,或者索引设计不合理,需要从根本上调整。

5. 监控与索引生命周期管理

5.1 怎么知道一个索引到底有没有被用过

很多表上积累了各种历史索引,没人敢删,因为怕影响业务。其实 MySQL 提供了查询索引使用情况的能力,可以找出那些从来没被用过的索引。

通过系统库可以查到当前实例的索引使用统计。我常用的查询是这个:

SELECT OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME FROM sys.schema_unused_indexes;

这个结果直接列出未被使用的索引。不过要注意,它统计的是实例启动后的使用情况,如果实例刚重启,数据不完整,建议运行稳定一段时间后再看。

另外还可以看单独的索引使用状态:

SELECT * FROM performance_schema.table_io_waits_summary_by_index_usage WHERE OBJECT_SCHEMA = 'your_db' AND OBJECT_NAME = 'orders';

通过这个表能看到每个索引的读和写次数,用来判断哪些索引属于“只有写入维护成本、没有查询收益”的冗余索引。

5.2 大表加索引别再直接 ALTER

在线 DDL 在 MySQL 8.0 里已经比老版本好用很多,但千万不要在几千万行的大表上直接跑一条ALTER TABLE ADD INDEX。就算它支持在线 DDL,也会产生比较大的负载,可能在主从复制或业务高峰期造成问题。

我的经验是分步走:

  1. 在业务低峰期操作。
  2. 先创建索引文件,比如用CREATE INDEX并指定算法。
  3. 观察主从延迟和数据库负载。
  4. 如果延迟过高,就等待或分批处理。

MySQL 8.0 的语法示例:

ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at), ALGORITHM = INPLACE, LOCK = NONE;

ALGORITHM=INPLACE表示尽量不复制整表数据,LOCK=NONE表示尽量不阻塞读写。但实际是否真的全程无锁,取决于表结构、索引类型和并发写入情况。

更稳妥的方案是,在从库先建立索引,再通过主从切换把新索引提升为主库。当然,这样操作前要和团队充分沟通,做好回滚预案。

5.3 定期清理冗余索引

冗余索引主要有两类:

  • 完全重复的索引,比如同字段建了单列索引和联合索引,联合索引的前缀字段和单列索引完全重合。
  • 很少使用或从未使用的索引。

完全重复的索引可以直接删。比如有idx_user_id(user_id)和idx_user_status(user_id, status),前者完全可以删掉,因为后者已经覆盖了以前者为前缀的等值查询场景。

很少使用的索引需要谨慎。如果某条报表 SQL 一个月才跑一次,但索引占用了很大空间,我会建议评估后删除。如果确实需要,可以临时建上去再删。MySQL 8.0 支持不可见索引,这给了一个很好的过渡方案:

ALTER TABLE orders ALTER INDEX idx_user_status INVISIBLE;

把索引设为不可见后,优化器不会选择它,但索引本身还在,数据也还在维护。观察几天业务无异常,再彻底删除。这个方法非常安全,推荐大家使用。

5.4 常见问题速查表

表现可能原因排查方向
EXPLAIN 显示 type=ALL没有合适索引或索引失效检查 WHERE、JOIN、ORDER BY 字段
key 为 NULL优化器没有选索引检查统计信息、索引可见性、隐式转换
Extra 出现 filesort排序未走索引调整排序字段或联合索引顺序
Extra 出现 temporary分组/去重使用临时表检查 GROUP BY 字段是否在索引中
业务高峰写入变慢索引过多用 sys.schema_unused_indexes 找冗余索引
查询突然变慢统计信息过期或数据量突增更新统计信息,重新 EXPLAIN

6. 索引优化的最后一点个人心得

我在实际项目里踩过最多坑的,不是不知道索引怎么建,而是不知道什么时候该删索引。很多团队只负责加索引,不负责维护索引,结果表上一堆“历史遗留索引”成了写入瓶颈。

推荐一个我自己的操作习惯:每建一个索引,都顺手记录一下业务场景和预期收益。后续巡检时打开监控,如果这个索引在三个月内没有任何查询命中,就标记为候选删除,用不可见索引做灰度观察,确认无问题后物理删除。

另外一个心得是:不要迷信某一个“最佳实践”。索引优化永远和业务查询强相关。同样一张订单表,电商场景和财务场景的高频查询完全不同,网上抄来的索引方案不能直接用,必须拿真实 SQL 跑一遍执行计划,再看覆盖索引能不能进一步优化。

索引优化的过程,其实是一个不断做减法、再针对关键路径做加法的过程。2025 年的 MySQL 已经替你解决了很多底层问题,但如何从一堆可能的执行路径里挑出最快的那个,靠的还是对数据结构、统计信息和业务场景的理解。希望这份心得能帮你在实际项目里少走一些弯路。

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

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

立即咨询