MySQL慢查询分析与优化实战

摘要 :慢查询是 MySQL 性能问题中最常见、影响最大的杀手。本文从慢查询日志的配置开启讲起,通过 pt-query-digest 定位 TOP 慢 SQL,结合 EXPLAIN 诊断执行计划,给出 5 个真实优化案例(从全表扫描到覆盖索引、从深分页到子查询改写),最后搭建 Prometheus + Grafana 监控体系,形成完整的慢查询优化闭环。全文包含实战命令和性能对比数据,建议收藏。


一、慢查询优化的完整闭环

慢查询优化不是一次性任务,而是一个持续迭代的 PDCA 闭环:

发现 → 定位 → 诊断 → 优化 → 验证 → 监控 → 再回到发现

MySQL 查询执行流程如下:客户端发送 SQL → 解析器解析 → 预处理器处理 → 查询优化器生成执行计划 → 查询执行引擎调用存储引擎 → 返回结果。

理解这个流程对诊断慢查询至关重要:优化器选择的执行计划直接决定了查询效率,而执行计划又依赖于索引统计信息和成本估算。


二、第一步:发现慢查询

2.1 开启慢查询日志(Slow Query Log)

慢查询日志是 MySQL 内置的慢 SQL 记录机制,建议生产环境必开。

sql 复制代码
-- 查看当前配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- 动态开启(无需重启,但重启后失效)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
SET GLOBAL long_query_time = 1;  -- 超过 1 秒视为慢查询

-- 记录未使用索引的查询(强烈建议开启)
SET GLOBAL log_queries_not_using_indexes = 'ON';

永久生效配置my.cnf):

ini 复制代码
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
log_output = FILE  -- 或 TABLE,记录到 mysql.slow_log 表

2.2 使用 Performance Schema(MySQL 5.6+)

Performance Schema 提供更精细的语句级性能数据,无需写日志文件。

sql 复制代码
-- 开启 Statement 监控
UPDATE performance_schema.setup_consumers 
SET ENABLED = 'YES' WHERE NAME LIKE '%statements%';

-- 查看耗时 TOP 10 的 SQL
SELECT 
    DIGEST_TEXT AS query,
    COUNT_STAR AS exec_count,
    ROUND(SUM_TIMER_WAIT/1000000000000, 2) AS total_latency_sec,
    ROUND(AVG_TIMER_WAIT/1000000000000, 4) AS avg_latency_sec,
    ROUND(MAX_TIMER_WAIT/1000000000000, 4) AS max_latency_sec
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;

Performance Schema 的数据实时在内存中维护,适合需要即时分析的场景,但重启后数据会丢失。

2.3 使用 sys 系统库(MySQL 5.7+)

sys 库基于 Performance Schema 提供了更友好的视图:

sql 复制代码
-- 查看全表扫描次数最多的 SQL
SELECT * FROM sys.statements_with_full_table_scans 
ORDER BY rows_examined DESC LIMIT 10;

-- 查看执行次数最多且平均耗时高的 SQL
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile;

-- 查看使用临时表和文件排序的 SQL
SELECT * FROM sys.statements_with_sorting 
ORDER BY rows_sorted DESC LIMIT 10;

三、第二步:定位 TOP 慢 SQL

3.1 pt-query-digest:慢日志分析神器

pt-query-digest 是 Percona Toolkit 中的慢查询分析工具,能将混乱的慢日志整理成清晰的排名报告。

安装

bash 复制代码
# Ubuntu/Debian
apt-get install percona-toolkit

# CentOS/RHEL
yum install percona-toolkit

# 或下载源码包
wget https://www.percona.com/downloads/percona-toolkit/3.5.5/binary/tarball/percona-toolkit-3.5.5_x86_64.tar.gz

基本用法

bash 复制代码
# 分析慢查询日志,输出到文件
pt-query-digest /var/log/mysql/slow.log > slow_report.txt

# 只显示前 20 条(默认按总耗时排序)
pt-query-digest --limit 20 /var/log/mysql/slow.log

# 过滤只分析最近 1 小时的日志
pt-query-digest --since "1h" /var/log/mysql/slow.log

# 分析特定数据库的慢查询
pt-query-digest --filter '$event->{db} eq "mydb"' /var/log/mysql/slow.log

# 直接分析 Processlist(实时分析)
pt-query-digest --processlist h=localhost,u=root,p=password

3.2 pt-query-digest 报告核心指标解读

报告头部 Overall statistics 汇总了日志整体情况:

指标 含义 优化意义
Exec time SQL 总执行时间 找出时间杀手
Lock time 等待锁的时间 高说明有锁竞争
Rows sent 返回客户端的行数 高说明可能 SELECT *
Rows examine 扫描的行数 对比 Rows sent,比值越大效率越低
Rows affected 变更的行数 DML 语句的影响面
Tmp tables 创建临时表次数 高说明需要优化 GROUP BY / ORDER BY
Tmp disk tbl 磁盘临时表次数 高说明内存不足,性能急剧下降

排名部分关键字段

字段 含义 判断标准
Response time 该 SQL 总耗时及占比 占比 > 10% 必须优化
Calls 执行次数 高频慢查询影响面更大
R/Call 平均每次耗时 > 1s 必须优化,> 100ms 建议优化
V/M 响应时间方差均值比 > 0.1 说明执行时间波动大,可能存在锁竞争

分析口诀:先看 Response time 占比找元凶 → 再看 Calls 确认影响面 → 最后看 R/Call 评估单次伤害。


四、第三步:诊断执行计划

找到 TOP 慢 SQL 后,用 EXPLAIN 诊断其执行计划。

4.1 EXPLAIN 关键字段速查

上图展示了 MySQL Workbench 的可视化执行计划,能直观看到:

  • 全表扫描(Full Table Scan)的代价
  • 嵌套循环连接(Nested Loop Join)的过程
  • 索引查找(Key Lookup)的效率对比

核心字段诊断

字段 正常值 危险值 优化方向
type ref/range/eq_ref ALL/index 创建/调整索引
key 有具体索引名 NULL 检查 WHERE 条件是否命中索引
rows 远小于表总行数 接近表总行数 增加过滤条件或优化索引
Extra Using index Using filesort/Using temporary 利用覆盖索引、简化排序分组

4.2 常见执行计划问题诊断

sql 复制代码
-- 案例:检查一个慢查询的执行计划
EXPLAIN ANALYZE
SELECT o.*, u.name 
FROM orders o 
JOIN user u ON o.user_id = u.id 
WHERE o.status = 'pending' 
  AND o.create_time > '2024-01-01'
ORDER BY o.create_time DESC 
LIMIT 100;

常见问题排查

  1. type = ALL:全表扫描。检查是否有合适索引、是否索引失效、是否数据量过大导致优化器放弃索引。
  2. Extra = Using filesort:需要额外排序。尝试将 ORDER BY 列加入索引,或利用覆盖索引避免回表后排序。
  3. Extra = Using temporary:需要创建临时表。常见于复杂 GROUP BY,尝试简化查询或调整 GROUP BY 顺序匹配索引。
  4. rows 远大于实际返回数:说明扫描了大量无效数据。检查索引选择性,或增加更精确的过滤条件。

五、第四步:优化实战

案例 1:SELECT * 导致的回表灾难

场景:订单查询页面加载缓慢,用户反馈卡顿。

sql 复制代码
-- 原始慢查询 (平均 2.3s)
SELECT * FROM orders 
WHERE user_id = 12345 
  AND status = 'completed' 
ORDER BY create_time DESC 
LIMIT 10;

诊断

sql 复制代码
EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND status = 'completed' ...
-- 结果: type=ref, key=idx_user_id, rows=15000, Extra=Using where; Using filesort

问题分析:

  • 索引 idx_user_id 只包含 user_id,过滤后仍有 15000 行
  • SELECT * 导致回表 15000 次
  • ORDER BY create_time 需要额外排序(filesort)

优化方案

sql 复制代码
-- 创建覆盖索引,包含 WHERE、ORDER BY、SELECT 需要的所有列
CREATE INDEX idx_user_status_time 
ON orders(user_id, status, create_time, order_no, amount, pay_time);

-- 改写查询,只取必要字段(利用覆盖索引)
SELECT order_no, amount, status, create_time, pay_time 
FROM orders 
WHERE user_id = 12345 
  AND status = 'completed' 
ORDER BY create_time DESC 
LIMIT 10;

优化后 EXPLAIN

复制代码
type=ref, key=idx_user_status_time, rows=120, Extra=Using index

效果 :查询耗时从 2.3s → 12ms ,提升 190 倍


案例 2:深分页 LIMIT 100000, 10 的性能陷阱

场景:后台管理系统翻页到第 10000 页后,页面加载超过 5 秒。

sql 复制代码
-- 原始慢查询 (平均 4.8s)
SELECT * FROM orders 
WHERE status = 'completed' 
ORDER BY create_time DESC 
LIMIT 100000, 10;

诊断

复制代码
type=index, key=idx_status_time, rows=100010, Extra=Using where

问题分析:MySQL 的 LIMIT 实现是「先扫描 100010 行,再丢弃前 100000 行」,越往后越慢。

优化方案 1:延迟关联(Deferred Join)

sql 复制代码
-- 先查主键,再回表取数据
SELECT o.* 
FROM orders o
JOIN (
    SELECT id 
    FROM orders 
    WHERE status = 'completed' 
    ORDER BY create_time DESC 
    LIMIT 100000, 10
) tmp ON o.id = tmp.id;

优化方案 2:基于游标的分页(推荐)

sql 复制代码
-- 上一页最后一条记录的 create_time 为 '2024-03-15 14:30:00'
SELECT * FROM orders 
WHERE status = 'completed' 
  AND create_time < '2024-03-15 14:30:00'
ORDER BY create_time DESC 
LIMIT 10;

效果 :方案 2 从 4.8s → 15ms,且性能不随页码增加而下降。


案例 3:隐式类型转换导致索引失效

场景:手机号查询接口偶发卡顿,有时 50ms,有时 3s。

sql 复制代码
-- 表结构
CREATE TABLE user (
    id INT PRIMARY KEY,
    phone VARCHAR(20),  -- 注意是 VARCHAR
    INDEX idx_phone (phone)
);

-- 原始查询(Java 代码传入 long 类型)
SELECT * FROM user WHERE phone = 13800138000;

诊断

sql 复制代码
EXPLAIN SELECT * FROM user WHERE phone = 13800138000;
-- 结果: type=ALL, key=NULL, rows=5000000

问题分析:phone 是 VARCHAR,传入数字时 MySQL 隐式将 phone 列转换为数字再比较,相当于对索引列做了函数处理,导致索引失效。

优化方案

java 复制代码
// 修改 Java 代码,确保传入字符串
String phone = "13800138000";  // 原来是 Long phone = 13800138000L;
jdbcTemplate.query("SELECT * FROM user WHERE phone = ?", phone);

效果 :查询耗时从 3s → 5ms


案例 4:OR 条件导致的全表扫描

场景:订单搜索功能支持按订单号或手机号查询,慢查询日志中该 SQL 频繁出现。

sql 复制代码
-- 原始慢查询 (平均 1.5s)
SELECT * FROM orders 
WHERE order_no = 'ORD2024001' 
   OR user_phone = '13800138000';

诊断

复制代码
type=ALL, key=NULL, rows=10000000

问题分析:OR 两边条件分别适合不同索引(idx_order_noidx_user_phone),但优化器选择全表扫描。

优化方案:拆分为 UNION ALL

sql 复制代码
-- 优化后查询 (平均 15ms)
SELECT * FROM orders WHERE order_no = 'ORD2024001'
UNION ALL
SELECT * FROM orders WHERE user_phone = '13800138000' 
  AND order_no <> 'ORD2024001';  -- 避免重复

效果 :从 1.5s → 15ms ,提升 100 倍


案例 5:统计查询的索引优化与缓存策略

场景:首页 Dashboard 需要实时统计今日订单金额和数量,每刷新一次就查一次数据库。

sql 复制代码
-- 原始慢查询 (平均 800ms,并发高时更慢)
SELECT 
    COUNT(*) AS order_count,
    SUM(amount) AS total_amount,
    AVG(amount) AS avg_amount
FROM orders 
WHERE create_time >= CURDATE();

诊断

复制代码
type=range, key=idx_create_time, rows=50000, Extra=Using index condition

问题分析:虽然走了索引,但扫描行数多,且是聚合计算,CPU 开销大。高并发时成为瓶颈。

优化方案 1:覆盖索引

sql 复制代码
-- 创建覆盖索引(只包含查询需要的列)
CREATE INDEX idx_create_time_amount 
ON orders(create_time, amount);

-- 查询变为覆盖索引扫描
SELECT COUNT(*), SUM(amount), AVG(amount)
FROM orders 
WHERE create_time >= CURDATE();

优化方案 2:冗余统计表(最终采用)

sql 复制代码
-- 创建统计表
CREATE TABLE order_daily_stats (
    stat_date DATE PRIMARY KEY,
    order_count INT,
    total_amount DECIMAL(18,2),
    avg_amount DECIMAL(18,2),
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 通过定时任务或触发器更新(每 5 分钟)
INSERT INTO order_daily_stats (stat_date, order_count, total_amount, avg_amount)
SELECT CURDATE(), COUNT(*), SUM(amount), AVG(amount)
FROM orders 
WHERE create_time >= CURDATE()
ON DUPLICATE KEY UPDATE
    order_count = VALUES(order_count),
    total_amount = VALUES(total_amount),
    avg_amount = VALUES(avg_amount);

-- 查询变为毫秒级
SELECT * FROM order_daily_stats WHERE stat_date = CURDATE();

效果 :从 800ms → 2ms,且不受并发影响。


六、第五步:验证优化效果

优化后必须验证,避免引入新问题。

6.1 EXPLAIN 对比验证

sql 复制代码
-- 优化前保存执行计划
EXPLAIN SELECT ... \G  -- 保存输出

-- 优化后对比
EXPLAIN SELECT ... \G  -- 确认 type、rows、Extra 改善

6.2 性能测试

bash 复制代码
# 使用 mysqlslap 压测
mysqlslap --concurrency=50 --iterations=10 \
  --query="SELECT order_no, amount FROM orders WHERE user_id = 12345 AND status = 'completed' ORDER BY create_time DESC LIMIT 10" \
  --create-schema=mydb --delimiter=";" \
  --engine=innodb --number-of-queries=1000

# 或使用 sysbench
sysbench oltp_read_only --mysql-host=localhost --mysql-user=root \
  --mysql-password=xxx --mysql-db=mydb --tables=10 --table-size=1000000 \
  --threads=64 --time=60 --report-interval=10 run

6.3 生产灰度验证

sql 复制代码
-- 使用 MySQL 8.0 的 Query Rewrite 插件,先对部分流量生效
INSTALL PLUGIN query_rewrite SONAME 'rewriter.so';

-- 添加重写规则(先对 10% 流量生效,验证无误后再全量)
INSERT INTO query_rewrite.rewrite_rules (pattern, replacement, enabled)
VALUES (
    'SELECT * FROM orders WHERE user_id = ? AND status = ? ORDER BY create_time DESC LIMIT ?',
    'SELECT order_no, amount, status, create_time, pay_time FROM orders WHERE user_id = ? AND status = ? ORDER BY create_time DESC LIMIT ?',
    'YES'
);
CALL query_rewrite.flush_rewrite_rules();

七、第六步:搭建监控体系

7.1 Prometheus + mysqld_exporter 监控

yaml 复制代码
# docker-compose.yml
version: '3'
services:
  prometheus:
    image: prom/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  mysqld_exporter:
    image: prom/mysqld-exporter
    environment:
      - DATA_SOURCE_NAME=root:password@(mysql:3306)/
    ports:
      - "9104:9104"

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"

关键监控指标

指标 PromQL 告警阈值
慢查询速率 rate(mysql_global_status_slow_queries[5m]) > 10/min
平均查询耗时 mysql_global_status_slow_queries / mysql_global_status_queries > 1%
全表扫描次数 rate(mysql_global_status_select_scan[5m]) > 100/min
活跃连接数 mysql_global_status_threads_running > 80% max_connections
锁等待时间 mysql_global_status_innodb_row_lock_waits > 50/min

上图展示了 Grafana 中 MySQL 监控看板的典型布局,可以直观看到:

  • 系统指标(CPU、内存、磁盘 IO)
  • MySQL 指标(延迟、QPS、慢查询数、活跃连接数)
  • 异常客户端连接预警

7.2 慢查询自动巡检脚本

bash 复制代码
#!/bin/bash
# slow_query_check.sh - 每日慢查询巡检

LOG_FILE="/var/log/mysql/slow.log"
REPORT_FILE="/tmp/slow_report_$(date +%Y%m%d).txt"
THRESHOLD=10  # 慢查询数量阈值

# 生成报告
pt-query-digest --limit 20 $LOG_FILE > $REPORT_FILE

# 提取 TOP 1 的耗时占比
TOP_PCT=$(grep -A 1 "Rank Query ID" $REPORT_FILE | head -3 | tail -1 | awk '{print $3}')

# 发送告警
if [ $(echo "$TOP_PCT > $THRESHOLD" | bc) -eq 1 ]; then
    curl -X POST "https://oapi.dingtalk.com/robot/send?access_token=xxx" \
      -H "Content-Type: application/json" \
      -d "{"msgtype": "markdown", "markdown": {"title": "MySQL慢查询告警", "text": "### MySQL 慢查询告警\nTOP 1 慢查询占比: ${TOP_PCT}%\n请查看报告: ${REPORT_FILE}"}}"
fi

# 归档日志(可选)
mv $LOG_FILE /var/log/mysql/slow_$(date +%Y%m%d).log
mysqladmin -uroot -p flush-logs slow

八、慢查询优化总结:决策矩阵

问题类型 诊断特征 优化手段 预期效果
全表扫描 type=ALL, rows≈总行数 创建索引、调整 WHERE 条件 10~1000 倍提升
回表过多 Extra=Using where, key 有值 覆盖索引、减少 SELECT 字段 5~50 倍提升
文件排序 Extra=Using filesort 索引包含 ORDER BY 列、利用覆盖索引 3~20 倍提升
深分页 LIMIT 100000+, rows 巨大 延迟关联、游标分页、ES 替代 100~1000 倍提升
隐式转换 key=NULL, 列类型与传入值不匹配 保持类型一致、代码层面修复 10~1000 倍提升
OR 失效 type=ALL, 多个 OR 条件 UNION ALL 拆分、分别走索引 10~100 倍提升
聚合统计 大表 COUNT/SUM/AVG 覆盖索引、冗余统计表、缓存 10~500 倍提升
锁竞争 Lock time 高, V/M > 0.1 减少事务范围、调整隔离级别、优化索引 2~10 倍提升

九、面试高频考点速记

Q1:如何定位生产环境的慢查询?

  1. 开启 slow_query_log 和 log_queries_not_using_indexes
  2. 使用 pt-query-digest 分析慢日志,按 Response time 排序找 TOP SQL
  3. 结合 Performance Schema 的 events_statements_summary_by_digest 查看实时数据
  4. 使用 sys 库的 statements_with_full_table_scans 等视图快速定位问题

Q2:pt-query-digest 报告中的 V/M 是什么意思?

V/M 是响应时间的方差均值比(Variance-to-Mean ratio)。值越大说明 SQL 执行时间波动越大,可能存在锁竞争、数据分布不均或缓存命中率低的问题。V/M > 0.1 需要关注。

Q3:LIMIT 100000, 10 为什么慢?如何优化?

MySQL 的 LIMIT 实现是先扫描 offset + limit 行,再丢弃 offset 行。深分页时扫描量巨大。

优化方案:1)延迟关联先查主键再回表;2)基于上一页最后记录的游标分页(推荐);3)使用搜索引擎(ES)替代。

Q4:SELECT * 为什么不好?

  1. 增加网络 IO,返回无用数据;2. 无法使用覆盖索引,必须回表;3. 增加内存消耗;4. 表结构变更可能导致应用报错。应只查询需要的字段。

Q5:优化后如何验证效果?

  1. EXPLAIN 对比执行计划(type、rows、Extra 改善);2. mysqlslap / sysbench 压测对比 QPS/TPS;3. 生产灰度发布,观察慢查询日志和监控指标;4. 关注业务指标(页面加载时间、接口成功率)。

结语

慢查询优化是一个系统工程:从日志配置到工具分析,从执行计划诊断到 SQL 改写,从索引设计到架构调整,最后通过监控体系持续闭环。

记住三个核心原则:

  1. 先定位再优化:用数据说话,不要凭感觉改 SQL
  2. 索引是银弹但不是万能:覆盖索引能解决 80% 的查询性能问题,但深分页、聚合统计需要架构层面解决
  3. 监控是闭环的关键:没有监控的优化等于没优化,问题会反复出现

上图展示了真实的优化效果对比:优化前数据库操作耗时波动剧烈(峰值 6s+),优化后趋于平稳(<1s)。这正是慢查询优化带来的直接业务价值。


如果本文对你有帮助,欢迎点赞收藏!

相关推荐
小江的记录本5 小时前
【Java基础】Java 8-21新特性 :JDK17:密封类、模式匹配、Record类(附《思维导图》+《面试高频考点清单》)
java·数据结构·后端·python·mysql·面试·职场和发展
小江的记录本5 小时前
【Java基础】集合框架: ArrayList vs LinkedList 核心区别、扩容机制(附《思维导图》+《面试高频考点清单》)
java·数据库·python·mysql·spring·面试·maven
石小千5 小时前
mysql8全文检索
mysql·全文检索
wujt88885 小时前
mysql 比较数据库
数据库·mysql·oracle
小江的记录本16 小时前
【Java基础】泛型:泛型擦除、通配符、上下界限定(附《思维导图》+《面试高频考点清单》)
java·数据结构·后端·mysql·spring·面试·职场和发展
bqq1986102618 小时前
MySQL 8与MySQL 5.7的主要区别
数据库·mysql
阿坤带你走近大数据19 小时前
Java中的JVM、类加载记住、多线程、性能优化的概念
java·jvm·性能优化
chushiyunen19 小时前
r树索引、mysql对r树的支持
数据库·mysql
罗超驿19 小时前
16.深入理解数据库事务:从转账场景剖析ACID四大特性与回滚(Rollback)机制
数据库·mysql