{T}

执行计划与慢查询定位

数据库线上性能问题最常见的表象是:

  • 接口 RT(响应时间)变高
  • 某些 SQL 特别慢
  • CPU 或 IO 异常抖动
  • 数据库连接池爆满

这类问题如果只靠肉眼看 SQL 文本,很难判断真正瓶颈。执行计划和慢查询定位,就是把"SQL 看起来没问题"转成"数据库实际怎么执行"的关键手段。

一、执行计划详解

1.1 什么是执行计划

执行计划(Execution Plan)是数据库查询优化器根据 SQL 语句生成的执行方案,描述了数据库如何检索数据、使用哪些索引、表的连接顺序、扫描方式等关键信息。通过执行计划可以:

  • 判断 SQL 是否使用了索引
  • 了解数据扫描的行数和方式
  • 发现性能瓶颈(全表扫描、临时表、文件排序等)
  • 评估 SQL 的执行成本

1.2 EXPLAIN 基本用法

基本语法

sql
-- 查看执行计划(不实际执行)
EXPLAIN SELECT * FROM users WHERE age > 30;

-- MySQL 8.0+ 支持更详细的格式
EXPLAIN FORMAT=TREE SELECT * FROM users WHERE age > 30;

-- 实际执行并返回详细统计信息(MySQL 8.0.18+)
EXPLAIN ANALYZE SELECT * FROM users WHERE age > 30;

EXPLAIN vs EXPLAIN ANALYZE

特性EXPLAINEXPLAIN ANALYZE
是否执行SQL否(仅估算)是(实际执行)
输出内容预估的执行计划预估 + 实际执行统计
准确性基于统计信息估算包含真实执行数据
适用场景日常分析、生产环境测试环境、性能调优
性能影响可能较慢(实际执行)

重要提示:

  • EXPLAIN ANALYZE 会实际执行 SQL,生产环境慎用(特别是慢查询)
  • EXPLAIN 仅生成执行计划,不会执行 SQL,适合生产环境

1.3 EXPLAIN 输出字段详解

完整示例

sql
EXPLAIN SELECT o.order_id, o.order_no, u.username 
FROM orders o 
JOIN users u ON o.user_id = u.user_id 
WHERE o.status = 'PAID' AND u.age > 25 
ORDER BY o.created_at DESC 
LIMIT 10;

输出结果:

code
+----+-------------+-------+------------+--------+---------------------+---------------------+---------+----------------------+------+----------+---------------------------------------+
| id | select_type | table | partitions | type   | possible_keys       | key                 | key_len | ref                  | rows  | filtered | Extra                                 |
+----+-------------+-------+------------+--------+---------------------+---------------------+---------+----------------------+------+----------+---------------------------------------+
|  1 | SIMPLE      | o     | NULL       | ref    | idx_user_status,idx_status | idx_status   | 10      | const                |  1520 |    10.00 | Using index condition; Using filesort |
|  1 | SIMPLE      | u     | NULL       | eq_ref | PRIMARY,idx_age     | PRIMARY             | 4       | test.o.user_id       |     1 |    33.33 | Using where                           |
+----+-------------+-------+------------+--------+---------------------+---------------------+---------+----------------------+------+----------+---------------------------------------+

id - 查询标识符

含义:标识 SELECT 的序号,表示查询的执行顺序

规则:

  • id 相同:从上往下顺序执行
  • id 不同:id 越大越先执行(子查询)
  • id 为 NULL:表示结果集,用于合并结果

示例:

sql
-- 关联查询:id 相同,从上往下执行
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- 结果:id=1(table=orders), id=1(table=users) → 先查 orders,再查 users

-- 子查询:id 不同,大的先执行
EXPLAIN SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE age > 30);
-- 结果:id=2(子查询,users表), id=1(主查询,orders表) → 先执行子查询

select_type - 查询类型

含义:表示查询的类型,主要用于区分简单查询、复杂查询

类型说明性能影响
SIMPLE简单查询,不包含子查询或 UNION√ 好
PRIMARY外层查询(复杂查询的主查询)正常
SUBQUERY子查询(SELECT/WHERE 中的子查询)可能慢
DERIVED派生表(FROM 子句中的子查询)较慢,会创建临时表
UNIONUNION 中的第二个或后面的查询需要合并结果
UNION RESULTUNION 的结果集需要创建临时表
DEPENDENT SUBQUERY依赖外层查询的子查询× 慢,会执行多次
DEPENDENT UNION依赖外层查询的 UNION× 慢
MATERIALIZED物化子查询(MySQL 5.6+)中等

示例:

sql
-- SIMPLE:简单查询
EXPLAIN SELECT * FROM users WHERE age > 30;

-- SUBQUERY:子查询
EXPLAIN SELECT * FROM orders WHERE user_id = (SELECT id FROM users WHERE username = 'admin');

-- DERIVED:派生表
EXPLAIN SELECT * FROM (SELECT user_id, COUNT(*) as cnt FROM orders GROUP BY user_id) t WHERE cnt > 10;

-- DEPENDENT SUBQUERY:依赖外层的子查询(性能差)
EXPLAIN SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

优化建议:

  • 尽量避免 DEPENDENT SUBQUERY,可改写为 JOIN
  • DERIVED 会创建临时表,数据量大时考虑改写

table - 访问的表

含义:显示这一行数据是关于哪张表的

特殊值:

  • <derivedN>:派生表,N 为 id 值
  • <unionM,N>:UNION 结果,M 和 N 为 id 值
  • <subqueryN>:物化子查询

partitions - 分区信息

含义:匹配的分区,非分区表为 NULL

示例:

sql
-- 分区表演示
CREATE TABLE order_partition (
    order_id INT,
    created_at DATE
) PARTITION BY RANGE (YEAR(created_at)) (
    PARTITION p2022 VALUES LESS THAN (2023),
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025)
);

EXPLAIN SELECT * FROM order_partition WHERE created_at >= '2023-01-01';
-- partitions: p2023,p2024

type - 访问类型(重要)

含义:表示 MySQL 如何查找数据,从好到差依次为:

类型说明性能示例场景
system表只有一行(system 表)系统表
const单行匹配,主键或唯一索引WHERE id = 100
eq_ref关联查询,使用主键或唯一索引JOIN ... ON a.id = b.id
ref非唯一索引,返回多行WHERE idx_col = 'value'
fulltext全文索引全文搜索
ref_or_null类似 ref,但包含 NULLWHERE idx_col = 'value' OR idx_col IS NULL
index_merge索引合并多个索引条件 OR 连接
range索引范围扫描WHERE age > 20 AND age < 30
index全索引扫描扫描整个索引树
ALL全表扫描无索引或索引失效

详细说明:

1. system (最优)

sql
-- MyISAM 或 Memory 引擎的系统表
EXPLAIN SELECT * FROM mysql.proxies_priv WHERE 1=1;

2. const (最优)

sql
-- 主键或唯一索引等值查询
EXPLAIN SELECT * FROM users WHERE id = 100;  -- 主键
EXPLAIN SELECT * FROM users WHERE username = 'admin';  -- 唯一索引

3. eq_ref (关联查询最优)

sql
-- JOIN 时使用主键或唯一索引
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- users 表的 type = eq_ref(使用主键)

4. ref (较好)

sql
-- 非唯一索引等值查询,可能返回多行
EXPLAIN SELECT * FROM orders WHERE status = 'PAID';  -- status 有索引但不是唯一

5. range (中等)

sql
-- 索引范围扫描:>, <, >=, <=, BETWEEN, IN
EXPLAIN SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31';
EXPLAIN SELECT * FROM users WHERE age IN (20, 25, 30);

6. index (较差)

sql
-- 全索引扫描:遍历整个索引树
EXPLAIN SELECT id FROM orders;  -- 如果 id 是主键(聚簇索引)
EXPLAIN SELECT status FROM orders;  -- 如果 status 有索引

7. ALL (最差)

sql
-- 全表扫描:遍历整个表
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2024;  -- 索引失效
EXPLAIN SELECT * FROM orders;  -- 无 WHERE 条件

性能优化目标:

  • 至少达到 range 级别
  • 关联查询至少达到 ref 级别
  • 避免 ALL 全表扫描

possible_keys - 可能使用的索引

含义:查询可能使用的索引列表

示例:

sql
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';

-- possible_keys: idx_user_id, idx_status
-- 表示这两个索引可能被使用

注意:

  • 列出的是"可能"的索引,不一定会使用
  • 为 NULL 表示没有可用索引

key - 实际使用的索引(重要)

含义:查询实际使用的索引

示例:

sql
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';

-- possible_keys: idx_user_id, idx_status
-- key: idx_user_id  (实际使用了 idx_user_id)

特殊情况:

  • key = NULL:未使用索引
  • key = PRIMARY:使用了主键
  • possible_keys 不同:实际使用的索引是优化器选择的结果

优化提示:

  • 如果 possible_keys 有值但 key 为 NULL,可能是因为:
    • 数据量小,全表扫描更快
    • 索引选择性差
    • 索引不符合查询条件

key_len - 使用的索引长度

含义:使用的索引字节数,可以判断联合索引使用了哪些列

计算规则:

数据类型字节数说明
INT4-
BIGINT8-
VARCHAR(N)N×3 + 2UTF8MB4 编码,N 为字符数
DATE3-
DATETIME8-
NULL+1允许 NULL 则额外加 1

示例:

sql
-- 创建联合索引
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at);

-- 查询 1:只使用 user_id
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- key_len = 4 (INT 类型,user_id NOT NULL)

-- 查询 2:使用 user_id + status
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';
-- key_len = 4 + 10 (status VARCHAR(3), UTF8MB4: 3*3+1=10)

-- 查询 3:使用全部三个字段
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID' AND created_at > '2024-01-01';
-- key_len = 4 + 10 + 8 (DATETIME)

重要用途:判断联合索引的利用率

sql
-- 联合索引:idx_user_status_created(user_id, status, created_at)
-- 最大 key_len = 4 + 10 + 8 = 22

-- 如果 key_len < 22,说明索引没有完全利用
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND created_at > '2024-01-01';
-- 跳过了 status,只用了 user_id
-- key_len = 4 (只用了第一列)

ref - 索引比较的列

含义:表示索引列与哪些列或常量进行比较

常见值:

  • const:常量
  • 数据库名.表名.列名:关联的字段
  • func:使用函数

示例:

sql
-- 常量比较
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- ref = const

-- 关联查询
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- orders 表:ref = test.u.id (使用 users 表的 id)
-- users 表:ref = test.o.user_id (使用 orders 表的 user_id)

rows - 预估扫描行数(重要)

含义:MySQL 估计需要扫描的行数,越小越好

示例:

sql
-- 无索引:扫描全表
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- rows = 100000 (全表扫描)

-- 有索引:大幅减少
EXPLAIN SELECT * FROM orders WHERE created_at >= '2024-01-01';
-- rows = 1520 (使用索引范围扫描)

注意:

  • 这是预估值,可能不准确(InnoDB 估计值误差较大)
  • 是"扫描行数",不是"返回行数"
  • 对于关联查询,每个表的 rows 相乘得到总成本

优化目标:

  • rows 值越小越好
  • 与实际返回行数对比,差异大说明索引选择性问题

filtered - 过滤百分比

含义:表示条件过滤后剩余记录的百分比(MySQL 5.7+)

示例:

sql
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';

-- rows = 1000, filtered = 10.00
-- 表示:扫描 1000 行,其中 10%(100 行)满足 status = 'PAID'

计算:

  • 实际返回行数 ≈ rows × (filtered / 100)
  • filtered 越大越好(接近 100)

Extra - 额外信息(重要)

含义:包含不适合在其他列显示的额外信息,很多信息直接反映性能问题

重要字段值:

1. Using index (好)

sql
-- 覆盖索引:查询的所有列都在索引中
EXPLAIN SELECT user_id, status FROM orders WHERE user_id = 100;
-- Extra: Using index
-- 不需要回表,性能最优

2. Using where (正常)

sql
-- WHERE 条件过滤
EXPLAIN SELECT * FROM users WHERE age > 30 AND username LIKE '%admin%';
-- Extra: Using where
-- MySQL 服务器层过滤,正常情况

3. Using index condition (较好)

sql
-- 索引下推(ICP):在存储引擎层过滤
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status LIKE 'P%';
-- Extra: Using index condition
-- MySQL 5.6+ 优化,减少回表次数

4. Using temporary (差)

sql
-- 使用临时表
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
-- Extra: Using temporary
-- 通常用于 GROUP BY, ORDER BY, 需要优化

优化:

sql
-- 创建索引避免临时表
CREATE INDEX idx_status ON orders(status);
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
-- Extra: Using index (变成覆盖索引)

5. Using filesort (差)

sql
-- 文件排序:无法使用索引排序
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
-- Extra: Using filesort
-- 需要在内存或磁盘排序,性能差

优化:

sql
-- 创建联合索引
CREATE INDEX idx_user_created ON orders(user_id, created_at);
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
-- Extra: NULL (使用索引排序)

6. Using join buffer (差)

sql
-- 关联查询未使用索引,需要连接缓冲区
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_name = u.username;
-- Extra: Using join buffer (Block Nested Loop)
-- 表示 users 表没有使用索引,需要优化

7. Using where; Using temporary; Using filesort (最差)

sql
-- 三重组合:WHERE 过滤 + 临时表 + 文件排序
EXPLAIN SELECT status, COUNT(*) FROM orders WHERE user_id = 100 GROUP BY status ORDER BY COUNT(*) DESC;
-- Extra: Using where; Using temporary; Using filesort
-- 性能很差,需要重点优化

8. Impossible WHERE

sql
-- WHERE 条件永远为 FALSE
EXPLAIN SELECT * FROM users WHERE id = 1 AND id = 2;
-- Extra: Impossible WHERE

9. Using union (索引合并)

sql
-- OR 条件使用多个索引
EXPLAIN SELECT * FROM orders WHERE user_id = 100 OR status = 'PAID';
-- Extra: Using union(idx_user_id, idx_status); Using where
-- MySQL 5.0+ 支持索引合并

10. Using MRR (优化)

sql
-- Multi-Range Read 优化
EXPLAIN SELECT * FROM orders WHERE user_id IN (100, 200, 300);
-- Extra: Using index condition; Using MRR
-- MySQL 5.6+ 优化,减少随机 IO

1.4 执行计划分析实战

案例 1:判断是否使用索引

sql
-- 创建索引
CREATE INDEX idx_user_id ON orders(user_id);

-- 查询执行计划
EXPLAIN SELECT * FROM orders WHERE user_id = 100;

-- 分析:
-- type = ref:使用非唯一索引,较好
-- key = idx_user_id:确实使用了索引
-- rows = 100:扫描 100 行
-- Extra = NULL:无额外问题

案例 2:联合索引最左匹配

sql
-- 创建联合索引
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at);

-- 查询 1:符合最左匹配
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- key = idx_user_status_created, key_len = 4

-- 查询 2:符合最左匹配(使用前两列)
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';
-- key = idx_user_status_created, key_len = 14

-- 查询 3:不符合最左匹配(跳过 user_id)
EXPLAIN SELECT * FROM orders WHERE status = 'PAID';
-- key = NULL, type = ALL(全表扫描)

案例 3:避免文件排序

sql
-- 问题 SQL
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC LIMIT 10;
-- Extra: Using filesort (需要排序)

-- 优化:创建联合索引
CREATE INDEX idx_user_created ON orders(user_id, created_at);

-- 再次查看
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC LIMIT 10;
-- Extra: NULL (使用索引排序,无文件排序)

二、慢查询日志配置与分析

2.1 慢查询日志概述

慢查询日志(Slow Query Log)记录了执行时间超过指定阈值的 SQL 语句,是定位性能问题的重要工具。

作用:

  • 发现执行慢的 SQL
  • 分析性能瓶颈
  • 为优化提供依据

2.2 慢查询日志配置

查看当前配置

sql
-- 查看慢查询日志开关
SHOW VARIABLES LIKE 'slow_query_log';

-- 查看慢查询时间阈值(单位:秒)
SHOW VARIABLES LIKE 'long_query_time';

-- 查看慢查询日志文件路径
SHOW VARIABLES LIKE 'slow_query_log_file';

-- 查看是否记录未使用索引的 SQL
SHOW VARIABLES LIKE 'log_queries_not_using_indexes';

-- 查看慢查询日志输出格式
SHOW VARIABLES LIKE 'log_output';

配置参数详解

参数默认值说明推荐值
slow_query_logOFF是否启用慢查询日志ON
long_query_time10慢查询时间阈值(秒)1-3
slow_query_log_filehost_name-slow.log日志文件路径指定路径
log_queries_not_using_indexesOFF是否记录未使用索引的 SQLON
log_outputFILE日志输出方式(FILE/TABLE/NONE)FILE
min_examined_row_limit0扫描行数阈值100

开启慢查询日志

方式 1:临时配置(重启失效)

sql
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';

-- 设置慢查询时间阈值为 2 秒
SET GLOBAL long_query_time = 2;

-- 记录未使用索引的 SQL
SET GLOBAL log_queries_not_using_indexes = 'ON';

-- 设置日志文件路径
SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log';

方式 2:永久配置(修改配置文件)

编辑 my.cnf (Linux) 或 my.ini (Windows):

ini
[mysqld]
# 开启慢查询日志
slow_query_log = 1

# 慢查询时间阈值(秒)
long_query_time = 2

# 日志文件路径
slow_query_log_file = /var/log/mysql/mysql-slow.log

# 记录未使用索引的 SQL
log_queries_not_using_indexes = 1

# 日志输出方式(FILE/TABLE)
log_output = FILE

# 扫描行数阈值(少于该值不记录)
min_examined_row_limit = 100

重启 MySQL 生效:

bash
# Linux
systemctl restart mysqld

# 或
service mysql restart

方式 3:动态配置(推荐)

sql
-- 动态配置,立即生效,重启后失效
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2;
SET GLOBAL log_queries_not_using_indexes = 'ON';

-- 同时修改配置文件实现永久生效

配置注意事项

1. long_query_time 设置

sql
-- 不要设置太大,否则无法捕获慢查询
SET GLOBAL long_query_time = 10;  -- × 太大

-- 也不要设置太小,否则日志爆炸
SET GLOBAL long_query_time = 0.1;  -- × 太小

-- 推荐值:1-3 秒
SET GLOBAL long_query_time = 2;  -- √ 合理

2. log_queries_not_using_indexes

sql
-- 开启后会记录所有未使用索引的 SQL,日志量可能很大
SET GLOBAL log_queries_not_using_indexes = 'ON';

-- 配合 min_examined_row_limit 过滤
SET GLOBAL min_examined_row_limit = 100;  -- 扫描少于 100 行不记录

3. 日志输出方式

sql
-- FILE:记录到文件(推荐,性能好)
SET GLOBAL log_output = 'FILE';

-- TABLE:记录到 mysql.slow_log 表
SET GLOBAL log_output = 'TABLE';

-- 查询表日志
SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 10;

-- FILE,TABLE:同时记录到文件和表
SET GLOBAL log_output = 'FILE,TABLE';

2.3 慢查询日志分析

查看慢查询日志

方式 1:直接查看文件

bash
# 查看最近的慢查询
tail -f /var/log/mysql/mysql-slow.log

# 查看慢查询数量
grep -c "Query_time" /var/log/mysql/mysql-slow.log

# 提取 SQL 语句
grep "Query_time" /var/log/mysql/mysql-slow.log -A 3

方式 2:查询 mysql.slow_log 表

sql
-- 查询最近的慢查询
SELECT 
    start_time,
    user_host,
    query_time,
    lock_time,
    rows_sent,
    rows_examined,
    db,
    sql_text
FROM mysql.slow_log
ORDER BY start_time DESC
LIMIT 10;

-- 统计慢查询 TOP 10
SELECT 
    db,
    sql_text,
    COUNT(*) as exec_count,
    AVG(query_time) as avg_time,
    MAX(query_time) as max_time,
    SUM(rows_examined) as total_rows
FROM mysql.slow_log
WHERE start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY db, sql_text
ORDER BY avg_time DESC
LIMIT 10;

使用 mysqldumpslow 分析工具

mysqldumpslow 是 MySQL 自带的慢查询日志分析工具,可以汇总相似的 SQL。

基本用法:

bash
# 显示执行时间最长的 10 条 SQL
mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.log

# 显示访问次数最多的 10 条 SQL
mysqldumpslow -s c -t 10 /var/log/mysql/mysql-slow.log

# 显示平均执行时间最长的 10 条 SQL
mysqldumpslow -s at -t 10 /var/log/mysql/mysql-slow.log

# 显示锁定时间最长的 10 条 SQL
mysqldumpslow -s l -t 10 /var/log/mysql/mysql-slow.log

# 显示返回记录数最多的 10 条 SQL
mysqldumpslow -s r -t 10 /var/log/mysql/mysql-slow.log

参数说明:

参数说明
-s t按查询时间排序
-s at按平均查询时间排序
-s c按查询次数排序
-s l按锁定时间排序
-s r按返回记录数排序
-s ar按平均返回记录数排序
-t N显示前 N 条
-g pattern正则匹配 SQL

示例:

bash
# 查找包含 "SELECT" 的慢查询 TOP 10
mysqldumpslow -s t -t 10 -g "SELECT" /var/log/mysql/mysql-slow.log

# 查询某个数据库的慢查询
mysqldumpslow -s t -t 10 -g "db_name" /var/log/mysql/mysql-slow.log

输出示例:

code
Reading mysql slow query log from /var/log/mysql/mysql-slow.log
Count: 152  Time=8.23s (1251s)  Lock=0.00s (0s)  Rows=100.0 (15200), root[root]@localhost
  SELECT * FROM orders WHERE status = 'S' ORDER BY created_at DESC LIMIT N

Count: 89  Time=5.12s (456s)  Lock=0.00s (0s)  Rows=50.0 (4450), root[root]@localhost
  SELECT * FROM users WHERE username LIKE 'S' ORDER BY id DESC LIMIT N

解读:

  • Count: 152:执行 152 次
  • Time=8.23s:平均执行时间 8.23 秒
  • (1251s):总执行时间 1251 秒
  • Rows=100.0:平均返回 100 行
  • S:参数被替换为通配符

使用 pt-query-digest 分析工具(推荐)

pt-query-digest 是 Percona Toolkit 的一部分,功能更强大。

安装:

bash
# Ubuntu/Debian
sudo apt-get install percona-toolkit

# CentOS/RHEL
sudo yum install percona-toolkit

# macOS
brew install percona-toolkit

基本用法:

bash
# 分析慢查询日志
pt-query-digest /var/log/mysql/mysql-slow.log

# 分析最近 1 小时的慢查询
pt-query-digest --since '1h' /var/log/mysql/mysql-slow.log

# 分析某个时间段的慢查询
pt-query-digest --since '2024-01-01 00:00:00' --until '2024-01-02 00:00:00' /var/log/mysql/mysql-slow.log

# 过滤某个数据库
pt-query-digest --filter '$event->{db} =~ /mydb/' /var/log/mysql/mysql-slow.log

# 输出到文件
pt-query-digest /var/log/mysql/mysql-slow.log > slow_report.txt

输出示例:

code
# 3600ms user time, 20ms system time, 24.73M rss, 204.84M vsz
# Current date: Mon Mar 30 12:00:00 2024
# Hostname: mysql-server
# Files: /var/log/mysql/mysql-slow.log
# Overall: 15.23k total, 152 unique, 45.23 QPS, 0.23x concurrency ________
# Time range: 2024-03-29T00:00:00 to 2024-03-30T00:00:00
# Attribute          total     min     max     avg     95%  stddev  median
# ============     ======= ======= ======= ======= ======= ======= =======
# Exec time          3726s     1us   125s   245ms   100ms      3s    50ms
# Lock time            12s       0     1s   787us   159us    12ms    50us
# Rows sent         10.23M       0 100.00k  704.33   50.00   4.12k    0.99
# Rows examine     512.34M       0   1.50M  35.29k   1.50M 123.45k  100.00

# Profile
# Rank Query ID                            Response time   Calls R/Call V/M   Item
# ==== =================================== =============== ===== ====== ===== ===============
#    1 0x1234567890ABCDEF1234567890ABCDEF  1523.2345 40.9%  1523 1.0000  0.00 SELECT orders
#    2 0xFEDCBA0987654321FEDCBA0987654321   823.4567 22.1%   823 1.0000  0.00 SELECT users
#    3 0xABCDEF1234567890ABCDEF1234567890   512.3456 13.8%   512 1.0000  0.00 SELECT products

# Query 1: 45.23 QPS, 0.23x concurrency, ID 0x1234567890ABCDEF1234567890ABCDEF at byte 0
# This item is included in the report because it matches --limit.
# Scores: V/M = 0.00
# Time range: 2024-03-29T00:00:00 to 2024-03-30T00:00:00
# Attribute    pct   total     min     max     avg     95%  stddev  median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Count         10    1523
# Exec time     40   1523s     1s      5s      1s      3s   500ms      1s
# Lock time      0   152ms    50us   100us   100us   100us    10us   100us
# Rows sent      1 152.30k     100     100     100     100       0     100
# Rows examine  29 152.30M 100.00k 100.00k 100.00k 100.00k       0 100.00k
# Query_time distribution
#   1us
#  10us
# 100us
#   1ms
#  10ms
# 100ms  ################################
#    1s  ################################################################
#   10s
#  100s
#    1s
#  10s
# Tables
#    SHOW TABLE STATUS LIKE 'orders'\G
#    SHOW CREATE TABLE `orders`\G
# EXPLAIN /*!50100 PARTITIONS*/
SELECT * FROM orders WHERE status = 'PAID' ORDER BY created_at DESC LIMIT 100\G

解读:

  • Response time 40.9%:该 SQL 占总响应时间的 40.9%,优先优化
  • Rows examine 152.30M:扫描了 152.30M 行,但只返回 152.30k 行,效率低
  • Query_time distribution:执行时间分布,大部分在 1 秒左右

2.4 实时监控慢查询

使用 performance_schema(MySQL 5.7+)

sql
-- 开启事件监控
UPDATE performance_schema.setup_instruments 
SET ENABLED = 'YES', TIMED = 'YES' 
WHERE NAME LIKE '%statement/%';

UPDATE performance_schema.setup_consumers 
SET ENABLED = 'YES' 
WHERE NAME LIKE '%events_statements_%';

-- 查询执行时间最长的 SQL
SELECT 
    DIGEST_TEXT as sql_text,
    COUNT_STAR as exec_count,
    SUM_TIMER_WAIT/1000000000 as total_time_sec,
    AVG_TIMER_WAIT/1000000000 as avg_time_sec,
    MAX_TIMER_WAIT/1000000000 as max_time_sec,
    SUM_ROWS_EXAMINED as rows_examined,
    SUM_ROWS_SENT as rows_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;

-- 查询全表扫描的 SQL
SELECT 
    DIGEST_TEXT as sql_text,
    COUNT_STAR as exec_count,
    SUM_NO_INDEX_USED as no_index_count,
    SUM_ROWS_EXAMINED as rows_examined
FROM performance_schema.events_statements_summary_by_digest
WHERE SUM_NO_INDEX_USED > 0
ORDER BY SUM_ROWS_EXAMINED DESC
LIMIT 10;

使用 sys schema(MySQL 5.7+)

sql
-- 查看执行时间最长的 SQL
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 10;

-- 查看全表扫描的 SQL
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;

-- 查看使用临时表的 SQL
SELECT * FROM sys.statements_with_temp_tables LIMIT 10;

-- 查看最新的慢查询
SELECT * FROM sys.latest_file_io ORDER BY latency DESC LIMIT 10;

三、索引优化建议

3.1 索引设计原则

最左匹配原则

联合索引按照定义顺序从左到右匹配,查询条件必须包含索引的最左列。

sql
-- 创建联合索引
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at);

-- √ 命中索引(使用全部列)
SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID' AND created_at > '2024-01-01';

-- √ 命中索引(使用前两列)
SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';

-- √ 命中索引(只使用第一列)
SELECT * FROM orders WHERE user_id = 100;

-- × 不命中索引(跳过 user_id)
SELECT * FROM orders WHERE status = 'PAID';

-- × 不命中索引(跳过前两列)
SELECT * FROM orders WHERE created_at > '2024-01-01';

--  部分命中(只使用第一列,跳过 status)
SELECT * FROM orders WHERE user_id = 100 AND created_at > '2024-01-01';
-- key_len 显示只用了 user_id

设计建议:

  • 联合索引列顺序:等值查询列 > 范围查询列 > 排序列
  • 高选择性列放前面(区分度高)
  • 最常用的查询条件放最左

覆盖索引

查询的所有列都在索引中,不需要回表查询。

sql
-- 创建联合索引
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at);

-- × 需要回表
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- Extra: NULL (需要回表查询其他列)

-- √ 覆盖索引
EXPLAIN SELECT user_id, status, created_at FROM orders WHERE user_id = 100;
-- Extra: Using index (不需要回表)

-- √ 部分覆盖索引
EXPLAIN SELECT user_id, status FROM orders WHERE user_id = 100;
-- Extra: Using index (也不需要回表)

优化建议:

  • 尽量使用覆盖索引,减少回表
  • SELECT 只查询需要的列
  • 统计查询尽量使用覆盖索引
sql
-- × 性能差
SELECT COUNT(*) FROM orders WHERE user_id = 100;

-- √ 使用覆盖索引
CREATE INDEX idx_user_id ON orders(user_id);
SELECT COUNT(user_id) FROM orders WHERE user_id = 100;
-- Extra: Using index

索引选择性

索引列的区分度,选择性越高,索引效果越好。

计算公式:

sql
-- 选择性 = COUNT(DISTINCT column) / COUNT(*)
SELECT 
    COUNT(DISTINCT user_id) / COUNT(*) as user_id_selectivity,
    COUNT(DISTINCT status) / COUNT(*) as status_selectivity,
    COUNT(DISTINCT created_at) / COUNT(*) as created_at_selectivity
FROM orders;

示例:

sql
-- 假设 orders 表有 100 万行数据
-- user_id: 10 万个不同值,选择性 = 100000/1000000 = 0.1 (高)
-- status: 5 个不同值,选择性 = 5/1000000 = 0.000005 (低)
-- created_at: 365 个不同值,选择性 = 365/1000000 = 0.000365 (中)

-- 索引设计:选择性高的列放前面
CREATE INDEX idx_user_created_status ON orders(user_id, created_at, status);

判断索引是否有用:

sql
-- 查看某列的选择性
SELECT 
    COUNT(*) as total_rows,
    COUNT(DISTINCT status) as distinct_values,
    COUNT(DISTINCT status) / COUNT(*) as selectivity
FROM orders;

-- 选择性 < 0.1:索引效果差
-- 选择性 > 0.5:索引效果好
-- 选择性 > 0.8:索引效果很好

3.2 避免索引失效

1. 避免在索引列上使用函数

sql
-- × 索引失效:使用函数
EXPLAIN SELECT * FROM orders WHERE YEAR(created_at) = 2024;
-- type = ALL (全表扫描)

-- √ 索引生效:范围查询
EXPLAIN SELECT * FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- type = range

常见错误:

sql
-- × 使用函数
WHERE DATE(created_at) = '2024-03-30'
WHERE UPPER(username) = 'ADMIN'
WHERE SUBSTRING(order_no, 1, 4) = '2024'

-- √ 优化
WHERE created_at >= '2024-03-30 00:00:00' AND created_at < '2024-03-31 00:00:00'
WHERE username = 'ADMIN' OR username = 'admin'
WHERE order_no LIKE '2024%'

2. 避免隐式类型转换

sql
-- 假设 user_id 是 INT 类型,order_no 是 VARCHAR 类型

-- × 隐式转换:字符串转数字
EXPLAIN SELECT * FROM orders WHERE user_id = '100';
-- 虽然可以工作,但性能稍差

-- × 隐式转换:数字转字符串,索引失效
EXPLAIN SELECT * FROM orders WHERE order_no = 20240330001;
-- type = ALL (全表扫描)

-- √ 类型匹配
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
EXPLAIN SELECT * FROM orders WHERE order_no = '20240330001';

常见错误:

sql
-- × 隐式转换
WHERE user_id = '100'  -- INT 列用字符串
WHERE order_no = 123   -- VARCHAR 列用数字
WHERE created_at = '2024-03-30'  -- DATETIME 列用 DATE

-- √ 类型匹配
WHERE user_id = 100
WHERE order_no = '123'
WHERE created_at = '2024-03-30 00:00:00'

3. 避免使用 != 或 <>

sql
-- × 索引可能失效
EXPLAIN SELECT * FROM orders WHERE status != 'CANCELLED';
-- type = ALL 或 range(取决于数据分布)

-- √ 改用 IN
EXPLAIN SELECT * FROM orders WHERE status IN ('PAID', 'SHIPPED', 'COMPLETED');
-- type = range

4. 避免使用 OR 连接不同字段

sql
-- × 索引失效(MySQL 5.6 之前)
EXPLAIN SELECT * FROM orders WHERE user_id = 100 OR status = 'PAID';
-- type = ALL

-- √ 使用 UNION
EXPLAIN 
SELECT * FROM orders WHERE user_id = 100
UNION
SELECT * FROM orders WHERE status = 'PAID';
-- 各自使用索引

-- √ MySQL 5.6+ 支持索引合并
EXPLAIN SELECT * FROM orders WHERE user_id = 100 OR status = 'PAID';
-- type = index_merge (使用两个索引)

5. 避免 LIKE 以通配符开头

sql
-- × 索引失效
EXPLAIN SELECT * FROM users WHERE username LIKE '%admin%';
-- type = ALL

-- × 索引失效
EXPLAIN SELECT * FROM users WHERE username LIKE '%admin';
-- type = ALL

-- √ 索引生效
EXPLAIN SELECT * FROM users WHERE username LIKE 'admin%';
-- type = range

-- 解决方案:使用全文索引
ALTER TABLE users ADD FULLTEXT INDEX idx_username(username);
SELECT * FROM users WHERE MATCH(username) AGAINST('admin');

6. 避免 IS NULL / IS NOT NULL

sql
-- × 可能不使用索引(取决于数据分布)
EXPLAIN SELECT * FROM orders WHERE status IS NULL;
-- type = ref 或 ALL

-- √ 优化:设置默认值
ALTER TABLE orders MODIFY status VARCHAR(20) NOT NULL DEFAULT 'PENDING';
EXPLAIN SELECT * FROM orders WHERE status = 'PENDING';
-- type = ref

7. 避免范围查询后的索引列失效

sql
-- 创建联合索引
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at);

-- × 范围查询后的列索引失效
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status > 'A' AND created_at > '2024-01-01';
-- key_len 显示只用了 user_id + status,created_at 索引失效

-- √ 优化:调整索引顺序
CREATE INDEX idx_user_created_status ON orders(user_id, created_at, status);
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND created_at > '2024-01-01' AND status > 'A';
-- 三个字段都能使用索引

3.3 索引优化实战

案例 1:优化 ORDER BY

sql
-- 问题 SQL
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC LIMIT 10;
-- Extra: Using filesort (文件排序)

-- 优化:创建联合索引
CREATE INDEX idx_user_created ON orders(user_id, created_at);

-- 再次查看
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC LIMIT 10;
-- Extra: NULL (使用索引排序)

-- 进一步优化:覆盖索引
EXPLAIN SELECT order_id, user_id, created_at FROM orders WHERE user_id = 100 ORDER BY created_at DESC LIMIT 10;
-- Extra: Using index (覆盖索引,最优)

案例 2:优化 GROUP BY

sql
-- 问题 SQL
EXPLAIN SELECT status, COUNT(*) FROM orders WHERE user_id = 100 GROUP BY status;
-- Extra: Using temporary; Using filesort (临时表 + 排序)

-- 优化:创建联合索引
CREATE INDEX idx_user_status ON orders(user_id, status);

-- 再次查看
EXPLAIN SELECT status, COUNT(*) FROM orders WHERE user_id = 100 GROUP BY status;
-- Extra: Using index (覆盖索引,无需临时表)

案例 3:优化分页查询

sql
-- 问题 SQL:深度分页
EXPLAIN SELECT * FROM orders ORDER BY id LIMIT 100000, 10;
-- rows = 100010 (扫描大量数据)

-- 优化 1:使用子查询
EXPLAIN SELECT * FROM orders o 
JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 10) t 
ON o.id = t.id;
-- 先查询主键(覆盖索引),再关联查询,性能更好

-- 优化 2:记录上次的最大 ID
EXPLAIN SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;
-- rows = 10 (使用范围查询)

四、常见慢查询场景和优化方法

4.1 全表扫描

现象:type = ALL,扫描整个表

原因:

  • 无索引
  • 索引失效
  • MySQL 认为全表扫描更快

优化方法:

sql
-- 案例:按用户名查询
EXPLAIN SELECT * FROM users WHERE username = 'admin';
-- type = ALL

-- 优化:创建索引
CREATE INDEX idx_username ON users(username);

EXPLAIN SELECT * FROM users WHERE username = 'admin';
-- type = ref

4.2 回表查询过多

现象:Extra = NULL,使用了索引但需要回表

优化方法:使用覆盖索引

sql
-- 案例:查询用户 ID 和状态
EXPLAIN SELECT user_id, status, created_at FROM orders WHERE user_id = 100;
-- Extra: NULL (需要回表)

-- 优化:创建覆盖索引
CREATE INDEX idx_user_status_created ON orders(user_id, status, created_at);

EXPLAIN SELECT user_id, status, created_at FROM orders WHERE user_id = 100;
-- Extra: Using index (覆盖索引)

4.3 文件排序

现象:Extra = Using filesort

原因:

  • ORDER BY 字段无索引
  • ORDER BY 字段与索引顺序不一致

优化方法:

sql
-- 案例:按创建时间排序
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
-- Extra: Using filesort

-- 优化:创建联合索引
CREATE INDEX idx_user_created ON orders(user_id, created_at);

EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
-- Extra: NULL

4.4 临时表

现象:Extra = Using temporary

原因:

  • GROUP BY 字段无索引
  • DISTINCT 查询
  • UNION 查询

优化方法:

sql
-- 案例:按状态分组统计
EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
-- Extra: Using temporary

-- 优化:创建索引
CREATE INDEX idx_status ON orders(status);

EXPLAIN SELECT status, COUNT(*) FROM orders GROUP BY status;
-- Extra: Using index

4.5 索引选择错误

现象:有多个可用索引,但 MySQL 选择了不合适的索引

优化方法:

sql
-- 案例:MySQL 选择了错误的索引
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';
-- possible_keys: idx_user_id, idx_status
-- key: idx_status (选择了错误的索引)

-- 优化 1:使用 FORCE INDEX 强制使用索引
EXPLAIN SELECT * FROM orders FORCE INDEX(idx_user_id) WHERE user_id = 100 AND status = 'PAID';

-- 优化 2:使用 USE INDEX 建议 MySQL 使用索引
EXPLAIN SELECT * FROM orders USE INDEX(idx_user_id) WHERE user_id = 100 AND status = 'PAID';

-- 优化 3:创建更合适的联合索引
CREATE INDEX idx_user_status ON orders(user_id, status);

4.6 关联查询性能差

现象:关联查询慢,Extra = Using join buffer

原因:

  • 关联字段无索引
  • 关联字段类型不匹配

优化方法:

sql
-- 案例:关联查询
EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- users 表: type = eq_ref (使用主键,好)
-- orders 表: type = ALL (无索引,差)

-- 优化:为关联字段创建索引
CREATE INDEX idx_user_id ON orders(user_id);

EXPLAIN SELECT * FROM orders o JOIN users u ON o.user_id = u.id;
-- orders 表: type = ref

4.7 深度分页

现象:LIMIT offset 很大时查询慢

原因:MySQL 需要扫描 offset + limit 行数据

优化方法:

sql
-- 问题 SQL:深度分页
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100000, 10;
-- 扫描 100010 行

-- 优化 1:使用子查询
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY created_at DESC LIMIT 100000, 10) t
ON o.id = t.id;
-- 子查询使用覆盖索引,性能更好

-- 优化 2:记录上次的最大值
SELECT * FROM orders 
WHERE created_at < '2024-03-30 12:00:00' 
ORDER BY created_at DESC 
LIMIT 10;
-- 使用范围查询,无需扫描大量数据

-- 优化 3:业务限制(禁止深度分页)
-- 只允许查看前 100 页
SELECT * FROM orders ORDER BY created_at DESC LIMIT 1000;

4.8 OR 条件优化

现象:使用 OR 导致索引失效

优化方法:

sql
-- 问题 SQL
SELECT * FROM orders WHERE user_id = 100 OR status = 'PAID';
-- MySQL 5.6 之前:全表扫描
-- MySQL 5.6+:索引合并(index_merge),性能中等

-- 优化 1:使用 UNION
SELECT * FROM orders WHERE user_id = 100
UNION
SELECT * FROM orders WHERE status = 'PAID';
-- 各自使用索引,性能好

-- 优化 2:创建联合索引(如果条件固定)
CREATE INDEX idx_user_status ON orders(user_id, status);
SELECT * FROM orders WHERE user_id = 100 OR status = 'PAID';
-- 仍然可以使用索引合并,性能更好

五、性能分析工具使用

5.1 SHOW PROFILE

SHOW PROFILE 是 MySQL 提供的 SQL 性能分析工具,可以查看 SQL 执行的详细耗时。

启用 PROFILE

sql
-- 查看是否启用
SHOW VARIABLES LIKE 'profiling';

-- 启用 profiling
SET profiling = 1;

-- 或设置保留的历史记录数
SET profiling_history_size = 100;

使用 PROFILE 分析

sql
-- 执行 SQL
SELECT * FROM orders WHERE user_id = 100;

-- 查看最近的 SQL 列表
SHOW PROFILES;

-- 查看指定 SQL 的详细耗时(Query_ID 来自 SHOW PROFILES)
SHOW PROFILE FOR QUERY 1;

-- 查看所有性能指标
SHOW PROFILE ALL FOR QUERY 1;

-- 查看特定指标
SHOW PROFILE CPU, BLOCK IO FOR QUERY 1;

PROFILE 输出示例

code
+----------------------+----------+
| Status               | Duration |
+----------------------+----------+
| starting             | 0.000100 |
| checking permissions | 0.000010 |
| Opening tables       | 0.000020 |
| init                 | 0.000030 |
| System lock          | 0.000010 |
| optimizing           | 0.000010 |
| statistics           | 0.000050 |
| preparing            | 0.000020 |
| executing            | 0.000005 |
| Sending data         | 0.500000 |  ← 主要耗时
| end                  | 0.000010 |
| query end            | 0.000005 |
| closing tables       | 0.000010 |
| freeing items        | 0.000020 |
| cleaning up          | 0.000010 |
+----------------------+----------+

关键指标:

  • Sending data:发送数据,通常是查询和传输数据的主要耗时
  • Sorting result:排序耗时
  • Creating tmp table:创建临时表
  • Copying to tmp table:拷贝数据到临时表
  • System lock:系统锁等待
  • Table lock:表锁等待

注意:MySQL 5.7 后 SHOW PROFILE 被标记为过时,推荐使用 performance_schema

5.2 Performance Schema

MySQL 5.5+ 提供的性能监控框架,可以监控 MySQL 的各种内部操作。

查看等待事件

sql
-- 启用等待事件监控
UPDATE performance_schema.setup_instruments 
SET ENABLED = 'YES', TIMED = 'YES' 
WHERE NAME LIKE '%wait/%';

UPDATE performance_schema.setup_consumers 
SET ENABLED = 'YES' 
WHERE NAME LIKE '%events_waits_%';

-- 查看等待事件
SELECT 
    EVENT_NAME,
    COUNT_STAR as count,
    SUM_TIMER_WAIT/1000000000 as total_sec,
    AVG_TIMER_WAIT/1000000000 as avg_sec
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE SUM_TIMER_WAIT > 0
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;

查看 SQL 执行统计

sql
-- 查看 SQL 执行统计
SELECT 
    DIGEST_TEXT as sql_text,
    COUNT_STAR as exec_count,
    SUM_TIMER_WAIT/1000000000 as total_sec,
    AVG_TIMER_WAIT/1000000000 as avg_sec,
    MAX_TIMER_WAIT/1000000000 as max_sec,
    SUM_ROWS_EXAMINED as rows_examined,
    SUM_ROWS_SENT as rows_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;

查看文件 IO 统计

sql
-- 查看文件 IO 统计
SELECT 
    FILE_NAME,
    COUNT_READ,
    COUNT_WRITE,
    SUM_NUMBER_OF_BYTES_READ/1024/1024 as read_mb,
    SUM_NUMBER_OF_BYTES_WRITE/1024/1024 as write_mb
FROM performance_schema.file_summary_by_instance
ORDER BY SUM_NUMBER_OF_BYTES_READ + SUM_NUMBER_OF_BYTES_WRITE DESC
LIMIT 10;

5.3 Sys Schema

MySQL 5.7+ 提供的系统视图,简化了 performance_schema 的查询。

常用视图

sql
-- 查看执行时间最长的 SQL
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 10;

-- 查看全表扫描的 SQL
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;

-- 查看使用临时表或文件排序的 SQL
SELECT * FROM sys.statements_with_temp_tables LIMIT 10;

-- 查看冗余索引
SELECT * FROM sys.schema_redundant_indexes;

-- 查看未使用的索引
SELECT * FROM sys.schema_unused_indexes;

-- 查看表统计信息
SELECT * FROM sys.schema_table_statistics LIMIT 10;

-- 查看 IO 统计
SELECT * FROM sys.io_global_by_file_by_bytes LIMIT 10;

-- 查看内存使用
SELECT * FROM sys.memory_global_by_current_bytes LIMIT 10;

5.4 MySQL Workbench

MySQL 官方提供的图形化管理工具,包含性能分析功能。

功能:

  • 可视化执行计划
  • 性能仪表板
  • 慢查询日志分析
  • 配置管理

5.5 其他工具

pt-index-usage

分析慢查询日志,找出未使用的索引。

bash
pt-index-usage /var/log/mysql/mysql-slow.log --host=localhost --user=root --password=123456

pt-mysql-summary

生成 MySQL 配置和状态摘要。

bash
pt-mysql-summary --host=localhost --user=root --password=123456

MySQL Enterprise Monitor

MySQL 企业版提供的监控工具,需要付费。

六、慢查询优化实战案例

6.1 案例 1:电商订单查询优化

业务场景:查询某个用户的订单列表,按创建时间倒序分页。

问题 SQL:

sql
SELECT * FROM orders 
WHERE user_id = 1001 
ORDER BY created_at DESC 
LIMIT 0, 20;

执行计划分析:

sql
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 ORDER BY created_at DESC LIMIT 0, 20;

-- 结果:
-- type: ref (使用索引 idx_user_id)
-- key: idx_user_id
-- rows: 1520
-- Extra: Using filesort (文件排序)

问题:

  1. 使用了索引 idx_user_id,但需要回表查询所有字段
  2. ORDER BY created_at DESC 导致文件排序
  3. 扫描行数较多(1520 行)

优化方案:

sql
-- 方案 1:创建联合索引
CREATE INDEX idx_user_created ON orders(user_id, created_at);

-- 再次查看执行计划
EXPLAIN SELECT * FROM orders WHERE user_id = 1001 ORDER BY created_at DESC LIMIT 0, 20;
-- type: ref
-- key: idx_user_created
-- Extra: NULL (无文件排序)

-- 方案 2:使用覆盖索引(如果只需要部分字段)
EXPLAIN SELECT order_id, user_id, created_at, status 
FROM orders 
WHERE user_id = 1001 
ORDER BY created_at DESC 
LIMIT 0, 20;
-- Extra: Using index (覆盖索引)

性能对比:

优化前优化后提升
执行时间: 50ms执行时间: 5ms10倍
扫描行数: 1520扫描行数: 2076倍
文件排序: 是文件排序: 否

6.2 案例 2:社交平台动态查询优化

业务场景:查询用户关注的动态,按时间倒序展示。

问题 SQL:

sql
SELECT d.* 
FROM dynamics d
JOIN user_follows f ON d.user_id = f.follow_user_id
WHERE f.user_id = 1001
ORDER BY d.created_at DESC
LIMIT 0, 20;

执行计划分析:

sql
EXPLAIN SELECT d.* FROM dynamics d JOIN user_follows f ON d.user_id = f.follow_user_id WHERE f.user_id = 1001 ORDER BY d.created_at DESC LIMIT 0, 20;

-- 结果:
-- dynamics 表: type: ALL (全表扫描)
-- Extra: Using temporary; Using filesort (临时表 + 文件排序)

问题:

  1. dynamics 表全表扫描
  2. 使用了临时表和文件排序
  3. 关联字段 user_id 无索引

优化方案:

sql
-- 方案 1:创建索引
CREATE INDEX idx_user_created ON dynamics(user_id, created_at);
CREATE INDEX idx_user_id ON user_follows(user_id);

-- 再次查看执行计划
EXPLAIN SELECT d.* FROM dynamics d JOIN user_follows f ON d.user_id = f.follow_user_id WHERE f.user_id = 1001 ORDER BY d.created_at DESC LIMIT 0, 20;
-- dynamics 表: type: ref
-- Extra: Using filesort (仍有文件排序)

-- 方案 2:改写 SQL,先查关注列表
SELECT d.* FROM dynamics d
WHERE d.user_id IN (
    SELECT follow_user_id FROM user_follows WHERE user_id = 1001
)
ORDER BY d.created_at DESC
LIMIT 0, 20;
-- 子查询使用索引,但需要创建临时表

-- 方案 3:使用冗余字段(推荐)
-- 在 dynamics 表中添加冗余字段:is_followed(是否被关注)
-- 或使用消息队列推送动态到用户的 feed 表

最终方案:业务重构,使用推拉结合的 Feed 流架构。

6.3 案例 3:报表统计查询优化

业务场景:统计每个状态的订单数量和总金额。

问题 SQL:

sql
SELECT 
    status,
    COUNT(*) as order_count,
    SUM(amount) as total_amount
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY status;

执行计划分析:

sql
EXPLAIN SELECT status, COUNT(*) as order_count, SUM(amount) as total_amount FROM orders WHERE created_at >= '2024-01-01' GROUP BY status;

-- 结果:
-- type: ALL (全表扫描)
-- Extra: Using temporary; Using filesort

问题:

  1. 全表扫描
  2. 使用临时表和文件排序
  3. SUM(amount) 需要回表查询

优化方案:

sql
-- 方案 1:创建联合索引
CREATE INDEX idx_created_status ON orders(created_at, status, amount);

-- 再次查看执行计划
EXPLAIN SELECT status, COUNT(*) as order_count, SUM(amount) as total_amount FROM orders WHERE created_at >= '2024-01-01' GROUP BY status;
-- type: range
-- Extra: Using index; Using temporary (使用覆盖索引,但仍有临时表)

-- 方案 2:使用物化视图(MySQL 不支持,可用定时任务替代)
CREATE TABLE order_stats_daily (
    stat_date DATE,
    status VARCHAR(20),
    order_count INT,
    total_amount DECIMAL(10,2),
    PRIMARY KEY(stat_date, status)
);

-- 定时任务每天统计一次
INSERT INTO order_stats_daily
SELECT 
    CURDATE(),
    status,
    COUNT(*),
    SUM(amount)
FROM orders
WHERE created_at >= CURDATE()
GROUP BY status
ON DUPLICATE KEY UPDATE
    order_count = VALUES(order_count),
    total_amount = VALUES(total_amount);

-- 查询时直接从统计表读取
SELECT * FROM order_stats_daily WHERE stat_date = '2024-03-30';

6.4 案例 4:模糊搜索优化

业务场景:搜索商品名称包含"手机"的商品。

问题 SQL:

sql
SELECT * FROM products WHERE name LIKE '%手机%';

执行计划分析:

sql
EXPLAIN SELECT * FROM products WHERE name LIKE '%手机%';

-- 结果:
-- type: ALL (全表扫描)
-- Extra: Using where

问题:

  1. %手机% 导致索引失效
  2. 全表扫描

优化方案:

sql
-- 方案 1:使用全文索引
ALTER TABLE products ADD FULLTEXT INDEX idx_name(name);

SELECT * FROM products WHERE MATCH(name) AGAINST('手机');
-- 使用全文索引,性能好

-- 方案 2:使用 Elasticsearch
-- 将商品数据同步到 ES,使用 ES 进行搜索
-- 适合大规模数据搜索

-- 方案 3:使用 LIKE '手机%' (如果业务允许)
SELECT * FROM products WHERE name LIKE '手机%';
-- 可以使用索引

6.5 案例 5:大表更新优化

业务场景:批量更新订单状态。

问题 SQL:

sql
UPDATE orders SET status = 'CANCELLED' WHERE created_at < '2023-01-01';

问题:

  1. 锁表时间过长
  2. 影响其他查询
  3. 可能导致主从延迟

优化方案:

sql
-- 方案 1:分批更新
-- 每次更新 1000 条
UPDATE orders SET status = 'CANCELLED' 
WHERE created_at < '2023-01-01' AND status != 'CANCELLED'
LIMIT 1000;

-- 循环执行,直到影响行数为 0
-- 脚本示例(Python):
"""
affected_rows = 1
while affected_rows > 0:
    cursor.execute('''
        UPDATE orders SET status = 'CANCELLED' 
        WHERE created_at < '2023-01-01' AND status != 'CANCELLED'
        LIMIT 1000
    ''')
    affected_rows = cursor.rowcount
    connection.commit()
    time.sleep(1)  # 休息 1 秒,降低影响
"""

-- 方案 2:使用存储过程
DELIMITER //
CREATE PROCEDURE batch_update_orders()
BEGIN
    DECLARE done INT DEFAULT FALSE;
    DECLARE affected_rows INT DEFAULT 1;
    
    WHILE affected_rows > 0 DO
        UPDATE orders SET status = 'CANCELLED' 
        WHERE created_at < '2023-01-01' AND status != 'CANCELLED'
        LIMIT 1000;
        
        SET affected_rows = ROW_COUNT();
        DO SLEEP(1);
    END WHILE;
END //
DELIMITER ;

CALL batch_update_orders();

-- 方案 3:归档历史数据
-- 创建归档表
CREATE TABLE orders_archive LIKE orders;

-- 迁移数据
INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < '2023-01-01';

-- 删除原表数据
DELETE FROM orders WHERE created_at < '2023-01-01';

七、常见问题和解决方案

7.1 索引建立了但不生效

原因:

  1. 索引选择性太低
  2. 隐式类型转换
  3. 使用函数
  4. MySQL 优化器认为全表扫描更快

排查方法:

sql
-- 查看 possible_keys 和 key
EXPLAIN SELECT * FROM orders WHERE user_id = 100;

-- possible_keys: idx_user_id
-- key: NULL (未使用索引)

-- 原因 1:选择性太低
SELECT COUNT(DISTINCT user_id) / COUNT(*) FROM orders;
-- 结果 < 0.1,选择性太低

-- 原因 2:隐式转换
SHOW CREATE TABLE orders;
-- user_id INT
SELECT * FROM orders WHERE user_id = '100';  -- 字符串

-- 原因 3:使用函数
SELECT * FROM orders WHERE DATE(created_at) = '2024-03-30';

-- 原因 4:数据量小
SELECT COUNT(*) FROM orders;  -- 数据量很小

解决方案:

  • 提高索引选择性
  • 避免隐式转换
  • 避免使用函数
  • 使用 FORCE INDEX 强制使用索引

7.2 慢查询日志不记录

原因:

  1. 慢查询日志未开启
  2. long_query_time 设置太大
  3. SQL 执行时间未超过阈值
  4. 日志文件权限问题

排查方法:

sql
-- 检查配置
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'slow_query_log_file';

-- 测试慢查询
SELECT SLEEP(3);  -- 执行 3 秒
-- 查看是否记录到日志

解决方案:

sql
-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;

-- 检查日志文件权限
-- Linux:
-- ls -l /var/log/mysql/mysql-slow.log
-- chown mysql:mysql /var/log/mysql/mysql-slow.log
-- chmod 644 /var/log/mysql/mysql-slow.log

7.3 优化后性能反而下降

原因:

  1. 索引过多,影响写入性能
  2. 统计信息不准确
  3. MySQL 选择了错误的执行计划

排查方法:

sql
-- 查看索引数量
SHOW INDEX FROM orders;

-- 查看表统计信息
SHOW TABLE STATUS LIKE 'orders';

-- 分析表(更新统计信息)
ANALYZE TABLE orders;

-- 查看执行计划
EXPLAIN SELECT * FROM orders WHERE ...;

解决方案:

  • 删除冗余索引
  • 更新统计信息
  • 使用 FORCE INDEX 强制使用索引

7.4 分页查询越往后越慢

原因:MySQL 需要扫描 offset + limit 行数据

示例:

sql
-- 第 1 页:扫描 10 行
SELECT * FROM orders ORDER BY id LIMIT 0, 10;

-- 第 10000 页:扫描 100010 行
SELECT * FROM orders ORDER BY id LIMIT 100000, 10;

解决方案:

sql
-- 方案 1:使用子查询
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 10) t
ON o.id = t.id;

-- 方案 2:记录上次的最大 ID
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;

-- 方案 3:限制分页深度(业务层面)
-- 只允许查看前 100 页

7.5 COUNT(*) 查询慢

原因:InnoDB 需要扫描全表统计行数

解决方案:

sql
-- 方案 1:使用覆盖索引
CREATE INDEX idx_status ON orders(status);
SELECT COUNT(status) FROM orders;  -- 使用覆盖索引

-- 方案 2:缓存计数
CREATE TABLE table_counts (
    table_name VARCHAR(50) PRIMARY KEY,
    row_count BIGINT
);

INSERT INTO table_counts VALUES ('orders', 0);

-- 每次插入/删除时更新
INSERT INTO orders (...) VALUES (...);
UPDATE table_counts SET row_count = row_count + 1 WHERE table_name = 'orders';

-- 查询时直接读取
SELECT row_count FROM table_counts WHERE table_name = 'orders';

-- 方案 3:使用 Redis 缓存计数
-- 每次插入/删除时更新 Redis 计数器

八、面试要点

8.1 基础问题

Q1:什么是执行计划?如何查看?

A:执行计划是数据库查询优化器根据 SQL 语句生成的执行方案,描述了数据库如何检索数据。使用 EXPLAIN 命令查看,EXPLAIN ANALYZE 可以看到实际执行统计。

Q2:EXPLAIN 中 type 字段有哪些值?性能从好到差排序。

A:从好到差依次为:

  • system:系统表,只有一行
  • const:主键或唯一索引等值查询
  • eq_ref:关联查询使用主键或唯一索引
  • ref:非唯一索引等值查询
  • range:索引范围扫描
  • index:全索引扫描
  • ALL:全表扫描

Q3:Extra 字段中出现 Using filesort 和 Using temporary 表示什么?如何优化?

A:

  • Using filesort:需要文件排序,无法使用索引排序。优化:创建合适的联合索引。
  • Using temporary:需要创建临时表,通常用于 GROUP BY 或 DISTINCT。优化:创建索引,或使用覆盖索引。

Q4:什么是回表?如何避免?

A:回表是指通过非聚簇索引查找数据时,需要先查索引树找到主键值,再通过主键到聚簇索引查找完整记录。避免方法:使用覆盖索引,让查询的所有列都在索引中。

Q5:联合索引的最左匹配原则是什么?

A:联合索引按照定义顺序从左到右匹配,查询条件必须包含索引的最左列,否则索引失效。例如索引 (a, b, c),查询条件 WHERE b = 1 无法使用索引。

8.2 进阶问题

Q1:如何分析慢查询?

A:

  1. 开启慢查询日志,设置 long_query_time 阈值
  2. 使用 mysqldumpslowpt-query-digest 分析慢查询日志
  3. 使用 EXPLAIN 查看执行计划
  4. 使用 SHOW PROFILE 查看详细耗时
  5. 分析是否是索引问题、锁等待、临时表等问题
  6. 针对性优化

Q2:什么情况下索引会失效?

A:

  1. 在索引列上使用函数:WHERE YEAR(created_at) = 2024
  2. 隐式类型转换:WHERE order_no = 123 (order_no 是 VARCHAR)
  3. 使用 !=<>
  4. 使用 OR 连接不同字段(MySQL 5.6 之前)
  5. LIKE 以通配符开头:WHERE name LIKE '%abc'
  6. IS NULLIS NOT NULL(可能失效)
  7. 范围查询后的索引列失效:WHERE a = 1 AND b > 2 AND c = 3(c 索引失效)

Q3:如何优化深度分页查询?

A:

  1. 使用子查询:先查询主键 ID,再关联查询
    sql
    SELECT o.* FROM orders o
    JOIN (SELECT id FROM orders ORDER BY id LIMIT 100000, 10) t
    ON o.id = t.id;
  2. 记录上次的最大 ID:使用范围查询代替 LIMIT offset
    sql
    SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 10;
  3. 业务限制:只允许查看前 N 页

Q4:如何优化 ORDER BY?

A:

  1. 创建联合索引,让 ORDER BY 使用索引排序
    sql
    CREATE INDEX idx_user_created ON orders(user_id, created_at);
    SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
  2. 使用覆盖索引,避免回表
  3. 减少 SELECT 字段,只查询需要的列
  4. 增加 sort_buffer_size 参数(MySQL 配置)

Q5:如何判断索引是否有用?

A:

  1. 查看索引选择性:COUNT(DISTINCT column) / COUNT(*),选择性越高越好(> 0.5)
  2. 使用 EXPLAIN 查看 typekey 字段
  3. 查看 rows 字段,扫描行数越少越好
  4. 查看 Extra 字段,是否使用了临时表或文件排序
  5. 对比优化前后的执行时间

8.3 实战问题

Q1:慢查询日志中有一条 SQL 执行 8 秒,但 EXPLAIN 看起来很正常,可能是什么原因?

A:

  1. 锁等待:SQL 本身执行很快,但等待锁释放耗时很长
    • 排查方法:SHOW ENGINE INNODB STATUS 查看锁信息
    • 或查看 performance_schema.data_locks
  2. 数据量变化:执行时数据量大,现在数据量小
  3. 缓存未命中:执行时数据不在 buffer pool,需要从磁盘读取
  4. 并发压力:执行时系统负载高
  5. 统计信息不准确:优化器选择了错误的执行计划

Q2:表中有多个索引,MySQL 如何选择使用哪个?

A: MySQL 优化器基于成本模型选择索引,考虑因素包括:

  1. 索引选择性:区分度高的索引优先
  2. 扫描行数:预估扫描行数少的索引优先
  3. 索引类型:主键 > 唯一索引 > 普通索引
  4. 是否需要回表:覆盖索引优先
  5. 是否需要排序:可以使用索引排序的优先

可以查看执行计划的 possible_keyskey 字段,或使用 FORCE INDEX 强制使用指定索引。

Q3:如何优化 COUNT(*) 查询?

A:

  1. 使用覆盖索引:
    sql
    CREATE INDEX idx_status ON orders(status);
    SELECT COUNT(status) FROM orders;
  2. 缓存计数:维护一个计数表或使用 Redis 缓存
  3. 预估行数:使用 SHOW TABLE STATUS 查看预估行数
  4. 业务优化:避免实时 COUNT,改为定时统计

Q4:生产环境如何安全地添加索引?

A:

  1. 在从库上先创建索引,测试性能
  2. 使用 ALGORITHM=INPLACE, LOCK=NONE(MySQL 5.6+),避免锁表
    sql
    ALTER TABLE orders ADD INDEX idx_user_id(user_id), ALGORITHM=INPLACE, LOCK=NONE;
  3. 在业务低峰期操作
  4. 使用 pt-online-schema-change 工具(在线 DDL)
    bash
    pt-online-schema-change --alter "ADD INDEX idx_user_id(user_id)" D=mydb,t=orders --execute
  5. 监控性能影响

Q5:如何监控和预警慢查询?

A:

  1. 定期分析慢查询日志,使用 pt-query-digest
  2. 监控 MySQL 关键指标:
    • 慢查询数量:SHOW GLOBAL STATUS LIKE 'Slow_queries'
    • 查询缓存命中率(如果启用)
    • 线程数:SHOW STATUS LIKE 'Threads_connected'
  3. 使用监控工具:
    • Prometheus + Grafana
    • MySQL Enterprise Monitor
    • Percona Monitoring and Management (PMM)
  4. 设置告警阈值:
    • 慢查询数量 > N/小时
    • 平均查询时间 > N 秒
  5. 定期审查和优化 Top N 慢查询

九、总结

核心要点

  1. 执行计划是 SQL 优化的基础工具,必须掌握 EXPLAIN 各字段的含义
  2. 慢查询日志是发现问题的第一步,要合理配置和分析
  3. 索引不是万能的,有索引也可能慢,要理解索引的工作原理
  4. 优化要结合业务场景,不能脱离实际数据分布和访问模式
  5. 性能优化是持续过程,要建立监控和治理机制

优化流程

code
发现问题 → 定位问题 → 分析原因 → 制定方案 → 实施优化 → 验证效果 → 监控改进
    ↓          ↓          ↓          ↓          ↓          ↓          ↓
慢查询日志   EXPLAIN   索引/锁/SQL  创建索引    验证计划   对比性能   持续监控

最佳实践

  1. 设计阶段:

    • 合理设计索引,遵循最左匹配原则
    • 高选择性列放联合索引前面
    • 考虑覆盖索引,减少回表
  2. 开发阶段:

    • 避免 SELECT *,只查询需要的列
    • 避免 WHERE 条件使用函数或隐式转换
    • 使用 EXPLAIN 验证执行计划
  3. 测试阶段:

    • 使用生产数据量级测试
    • 分析慢查询日志
    • 压测验证性能
  4. 生产阶段:

    • 监控慢查询
    • 定期优化 Top N 慢查询
    • 建立预警机制

常用命令速查

sql
-- 查看执行计划
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;  -- MySQL 8.0.18+

-- 慢查询配置
SHOW VARIABLES LIKE 'slow_query_log';
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2;

-- 查看索引
SHOW INDEX FROM table_name;

-- 分析表(更新统计信息)
ANALYZE TABLE table_name;

-- 查看表状态
SHOW TABLE STATUS LIKE 'table_name';

-- 查看性能统计
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 10;
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;

-- Profile 分析
SET profiling = 1;
SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;

版本差异(MySQL 5.7 → 8.0/8.4)

特性旧版(本文编写时,MySQL 5.7)当前(MySQL 8.0/8.4 LTS)
默认字符集utf8(需显式配置 utf8mb4)utf8mb4(MySQL 8.0 起默认)
索引普通 B+Tree降序索引、隐藏索引、函数索引(8.0+)
SQL 能力常规查询递归 CTE、窗口函数(8.0+)
版本策略5.78.0(主流)/ 8.4 LTS / 9.x(创新版)
Java 驱动mysql-connector-java 5.x/8.0mysql-connector-j 8.x/9.x

本文基于 MySQL 5.7 编写,核心概念(索引、事务、锁、MVCC、InnoDB)在 8.0/8.4 中依然适用;8.0 的默认字符集、隐藏索引与 SQL 增强(CTE/窗口函数)是升级后的主要差异。