强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

第 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_refJOIN 时主键/唯一索引等值匹配⭐⭐⭐⭐⭐
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(索引失效)

8.1.4 Extra 列常见值

Extra含义好坏
Using index覆盖索引,不回表✅ 很好
Using where在 Server 层过滤中性
Using index condition索引下推(ICP)✅ 好
Using temporary使用临时表⚠️ 需要优化
Using filesort额外排序(未用索引排序)⚠️ 需要优化
Using join bufferJOIN 缓冲(被驱动表无索引)⚠️ 需要优化
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

8.3 Performance Schema 查询分析

-- 查看最耗时的 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

#检查项说明
1EXPLAIN type 至少是 rangeALL 全表扫描需要优化
2key 列不能为 NULL必须使用索引
3rows 尽量小扫描行数越少越好
4没有 Using filesort避免额外排序
5没有 Using temporary避免临时表
6没有 Using join bufferJOIN 列需有索引
7Extra 有 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 和执行时间

扩展阅读