MySQL 慢查询定位与索引失效:从 slow log 到 EXPLAIN

现象与影响面:先别急着加索引

典型症状:监控里接口 P99 从 80 ms 爬到 2 s,应用日志无异常,CPU 不高,数据库慢得像睡着了。影响面通常是渐进的——起初只在业务高峰慢,后来全天都慢。这时候最常见也最贵的错误动作是凭着直觉给某个列加索引:加对了是运气,加错了不光没用,还吃写入放大和优化器选择空间。这篇文章给的是一条可复现的排查主线:先让慢查询现形,再读执行计划,最后对照索引失效场景修。走完主线还解决不了的,才轮到参数调优和架构手段。前置知识:连接层的问题(连接打满、谁占着连接)与本文互补,本文往下钻一层,到“单条查询层”。

第零步:确认锅是数据库的

接口慢不等于数据库慢,先进数据库这扇门前花五分钟排除外围。方法是在应用侧分段计时(HTTP 入口到 handler、handler 到 SQL 返回各打一个点),或在网关看整体耗时与应用日志里的 SQL 耗时做差:如果 SQL 耗时只占接口耗时的零头,瓶颈在应用或下游,本文帮不了你;如果大头落在 SQL 上,才继续往下。另一个容易误诊的方向是连接等待——耗时发生在”拿连接”而不是”执行查询”,那是连接数打满的领域,先用 SHOW PROCESSLIST; 看一眼是查询在跑还是连接在等。确认是查询本身慢,进第一步。

第一步:让慢查询现形

没有慢日志,一切优化都是猜。慢查询日志(slow query log)把超过阈值的 SQL 原样记进文件,这是唯一的”现场证据”。在数据库实例上执行(注意:所有 SQL 在 MySQL 里跑, pt-query-digest 在有 Percona Toolkit 的机器上跑):

-- 开启慢日志。SET PERSIST 会同时写入内存与 mysqld-auto.cnf,重启不丢(MySQL 8.0+)
SET PERSIST slow_query_log = ON;
-- 阈值按业务定:线上常见 0.5s 或 1s;排障期可临时压到 0.1s 抓更多现场
SET PERSIST long_query_time = 0.5;
-- 为什么顺手开这两个:不走索引的查询哪怕 0.01s 也记录;配合 min_examined_row_limit
-- 设一个下限(如 100),避免 tiny 表的小查询刷爆日志
SET PERSIST log_queries_not_using_indexes = ON;

-- 看配置是否生效、文件写到哪
SHOW VARIABLES LIKE 'slow_query_log_file';

拿到原始日志后别逐条读——里面 90% 是重复语句的不同次执行。用 Percona Toolkit 的 pt-query-digest 按指纹聚合,直接给你按总耗时排序的榜单:

# 聚合慢日志:输出按总耗时排序的查询排行 + 每条的时间分布
1. --limit 10 只看前十名,排障从第一名开始,它通常占总耗时的大头
pt-query-digest --limit 10 /var/lib/mysql/<主机名>-slow.log

榜单里每条查询的关键数字是总耗时占比、平均耗时、检查行数(Rows_examined)。平均耗时高但只执行两次的,先放一边;执行一万次、每次 300 ms 的,才是第一目标。这一层正常就往下走:拿着榜首 SQL 进第二步。

第二步:EXPLAIN 逐列解读

EXPLAIN 是优化器对”这条查询打算怎么执行”的自述。把榜首 SQL 原样加个前缀跑(在 MySQL 里执行):

EXPLAIN SELECT o.id, o.amount FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.tenant = 'shop-a' AND o.status = 2
ORDER BY o.created_at DESC LIMIT 20;

输出一张计划表,逐列怎么读:

列 看什么 危险信号
type 访问方式,效率从好到坏大致是 const > eq_ref > ref > range > index > ALL ALL(全表扫描);index(扫整棵索引树,常被误当好事,其实也是全扫)
key / possible_keys 实际用了哪个索引 / 优化器考虑过哪些 possible_keys 有值但 key 为 NULL——索引存在却没用上,直接跳第三步对照失效场景
rows 预估扫描行数 返回 20 行却预估扫几十万行——放大一万倍就是慢的原因本身
key_len 索引实际用了几列(联合索引用到第几段) 联合索引只用了第一列,后面几列的条件没吃进索引
Extra 额外动作,信息量最大的一列 Using filesort(排序没走索引,内存/磁盘排序);Using temporary(建临时表,GROUP BY/DISTINCT 常见);两者同时出现且查询高频,优先治理

Extra 里也有好信号:Using index 表示覆盖索引(查询所需列全在索引里,不用回表),这是 SELECT 只读几列场景追求的形态。拿样例查询可能出现的两份计划对照一下读法:一份是 type=ALL, key=NULL, rows=480000, Extra=Using where; Using filesort——orders 全表扫 48 万行再排序,慢得理直气壮;另一份是 type=ref, key=idx_user_status_created, rows=210, Extra=Using index condition——同样的结果只碰 210 行,这就是修复后的样子。读计划的顺序建议:先看 type 定位全扫,再看 key 确认索引使用,最后看 rows 与 Extra 判断扫描量与附加动作。这一步的产物是一句诊断,比如”orders 走了全表扫描且 Using filesort”——它决定第三步你去对照哪张失效场景表。

第三步:对照索引失效八大场景

“索引建了却没被用上”是慢查询里占比最高的一类根因。八个高频场景,每个给”错例 → 为什么失效 → 怎么改”:

场景 错例 失效原因与改法
函数包裹索引列 WHERE DATE(created_at) = ‘2026-04-01’ 列被函数包住后优化器无法使用其索引。改成范围条件:created_at >= '2026-04-01' AND created_at < '2026-04-02'
隐式类型转换 order_no 是 varchar,WHERE order_no = 13800138000 数字与字符串比较时字符串列被转成数字,索引失效。参数按列类型传字符串:order_no = '13800138000'
前导 % 的 LIKE WHERE name LIKE ‘%手机’ 前导通配符让 B+ 树前缀匹配失效。改后缀通配 '手机%',或用全文索引(FULLTEXT)/ 外置搜索引擎
违反最左前缀 索引 (a, b, c),WHERE b = 1 AND c = 2 联合索引必须从最左列开始连续使用。调整索引列序或让条件带上 a
OR 条件混用 WHERE a = 1 OR b = 2,仅 a 有索引 OR 两侧任一侧无索引,整条退化为全扫(index merge 不保证触发)。拆成 UNION ALL 两段各自走索引,或给 b 补索引
不等号与低选择性 WHERE status != 1,而 status=1 只占 10% 条件命中 90% 的行,优化器弃索引走全表是正确决策。这类要改设计:部分索引/覆盖索引,或把查询改写成等值条件
字符集或排序规则不一致 两表 JOIN,一边 utf8mb4_0900_ai_ci 一边 utf8mb4_general_ci JOIN 键排序规则不同无法直接走索引。统一表级 collation;迁移历史表时这是最常踩的静默坑
优化器选错索引 明明有更优索引,计划却选了低区分度那条 先 ANALYZE TABLE t; 刷新统计信息;仍不对再 FORCE INDEX(idx_xxx) 逃生。注意:FORCE 是逃生舱不是常驻代码,加注释说明原因并定期回顾

这层正常就往下走:场景对上、改完 SQL 或索引,重新 EXPLAIN 验证 type 与 rows 变化,再压测确认。

第四步:MySQL 8.0+ 的三个直接手段

如果静态计划看不出来(预估与实际差很远),8.0 系有几个”直接看真相”的工具,别绕过它们硬猜:

-- EXPLAIN ANALYZE(8.0.18+):真执行一遍并给出每步实际耗时与实际行数,
-- 预估 rows 与 actual rows 对不上 = 统计信息过期,先 ANALYZE TABLE
EXPLAIN ANALYZE SELECT ... ;

-- 不可见索引(8.0+):先把索引藏起来观察查询计划,确认没人依赖再真删。
-- 比直接 DROP INDEX 安全一个量级,删索引前都该走这一步
ALTER TABLE orders ALTER INDEX idx_old SET INVISIBLE;

-- 降序索引(8.0.8 起真正生效,InnoDB 完整支持):
-- 8.0 之前索引定义里的 DESC 会被解析但静默忽略;
-- ORDER BY x DESC, y ASC 这类混合排序,8.0+ 可以建 (x DESC, y ASC) 让排序走索引消掉 filesort
CREATE INDEX idx_recent ON orders (user_id, created_at DESC);

根因清单:按出现频率排

根因 特征 一行验证
索引失效(上表八类) EXPLAIN 显示 ALL / key 为 NULL 对照错例改写后 EXPLAIN 复验
缺索引 高频等值/范围条件列不在任何一个索引的最左前缀里 补索引后 Rows_examined 下降一个量级
大 OFFSET 深分页 LIMIT 100000, 20 型查询,扫描前 10 万行再丢弃 改游标分页(WHERE id < 上页末 id LIMIT 20)
回表过多 rows 小但耗时高,SELECT * 拖回表 建覆盖索引,Extra 出现 Using index
统计信息过期 预估 rows 与实际严重不符 EXPLAIN ANALYZE 对比后 ANALYZE TABLE
单表数据量到量级天花板 索引与 SQL 都没问题,仍慢 归档冷数据 / 分区 / 读写分离,参数调优救不了量级问题

预防:三件事

第一,慢日志常开(阈值放宽到不刷屏的程度),每周跑一次 pt-query-digest 看趋势,别等用户报障才想起它。配套要盯的指标收敛成一张小表:

指标 看什么 异常信号
慢查询增速(慢日志条数/小时) 新低效 SQL 是否在滋生 发布后曲线跳变——大概率是那次发布带进来的
Threads_running 并发执行中的查询数 持续走高 = 查询变慢在堆积,离连接打满不远
慢日志文件体积 日志自身的磁盘占用 高频低效查询能把日志写到撑盘,轮转与水位告警要配套
榜首查询的排名变化 周度 digest 榜单对比 新面孔冲进前三 = 数据量或执行计划变了

第二,索引变更走灰度:新索引直接上线、旧索引直接删都是有风险动作——删之前先 SET INVISIBLE 观察(见第四步),确认没有隐藏依赖再动手。第三,把 EXPLAIN 检查前移:新 SQL 上线前在评审里贴执行计划,type=ALL 且目标表会增长到十万行以上的,当场打回。

结论与边界

这条主线的价值在于每一步都有可留存的证据:慢日志榜单是”修哪条”,EXPLAIN 是”为什么慢”,失效场景表是”怎么改”。同样诚实地说边界:如果根因落在”单表数据量到天花板”,加索引和改 SQL 都只是缓刑,那时该讨论的是归档、分区还是架构拆分。处理的是这类慢查询最常见的连锁反应——查询变慢导致连接堆积。

延展阅读:容量与架构层面的取舍见《多节点集群架构规划》。

版本核实(2026-11 检索):降序索引自 MySQL 8.0.8 生效(此前 DESC 被解析但忽略,InnoDB 完整支持);EXPLAIN ANALYZE 需 8.0.18+;不可见索引为 8.0+ 特性;SET PERSIST 为 8.0+ 特性;pt-query-digest 属 Percona Toolkit。以上以 dev.mysql.com 官方文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇