先别急着打开编辑器敲CREATE INDEX。我见过太多团队,Index建了几十个,慢查询该慢还是慢,最后DBA一查,一半索引根本没被用过,另一半还在拖慢写入速度。MySQL索引优化的核心从来不是“会不会建”,而是“知不知道什么时候不该建、建了之后怎么验证、失效之后怎么排查”。这篇文章不打算从B+树教科书讲起,而是直接围绕一条慢SQL从100ms恶化到2s的完整调优过程,把索引设计、联合索引踩坑、排序优化、失效场景排查这些事一次说透。如果你是刚接手业务系统的后端开发,或者正在准备MySQL面试,这篇应该能帮你省不少时间。
1. 索引为什么会失效:从一条慢SQL的完整排查链路说起
1.1 现场还原:一条曾经很快的查询突然变慢了
先交代一下背景。我接手过一个订单查询系统,核心表order_info将近800万行,早期业务量小的时候,下面这条SQL跑得飞快:
sql复制SELECT order_id, user_id, amount, status
FROM order_info
WHERE user_id = 20240001
AND create_time >= '2024-01-01 00:00:00'
ORDER BY create_time DESC
LIMIT 20;
线上反馈说是订单查询页越翻越慢,一开始几十毫秒,后来稳定在1.5秒到2秒。拿到慢查询日志一看,这条SQL的rows扫描行数已经涨到了30多万,type是ALL,也就是全表扫描。800万行的表全扫一遍,即便只取20条,也要把全部数据读进内存做过滤和排序,不慢才怪。
1.2 排查第一步:先看表结构和已有索引
很多人一上来就EXPLAIN看执行计划,但EXPLAIN只能告诉你“现在用了什么”,不能告诉你“应该建什么”。我习惯先看表结构,确认现状再分析问题。
sql复制SHOW CREATE TABLE order_info\G
表里已有的索引大概是这样的:
| 索引名 | 索引列 | 类型 |
|---|---|---|
| PRIMARY | order_id | 聚集索引 |
| idx_user_id | user_id | 二级索引 |
| idx_create_time | create_time | 二级索引 |
从索引设计角度看,这个表不是没有索引,而是索引建得很“孤立”。idx_user_id管用户过滤,idx_create_time管时间过滤和排序,两个索引各自为政,谁也没法同时满足这条SQL的过滤加排序需求。
1.3 排查第二步:EXPLAIN里的关键线索
执行计划长这样:
code复制id | select_type | table | type | key | key_len | rows | Extra
1 | SIMPLE | order_info | ALL | NULL | NULL | 8350000 | Using where; Using filesort
type=ALL说明优化器认为现有索引都不能有效缩小扫描范围;Using filesort说明排序也没走索引,数据先按条件过滤出来,再在内存或磁盘里做一次排序。这两点同时出现,基本可以断定:现有索引结构跟这条SQL的访问路径不匹配。
1.4 为什么单列索引组合起来没用
这里有个非常经典的误区:user_id和create_time分别建了单列索引,查询条件里同时用到了这两列,MySQL为什么不能两个索引一起用?
其实MySQL 5.0之后的版本支持Index Merge,优化器确实有可能把两个单列索引的结果做交集合并。但Index Merge生效有条件:两个索引各自过滤出来的结果集都足够小,合并代价低于全表扫描。当user_id = 20240001在这个表里对应几十万行时,优化器一算账,觉得还不如直接全表扫。
更关键的是,即使Index Merge能用,它对ORDER BY create_time DESC也毫无帮助,因为排序字段不在任何一个联合索引里,Using filesort照样会出现。这个案例真正需要的,是一个(user_id, create_time)的联合索引。
1.5 修复:一个联合索引解决过滤和排序
sql复制ALTER TABLE order_info
ADD INDEX idx_user_create (user_id, create_time DESC);
MySQL 8.0支持索引列上的降序定义,这里create_time DESC能让ORDER BY create_time DESC直接走索引顺序扫描,连排序都省了。改完再看执行计划:
code复制id | select_type | table | type | key | key_len | rows | Extra
1 | SIMPLE | order_info | ref | idx_user_create | 9 | 423 | Using index condition
rows从835万掉到423,查询时间稳定在20ms以内。
提示:如果你还在用MySQL 5.7,没法定义降序索引,那就建
(user_id, create_time)升序索引,然后让SQL改成ORDER BY create_time ASC,再用应用层倒序显示,或者干脆接受Backward index scan。5.7对升序索引的反向扫描也是可以优化的,但8.0的降序索引在混合排序场景下优势更明显。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 联合索引的列顺序:决定索引生死的设计细节
2.1 最左前缀原则是怎么工作的
联合索引(a, b, c)在B+树里的排列规则是:先按a排序,a相同再按b排序,b也相同再按c排序。这个规则决定了查询条件必须从左往右匹配,一旦跳过某一列,后面的列就无法用于索引定位。
举个例子,索引是(user_id, create_time, status):
WHERE user_id = 1 AND create_time > '2024-01-01':能用到两列,因为user_id是等值匹配,create_time是范围匹配,范围之后的status用不上,但前两列能精确定位。WHERE user_id = 1 AND status = 0:只能用到user_id这一列,因为跳过了create_time。WHERE create_time > '2024-01-01':完全用不上这个索引。
很多人背过最左前缀原则,但遇到实际问题还是会犯迷糊。这里有一个判断技巧:把联合索引看成一本电话簿,先按姓氏排列,再按名字排列。你知道姓氏和名字就能快速翻到目标页;只知道名字不知道姓氏,那只能从头翻到尾。
2.2 等值条件放前面,范围条件放后面
回到刚才的订单查询案例。假设现在查询需求变了,不止查一个用户,还要按订单状态过滤:
sql复制SELECT order_id, user_id, amount, status
FROM order_info
WHERE user_id = 20240001
AND status = 1
AND create_time >= '2024-01-01 00:00:00'
ORDER BY create_time DESC
LIMIT 20;
这种情况下索引列应该怎么排?
user_id和status都是等值匹配,create_time是范围匹配。联合索引的正确写法是(user_id, status, create_time),把等值条件的列放在最前面,范围条件的列放在最后面。因为等值条件可以直接定位到索引树的某个精确区间,范围条件只能在这个基础上继续扫描,如果范围列放在前面,后续的等值列就发挥不了精确定位的作用了。
2.3 区分度高的列该不该永远放前面
很多文章会说“区分度高的列放联合索引左边”,这个说法不准确。准确说法是:在都是等值条件的场景下,把区分度高的列放前面,可以帮助优化器更快地收缩扫描范围;但如果有一个列是范围查询,那不管区分度高不高,都要放在等值列后面。
还有一种情况需要特别注意:如果两个列都是等值条件,那顺序其实可以灵活调整,因为等值条件不论先后都能定位到同一个精确区间。真正影响索引选择的是“哪个列能过滤掉更多无效数据”。比如user_id在这个表里能过滤掉99.9%的数据,status只能过滤掉一半,那user_id放前面更合理。
2.4 冗余索引的危害:为什么说索引不是越多越好
这个案例里原来的idx_user_id和idx_create_time在新建联合索引之后,实际上已经失去了存在价值。idx_user_id是idx_user_create的最左前缀,完全被覆盖;idx_create_time虽然不在idx_user_create的前缀里,但业务上已经很少单独按时间查订单了。
每个索引都对应一棵B+树,每次插入、更新、删除都要同步维护所有索引。索引越多,写入放大越严重。曾经有个项目在订单表上堆了11个索引,每天的批量导入任务从10分钟涨到40分钟,删掉5个冗余索引之后才恢复。删索引之前用sys.schema_unused_indexes查一下哪些索引从没被用过,心里就有数了。
3. 排序与分页场景的索引利用:filesort和深翻页的真实案例
3.1 Using filesort到底在做什么
Using filesort里的filesort不代表一定用到了磁盘文件,它指的是MySQL需要额外执行一次排序操作,而不是直接从索引顺序取数。排序可以在内存中完成(sort_buffer_size足够的话),也可能临时转储到磁盘。但无论哪种,它都意味着一次额外的计算开销。
来看一个典型场景:
sql复制SELECT order_id, user_id, amount
FROM order_info
WHERE user_id = 20240001
ORDER BY create_time DESC
LIMIT 20;
如果只有(user_id)索引,MySQL的流程是:走idx_user_id找到user_id = 20240001的所有主键,回表读取完整行,把create_time和order_id放入排序缓冲区,排序后取前20条。这个流程里,排序的输入集大小等于该用户的所有订单数,而不仅仅是最终返回的20条。
如果改成(user_id, create_time)联合索引,流程就完全不同了:索引本身就是按user_id再按create_time排好序的,MySQL直接按索引顺序扫描,取到20条就停,完全不需要排序。这就是为什么联合索引能同时优化WHERE和ORDER BY。
3.2 ORDER BY方向不一致的坑
MySQL 8.0之前,一个升序索引只能直接支持升序排序,如果要降序,还得额外做一次反向扫描。8.0之后支持降序索引列,可以同时支持混合方向排序。
假设你有这样一个查询:
sql复制SELECT order_id, user_id, create_time
FROM order_info
WHERE user_id = 20240001
ORDER BY create_time DESC, order_id ASC;
如果索引是(user_id, create_time DESC, order_id ASC),那排序可以完全走索引;如果索引方向定义错了,比如(user_id, create_time ASC, order_id ASC),MySQL就得把数据取出来重新排序,Using filesort就出现了。
3.3 深翻页优化:LIMIT 100000, 20为什么越来越慢
分页查询是另一个容易被索引坑吞噬的性能场景。LIMIT 100000, 20的逻辑是:从索引里扫描到第100020条,然后丢掉前100000条,只返回最后20条。扫描量随着页码增大而线性增长,即使索引完全生效,深翻页也快不了。
一个常见的优化方案是延迟关联:
sql复制SELECT o.order_id, o.user_id, o.amount
FROM order_info o
INNER JOIN (
SELECT order_id
FROM order_info
WHERE user_id = 20240001
ORDER BY create_time DESC
LIMIT 100000, 20
) t ON o.order_id = t.order_id;
子查询只走索引,索引里包含了order_id和create_time,不需要回表,扫描前100020条IO开销很小。拿到20个主键后再关联回原表取完整数据,回表次数从100020次降到了20次。这种写法在深翻页场景下通常能提升一个数量级。
如果没有主键可用,也可以考虑用游标分页代替偏移量分页,通过WHERE create_time < ?的方式翻页,前提是排序字段有索引且不重复,否则需要加一个唯一列做二级排序条件。
4. 那些让索引彻底失效的写法:高频雷区与验证方法
4.1 对索引列使用函数或隐式类型转换
数据量一大,下面这类SQL就会暴露问题:
sql复制WHERE DATE(create_time) = '2024-06-01'
WHERE user_id + 5 = 20240006
WHERE phone = 13800138000
第一条对create_time套了DATE()函数,MySQL没法直接用它去B+树里定位,只能全索引扫描后逐个计算函数值;第二条对索引列做了算术运算,同样失效;第三条如果phone列是VARCHAR类型,传进去的是整数,MySQL会尝试把列值转为数字再比较,索引也会失效。
正确的写法是:
sql复制WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00'
WHERE user_id = 20240001
WHERE phone = '13800138000'
判断原则很简单:索引列一旦离开“裸列”的形态,被函数、运算、类型转换包裹,利用索引的可能性就大打折扣。
4.2 LIKE前缀通配符和OR连接的失效逻辑
LIKE '%keyword'为什么走不了索引?因为B+树是按索引列值排序的,以通配符开头的条件无法确定扫描起点,只能全索引扫描。但LIKE 'keyword%'是可以走索引的,因为它能定位到一个明确的区间范围。
OR连接的问题在于:如果OR两侧的条件只有一个能走索引,另一个不能,优化器为了结果的正确性,可能会选择全表扫描。比如WHERE status = 1 OR amount > 100,就算status有索引,amount没有索引,最终也可能走全表。解决方案是拆成两个查询用UNION ALL合并,或者给两侧的条件都建上合适的索引。
4.3 数据分布导致优化器“看走眼”
索引失效不一定是SQL写法有问题,也可能是优化器基于统计信息做了“错误”的判断。优化器决定走不走索引,主要看预估的扫描行数占总行数的比例。如果表的索引统计信息过期,或者数据分布严重倾斜,优化器可能高估了索引命中的行数,从而放弃索引。
这种情况的处理方法是执行ANALYZE TABLE order_info;更新统计信息。如果更新完还是不走索引,可以用FORCE INDEX强制指定,但这不是长久之计,更根本的是检查索引设计是否真的匹配查询模式。
5. 利用EXPLAIN和慢查询日志验证索引优化效果
5.1 EXPLAIN关键列到底怎么看
执行计划是验证索引到底有没有生效的最直接工具。我判断一个执行计划是否健康,会重点关注四个指标:
| 列名 | 关注点 | 理想情况 |
|---|---|---|
| type | 访问类型 | const/ref/range,避免ALL |
| key | 实际使用的索引 | 非NULL,且与预期一致 |
| rows | 预估扫描行数 | 越小越好 |
| Extra | 额外信息 | 避免Using filesort、Using temporary |
type从好到差的顺序大概是:system > const > eq_ref > ref > range > index > ALL。ref说明用到了非唯一索引的等值匹配,range说明用到了范围匹配,两者都是健康状态。index表示扫描了整棵索引树,虽然比ALL好一点,但本质上也是全扫描,多见于覆盖索引场景。
Extra里出现了Using index是好消息,说明查询所需的数据都在索引树里,不需要回表;Using index condition说明用到了索引条件下推;Using where表示还需要额外过滤;Using filesort和Using temporary则是要盯紧的危险信号。
5.2 开启慢查询日志,建立优化闭环
调优不是改完SQL就结束,还要用数据验证效果。开启慢查询日志是建立闭环的第一步:
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
SET GLOBAL log_output = 'TABLE';
这样设置之后,执行时间超过1秒的SQL都会记录到mysql.slow_log表,可以用普通SELECT查询分析。线上环境建议把long_query_time设为0.5甚至更低,因为很多隐藏的性能问题都藏在几百毫秒的SQL里,等涨到一秒以上再发现就晚了。
5.3 一个完整的调优对比案例
拿最早那条订单查询SQL为例。优化前:
code复制rows: 8350000
Extra: Using where; Using filesort
耗时: 1.62s
加了(user_id, create_time)联合索引之后:
code复制rows: 423
Extra: Using index condition
耗时: 18ms
优化前后扫描行数差了接近两万倍。但这里要提醒一句:rows列的数值是优化器基于统计信息估算出来的,不是实际扫描行数。想知道真实的扫描行数,可以用EXPLAIN ANALYZE(MySQL 8.0.18+),它会真正执行SQL并返回实际耗时和行数,但注意它真的会执行写操作,生产环境慎用。
6. 结合业务场景选择主键和二级索引的策略
6.1 主键设计为什么直接决定索引上限
MySQL的InnoDB引擎里,主键索引就是聚集索引,表数据本身就是按主键顺序存储在B+树的叶子节点上。二级索引叶子节点存储的是主键值,所以每次通过二级索引查询最终都要回到主键索引取完整行,这个动作就叫回表。
主键的长度和写入顺序直接影响所有索引的性能。如果用一个UUID字符串做主键,每个二级索引的叶子节点都会冗余存储这个32字符的UUID,索引体积膨胀,IO开销变大,而且UUID随机性导致插入时频繁页分裂。如果用一个自增整数做主键,新数据永远追加在B+树末尾,页分裂概率极低,索引体积也小得多。
这个特性决定了,InnoDB表最好有主键,且主键最好是单调递增的数值类型。如果没有合适的天然主键,自增BIGINT几乎是唯一推荐方案。很多人纠结业务上有没有唯一键,但主键和唯一约束是两回事,主键更多承担的是物理存储组织的职责,不需要有业务含义。
6.2 覆盖索引:让查询彻底不回表
二级索引如果包含了查询所需的全部列,MySQL就不需要回表了,直接在索引树上完成查询,这是索引优化里性价比最高的手段之一。
举例:
sql复制SELECT user_id, create_time FROM order_info WHERE user_id = 20240001;
如果只有(user_id, create_time)联合索引,这个查询的Extra会显示Using index,因为user_id和create_time都在索引树上。但如果查询里加了一个amount列,索引里没有,那就得回表。所以设计索引时可以考虑把高频查询需要的字段“顺带”放进索引,但也要克制,索引列太多会导致索引体积过大,写入变慢,得不偿失。
6.3 前缀索引的应用和长度选择
字符串列做索引时,如果整列很长,索引体积会非常大。比如用户表的email列最长64个字符,为整列建索引不够划算,可以只取前N个字符建立前缀索引:
sql复制ALTER TABLE user_info ADD INDEX idx_email_prefix (email(20));
前缀索引的核心是选择一个足够长的N,让前缀区分度接近整列区分度。计算公式:
sql复制SELECT COUNT(DISTINCT LEFT(email, 20)) / COUNT(DISTINCT email) FROM user_info;
这个比例越接近1,说明前缀区分度越好。通常建议达到0.9以上再使用。但前缀索引有个缺陷:它无法用于ORDER BY和GROUP BY,因为索引里只存了前缀,无法判断完整的排序顺序。所以前缀索引只适合那种只需要等值查询的字符串列。
最后分享几个我实测下来的判断依据
索引优化做到最后,真正考验人的不是会不会写CREATE INDEX,而是面对一个具体的业务查询,能不能快速判断出该不该建索引、建什么形式的索引、建完之后怎么验证。我自己的判断流程固定成了一套:
第一步,把慢查询SQL拿出来,去掉所有SELECT列,只看WHERE条件、JOIN条件和ORDER BY字段;第二步,根据最左前缀原则设计联合索引,等值条件放前面,范围条件放后面,排序字段跟着范围条件走;第三步,建完索引立刻EXPLAIN验证,确认type不是ALL,key是预期索引,Extra里没有filesort;第四步,观察一周慢查询日志,确认新索引确实被用上,旧索引用sys.schema_unused_indexes识别出来之后删除。
这个方法不一定最优,但胜在稳。索引优化没有银弹,每张表的数据分布、查询模式、写入频率都不一样,能做的就是不断通过执行计划和慢查询日志去校准判断。希望这篇能把你在MySQL索引优化的路上往前推一小步。
