mysql-slow-query

Category: Data Risk: Low risk niuwoai/skills CC-BY-4.0

name: mysql-slow-query
description: MySQL 慢查询定位与索引优化。当接口变慢、数据库 CPU 打满、慢日志堆积、要看执行计划、要加索引又怕加错、或者遇到深分页和 N+1 时使用。触发词:慢查询、慢 SQL、SQL 优化、加索引、索引失效、EXPLAIN、执行计划、深分页、数据库 CPU 高、锁等待、死锁、N+1。不负责 MySQL 部署运维和主从复制搭建。

MySQL 慢查询定位与优化

顺序是先找到、再看懂、后动手。绝大多数「数据库慢」的现场,跳过前两步直接加索引,结果是索引越加越多、写入越来越慢、慢查询还在。

一、找到慢在哪

开慢日志。 生产上 long_query_time 设 1 秒起步,不要设 0(会把日志写爆)。

SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';

聚合分析,不要逐条看。pt-query-digestmysqldumpslow 把慢日志按「SQL 指纹」归并,按总耗时排序,不是按单次耗时。

判断依据:一条 5 秒执行 10 次的 SQL(总 50 秒),远不如一条 0.2 秒执行 5000 次的(总 1000 秒)重要。后者才是 CPU 杀手,而且它经常不在慢日志里,因为单次没超阈值。

在线抓现行。 数据库正在冒烟时:

SELECT id, user, db, command, time, state, LEFT(info, 200)
FROM information_schema.processlist
WHERE command <> 'Sleep' AND time > 1
ORDER BY time DESC;

state 列:Sending data 通常是扫行多,Copying to tmp table 是排序或分组落磁盘,Waiting for table metadata lock 是有 DDL 卡着。

没有慢日志的情况下,用 performance_schema 的语句摘要:

SELECT digest_text, count_star, avg_timer_wait/1e9 AS avg_ms,
       sum_timer_wait/1e12 AS total_s, sum_rows_examined/count_star AS avg_rows
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC LIMIT 20;

avg_rows(平均扫描行数)是最有价值的一列。扫描行数远大于返回行数,就是索引问题。

二、看懂执行计划

EXPLAIN ANALYZE SELECT ...;   -- MySQL 8.0.18+,给出实际耗时和实际行数
EXPLAIN FORMAT=JSON SELECT ...;

优先用 EXPLAIN ANALYZE。普通 EXPLAINrows 是估算值,经常和实际差一个数量级。

按这个顺序读:

看什么 危险信号 含义
type ALL 全表扫描
type index 全索引扫描,也很慢
key NULL 没走索引
rows × 嵌套层数 数量级远超返回行数 索引选择性差或没走上
Extra Using filesort 排序没走索引
Extra Using temporary 用了临时表,通常是 GROUP BY / DISTINCT
Extra Using where 且 rows 大 索引没过滤掉多少,回表后再过滤

Using index 是好东西,代表覆盖索引,不回表。

三、索引失效的常见原因

按现场出现频率排序:

  1. 在索引列上做运算或调用函数。 WHERE DATE(created_at) = '2026-09-03' 走不了索引。改成范围:WHERE created_at >= '2026-09-03' AND created_at < '2026-09-04'
  2. 隐式类型转换。 列是 VARCHAR,条件传了数字,整条索引作废。这是最隐蔽的一种,EXPLAIN 里只表现为不走索引。
  3. 字符集或排序规则不一致的 JOIN。 两张表的关联列一个 utf8mb4_general_ci 一个 utf8mb4_unicode_ci,JOIN 时必须转换,索引失效。
  4. 最左前缀没满足。 联合索引 (a, b, c),条件只有 bc,用不上。
  5. 范围条件后面的列用不上索引。 (a, b, c)WHERE a=? AND b>? AND c=?c 排不上用场。所以等值列放前面,范围列放最后
  6. OR 连接的两侧不都有索引。 有一侧没有就全表扫。改成 UNION ALL
  7. 前导通配符。 LIKE '%关键字%' 走不了 B+ 树,需要全文索引或搜索引擎。
  8. NOT IN / != 选择性太差,优化器主动放弃索引。

四、几类典型问题的改法

深分页。 LIMIT 1000000, 20 要先扫过一百万行再丢掉。改成游标:

-- 差
SELECT id, title FROM articles ORDER BY id LIMIT 1000000, 20;
-- 好
SELECT id, title FROM articles WHERE id > :last_id ORDER BY id LIMIT 20;

必须支持跳页时,用延迟关联:先在覆盖索引上取主键,再回表。

SELECT a.id, a.title
FROM articles a
JOIN (SELECT id FROM articles ORDER BY id LIMIT 1000000, 20) t ON a.id = t.id;

SELECT * 除了浪费网络,还会让本来能覆盖的索引失效,被迫回表。永远列出需要的列。顺带一提,password_hash 这类敏感列绝对不要出现在给前端的查询里,哪怕前端不用。

N+1。 ORM 里一个列表查询后逐条查关联,一百条数据一百零一次查询。用 preload / eager loading / JOIN FETCH,或者手工批量查一次再在内存里拼。

COUNT(*) 全表。 大表精确总数很贵。列表页的总数改成「估算值」(information_schema.tablestable_rows)或者干脆不显示总页数,只给「下一页」。

批量写入。 循环单条 INSERT 改成一条多值 INSERT,或者 INSERT ... ON DUPLICATE KEY UPDATE。差距通常是十倍以上。

五、加索引的纪律

  • 加之前先证明:给出 EXPLAIN ANALYZE 的前后对比,扫描行数从多少降到多少。
  • 联合索引优于多个单列索引。 顺序按「等值列(选择性高的在前) → 范围列 → 排序列」。
  • 所有外键列必须有索引。 没有的话,删父表记录会锁全子表。
  • 命名统一idx_<表>_<列>,唯一索引 uk_<表>_<列>
  • 走迁移文件,不要手改数据库。 大表加索引用 ALGORITHM=INPLACE, LOCK=NONE,或者上 gh-ost / pt-online-schema-change,别在业务高峰直接 ALTER
  • 加完复查冗余:如果已有 (a, b, c),就不要再建 (a)(a, b)
  • 单表索引控制在 5 个以内。每个索引都是写入时的额外代价。

六、锁与事务

慢查询排完还慢,看锁。

SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;
SHOW ENGINE INNODB STATUS\G   -- 看 LATEST DETECTED DEADLOCK 段

纪律很简单:事务要短,控制在秒级。事务里不要做 HTTP 调用、不要等用户输入、不要 sleep。长事务会一直持有锁并撑大 undo,是慢查询之外最常见的数据库雪崩原因。

死锁的通用解法是让所有事务以相同顺序访问资源,比如永远按主键升序更新。

七、交付

优化完给出四样:

  1. 问题 SQL 清单,按总耗时排序,标出前五。
  2. 每条的前后 EXPLAIN ANALYZE 对比,含扫描行数和实际耗时。
  3. 索引变更的迁移文件(up 和 down 成对,down 实际测过)。
  4. 上线后的观察项:慢日志条数、数据库 CPU、对应接口的 P99。