☰
MySQL慢SQL排查指南:索引建了为何还慢?优化实战与EXPLAIN诊断
2026/9/26 17:24:14 网站建设 项目流程

1. 先别急着怪索引:慢SQL到底慢在哪一步

我一直觉得MySQL里有个特别有意思的现象——越是刚接触索引的人,越容易陷入一个思维定式:"SQL慢?我已经建索引了啊,为什么还这么慢?"

这个问题的答案很有层次。是你建的索引压根没生效?还是生效了但帮倒忙,让查询更慢了?又或者慢的根源根本不在查询逻辑上,而是表结构、数据量、并发压力这些隐性因素在拖后腿?

我工作这些年被喊去救场的慢SQL里,真正"没建索引"的情况其实占少数,更多的情况恰恰就是上面那句灵魂拷问:索引建了,SQL还是慢。而这背后最常见的原因,可以分成两大类——

  • 索引压根没被用上:SQL写法绕开了索引,优化器觉得走全表扫描可能更快,索引在这条SQL里就是个花瓶。
  • 索引被用上了但还是慢:索引生效了,但它太"笨重",回表次数太大,或者它自己产生了额外的维护代价,把你省下来的时间又吃回去了。

要彻底讲清楚这两类问题,得先回到一个基础问题上:我们常说"走索引",走的是什么索引?这条索引路径上究竟发生了哪些事?

1.1 主键索引和辅助索引,走的路根本不是一条

MySQL默认的InnoDB引擎下,数据是存在主键索引的叶子节点上的,这种结构叫聚簇索引。你建的其他索引,不管叫普通索引、唯一索引还是联合索引,统称辅助索引或二级索引,它们的叶子节点存的是主键值,不是完整的数据行。

这意味着什么?当你执行一条WHERE name = '张三',如果name上有普通索引,MySQL先通过辅助索引找到一批主键值,再拿着这批主键值回到主键索引里找人——这个过程叫回表。

查询条件命中的行数越多,回表次数越多,查询就越慢。比如一个索引选择性很差的场景:表里有10万条数据,status字段只有0和1两种值,你建了idx_status,然后查WHERE status = 1。如果表里60%的数据status都是1,优化器算一下回表成本,大概率直接放弃索引走全表扫描——因为全表扫一遍可能比来回蹦跶更快。

这也是为什么很多公司会建议把gender、status这类低基数字段放到索引后面,甚至干脆别单独建索引。索引不是越多越好,也不是建了就一定给你加速,它更像一条专用快车道——你的车必须完全匹配它的规则,而且路上的目标不能太多,否则还不如直接顺着大马路扫一遍。

1.2 InnoDB的B+树长什么样,决定了你的查询会被怎么走

我一直觉得,理解B+树的形态是解开所有索引问题的钥匙。它比二叉树"胖",每个节点能存多个索引条目,树的高度通常只有3到4层。这意味着你从根节点出发,最多走三四个节点就能定位到叶子节点上的数据位置。

更重要的是,同一个节点在磁盘上物理连续。InnoDB以页(默认16KB)为单位读写,一次I/O能加载一整个页的数据。对范围查询BETWEEN 100 AND 200,索引可以顺着叶子节点的双向链表连续读取,而且InnoDB还会做预读——发现你在读相邻的页时,它会在后台把后面几页也读进来。

这就是索引的核心价值:把随机I/O变成顺序I/O。磁盘最怕的是转来转去找位置,最不怕的是连着读一段。

但反过来也是坑。如果你建了一个复合索引idx_a_b_c(a, b, c),却查WHERE c = 1,或者WHERE b = 2 AND a > 100这种,查询条件不满足索引的最左前缀规则,索引路径一开始就断了——B+树是按从左到右的字段顺序排列的,跳过了a直接查b或c,B+树没法知道应该走哪个分支,只能放弃这条路或者做额外的过滤。

2. 索引建了却不生效:我踩过的那些"看似没问题"的SQL写法

这一节必须重点讲。很多人建索引之前会查一下字段有没有索引,建完以后下一句还是慢SQL,问题就出在SQL写法和索引规则不匹配。我见过太多连面试都能答上来"最左前缀原则"的人,实际写代码的时候照样掉坑。

下面用几个最典型的场景说话,每个都是我在生产环境里真实处理过的。

2.1 对索引列做了函数计算或隐式转换

这是最常见的"自杀式"写法:

SELECT * FROM orders WHERE DATE(created_at) = '2024-06-01';

哪怕created_at上有索引,WHERE DATE(created_at)也会让索引失效。因为你把所有索引值都套了一层DATE函数,B+树里存的是原始的created_at值,不做一次全量计算,MySQL没法拿2024-06-01去匹配。优化器一算,算了,直接全表扫吧。

正确写法是把它改成范围查询:

SELECT * FROM orders WHERE created_at >= '2024-06-01 00:00:00' AND created_at < '2024-06-02 00:00:00';

这不只是为了索引生效,更是让条件变成可搜索的。函数包裹后,索引里存的原始值已经"变形"了,除非你建函数索引(MySQL 8.0支持),否则常规索引在这个查询上帮不了任何忙。

同样的坑还有隐式类型转换。比如phone字段是VARCHAR类型,你查WHERE phone = 13800138000,MySQL会把字符串列隐式转成数字比较,效果等同于对列做了CAST函数:

SELECT * FROM users WHERE CAST(phone AS SIGNED) = 13800138000;

索引又白建了。

2.2 LIKE必须以什么开头,这个细节很多人知道但屡屡忽略

大家都知道LIKE '%关键词%'走不了索引,因为索引是按首字母/首字节排序的,你不知道开头是什么,B+树不知道该往哪个分支走。

但有一个容易被忽略的变形:LIKE '%关键词'其实能走索引,前提是你把条件改成LIKE '关键词%'。反过来如果非要用%关键词结尾,那就老老实实接受全表扫描,或者另想办法。

这里有个进阶思路:MySQL 5.7之后支持生成列(Generated Column),你可以提前把需要模糊搜索的字段做反转,比如把abc123存成321cba,然后查询时也用反转后的前缀去匹配:

ALTER TABLE users ADD COLUMN reversed_name VARCHAR(255) GENERATED ALWAYS AS (REVERSE(name)) STORED, ADD INDEX idx_reversed_name (reversed_name); SELECT * FROM users WHERE reversed_name LIKE CONCAT(REVERSE(?), '%');

虽然冷门,但在某些必须做后缀匹配的场景下意外地好用。

2.3 OR连接条件让优化器"左右为难"

很多人以为OR只是把两个条件拼起来,不会影响索引。实际上这是个典型的优化器选择题:

SELECT * FROM products WHERE brand_id = 1 OR price = 99;

如果brand_id和price上都有索引,MySQL确实可以分别走这两个索引再合并结果。但如果只有一个字段有索引,优化器会怎么选?它发现OR要满足"任一条件成立"就得把所有可能性都找出来,索引路径只能覆盖一半,那不如干脆全表扫——这样至少结果一定正确,不折腾。

所以处理OR条件有个不成文的经验:要么保证OR两边的字段都有索引(或属于同一个联合索引),要么把它拆成UNION:

SELECT * FROM products WHERE brand_id = 1 UNION SELECT * FROM products WHERE price = 99;

拆开以后每条SQL各自走索引,再合并结果,往往比全表扫描快得多。如果你发现某条SQL在OR条件下虽然索引失效但数据量也不算大,那全表扫也就几毫秒,这时候强行改SQL反而浪费时间——优化要讲性价比,不是所有索引失效都必须修。

2.4 不等于、NOT IN和排序分组的隐藏雷区

还有一类隐蔽性更强的场景:查询条件本身能走索引,但你想把结果排序或分组。

SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC;

status有索引,create_time也有索引,但它们是两个独立索引。MySQL走了idx_status拿到一批主键值,回表取到数据后,还要额外做一次filesort(磁盘排序)把结果按create_time排好。

这里的慢不是索引没生效,而是"索引帮你找到行,行却不在正确顺序上"。如果业务上这条SQL很频繁,更好的做法是建联合索引idx_status_create_time(status, create_time)——先按status筛选,天然按create_time排好序,排序这一步直接从执行计划里消失了。

类似地,NOT IN、<>这些操作,优化器普遍认为"不匹配的条件走索引没太大优势",通常也会放弃索引。你当然可以通过UNION改写成多个正条件,但也要看数据分布值不值得,不能一刀切硬改。

3. 用EXPLAIN给慢SQL做一次深度体检:别再靠猜了

上面讲了不少索引失效场景,但实际工作中最忌讳的就是"靠猜"。我见过太多人,SQL变慢了,第一反应是"是不是索引没建对",然后埋头看半天变量、试半天新索引,也不看执行计划。你要是让一个DBA来看这个问题,他第一件事一定是跑一条EXPLAIN,就像去医院先拍片子,而不是凭感觉开药。

3.1 一条完整执行计划的阅读姿势

EXPLAIN的输出每一列都有意义,但真正判断"索引是否生效且高效"的关键集中在几个字段上。

先看一个经典例子:

EXPLAIN SELECT order_id, amount FROM orders WHERE status = 1 ORDER BY create_time DESC;

正常结果长这样(字段我精简了):

id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra 1 | SIMPLE | orders | ref | idx_status | idx_status | 1 | const | 21737 | Using filesort

这里最关键的信息是:

  • type:显示ref代表命中普通索引等值匹配。更理想的还有const(主键/唯一索引查一行)、eq_ref(连接查询中按唯一键取一行)。如果看到ALL,恭喜你,全表扫描,索引没被用上。看到index也要注意,它表示扫了整棵索引树,虽然没扫表但本质上也没省多少事。
  • key:MySQL最终决定使用的索引。如果key为NULL,而possible_keys有值,说明优化器经过成本评估,认为走索引不比全表扫描快,主动弃用了。
  • rows:预读取扫过的行数,越小越好。这个数字是估算值,但有很高的参考价值。
  • Extra:这里藏着很多惊喜和惊吓。出来的值五花八门,比如Using where代表SQL在存储引擎返回结果后还要在Server层过滤;Using temporary代表用了临时表,通常和GROUP BY有关;Using filesort代表需要额外排序。

上面这个例子,Extra里有Using filesort,提示我们排序出了问题——联合索引idx_status_create_time显然比单独idx_status更合适。

3.2 相同查询,不同写法的执行计划差异

我之前处理过一个具体案例,两张表结构都一样(因为历史分表),但同样的业务查询,一张表走了索引,另一张全表扫描,查出来的执行计划让我一眼就明白问题在哪了。

A表执行计划:

type: ref, key: idx_user_created, rows: 356, Extra: NULL

B表执行计划:

type: ALL, key: NULL, rows: 500000, Extra: Using where

同一个业务SQL,A表走了索引,B表直接全表扫了500万行。为什么会这样?因为B表的联合索引顺序是(created_at, user_id),而SQL条件是WHERE user_id = ? ORDER BY created_at——user_id在第二个位置,查询条件没从联合索引的最左列开始,索引直接失效。

这个案例很典型地说明了:同一个索引在不同表上可能完全没区别,但同一个SQL在不同索引结构上的表现可以天差地别。你光看SQL文本看不出任何问题,只有EXPLAIN能让你看到真相。

3.3 不只有EXPLAIN,EXPLAIN ANALYZE更直白

MySQL 8.0.18之后,EXPLAIN还多了一个用法:EXPLAIN ANALYZE。它不只是给个估算,而是真正执行一遍SQL,告诉你在哪里花了多少时间:

EXPLAIN ANALYZE SELECT order_id, amount FROM orders WHERE status = 1 ORDER BY create_time DESC;

输出结果里会带着实际的耗时信息,比如:

-> Sort: orders.create_time DESC (actual time=0.123..0.124 rows=20) -> Index lookup on orders using idx_status (status=1) (actual time=0.018..0.092 rows=30)

这段信息直接告诉你排序花了多久、索引查找花了多久。哪个环节吃掉了大头,一眼就清楚。

4. 索引用上了还是慢:那些"生效但低效"的深层问题

先声明一点:从这个章节开始,讨论的是比索引失效更"细"的问题。很多人在索引失效一条SQL都不背锅的时候,会把责任推给数据库参数或者服务器性能,但事实上,有些时候你真得仔细抠一下索引本身的设计。

4.1 回表成本被你严重低估了

还是回到回表的概念。辅助索引叶子节点只存主键值,你要查询的列如果不在这个索引里,就必须拿着主键回主键索引取整行数据。

假设有一条SQL:

SELECT user_name, email, phone FROM users WHERE nickname = '小李';

nickname上有辅助索引,但user_name、email、phone都不在索引里。这条SQL每命中一条记录就要回一次表,一次回表就是一次随机I/O。如果命中1000条记录,那就是1000次随机I/O,聚簇索引的叶子页分散在不同位置,磁盘可能要来回转上千次。

解决这个问题最常用的手段是覆盖索引:让索引覆盖你需要的所有字段,查询就不需要回表了。

ALTER TABLE users ADD INDEX idx_nickname_email_phone (nickname, email, phone);

这样查询ID、nickname、email、phone都能直接从索引里取,Extra还会出现Using index——这个标志是正面信号,代表索引已经包含了查询所需的数据,不用再回表。

不过我提醒一句:覆盖索引不是无脑加。它本质上是用额外存储空间换查询效率,联合索引的字段越多,B+树越"胖",写入时需要更新的索引列越多,写入成本相应上升。如果一个表频繁UPDATE/INSERT,加一堆宽索引可能会让你的写性能雪上加霜。

实际经验是:优先把高频查询的SELECT列补进索引,但保持在2到3个字段以内。如果需求明确,添加字段时把等值查询列放前面,范围查询列放中间,排序字段放最后,其他冗余需求谨慎追加。

4.2 联合索引的字段顺序决定生死

联合索引的字段顺序问题,是"索引用上了但还是慢"的高发区。

比如用户表场景:SELECT * FROM users WHERE age = 30 AND city = '上海';

如果你建了idx_city_age(city, age),这条SQL能很快过滤到city='上海'的数据,再进一步筛age=30。但如果你建的是idx_age_city(age, city),MySQL只能先按age过滤全部30岁的人,再额外筛选上海。逻辑上都能出正确结果,但前者过滤得更狠更快,rows估算会差好几倍。

更麻烦的是排序和分组对顺序的要求。联合索引天然支持"从左到右,先排序再分类"的查询。比如查最近30天每个城市的新增用户数,联合索引里把时间、城市分别放在哪,将直接决定SQL是否触发Using filesort或者Using temporary。

如果排序字段和等值筛选字段是同一张表上的不同字段,一个非常有效的设计思路是:

-- 场景:WHERE status = 1 AND channel = 'web' ORDER BY created_at -- 联合索引可以设计成 (status, channel, created_at) -- 这样status和channel做等值条件,created_at天然就是排好序的

也就是说,把等值条件字段放前面,排序字段放最后,这是一张万能配方。它让MySQL在索引树里依次精确匹配等值条件,最后沿着排序字段的顺序直接输出,不额外排序,也不产生临时表。

4.3 慢不慢,还得看数据分布:优化器的"理性选择"

有时候索引明明能用,优化器就是不用它,这不能全怪优化器"犯傻",它在做成本评估时相当精明。

优化器维护着一张关于表的数据统计信息,用来估算:走索引要扫多少行、回表多少次、每步大概要多长时间。如果它估算走索引的成本比全表扫描还高,它就会选择全表扫描。

我遇到过最典型的一个场景是"小表大索引":一张只有两万行左右的配置表,优化器判断一个4层的B+树来回跳转的成本远大于直接在两万行里顺序扫一遍。这时候,哪怕你把索引建得再完美,优化器依然会"无视"它,走全表扫描。这类情况人工干涉的空间不大,更好的策略是接受现实,让配置表保持小而精。

还有一种更值得警惕的情况:统计信息过期。表里数据经过大量增删改之后,information_schema.statistics里的基数信息可能严重失真,导致优化器选择了一个错误的执行路径。常见做法是定期执行ANALYZE TABLE,让统计信息刷新,有时一条SQL突然变慢,跑一次这个命令就好了。

4.4 深入细节:索引条件下推和排序分组的优化机会

有两个深度学习过MySQL内部执行原理的人才会主动去用的技术点,这里一并放出来,可能会对你理解"为什么索引建了还是慢"有启发。

索引条件下推(ICP)解决的问题是:在没有ICP之前,联合索引可能只用到第一个字段,其余筛选条件要在回表后逐行过滤。MySQL 5.6引入ICP后,存储引擎层可以直接根据联合索引中的其他字段做第二次过滤,减少回表次数。

怎么判断有没有用上ICP?看Extra里是否出现Using index condition。如果出现了,说明MySQL已经尽量利用索引列做过滤。想更主动一点,可以在筛选条件里尽量使用联合索引包含的字段,让ICP发挥更大空间。

排序分组的极端案例:当你的SQL同时有WHERE、GROUP BY、ORDER BY时,索引的设计要同时照顾三个动作。前面提到的联合索引顺序策略,不只是为了WHERE,更是为了让GROUP BY和ORDER BY能在索引顺序上直接完成,避免临时表。

一个我调过很多次的通用优化套路是:

-- 问题SQL: SELECT user_id, COUNT(*) FROM orders WHERE created_at >= '2024-01-01' GROUP BY user_id ORDER BY COUNT(*) DESC;

这种情况就算有索引也容易让GROUP BY产出临时表,因为索引默认是按数据物理顺序排列的,而不是按聚合结果排序。一个思路是改变索引结构让GROUP BY字段有序,或者接受全表排序的现实,业务上做分页处理。如果想同时优化两个动作,可以把字段做排列,先按user_id分组、再保证时间顺序支持范围条件,但代价是你必须接受对COUNT结果排序时的额外损耗。

5. 这些经验帮我少走了很多弯路,分享给你

最后这部分,跟大家说说我在实际项目里被问到最多的几个"经验性问题"和我的处理方法。这次不聊原理了,全是实操里边角料级的干货。

5.1 慢查询日志和pt-query-digest是排查慢SQL的黄金搭档

我在排查慢SQL的时候,第一步永远是开慢查询日志:

SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;

long_query_time = 1意味着超过1秒的SQL都会被记录下来。生产环境建议设到0.5或1秒就行,太多会拖累性能。

日志文件出来以后,手写脚本统计太累,我更推荐用Percona Toolkit里的pt-query-digest:

pt-query-digest /var/log/mysql/mysql-slow.log

它会按总耗时、执行次数、平均耗时做排行,一眼就能看到哪条SQL是"最该被优化的"。重要的是,它能帮你识别同一形态SQL的不同写法,避免你盯着一个文本去搜,结果漏掉了一堆类似写法。

5.2 千万别为了"平时用得上"给每列都加索引

我见过最生猛的做法是把一张表里几乎每个VARCHAR字段都加上索引,理由是"以后哪个SQL慢哪个SQL用得上"。实际上,索引越多,写入越慢,每次INSERT/UPDATE/DELETE都要同步维护所有索引。在表数据量过千万以后,哪怕你所有索引都用上了,MySQL也可能因为维护索引的代价出现严重的写锁竞争。

这里有个很朴素的判断标准:先看慢查询日志,确认哪些SQL真的慢,再决定给哪个列建索引。没有实际慢SQL表演过的字段,一律不加索引,这是DBA的基本修养。

5.3 不一定非要"把慢SQL改到极致",也可以让SQL走查询缓存(旧版)或让业务侧改变查询模式

MySQL 8.0之后已经移除了查询缓存,很多人还在用老思路指望一个开缓存就能把所有重复查询提速。现实是:为了从缓存拿结果,每次写入后缓存要失效,高并发读写场景下缓存命中率并不乐观。

真正值得花时间的,往往是搞清楚业务能不能换个查询模式。比如:报表统计天天跑一次大GROUP BY,与其反复优化这条SQL,不如提前算好汇总表,让报表直接读汇总结果。这个思路叫预聚合,配合索引加速明细查询,效果远超死磕单条SQL。

5.4 最后一个小技巧:每次改完索引,都要回EXPLAIN看一遍

这是我能给你最诚实的建议:不管你根据任何博客、任何经验之谈调整了索引,都必须用EXPLAIN验证一下这次改动到底有没有生效。我自己的习惯是改完索引以后,立刻跑一遍这个表上最常执行的10条SQL,看type、key、rows、Extra四个字段有没有变好。如果没有变好,说明要么是SQL写法仍有索引失效的地方,要么是优化器认为这步改动不划算。

说句实在的,MySQL索引优化这件事,从来不是"建了就完了",它是一个循环往复的过程:发现慢SQL,做EXPLAIN,分析执行计划,调整索引,再次验证。你踩过的坑越多、验证过的组合越多,接到"为什么建了索引SQL还是慢"这类问题时,判断得就越快。跟数据库打交道,经验就是这么一笔一笔攒下来的。

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

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

立即咨询