MySQL 慢查询分析与优化:用 EXPLAIN 定位性能瓶颈
前言
慢查询是数据库性能的头号敌人。一个不经意的全表扫描,可能在数据量小的时候毫无感觉,一旦数据增长到百万级,就会成为系统的致命瓶颈。本文系统讲解如何利用 EXPLAIN、执行计划分析、索引优化等手段,定位并解决 MySQL 慢查询问题。
开启慢查询日志
慢查询日志是排查慢查询的第一步。MySQL 提供两种方式记录慢查询:日志文件和 Performance Schema 慢查询表。
-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/lib/mysql/mysql-slow.log';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = ON;
-- MySQL 8.0+ 使用 Performance Schema 存储慢查询
SET GLOBAL performance_schema_events_statements_history_size = 100;
-- 查看慢查询汇总(MySQL 8.0+)
SELECT
DIGEST_TEXT AS query_sql,
COUNT_STAR AS exec_count,
AVG_TIMER_WAIT / 1000000000000 AS avg_latency_sec,
SUM_ROWS_EXAMINED AS rows_scanned
FROM performance_schema.events_statements_summary_by_digest
WHERE SUM_TIMER_WAIT > 1000000000000
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
-- 使用 mysqldumpslow 工具分析慢查询日志
-- mysqldumpslow -t 10 /var/lib/mysql/mysql-slow.log
-- 使用 pt-query-digest(Percona Toolkit)深度分析
-- pt-query-digest /var/lib/mysql/mysql-slow.log
EXPLAIN 执行计划详解
EXPLAIN 是分析查询性能最核心的工具。它展示 MySQL 查询优化器的执行计划。
-- 基本 EXPLAIN 用法
EXPLAIN SELECT u.name, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE u.age > 25
ORDER BY u.created_at DESC
LIMIT 20;
-- MySQL 8.0+ 支持 EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT ...
-- type: 访问类型(性能从好到差)
-- system:表只有一行
-- const:最多匹配一行
-- eq_ref:连接时通过主键或唯一索引等值匹配
-- ref:非唯一索引等值匹配
-- range:索引范围扫描
-- index:全索引扫描
-- ALL:全表扫描(性能最差,尽量避免)
-- key: 实际使用的索引
-- rows: 预估扫描行数,越大越慢
-- Extra: 额外信息
-- Using index:覆盖索引,无需回表
-- Using where:需要在存储引擎返回后用 WHERE 条件过滤
-- Using temporary:使用了临时表
-- Using filesort:需要文件排序
-- Using index condition:索引下推优化
典型慢查询案例与优化
案例1:全表扫描导致慢查询
-- 原始慢查询:users 表 100 万行,name 无索引
EXPLAIN SELECT * FROM users WHERE name = '张三';
-- type=ALL, rows=1000000, 严重全表扫描
-- 优化:为 name 列添加索引
ALTER TABLE users ADD INDEX idx_name (name);
-- 优化后
EXPLAIN SELECT * FROM users WHERE name = '张三';
-- type=ref, key=idx_name, rows=10,性能提升数万倍
-- 如果只需要查询 name 和 age,加覆盖索引更优
ALTER TABLE users ADD INDEX idx_name_age (name, age);
EXPLAIN SELECT name, age FROM users WHERE name = '张三';
-- Extra: Using index(Index Only Scan,无需回表)
案例2:隐式类型转换导致索引失效
-- age 是 INT 类型,传入字符串 '25'
EXPLAIN SELECT * FROM users WHERE age = '25';
-- 可能导致索引失效
-- phone 是 VARCHAR 类型,传入数字(最危险)
EXPLAIN SELECT * FROM users WHERE phone = 13800138000;
-- phone 索引完全失效!
-- 正确做法:传入正确类型
EXPLAIN SELECT * FROM users WHERE phone = '13800138000';
案例3:深度分页查询优化
-- 深度分页:偏移量很大时,MySQL 需要先扫描并丢弃大量数据
EXPLAIN SELECT * FROM orders ORDER BY id DESC LIMIT 100000, 20;
-- 扫描 100020 行,只返回 20 行,大量无用扫描
-- 优化方案1:使用主键延迟关联
SELECT * FROM orders
INNER JOIN (
SELECT id FROM orders ORDER BY id DESC LIMIT 100000, 20
) AS t USING (id);
-- 优化方案2:记录上次的最大 ID,使用 > 条件替代 OFFSET
SELECT * FROM orders
WHERE id < #{last_max_id}
ORDER BY id DESC
LIMIT 20;
-- 优化方案3:使用游标分页
SELECT * FROM orders
WHERE created_at < #{cursor}
ORDER BY created_at DESC
LIMIT 20;
案例4:COUNT(*) 慢查询
-- COUNT(*) 在大表上非常慢
SELECT COUNT(*) FROM orders WHERE status = 'paid';
-- 优化方案1:利用索引覆盖
ALTER TABLE orders ADD INDEX idx_status (status);
SELECT COUNT(*) FROM orders WHERE status = 'paid';
-- 优化方案2:维护计数器表
CREATE TABLE order_stats (
stat_date DATE NOT NULL,
status VARCHAR(20) NOT NULL,
cnt BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (stat_date, status)
);
-- 优化方案3:使用 EXPLAIN 估算行数
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
常见问题
Q1:EXPLAIN 显示 rows 预估不准确怎么办?
使用 ANALYZE TABLE 重新统计索引分布信息。MySQL 8.0+ 可以通过 optimizer_trace 查看优化器的完整决策过程。
Q2:联合查询(JOIN)很慢如何优化?
首先用 EXPLAIN 查看连接顺序和类型。确保被驱动表(大表作为被驱动时)有连接列的索引。如果 JOIN 后的结果集很大,考虑在应用层分两次查询代替 JOIN。
Q3:如何预防慢查询?
建立常态化监控机制,使用 SLO 告警。在新功能上线前执行 EXPLAIN 分析。代码评审阶段检查所有 SQL。使用 ORM 时注意生成的 SQL 是否符合预期。
延伸阅读
- MySQL 索引原理与优化
- MySQL 事务与隔离级别
- Percona Toolkit 官方文档:https://www.percona.com/doc/percona-toolkit/