
一句话结论慢查询的根因通常不在 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.7SELECT * FROM information_schema.INNODB_TRX\G SELECT * FROM performance_schema.data_lock_waits LIMIT10;# 3. 看机器层面是不是资源瓶颈CPU / IOtop-ciostat-x15如果PROCESSLIST里一堆线程卡在同一个 SQL 上 →是 DB 问题往下走。如果 DB 侧很干净但接口依然慢 →问题在上游应用层循环查库 N1、外部调用超时、GC、网络这时候死磕 SQL 是浪费时间。如果有大量锁等待 →这是锁问题不是慢查询问题方向完全不同查事务是否过大、是否有长事务未提交、索引缺失导致锁范围扩大。第 2 步把慢 SQL 抓出来方式 A慢查询日志最常用生产首选-- 查看状态SHOWVARIABLESLIKEslow_query%;SHOWVARIABLESLIKElong_query_time;-- 临时开启重启失效想永久改写 my.cnfSETGLOBALslow_query_logON;SETGLOBALlong_query_time1;-- 超过 1 秒记下来SETGLOBALlog_queries_not_using_indexesON;-- 没走索引的也记很有用日志默认在数据目录下的hostname-slow.log。分析它# 自带工具按总耗时排序看前 10 条mysqldumpslow-st-t10/var/lib/mysql/hostname-slow.log# 更推荐 pt-query-digestPercona Toolkit能做聚合、指纹归并pt-query-digest /var/lib/mysql/hostname-slow.logreport.txt关键看报告里的 “Query_time 总和” 和 “Rows_examined”而不是只看单条最慢的那条。方式 Bperformance_schema不想开慢日志时用-- MySQL 5.6开销比慢日志略高短期开UPDATEperformance_schema.setup_consumersSETENABLEDYESWHERENAMEevents_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 推荐信息更全EXPLAINFORMATJSONSELECT...\GEXPLAINANALYZESELECT...;-- 8.0真实执行 实际行数极有价值重点看这六个字段按优先级排字段看什么好 / 坏type访问类型const/eq_ref/ref/range✅ →ALL全表❌key实际用到的索引有名字 ✅ →NULL❌possible_keysvskey有候选但没用上两者不一致 索引存在但没命中这是最常见的故障形态rows估算扫描行数越小越好但它是估算值统计信息过期时会严重失真Extra附加信息Using index覆盖索引✅Using filesort、Using temporary❌filtered条件过滤后剩余比例很低说明索引选得不好或在回表后过滤type的粗略排序从好到坏systemconsteq_refreffulltextref_or_nullindex_mergeunique_subqueryindex_subqueryrangeindexALL实战中记住三个档位就行ref/range是及格线index是勉强ALL必须处理。Extra里几个高频信号Using index→ 覆盖索引不用回表很好。Using where→ 回表后再过滤一般意味着索引没覆盖全部条件。Using filesort→ 无法用索引完成排序需要额外排序数据量大时很痛。Using temporary→ 用了临时表常见于GROUP BY/DISTINCT无法用索引优化时。Using index condition→ ICP下推了一部分条件算好事。强烈建议用EXPLAIN ANALYZE8.0它给出实际行数与实际耗时能立刻暴露出“估算 rows1 实际扫了 200 万行”这种统计信息失真的情况——这是很多“索引加了却没生效”的真正原因。第 4 步对照清单找为什么没走索引这一步是整篇的核心。索引失效的原因其实很有限九成落在下面这几类① 最左前缀没满足联合索引头号杀手CREATEINDEXidx_a_b_cONt(a,b,c);SELECT*FROMtWHEREb1ANDc2;-- 用不上缺 aSELECT*FROMtWHEREa1ANDc2;-- 只用上 ab 断了后面 c 也用不上SELECT*FROMtWHEREa1ANDb1;-- ✅ 用上 a,bSELECT*FROMtWHEREa1ORDERBYb;-- ✅ 排序也能用上**口诀等值条件放前面范围条件放最后。**因为范围条件、、BETWEEN、LIKE x%之后的列无法再用于索引查找。② 对索引列做了运算或套了函数WHEREDATE(create_time)2026-10-01-- ❌ 函数破坏索引WHEREcreate_time2026-10-01ANDcreate_time2026-10-02-- ✅WHEREuser_id1100-- ❌WHEREamount*0.9100-- ❌规则让索引列单独出现在比较运算符的一侧。③ 隐式类型转换极其隐蔽极其常见字段是VARCHAR你传数字WHEREphone13800138000-- ❌ 字符串列接数字字面量全员转换索引失效WHEREphone13800138000-- ✅反过来数字列传字符串通常能用索引但字符集/collation 不一致如表是utf8mb4参数来自utf8的连接或另一张表的 join 列同样会触发隐式转换。**EXPLAIN里出现Using where且 warning 里有Conversion→ 基本就是它。**可以用SHOW WARNINGS;确认。④ 左模糊 / 双模糊WHEREnameLIKE%张-- ❌ 一定全表WHEREnameLIKE张%-- ✅ 最左前缀匹配能用必须双模糊的场景如站内搜索不要用LIKE硬扛考虑全文索引或专门的搜索引擎。⑤OR两边不都有索引WHEREa1ORb2-- 若 a、b 各自有索引可能走 index_merge否则全表可改写为UNION ALL分别走索引或者合并成一个联合索引。⑥ORDER BY/GROUP BY与索引方向不一致WHEREa1ORDERBYc-- idx(a,b,c) 用不上排序产生 Using filesort⑦SELECT *导致无法覆盖需要的列都在索引里 → 覆盖索引不回表多取一个大TEXT字段 → 必须回表甚至产生临时表。少取一列有时就是Using index和Using where的差别。⑧ 统计信息过期 / 优化器选错了索引表现索引明明在possible_keys也有但key是 NULL或者选了另一个更差的索引。ANALYZETABLEt;-- 更新统计信息先做这个EXPLAINFORMATJSON...;-- 看优化器成本决策-- 万不得已才用会让 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 结论是不可信的。四、八种高频场景与对应解法现象大概率原因处理全表扫描typeALL缺索引 / 触发了上面九条之一补联合索引先核对失效原因Using filesort 分页很慢排序无法用索引让ORDER BY命中联合索引前缀深度分页LIMIT 100000, 20扫到 10 万行再丢弃延迟关联见下或游标式翻页Using temporaryGROUP BY/DISTINCT无索引可用调整分组列顺序或预聚合索引存在但keyNULL隐式转换 / 统计信息过期 / 选择性差SHOW WARNINGS、ANALYZE TABLE、换列回表太多rows 大索引没覆盖查询列加覆盖索引或只取主键再回表写入变慢、磁盘涨索引过多 / 重复索引pt-duplicate-key-checker清理偶发抖动、平时很快锁等待 / 大事务 / 刷脏页查INNODB_TRX、拆分大事务、调innodb_flush_log_at_trx_commit等两个我最常用的具体手法手法一延迟关联解决深度分页-- 原来回表 100020 行丢弃 100000 行SELECT*FROMordersWHEREstatus1ORDERBYidLIMIT100000,20;-- 改写先在覆盖索引上定位 20 个主键再回表 20 次SELECTo.*FROMorders oJOIN(SELECTidFROMordersWHEREstatus1ORDERBYidLIMIT100000,20)tONo.idt.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显示rows3我看着很放心。上线后直接打挂。原因是统计信息很久没更新实际扫描了两百多万行。现在我的规矩8.0 一律用EXPLAIN ANALYZE看真实行数5.7 用ANALYZE TABLE刷新后再看并且对rows保持怀疑。车三为了一个后台报表加了三个索引。报表是好了第二天订单高峰期的写入 RT 涨了 40%因为每张订单要多维护三份 B 树。后来把那三个索引删掉改成夜里跑预聚合表两边都好了。教训写多读少的表索引要极度克制读多写少的表可以适度放宽。OLTP 和 OLAP 的账不能一起算。六、一张可以贴在显示器旁边的速查卡你要做的事命令看正在跑的 SQLSHOW FULL PROCESSLIST;开慢查询日志SET GLOBAL slow_query_logON; long_query_time1;聚合慢 SQLpt-query-digest slow.log/mysqldumpslow -s t -t 10看执行计划EXPLAIN/EXPLAIN FORMATJSON/EXPLAIN ANALYZE8.0刷新统计信息ANALYZE TABLE t;查锁/长事务SELECT * FROM information_schema.INNODB_TRX;找重复/冗余索引pt-duplicate-key-checker看表大小与行数information_schema.TABLESInnoDB 行数为估算强制用某索引慎用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、表结构、业务字段名原样贴到公开论坛分享案例请先脱敏。