Skip to content

慢 SQL 从发现到根治的完整流程 —— 慢查询优化实战 ​

属于 S1 MySQL 深入 · 深入篇第 4 章(重点章节) 上一篇:索引与 SQL 优化 下一篇:主从复制与高可用

《索引与 SQL 优化》讲清楚了"索引为什么生效/失效"。但线上真实的场景是:你根本不知道哪条 SQL 慢,也不知道它为什么慢;更扎心的是——索引已经建得很合理,SQL 还是慢。这时候 EXPLAIN + 建索引 这套"入门三板斧"就失效了,真正的战斗才刚刚开始。

这一篇是实战章:先讲"怎么发现慢 SQL、怎么定位根因",再上实战中更高频的六大类进阶优化手段(按实战价值排序):重构 SQL 逻辑 → 根治隐式转换与函数 → 深挖排序分组 → 数据量大的物理手段 → 调整连接与锁的姿势 → SQL Hints 干预执行计划,最后是血泪避坑总结 + 完整案例。


第一步:怎么发现慢 SQL —— 慢查询日志 ​

MySQL 默认不记录慢查询,需要手动打开(生产建议打开,代价很小):

sql
-- 查看当前状态
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';

-- 动态开启(重启失效;要永久生效改 my.cnf 的 [mysqld] 段)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;        -- 超过 1 秒的记录,单位秒
SET GLOBAL log_queries_not_using_indexes = ON;  -- 没走索引的也记录(开发环境开)

分析工具:

bash
# MySQL 自带:按平均查询时间排序
mysqldumpslow -s t -t 10 /var/lib/mysql/slow.log

# 社区神器:pt-query-digest(Percona Toolkit),输出报表更专业
pt-query-digest /var/lib/mysql/slow.log

正确姿势:把慢日志拉到本地用 pt-query-digest 汇总 → 按"总耗时 = 次数 × 单次耗时"排序 → 优先优化"次数多且单次慢"的语句(总耗时最大,收益最高),而不是只看单次最慢的。

注意:long_query_time=1 只抓 1 秒以上的;很多慢 SQL 单次 200ms 但每秒执行 100 次,累计耗时才是大头。抓取阈值和业务容忍度匹配(核心接口 P99 的容忍度就是你的阈值)。

第二步:怎么定位根因 —— EXPLAIN 全字段精讲 ​

《索引与 SQL 优化》讲了 type/key/rows/Extra 四个关键列,这一篇补齐剩下的字段,凑成完整的地图:

sql
EXPLAIN SELECT u.id, u.name, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.city = '深圳' AND o.status = 1
ORDER BY o.created_at DESC
LIMIT 10;
字段含义判断要点
id执行步骤编号,越大越先执行id 相同 → 从上往下;id 不同 → 大者先
select_type查询类型SIMPLE 简单查询 / PRIMARY 外层 / SUBQUERY 子查询 / DERIVED 派生表
table访问哪张表(含别名)—
type访问类型const > eq_ref > ref > range > index > ALL,目标是 range 及以上
possible_keys可能用到的索引有值但 key 为空 = 优化器评估后放弃了
key实际用的索引NULL = 没走索引,重点排查
key_len用到的索引字节数联合索引看它判断"用到第几列"(数字大 = 用到的列多)
ref索引匹配的列/常量—
rows预估扫描行数越小越好;与真实偏差大 = 统计信息过期
filtered过滤比例(%)100% 表示没过滤,越小说明 WHERE 筛选越狠
Extra附加信息见下表,重点信号

Extra 里的关键信号(血泪教训:别只盯 rows,Using temporary 和 Using filesort 出现就意味着必有大坑,优先干掉它们):

Extra含义处理
Using index覆盖索引✅ 最优
Using where存储引擎返回后 Server 层再过滤正常(配合 type=ref 等)
Using index condition索引下推(ICP)✅ 8.0 常见,好
Using filesort额外排序⚠️ CPU 杀手:排序字段没进索引,想办法消除
Using temporary用了临时表⚠️ 常见于 GROUP BY/DISTINCT 无索引,最差要避免
Using join bufferJOIN 没走索引,用了 join buffer⚠️ 右表关联列要建索引,或调大 join_buffer_size(见手段五)
Using where; Using index覆盖 + 过滤✅ 好

rows 不可全信:它是基于统计信息(SHOW STATISTICS / information_schema)的估算。表数据变化大但统计信息没更新(ANALYZE TABLE),优化器会选错索引——这是"明明有索引却不走"的一大原因。

第三步:优化器为什么"不听话" —— OPTIMIZER_TRACE ​

当 EXPLAIN 显示优化器没用你预期的索引时,打开优化器追踪看它到底怎么想的:

sql
SET optimizer_trace = 'enabled=on';
SELECT * FROM orders WHERE status = 1 AND created_at > '2024-01-01' ORDER BY created_at;
SELECT * FROM information_schema.OPTIMIZER_TRACE\G   -- 看 JSON 输出
SET optimizer_trace = 'enabled=off';

trace 里重点看 rows_estimation(各索引的行数估算)和 considered_execution_plans(对比了哪些方案、为什么选了这个)。常见"不听话"原因:

  1. 统计信息过期 → ANALYZE TABLE t; 更新统计。
  2. 强制索引比全表扫更贵:数据量小、或回表比例太高(比如过滤条件命中 60% 的行),优化器判断全表扫更快。这时别硬塞索引,先看 SQL 写法/查询需求。
  3. 实在要干预:SELECT ... FORCE INDEX (idx_name) ...(临时手段,不推荐长期用,索引一改就失效;正确做法是让优化器自己选对)——详见手段六。

第四步:六大类优化手段(按实战高频排序) ​

手段一:重构 SQL 逻辑 —— 减量比提速更狠 ​

索引是"提速",重构是"减量"。很多时候 SQL 慢不是索引不行,而是要处理的数据量太大。三个立竿见影的重构:

① 改 SELECT * 为覆盖索引字段:强制只查索引树里有的字段,Extra 显示 Using index,免回表。

sql
-- 慢:SELECT * 每行都要回表取全字段
SELECT * FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 10;

-- 快:高频列表只查 id, name, status,配合联合索引 (status, created_at, id) 直接覆盖
SELECT id, name, status FROM orders WHERE status = 1 ORDER BY created_at DESC LIMIT 10;

② 改"大 OFFSET"为"游标 / 延迟关联":LIMIT 100000, 10 会让数据库扫描 10 万行再丢 9.9 万行。实战做法是先走覆盖索引取主键,再回表:

sql
-- 慢:扫描 10 万行再丢弃
SELECT * FROM orders ORDER BY id LIMIT 100000, 10;

-- 快(延迟关联):子查询只扫索引树(覆盖索引,无回表),再连表取全行
SELECT * FROM orders t1
JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 10) t2 ON t1.id = t2.id;

-- 更快(游标分页):记住上一页最后一个 id,扫描量从 10 万降到 10
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;

③ 改"复杂关联"为"多次查询":微服务/高并发场景,3 张表以上的 JOIN 往往不如拆成多次单表查询,在应用内存里做关联——尤其分库分表后,跨库 JOIN 是大忌(数据不在一个实例,物理上就 JOIN 不了)。

go
// 慢的源头:一次 3 表 JOIN,锁 + 网络 + 临时表全压在数据库
// SELECT u.name, o.amount, p.title FROM users u
//   JOIN orders o ON u.id = o.user_id JOIN products p ON o.product_id = p.id
//   WHERE u.id = 123;

// 重构:3 次单表查询,应用层组装(数据量小时更快、更好缓存、更好扩展)
order := db.Query(ctx, "SELECT * FROM orders WHERE user_id = ?", 123)
product := db.Query(ctx, "SELECT * FROM products WHERE id = ?", order.ProductID)
user := db.Query(ctx, "SELECT * FROM users WHERE id = ?", 123)

适用边界:拆查询适合"返回行数少、能命中各自索引、可接受多一次网络往返"的场景;如果本来就是走索引的大结果集 JOIN,或数据在同一个实例且 JOIN 走索引(NLJ)很顺,别盲目拆——拆错反而多 N 次网络 + N 次索引查找。判断标准:单表能过滤掉绝大多数行,再拆。

手段二:根治"隐式类型转换"和"函数破坏索引"(最隐蔽的慢查询) ​

这是最隐蔽的一类:EXPLAIN 看起来用了索引,但实际只用到了一小部分数据——因为列被转换/运算后,索引的有序性被破坏了。

隐蔽场景错误写法正确写法
隐式类型转换WHERE phone = 13800138000(phone 是 varchar,全表转数字再比较,索引失效)WHERE phone = '13800138000'(传字符串)
索引列做函数/运算WHERE DATE(create_time) = '2026-08-24'(列被函数包裹)WHERE create_time >= '2026-08-24 00:00:00' AND create_time < '2026-08-25 00:00:00'(范围查询,索引友好)
前导模糊WHERE name LIKE '%关键词%'(前缀未知,无法定位起始)WHERE name LIKE '关键词%';必须前后模糊就上 Elasticsearch,别死磕数据库

补充:age + 1 > 30(列上运算)、LEFT(name, 1) = '张'(函数)同理失效;字符串列与数字列做 = 时,数字会被转成字符串还是字符串转数字,取决于类型——phone varchar = 数字 是字符串列转数字,必失效。

手段三:深挖"排序"与"分组"的陷阱(filesort / 临时表是 CPU 杀手) ​

ORDER BY 和 GROUP BY 导致的 Using filesort(文件排序)和 Using temporary(临时表)是 CPU 杀手,也是最常被忽略的两项。

① 排序走索引:让 ORDER BY 的字段和 WHERE 的字段组成联合索引,且顺序严格一致。

sql
-- WHERE a=1 ORDER BY b:建索引 (a, b)
-- B+ 树先按 a 定位,a 相同时天然按 b 有序 → 直接顺序取,免 filesort
SELECT * FROM t WHERE a = 1 ORDER BY b LIMIT 10;
ALTER TABLE t ADD INDEX idx_a_b (a, b);   -- 等值列在前、排序列在后

② 分组前先过滤:GROUP BY 很重(要排序 + 建临时表),务必先用 WHERE 筛掉 90% 的数据再分组;能用 WHERE 就绝不用 HAVING 过滤(HAVING 在分组后才执行)。

sql
-- 慢:先全表分组再筛组
SELECT city, COUNT(*) FROM users GROUP BY city HAVING status = 1;
-- 快:先 WHERE 过滤再分组(行数骤减,分组开销直线下降)
SELECT city, COUNT(*) FROM users WHERE status = 1 GROUP BY city;

③ 拒绝 DISTINCT 滥用:DISTINCT 本质也是排序去重。能用 EXISTS 代替时,优先 EXISTS:

sql
-- 慢:DISTINCT 对全结果排序去重
SELECT DISTINCT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE o.status = 1;

-- 快:EXISTS 命中即停,不去重不排序
SELECT u.name FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 1);

手段四:数据量巨大的物理手段(降维打击) ​

当单表数据过亿,索引本身也变得臃肿(索引比数据还大、B+ 树层数变高、Buffer Pool 装不下),SQL 层面的优化到顶了,就需要物理层面动刀。三招按性价比排序:

① 冷热分离(归档)——性价比最高:把 3 年前的历史数据迁移到历史库/归档表,在线库只留热数据。比任何索引都管用——数据量减半,索引、Buffer Pool、扫描量全部跟着减。

sql
-- 归档:把 2023 年之前的订单搬到 history_orders,再删除在线库数据(分批删,见手段五)
INSERT INTO history_orders SELECT * FROM orders WHERE created_at < '2023-01-01';
-- 分批删除,避免一次性大事务锁死
DELETE FROM orders WHERE created_at < '2023-01-01' LIMIT 1000;  -- 循环执行

② 分区表(Partitioning):按日期范围分区(如每天/每月一个分区)。查询带上分区键,数据库裁剪分区,只扫对应区:

sql
CREATE TABLE orders (
  id BIGINT, created_at DATETIME, ...
) PARTITION BY RANGE (TO_DAYS(created_at)) (
  PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
  PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
  PARTITION p_max VALUES LESS THAN MAXVALUE
);
-- 查询带分区键 created_at,EXPLAIN 里 partitions 列只显示命中分区

注意:分区键必须是主键/唯一键的一部分(InnoDB 限制);分区数不宜过多(上千个分区元数据开销反而大)。分区解决的是"扫描量",不是"并发写"——写并发高要上分库分表。

③ 分库分表(Sharding):按用户 ID 哈希分 16/64 张表,把压力打散(详见 S5 高并发场景题"海量数据分片"与 backlog)。三个必须提前知道的代价:

  • ID 生成要换雪花算法(分表后自增 ID 会撞);
  • 跨表聚合查询变复杂(ORDER BY 全局排序、COUNT 求和都要在应用层/中间件做);
  • 跨库事务基本没戏(别指望 2PC,按最终一致设计)。

面试判断标准:分库分表是"最后的手段"。先冷热分离,再分区表,扛不住了才分库分表——上来就分库分表的,基本是没把前面三步做透。

手段五:调整"连接"与"锁"的姿势(等锁比执行更慢) ​

有时候 SQL 本身不慢,是等锁等慢了(Waiting for table metadata lock、行锁等待、Lock wait timeout exceeded)。优化"锁的姿势"往往被忽视,但实战价值极高:

① 事务里快查快放:把 SELECT ... FOR UPDATE 放在事务最后,缩小锁范围;不要在事务里做远程 RPC 调用或大批量循环查询——锁持有时间和事务长度成正比:

go
// 坏:事务里做 RPC(锁持有几十 ms ~ 几百 ms)
tx.Begin()
row := tx.Query("SELECT * FROM account WHERE id = 1 FOR UPDATE")
resp := rpc.Call("deduct", ...)   // 远程调用,锁一直拿着!
tx.Commit()

// 好:先取数(不加锁/快照读),RPC 在外,最后才加锁改
tx.Begin()
row := tx.Query("SELECT * FROM account WHERE id = 1")   // 快照读,不加锁
resp := rpc.Call("deduct", ...)
tx.Query("UPDATE account SET balance = ? WHERE id = 1", resp.Amount)
tx.Commit()

② 拆分大事务:一个事务更新 10 万行,会产生巨大的行锁 + undo 日志(回滚段膨胀),还可能拖垮主从复制(binlog 单事务过大)。实战改为批次循环:

sql
-- 坏:一次 UPDATE 10 万行,锁 10 万行 + undo 巨大
UPDATE orders SET status = 5 WHERE status = 1;

-- 好:分批 LIMIT 1000,循环 100 次提交,中间让出锁资源
UPDATE orders SET status = 5 WHERE status = 1 LIMIT 1000;  -- 应用层循环,每次间隔 SLEEP(0.1)

③ 调整 join_buffer_size:如果被迫有 JOIN 且无法走索引(驱动表全表扫描),适当调大 join_buffer_size 可减少临时表落盘(BNL 模式在内存里批处理):

sql
-- 查看当前值(默认 256KB)
SHOW VARIABLES LIKE 'join_buffer_size';
SET GLOBAL join_buffer_size = 4194304;   -- 4MB(每个 JOIN 连接都会分配,别盲目调大)

注意:join_buffer_size 是每个连接、每个 JOIN都分配一份,调太大会吃光内存;它是"全表扫描 JOIN 的兜底",治标不治本——根本解法还是给被驱动表关联列建索引(见 S5 的 JOIN 优化)。

手段六:SQL Hints 干预执行计划(终极武器,慎用) ​

当优化器(CBO)选错索引时——比如明明有更好的索引,它却用了全表扫描——可以用 Hints 强行指定。这是"最后一招",使用前必须先 ANALYZE TABLE 确认统计信息没骗人:

① FORCE INDEX 强制索引:

sql
-- 优化器走了全表扫描,但我们知道 idx_create_time 更快
EXPLAIN SELECT * FROM orders WHERE create_time > '2024-01-01';
-- 强行指定索引
SELECT * FROM orders FORCE INDEX (idx_create_time) WHERE create_time > '2024-01-01';

② STRAIGHT_JOIN 强制驱动表顺序:优化器可能用大表驱动小表(灾难),STRAIGHT_JOIN 强制按书写顺序执行:

sql
-- 默认:优化器可能先执行大表 orders(全表扫当驱动表)
SELECT * FROM orders o JOIN users u ON o.user_id = u.id WHERE u.vip = 1;

-- 强制:先执行小表 users,再驱动 orders(必须把"小表"写在前面)
SELECT STRAIGHT_JOIN * FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.vip = 1;

使用原则(必背):

  1. Hints 是临时手段:索引一变更、数据分布一变,Hints 就会失效甚至帮倒忙;
  2. 先 ANALYZE TABLE + OPTIMIZER_TRACE 搞清楚优化器为什么选错,再考虑 Hints;
  3. 长期方案是让优化器自己选对:更新统计信息、加更合适的索引、改写 SQL 让成本估算更准——Hints 只在"线上紧急止血"时用。

第五步:一个完整案例 —— 从慢日志到验证 ​

背景:电商订单接口变慢,pt-query-digest 报表显示一条 SQL 占总耗时 60%。

Step 1 抓到 SQL(慢日志):

sql
SELECT * FROM orders
WHERE user_id = 12345 AND status IN (1, 2)
ORDER BY created_at DESC LIMIT 20;

Step 2 EXPLAIN 定位:

text
type: ALL        ← 全表扫描!
key: NULL        ← 没走索引
rows: 2,400,000  ← 扫了全表
Extra: Using where; Using filesort

Step 3 分析根因:user_id 有索引 idx_user_id,为什么没用?—— 查询里 status IN (1,2) + ORDER BY created_at,优化器算了下:用 idx_user_id 找到该用户所有订单(可能几千行)再 filesort;全表扫 + filesort 的行数估算反而…… 不对,这里真正的问题通常是:数据量大、统计信息过期,或优化器预估用 idx_user_id 后回表太多。

Step 4 对症下药(按优先级):

sql
-- 1) 先更新统计信息(10% 概率就是它)
ANALYZE TABLE orders;

-- 2) 覆盖索引:把查询要的列全塞进联合索引,免回表 + 免 filesort
ALTER TABLE orders ADD INDEX idx_user_status_ctime (user_id, status, created_at);
-- 现在 EXPLAIN 应显示:type=ref, key=idx_user_status_ctime, Extra=Using index condition(无 filesort)

Step 5 验证(必须实测,不能只看 EXPLAIN):

sql
EXPLAIN SELECT ... ;            -- 看 type/key/Extra 变化

-- 开 profiling 看真实耗时分布(EXPLAIN 只是预估!)
SET profiling = 1;
SELECT * FROM orders WHERE user_id = 12345 AND status IN (1, 2)
  ORDER BY created_at DESC LIMIT 20;
SHOW PROFILES;                  -- 对比优化前后的真实耗时

-- 防缓存干扰:SQL_NO_CACHE + 多跑几次取均值
SELECT SQL_NO_CACHE ... ;       -- 8.0 缓存默认关闭,重点是多次执行取 P50/P99
-- 优化前:1.2s;优化后:15ms → 80 倍提升

验证铁律:优化必须用 EXPLAIN + **真实执行时间(profiling / 多次实测)**双重验证;生产上线走灰度;每次优化只改一处,变量隔离才能归因。

第六步:实战避坑总结(血泪教训) ​

  1. 别只看 rows,优先看 Extra:Using temporary 和 Using filesort 出现就意味着必有大坑,优先干掉它们(对应手段三),它们的危害比 rows 大一个量级。
  2. 监控实际耗时,别信 EXPLAIN 的预估:EXPLAIN 是估算,必须 SET profiling=1; SHOW PROFILES; 看真实耗时;对比测试用 SQL_NO_CACHE + 多次执行取均值(避免缓存干扰)。
  3. 优化是"对症下药"不是"套餐式":一次只改一处、改完必验证、上线必灰度——否则多个变量混在一起,永远不知道是谁起的作用。
  4. 终极底线:如果上面六类手段都做透了,单次查询依然超过 1 秒,请放弃纯数据库解决方案——读多写少的查询接 Redis 缓存,复杂搜索/全文检索接 Elasticsearch,统计分析接 OLAP/数仓。数据库不是万能的,别死磕。

面试追问(能连答三层) ​

  1. Q:为什么 rows 很大但索引还是没用? → 优化器比较的是"全表扫成本 vs 走索引+回表成本",回表比例超过阈值(约 20% 行数)时全表扫更便宜;也可能统计信息过期导致成本估算错误(先 ANALYZE TABLE)。
  2. Q:NLJ 和 BNL 有什么区别? → NLJ 是逐行嵌套循环查被驱动表;BNL 先把驱动表批量读进 join buffer,再一次性与被驱动表匹配,减少被驱动表访问次数(用空间换 IO);BNL 是被驱动表无索引时的兜底,Using join buffer 出现就是它——调大 join_buffer_size 只能缓解,建索引才是根治。
  3. Q:什么情况下该用 FORCE INDEX? → 统计信息已更新、优化器仍选错(OPTIMIZER_TRACE 里能看到它算了但没选)时的线上止血手段;长期要靠更优的索引设计或 SQL 改写,Hints 会随索引变更失效。
  4. Q:分库分表和分区表怎么选? → 分区表解决"扫描量"(数据还在同一实例,单表过大、冷热明显时用);分库分表解决"并发写与容量"(单实例扛不住时用)。顺序:冷热分离 → 分区表 → 分库分表,别一上来就分库分表。

串起来 ​

慢查询优化是一条流水线:慢日志发现 → pt-query-digest 按总耗时排序 → EXPLAIN 看 type/key/rows/Extra → OPTIMIZER_TRACE 看优化器决策;索引合理还慢时,上六大类进阶手段:重构 SQL 减量(覆盖索引/延迟关联/拆 JOIN)、根治隐式转换与函数、深挖排序分组陷阱、数据量大的物理手段(冷热分离/分区表/分库分表)、调整锁与事务姿势、Hints 干预执行计划;最后用 profiling 实测 + SQL_NO_CACHE 验证,真优化不动就上 Redis / ES 兜底。掌握了这条链路,面试里任何"一条 SQL 慢怎么办"都能给出从工具到根因、从 SQL 到架构的完整回答。

下一篇进入 主从复制与高可用:单机 MySQL 扛不住读压力、也怕宕机丢数据,主从架构是怎么解决这两个问题的?

持续学习,持续构建。