第 8 章:查询优化
第 8 章:查询优化
学会用 EXPLAIN 读懂执行计划,是 SQL 调优的基本功。
8.1 EXPLAIN 执行计划
8.1.1 基本用法
-- 在 SELECT 前加 EXPLAIN
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- EXPLAIN ANALYZE(8.0.18+,显示实际执行时间)
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 100;
-- 格式化输出
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 100;
EXPLAIN FORMAT=TREE SELECT * FROM orders WHERE user_id = 100;
8.1.2 EXPLAIN 各列详解
| 列名 | 说明 | 重点关注 |
|---|
| id | 查询编号,相同 id 从上到下执行 | 子查询 id 不同 |
| select_type | 查询类型 | SIMPLE/PRIMARY/SUBQUERY/DERIVED |
| table | 访问的表 | 表名或别名 |
| partitions | 匹配的分区 | 分区表才会显示 |
| type | 访问类型 | ⭐ 性能关键指标 |
| possible_keys | 可能使用的索引 | 参考,不是实际使用的 |
| key | 实际使用的索引 | ⭐ NULL 表示未使用索引 |
| key_len | 索引使用的字节数 | 越短越好,可推断用了几个索引列 |
| ref | 索引的哪一列被使用 | const/列名/func |
| rows | 预估扫描行数 | ⭐ 越小越好 |
| filtered | 过滤比例(百分比) | 越高越好(100%最好) |
| Extra | 额外信息 | ⭐ 包含重要优化提示 |
8.1.3 type 访问类型(性能从好到差)
| type | 说明 | 性能 |
|---|
| system | 表只有一行 | 最好 |
| const | 主键/唯一索引等值查找 | ⭐⭐⭐⭐⭐ |
| eq_ref | JOIN 时主键/唯一索引等值匹配 | ⭐⭐⭐⭐⭐ |
| ref | 普通索引等值查找 | ⭐⭐⭐⭐ |
| range | 索引范围扫描 | ⭐⭐⭐⭐ |
| index | 全索引扫描(遍历索引树) | ⭐⭐⭐ |
| ALL | 全表扫描 | ⭐ 最差 |
-- const:主键等值查询
EXPLAIN SELECT * FROM users WHERE id = 100;
-- type: const, key: PRIMARY
-- eq_ref:JOIN 时主键匹配
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- users 表的 type: eq_ref
-- ref:普通索引等值
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- type: ref, key: idx_user_id
-- range:范围扫描
EXPLAIN SELECT * FROM orders WHERE created_at >= '2026-01-01';
-- type: range
-- index:全索引扫描
EXPLAIN SELECT user_id FROM orders;
-- type: index(只扫描索引树,不读数据)
-- ALL:全表扫描
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- type: ALL(索引失效)
| Extra | 含义 | 好坏 |
|---|
| Using index | 覆盖索引,不回表 | ✅ 很好 |
| Using where | 在 Server 层过滤 | 中性 |
| Using index condition | 索引下推(ICP) | ✅ 好 |
| Using temporary | 使用临时表 | ⚠️ 需要优化 |
| Using filesort | 额外排序(未用索引排序) | ⚠️ 需要优化 |
| Using join buffer | JOIN 缓冲(被驱动表无索引) | ⚠️ 需要优化 |
| Select tables optimized away | 聚合直接在索引上完成 | ✅ 很好 |
-- Using index(覆盖索引)
EXPLAIN SELECT email, status FROM users WHERE email = '[email protected]';
-- Extra: Using index
-- Using filesort(需要优化)
EXPLAIN SELECT * FROM users ORDER BY created_at DESC;
-- 如果 created_at 没有索引 → Extra: Using filesort
-- Using temporary(需要优化)
EXPLAIN SELECT DISTINCT username FROM users;
-- 可能使用临时表去重
-- Using index condition(索引下推)
EXPLAIN SELECT * FROM orders WHERE user_id > 100 AND status = 1;
-- Extra: Using index condition(MySQL 5.6+ 的 ICP 优化)
8.2 慢查询日志
8.2.1 开启慢查询日志
-- 查看当前状态
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过 1 秒记录
SET GLOBAL log_queries_not_using_indexes = ON; -- 记录未使用索引的查询
SET GLOBAL min_examined_row_limit = 100; -- 至少扫描 100 行才记录
-- 永久生效需写入 my.cnf
-- [mysqld]
-- slow_query_log = ON
-- slow_query_log_file = /var/log/mysql/slow.log
-- long_query_time = 1
-- log_queries_not_using_indexes = ON
8.2.2 分析慢查询日志
# 使用 mysqldumpslow 工具
# 按查询时间排序,显示前 10 条
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 按出现次数排序
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# 按锁时间排序
mysqldumpslow -s l -t 10 /var/log/mysql/slow.log
# 使用 pt-query-digest(Percona Toolkit,更强大)
pt-query-digest /var/log/mysql/slow.log > slow_report.txt
# 只分析最近 1 小时的慢查询
pt-query-digest --since '1h' /var/log/mysql/slow.log
-- 查看最耗时的 SQL(按总执行时间排序)
SELECT
DIGEST_TEXT AS query,
COUNT_STAR AS exec_count,
ROUND(SUM_TIMER_WAIT / 1e12, 2) AS total_time_s,
ROUND(AVG_TIMER_WAIT / 1e12, 4) AS avg_time_s,
SUM_ROWS_EXAMINED AS rows_examined,
SUM_ROWS_SENT AS rows_sent
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = 'myapp'
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;
-- 查看全表扫描的查询
SELECT
DIGEST_TEXT,
COUNT_STAR,
SUM_ROWS_EXAMINED,
SUM_ROWS_SENT
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = 'myapp'
AND SUM_ROWS_EXAMINED > SUM_ROWS_SENT * 10 -- 扫描行远大于返回行
ORDER BY SUM_ROWS_EXAMINED DESC
LIMIT 10;
-- 重置统计
TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;
8.4 常见查询优化场景
8.4.1 深分页优化
-- ❌ 问题:OFFSET 很大时性能极差
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 1000000;
-- 需要扫描 1000010 行,丢弃前 1000000 行
-- ✅ 方案 1:基于游标(Keyset Pagination)
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 10;
-- ✅ 方案 2:延迟关联(Deferred Join)
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 10 OFFSET 1000000
) t ON o.id = t.id;
-- 子查询走覆盖索引,只回表 10 行
-- ✅ 方案 3:书签法(记住上次查询的最大 ID)
-- 第一页
SELECT * FROM orders ORDER BY id LIMIT 10;
-- 假设最后一条 id = 100
-- 第二页
SELECT * FROM orders WHERE id > 100 ORDER BY id LIMIT 10;
8.4.2 COUNT(*) 优化
-- COUNT(*) vs COUNT(1) vs COUNT(col)
SELECT COUNT(*) FROM orders; -- 统计所有行(包括 NULL)
SELECT COUNT(1) FROM orders; -- 等价于 COUNT(*)
SELECT COUNT(user_id) FROM orders; -- 统计 user_id 不为 NULL 的行
-- 性能上 InnoDB 中 COUNT(*) 和 COUNT(1) 没有区别
-- InnoDB 会选最小的索引来遍历计数
-- 优化方案:
-- 1. 使用缓存存储计数(Redis)
-- 2. 使用近似值
SELECT TABLE_ROWS FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'myapp' AND TABLE_NAME = 'orders';
-- 注意:这个值可能不精确(InnoDB 的估算值)
-- 3. 额外计数表
CREATE TABLE table_counts (
table_name VARCHAR(50) PRIMARY KEY,
row_count BIGINT NOT NULL DEFAULT 0
);
8.4.3 ORDER BY 优化
-- ❌ Using filesort(全表排序)
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
-- 如果没有 (user_id, created_at) 联合索引 → filesort
-- ✅ 使用联合索引避免排序
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
-- Extra: 无 Using filesort
-- ❌ ORDER BY 多列方向不一致
SELECT * FROM orders ORDER BY user_id ASC, created_at DESC;
-- MySQL 8.0 之前不能使用索引排序
-- ✅ MySQL 8.0+ 降序索引
ALTER TABLE orders ADD INDEX idx_user_asc_created_desc (user_id, created_at DESC);
8.4.4 GROUP BY 优化
-- GROUP BY 也会产生 filesort 或 temporary
EXPLAIN SELECT user_id, COUNT(*) FROM orders GROUP BY user_id;
-- 优化:确保 GROUP BY 列有索引
ALTER TABLE orders ADD INDEX idx_user_id (user_id);
-- GROUP BY + ORDER BY 优化
SELECT user_id, COUNT(*) AS cnt
FROM orders
GROUP BY user_id
ORDER BY cnt DESC;
-- 如果 GROUP BY 和 ORDER BY 字段不同,通常无法避免 filesort
-- 可以用 LIMIT 限制返回行数减少排序开销
8.4.5 JOIN 优化
-- ❌ 被驱动表无索引
EXPLAIN SELECT * FROM orders o
JOIN users u ON o.user_name = u.username;
-- 如果 users.username 没有索引 → ALL 扫描
-- ✅ 为 JOIN 列创建索引
ALTER TABLE users ADD INDEX idx_username (username);
-- 小表驱动大表
-- MySQL 优化器通常会自动选择,但可以通过 STRAIGHT_JOIN 强制
SELECT STRAIGHT_JOIN * FROM small_table s
JOIN big_table b ON s.id = b.small_id;
-- small_table 作为驱动表
-- 减少 JOIN 返回的列
-- ❌ 返回不需要的列
SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- ✅ 只返回需要的列
SELECT o.order_no, u.username, o.total_amount
FROM orders o JOIN users u ON o.user_id = u.id;
8.4.6 子查询优化为 JOIN
-- ❌ 子查询(可能效率低)
SELECT * FROM users WHERE id IN (
SELECT user_id FROM orders WHERE status = 1
);
-- ✅ 改写为 JOIN
SELECT DISTINCT u.* FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.status = 1;
-- ✅ 或使用 EXISTS
SELECT * FROM users u WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 1
);
8.5 索引下推(ICP)
Index Condition Pushdown 是 MySQL 5.6 引入的优化。
-- 联合索引:INDEX idx_user_status (user_id, status)
-- 查询:WHERE user_id > 100 AND status = 1
-- 没有 ICP 时:
-- 1. 通过索引找到 user_id > 100 的所有记录
-- 2. 回表获取完整行
-- 3. Server 层过滤 status = 1
-- 有 ICP 时:
-- 1. 通过索引找到 user_id > 100 的记录
-- 2. 在存储引擎层就过滤 status = 1(索引中包含 status)
-- 3. 只对满足条件的记录回表
-- → 减少回表次数
EXPLAIN SELECT * FROM orders WHERE user_id > 100 AND status = 1;
-- Extra: Using index condition ← ICP 生效
8.6 查询优化 Checklist
| # | 检查项 | 说明 |
|---|
| 1 | EXPLAIN type 至少是 range | ALL 全表扫描需要优化 |
| 2 | key 列不能为 NULL | 必须使用索引 |
| 3 | rows 尽量小 | 扫描行数越少越好 |
| 4 | 没有 Using filesort | 避免额外排序 |
| 5 | 没有 Using temporary | 避免临时表 |
| 6 | 没有 Using join buffer | JOIN 列需有索引 |
| 7 | Extra 有 Using index | 优先覆盖索引 |
| 8 | 避免 SELECT * | 只查需要的列 |
| 9 | 避免索引列上函数/运算 | 会导致索引失效 |
| 10 | 深分页用游标分页 | 避免大 OFFSET |
业务场景
场景 1:慢查询排查流程
1. 开启慢查询日志,收集慢 SQL
↓
2. 用 pt-query-digest 分析,找出 Top N
↓
3. 对每个慢 SQL 执行 EXPLAIN
↓
4. 分析执行计划:type、key、rows、Extra
↓
5. 定位问题:缺索引?索引失效?深分页?JOIN 无索引?
↓
6. 优化:加索引 / 改写 SQL / 拆分查询
↓
7. 验证:对比优化前后的 EXPLAIN 和执行时间
扩展阅读