说实话,提到 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 通过采样页的方式估算基数,数据大量增删后,统计信息可能没有及时刷新。
遇到这种情况,我的处理顺序是:
- 先看数据量和统计信息时间,判断是否过期。
- 执行
ANALYZE TABLE orders;更新统计信息。 - 重新
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,也会产生比较大的负载,可能在主从复制或业务高峰期造成问题。
我的经验是分步走:
- 在业务低峰期操作。
- 先创建索引文件,比如用
CREATE INDEX并指定算法。 - 观察主从延迟和数据库负载。
- 如果延迟过高,就等待或分批处理。
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 已经替你解决了很多底层问题,但如何从一堆可能的执行路径里挑出最快的那个,靠的还是对数据结构、统计信息和业务场景的理解。希望这份心得能帮你在实际项目里少走一些弯路。