news 2026/10/7 14:29:35

MySQL 慢查询:从定位到优化,一套可复用的排查流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL 慢查询:从定位到优化,一套可复用的排查流程

一句话结论:慢查询的根因通常不在 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 temporaryGROUP 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 的账不能一起算。

六、一张可以贴在显示器旁边的速查卡
你要做的事命令
看正在跑的 SQLSHOW FULL PROCESSLIST;
开慢查询日志SET GLOBAL slow_query_log=ON; long_query_time=1;
聚合慢 SQLpt-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 的定位姿势——不同引擎的执行计划解读差异不小,评论区补全比正文更有价值。

⚠️几点说明:

  1. 文中命令为通用思路,不同版本(5.7 / 8.0 / 8.4)、不同云厂商托管实例的参数名与权限限制可能有差异(例如部分 RDS 不允许SET GLOBAL,需通过控制台参数组修改),请以你实际环境为准。
  2. 生产环境操作请遵守变更规范:加删索引在大表上是 DDL,可能锁表或消耗大量 IO,建议使用 Online DDL /pt-online-schema-change/ gh-ost 等工具,并走审批与灰度流程;ANALYZE TABLE在大表上也有开销。
  3. 本文不构成性能优化的唯一标准答案;同一现象在不同数据分布和业务形态下可能有完全不同的根因,以实际观测数据为准,不要套用结论。
  4. 不要把线上慢 SQL、表结构、业务字段名原样贴到公开论坛,分享案例请先脱敏。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 14:29:15

cursor.execute(sql1, args) 多参数占位符统一用 %s 的写法与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 14:28:22

MCP详细介绍:从Function Calling到AI Agent的落地实践与TaoToken统一接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/7 14:27:28

STM32参考设计资源全攻略:从官方到开源的搜索方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华