一句话结论:慢查询的根因通常不在 SQL 语法,而在“有没有走对索引”和“一次拿了多少行”。把这两件事查清楚,80% 的性能问题能在十分钟内定位。
一、一个典型的凌晨
线上告警:某个列表接口 RT 从 200ms 涨到 8s,数据库 CPU 冲到 90%。
开发同学的第一反应往往是:“是不是服务器扛不住了?要不要扩容?”然后开始改连接池、加缓存、讨论分库分表。
半小时后,有人顺手把那条 SQL 拿出来EXPLAIN一看——**全表扫描,一个索引都没用上。**加上联合索引,30ms。
扩容的方案撤了,缓存也没加。
这不是段子,而是绝大多数“数据库性能问题”的真实结局:在还没确认是不是索引问题之前,就已经开始讨论架构改造了。架构改造不会错,但它很贵,而且常常解决不了真正的问题。
今天把这条排查链写清楚:从发现慢 → 找到具体 SQL → 看懂执行计划 → 给出改动 → 验证效果。不依赖商业工具,MySQL 自带的就够。
适用版本:MySQL 5.7 / 8.0 / 8.4 思路通用,个别语法差异文中标注。
二、先说三件"别急着做"的事
1. 别急着加索引。
索引不是免费的。每多一个索引,写入、更新、删除都要多维护一份数据结构,磁盘也要多占空间。**盲目堆索引的库,写性能会肉眼可见地变差。**先确认这个索引会被哪些查询用到、命中频率如何,再动手。
2. 别急着上SELECT COUNT(*)或SELECT *去试探。
在生产库对大表跑全表聚合,本身就可能把机器打挂。要试就去从库或预发环境,带LIMIT,且避开业务高峰。
3. 别只看单条 SQL,要看"它被调了多少次"。
一条执行 50ms 但每秒被调用 2000 次的 SQL,危害远大过一条执行 2s 但每天跑一次的报表。总耗时 = 单次耗时 × 执行次数,两个因子都要看。这也是为什么慢查询日志和pt-query-digest这类聚合工具比单条EXPLAIN更有价值。
三、主干流程:五步,从现象到代码行
第 1 步:确认"慢"到底在哪一层
这是最容易白忙的一步。接口慢 ≠ 数据库慢。
花两分钟排除:
# 1. 看数据库侧:当前活跃连接和正在跑的语句SHOW PROCESSLIST;--5.7SHOW FULL PROCESSLIST;-- 看完整 SQL# 2. 看是否有锁等待(大量线程处于 Sending data / Waiting for table metadata lock)# MySQL 8.0 / 5.7:SELECT * FROM information_schema.INNODB_TRX\G SELECT * FROM performance_schema.data_lock_waits LIMIT10;# 3. 看机器层面是不是资源瓶颈(CPU / IO)top-ciostat-x15- 如果
PROCESSLIST里一堆线程卡在同一个 SQL 上 →是 DB 问题,往下走。 - 如果 DB 侧很干净,但接口依然慢 →问题在上游(应用层循环查库 N+1、外部调用超时、GC、网络),这时候死磕 SQL 是浪费时间。
- 如果有大量锁等待 →这是锁问题,不是慢查询问题,方向完全不同(查事务是否过大、是否有长事务未提交、索引缺失导致锁范围扩大)。
第 2 步:把慢 SQL 抓出来
方式 A:慢查询日志(最常用,生产首选)
-- 查看状态SHOWVARIABLESLIKE'slow_query%';SHOWVARIABLESLIKE'long_query_time';-- 临时开启(重启失效,想永久改写 my.cnf)SETGLOBALslow_query_log=ON;SETGLOBALlong_query_time=1;-- 超过 1 秒记下来SETGLOBALlog_queries_not_using_indexes=ON;-- 没走索引的也记,很有用日志默认在数据目录下的hostname-slow.log。分析它:
# 自带工具,按总耗时排序看前 10 条mysqldumpslow-st-t10/var/lib/mysql/hostname-slow.log# 更推荐 pt-query-digest(Percona Toolkit),能做聚合、指纹归并pt-query-digest /var/lib/mysql/hostname-slow.log>report.txt关键:看报告里的 “Query_time 总和” 和 “Rows_examined”,而不是只看单条最慢的那条。
方式 B:performance_schema(不想开慢日志时用)
-- MySQL 5.6+,开销比慢日志略高,短期开UPDATEperformance_schema.setup_consumersSETENABLED='YES'WHERENAME='events_statements_history_long';-- 8.0 可直接查聚合视图SELECTDIGEST_TEXT,COUNT_STAR,AVG_TIMER_WAIT/1000000000avg_sec,SUM_TIMER_WAIT/1000000000total_sec,SUM_ROWS_EXAMINEDFROMperformance_schema.events_statements_summary_by_digestORDERBYtotal_secDESCLIMIT10;方式 C:线上正在跑的慢语句
-- 8.0SELECT*FROMperformance_schema.events_statements_currentWHERETIMER_WAITISNOTNULLORDERBYTIMER_WAITDESCLIMIT10;抓到 SQL 之后,第一件事不是优化,是把它原样存下来(含当时的参数值)。后面每一步验证都要用它。
第 3 步:看懂执行计划(这一步决定你后面两小时有没有用)
EXPLAINSELECT...;-- 8.0 推荐,信息更全EXPLAINFORMAT=JSONSELECT...\GEXPLAINANALYZESELECT...;-- 8.0+,真实执行 + 实际行数,极有价值重点看这六个字段,按优先级排:
| 字段 | 看什么 | 好 / 坏 |
|---|---|---|
type | 访问类型 | const/eq_ref/ref/range✅ →ALL(全表)❌ |
key | 实际用到的索引 | 有名字 ✅ →NULL❌ |
possible_keysvskey | 有候选但没用上 | 两者不一致 =索引存在但没命中,这是最常见的故障形态 |
rows | 估算扫描行数 | 越小越好,但它是估算值,统计信息过期时会严重失真 |
Extra | 附加信息 | Using index(覆盖索引)✅;Using filesort、Using temporary❌ |
filtered | 条件过滤后剩余比例 | 很低说明索引选得不好或在回表后过滤 |
type的粗略排序(从好到坏):system>const>eq_ref>ref>fulltext>ref_or_null>index_merge>unique_subquery>index_subquery>range>index>ALL
实战中记住三个档位就行:ref/range是及格线,index是勉强,ALL必须处理。
Extra里几个高频信号:
Using index→ 覆盖索引,不用回表,很好。Using where→ 回表后再过滤,一般意味着索引没覆盖全部条件。Using filesort→ 无法用索引完成排序,需要额外排序(数据量大时很痛)。Using temporary→ 用了临时表,常见于GROUP BY/DISTINCT无法用索引优化时。Using index condition→ ICP,下推了一部分条件,算好事。
强烈建议用EXPLAIN ANALYZE(8.0+):它给出实际行数与实际耗时,能立刻暴露出“估算 rows=1 实际扫了 200 万行”这种统计信息失真的情况——这是很多“索引加了却没生效”的真正原因。
第 4 步:对照清单,找"为什么没走索引"
这一步是整篇的核心。索引失效的原因其实很有限,九成落在下面这几类:
① 最左前缀没满足(联合索引头号杀手)
CREATEINDEXidx_a_b_cONt(a,b,c);SELECT*FROMtWHEREb=1ANDc=2;-- 用不上(缺 a)SELECT*FROMtWHEREa=1ANDc=2;-- 只用上 a,b 断了后面 c 也用不上SELECT*FROMtWHEREa=1ANDb=1;-- ✅ 用上 a,bSELECT*FROMtWHEREa=1ORDERBYb;-- ✅ 排序也能用上**口诀:等值条件放前面,范围条件放最后。**因为范围条件(>、<、BETWEEN、LIKE 'x%')之后的列无法再用于索引查找。
② 对索引列做了运算或套了函数
WHEREDATE(create_time)='2026-10-01'-- ❌ 函数破坏索引WHEREcreate_time>='2026-10-01'ANDcreate_time<'2026-10-02'-- ✅WHEREuser_id+1=100-- ❌WHEREamount*0.9>100-- ❌规则:让索引列单独出现在比较运算符的一侧。
③ 隐式类型转换(极其隐蔽,极其常见)
字段是VARCHAR,你传数字:
WHEREphone=13800138000-- ❌ 字符串列接数字字面量,全员转换,索引失效WHEREphone='13800138000'-- ✅反过来(数字列传字符串)通常能用索引,但字符集/collation 不一致(如表是utf8mb4,参数来自utf8的连接或另一张表的 join 列)同样会触发隐式转换。**EXPLAIN里出现Using where且 warning 里有Conversion→ 基本就是它。**可以用SHOW WARNINGS;确认。
④ 左模糊 / 双模糊
WHEREnameLIKE'%张'-- ❌ 一定全表WHEREnameLIKE'张%'-- ✅ 最左前缀匹配,能用必须双模糊的场景(如站内搜索),不要用LIKE硬扛,考虑全文索引或专门的搜索引擎。
⑤OR两边不都有索引
WHEREa=1ORb=2-- 若 a、b 各自有索引,可能走 index_merge;否则全表可改写为UNION ALL分别走索引,或者合并成一个联合索引。
⑥ORDER BY/GROUP BY与索引方向不一致
WHEREa=1ORDERBYc-- idx(a,b,c) 用不上排序,产生 Using filesort⑦SELECT *导致无法覆盖
需要的列都在索引里 → 覆盖索引,不回表;多取一个大TEXT字段 → 必须回表,甚至产生临时表。少取一列,有时就是Using index和Using where的差别。
⑧ 统计信息过期 / 优化器选错了索引
表现:索引明明在,possible_keys也有,但key是 NULL,或者选了另一个更差的索引。
ANALYZETABLEt;-- 更新统计信息,先做这个EXPLAINFORMAT=JSON...;-- 看优化器成本决策-- 万不得已才用(会让 SQL 失去适应性):SELECT*FROMtFORCEINDEX(idx_a_b_c)WHERE...;⑨ 数据分布极端 / 选择性太差
性别、状态这种只有几个取值的列,建索引意义很小——优化器算出来回表更贵,会直接走全表。索引应该建在“区分度高”的列上。
第 5 步:改完必须验证(否则等于没做)
优化不是“加了索引”就结束,而是“指标降下来”才算结束。验证三件套:
-- 1. 执行计划确实变了EXPLAINANALYZE<原SQL>;-- 2. 真实耗时(多跑几次,看冷/热缓存两种情况)SELECTSQL_NO_CACHE...;-- 5.7 绕过查询缓存;8.0 已移除该提示-- 3. 上线后持续观察-- 慢日志里这条 SQL 的 Query_time 总和是否下降、Rows_examined 是否下降**判断标准只看两个数:Rows_examined(扫描行数)和Query_time。**其他都是中间产物。
另外务必在预发环境用接近线上的数据量压一遍。在 1000 行的小表上,全表扫描比走索引还快——小表上的 EXPLAIN 结论是不可信的。
四、八种高频场景与对应解法
| 现象 | 大概率原因 | 处理 |
|---|---|---|
全表扫描,type=ALL | 缺索引 / 触发了上面九条之一 | 补联合索引,先核对失效原因 |
Using filesort+ 分页很慢 | 排序无法用索引 | 让ORDER BY命中联合索引前缀 |
深度分页LIMIT 100000, 20 | 扫到 10 万行再丢弃 | 延迟关联(见下)或游标式翻页 |
Using temporary | GROUP BY/DISTINCT无索引可用 | 调整分组列顺序或预聚合 |
索引存在但key=NULL | 隐式转换 / 统计信息过期 / 选择性差 | SHOW WARNINGS、ANALYZE TABLE、换列 |
| 回表太多(rows 大) | 索引没覆盖查询列 | 加覆盖索引,或只取主键再回表 |
| 写入变慢、磁盘涨 | 索引过多 / 重复索引 | pt-duplicate-key-checker清理 |
| 偶发抖动、平时很快 | 锁等待 / 大事务 / 刷脏页 | 查INNODB_TRX、拆分大事务、调innodb_flush_log_at_trx_commit等 |
两个我最常用的具体手法:
手法一:延迟关联(解决深度分页)
-- 原来:回表 100020 行,丢弃 100000 行SELECT*FROMordersWHEREstatus=1ORDERBYidLIMIT100000,20;-- 改写:先在覆盖索引上定位 20 个主键,再回表 20 次SELECTo.*FROMorders oJOIN(SELECTidFROMordersWHEREstatus=1ORDERBYidLIMIT100000,20)tONo.id=t.id;前提是idx(status, id)存在。效果通常是数量级的差别。
手法二:游标式翻页(能改业务就用这个)
SELECT*FROMordersWHEREid>#{last_id} ORDER BY id LIMIT 20;没有 OFFSET,怎么翻都快。代价是前端不能跳页——大多数 App 的信息流本来就不需要跳页。
五、三个我亲手翻过的车
车一:给 varchar 字段传 int,索引当场失效。
一张用户表,mobile上有唯一索引,查询却全表扫描。排查半天,最后是 ORM 映射里那个字段被定义成了Long。EXPLAIN的 warning 里明明白白写着Truncated incorrect DOUBLE value,我愣是没去看。
现在我的规矩:possible_keys有而key为 NULL,第一步先查隐式转换,第二步再想别的。
车二:信了rows,结果被坑。
一个分区大表,EXPLAIN显示rows=3,我看着很放心。上线后直接打挂。原因是统计信息很久没更新,实际扫描了两百多万行。
现在我的规矩:8.0 一律用EXPLAIN ANALYZE看真实行数;5.7 用ANALYZE TABLE刷新后再看,并且对rows保持怀疑。
车三:为了一个后台报表加了三个索引。
报表是好了,第二天订单高峰期的写入 RT 涨了 40%,因为每张订单要多维护三份 B+ 树。后来把那三个索引删掉,改成夜里跑预聚合表,两边都好了。
教训:写多读少的表,索引要极度克制;读多写少的表,可以适度放宽。OLTP 和 OLAP 的账不能一起算。
六、一张可以贴在显示器旁边的速查卡
| 你要做的事 | 命令 |
|---|---|
| 看正在跑的 SQL | SHOW FULL PROCESSLIST; |
| 开慢查询日志 | SET GLOBAL slow_query_log=ON; long_query_time=1; |
| 聚合慢 SQL | pt-query-digest slow.log/mysqldumpslow -s t -t 10 |
| 看执行计划 | EXPLAIN/EXPLAIN FORMAT=JSON/EXPLAIN ANALYZE(8.0+) |
| 刷新统计信息 | ANALYZE TABLE t; |
| 查锁/长事务 | SELECT * FROM information_schema.INNODB_TRX; |
| 找重复/冗余索引 | pt-duplicate-key-checker |
| 看表大小与行数 | information_schema.TABLES(InnoDB 行数为估算) |
| 强制用某索引(慎用) | FORCE INDEX(idx_name) |
| 确认有没有走索引 | 看type是否为ALL、key是否为 NULL |
最后补一句比技术更重要的话:
每次处理完,记一条不超过三百字的复盘:现象 → 关键证据(贴EXPLAIN输出)→ 根因 → 改了什么 → 前后Rows_examined对比。
这份记录的价值在于:下一次半夜告警响的时候,你不用再从头推理一遍。排查能力的复利,来自记录,不来自经历。
而索引优化这件事,恰恰是最容易形成模式识别的领域——你见过的失效案例越多,下次定位就越快。
你遇到过最离谱的“索引加了却不生效”是什么情况?
欢迎在评论区说说(最好带上EXPLAIN的关键字段和你的最终结论)。我把“varchar 传 int”那条放上面了,期待有人比我更惨。也欢迎补充 PostgreSQL / TiDB / ClickHouse 的定位姿势——不同引擎的执行计划解读差异不小,评论区补全比正文更有价值。
⚠️几点说明:
- 文中命令为通用思路,不同版本(5.7 / 8.0 / 8.4)、不同云厂商托管实例的参数名与权限限制可能有差异(例如部分 RDS 不允许
SET GLOBAL,需通过控制台参数组修改),请以你实际环境为准。 - 生产环境操作请遵守变更规范:加删索引在大表上是 DDL,可能锁表或消耗大量 IO,建议使用 Online DDL /
pt-online-schema-change/ gh-ost 等工具,并走审批与灰度流程;ANALYZE TABLE在大表上也有开销。 - 本文不构成性能优化的唯一标准答案;同一现象在不同数据分布和业务形态下可能有完全不同的根因,以实际观测数据为准,不要套用结论。
- 不要把线上慢 SQL、表结构、业务字段名原样贴到公开论坛,分享案例请先脱敏。