【Java项目技术亮点】EXPLAIN深度分析与慢查询治理

写在前面:说实话,做后端开发这些年,我见过太多项目上线后因为一条慢SQL把数据库拖垮的事故。有一次凌晨两点被叫醒,生产环境MySQL CPU飙到90%,一查发现一条统计SQL执行了30秒,连接池被打满,整个服务差点雪崩。那次之后我才真正重视起慢查询治理。这篇文章把我踩过的坑和积累的经验整理出来,希望能帮你少走点弯路。

文章目录


一、为什么慢查询是性能杀手?

1.1 一个真实的生产事故

去年双十一前夕,我们系统的订单查询接口突然超时。

监控告警狂响,MySQL CPU飙到90%,QPS从平时的2000暴跌到200。

紧急排查发现,运营同学跑了一条统计SQL:

sql 复制代码
SELECT COUNT(*) FROM order WHERE create_time > '2024-01-01' AND status = 1;

这条SQL执行了30秒 ,扫描了800万行数据。

连接池被打满,其他正常请求全部排队等待,整个系统差点雪崩。

1.2 慢查询的危害

危害类型 具体表现 影响程度
响应延迟 接口响应从100ms变成10s+ 用户体验极差
连接池打满 数据库连接被慢查询占满 系统不可用
雪崩效应 上游服务超时重试,流量翻倍 级联故障
主从延迟 慢查询在主库执行,从库复制滞后 数据不一致

1.3 生活类比:堵车

慢查询就像城市主干道上的严重堵车。

一条主干道堵了,整个城市的交通都受影响。

救护车、消防车过不去,后果可想而知。

数据库也是一样,一条慢SQL能把整个系统拖下水。


二、开启慢查询日志

2.1 MySQL配置

找到MySQL配置文件(通常是 my.cnfmy.ini),添加或修改以下配置:

ini 复制代码
[mysqld]
# 开启慢查询日志
slow_query_log = 1

# 慢查询日志文件路径
slow_query_log_file = /var/log/mysql/slow.log

# 超过1秒的查询记录为慢查询
long_query_time = 1

# 记录未使用索引的查询(建议开启)
log_queries_not_using_indexes = 1

修改后重启MySQL:

bash 复制代码
sudo systemctl restart mysql

2.2 查看慢查询日志

方式一:直接查看日志文件

bash 复制代码
# 查看最新的慢查询
tail -f /var/log/mysql/slow.log

方式二:使用 mysqldumpslow 工具

bash 复制代码
# 按执行时间排序,显示前10条
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log

# 按执行次数排序
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log

方式三:使用 pt-query-digest 工具(推荐)

bash 复制代码
# 安装 Percona Toolkit
sudo apt-get install percona-toolkit

# 分析慢查询日志,生成详细报告
pt-query-digest /var/log/mysql/slow.log > slow_query_report.txt

pt-query-digest 输出包含:

  • Rank:按查询时间占比排名
  • Query ID:查询指纹
  • Response time:总响应时间和单次平均时间
  • Calls:执行次数
  • R/Call:每次调用平均时间
  • V/M:响应时间方差均值(越大越不稳定)

2.3 慢查询日志分析要点

指标 含义 关注重点
频率 多久出现一次 高频慢查询优先处理
执行时间 单次执行多久 超过1秒就要警惕
返回行数 返回了多少数据 返回行数远大于需要 = 浪费
扫描行数 扫描了多少行 扫描行数 / 返回行数 > 100 = 严重

三、EXPLAIN字段逐行解读

3.1 EXPLAIN基本用法

sql 复制代码
EXPLAIN SELECT * FROM user WHERE id = 100;

输出大概长这样:

复制代码
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | user  | NULL       | const | PRIMARY       | PRIMARY | 4       | const |    1 |   100.00 | NULL  |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+

下面逐字段解读。

3.2 id列:执行顺序

id相同,执行顺序从上到下;id不同,id越大越先执行。

sql 复制代码
-- id相同的情况
EXPLAIN SELECT * FROM user u, order o WHERE u.id = o.user_id;
-- id都是1,先执行user表,再执行order表

-- id不同的情况(子查询)
EXPLAIN SELECT * FROM user WHERE id = (SELECT user_id FROM order WHERE id = 100);
-- 子查询id=2先执行,外层id=1后执行

3.3 select_type列:查询类型

类型 含义 示例
SIMPLE 简单查询,不包含子查询或UNION SELECT * FROM user
PRIMARY 最外层查询 包含子查询时的外层
SUBQUERY 子查询 WHERE id IN (SELECT ...)
DERIVED 派生表(FROM后的子查询) FROM (SELECT ...) AS t
UNION UNION中的第二个及后续查询 SELECT ... UNION SELECT ...

3.4 table列:访问的表

显示当前行访问的是哪张表。

如果是派生表,会显示 <derivedN>,N是子查询的id。

3.5 type列:访问类型(重点!)

type是判断SQL性能的核心指标,性能从好到差:

复制代码
system > const > eq_ref > ref > range > index > ALL
type 含义 性能 示例
system 表只有一行数据 极好 系统表
const 通过主键或唯一索引一次命中 极好 WHERE id = 1
eq_ref JOIN时,被驱动表通过主键或唯一索引匹配 极好 联表查询,ON条件是主键
ref 通过普通索引匹配 WHERE name = '张三'(name有索引)
range 索引范围扫描 还行 WHERE id BETWEEN 1 AND 100
index 全索引扫描 较差 扫描整个索引树
ALL 全表扫描 极差 没有用到索引

踩坑提醒 :type出现 indexALL 就要警惕了。我见过太多人看到 index 以为用了索引就万事大吉,实际上 index 是扫描整个索引树,和全表扫描差不了多少!

3.6 possible_keys列:可能使用的索引

显示MySQL认为可能用到的索引。

注意:只是"可能",实际不一定用。

3.7 key列:实际使用的索引

显示MySQL实际选择的索引。

如果为NULL,表示没有用到索引。

3.8 key_len列:索引使用长度

这个字段很关键!可以判断联合索引实际用了几个字段。

数据类型 key_len
int 4
bigint 8
varchar(20) 20 * 4 + 2 = 82(utf8mb4)
datetime 5

示例:

sql 复制代码
-- 联合索引 idx_name_age (name, age)
EXPLAIN SELECT * FROM user WHERE name = '张三' AND age = 20;
-- key_len = 82 + 4 = 86,说明两个字段都用到了

EXPLAIN SELECT * FROM user WHERE name = '张三';
-- key_len = 82,说明只用到了name字段

3.9 ref列:索引匹配条件

显示索引的哪一列被使用了,常见值:

  • const:常量匹配
  • 库名.表名.字段名:表的字段匹配

3.10 rows列:预估扫描行数

MySQL预估需要扫描的行数。这个数字越小越好

踩坑提醒:rows是预估的,不是实际的!基于统计信息计算,如果统计信息过期,这个值可能偏差很大。我踩过这个坑,EXPLAIN显示rows=100,实际执行扫描了100万行。

3.11 Extra列:额外信息(重点!)

Extra值 含义 好坏
Using index 覆盖索引,不需要回表 极好
Using index condition ICP索引下推,减少回表
Using where Server层过滤数据 一般
Using filesort 文件排序,没有用到索引排序
Using temporary 使用了临时表
Using join buffer 使用Join缓存 一般

示例解读:

sql 复制代码
-- 覆盖索引,性能好
EXPLAIN SELECT id, name FROM user WHERE name = '张三';
-- Extra: Using index

-- 文件排序,性能差
EXPLAIN SELECT * FROM user ORDER BY age;
-- Extra: Using filesort

-- 使用了临时表
EXPLAIN SELECT status, COUNT(*) FROM user GROUP BY status;
-- Extra: Using temporary; Using filesort

四、常见慢查询场景与优化

4.1 场景1:全表扫描(type=ALL)

问题SQL:

sql 复制代码
-- user表的phone字段没有索引
EXPLAIN SELECT * FROM user WHERE phone = '13800138000';

EXPLAIN结果:

复制代码
type: ALL
rows: 1000000
Extra: Using where

优化方案:加索引

sql 复制代码
-- 添加索引
ALTER TABLE user ADD INDEX idx_phone (phone);

-- 再次EXPLAIN
type: ref
rows: 1
key: idx_phone

4.2 场景2:索引失效

2.1 隐式类型转换

sql 复制代码
-- phone是varchar类型,传入数字
EXPLAIN SELECT * FROM user WHERE phone = 13800138000;
-- type: ALL,索引失效!

-- 正确写法
EXPLAIN SELECT * FROM user WHERE phone = '13800138000';
-- type: ref,索引生效

2.2 对索引字段做函数操作

sql 复制代码
-- 错误:对create_time做函数操作
EXPLAIN SELECT * FROM user WHERE YEAR(create_time) = 2024;
-- type: ALL

-- 正确:改写为范围查询
EXPLAIN SELECT * FROM user WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31';
-- type: range

2.3 like '%xxx' 前缀模糊

sql 复制代码
-- 前缀模糊,索引失效
EXPLAIN SELECT * FROM user WHERE name LIKE '%张三%';
-- type: ALL

-- 后缀模糊,索引生效
EXPLAIN SELECT * FROM user WHERE name LIKE '张三%';
-- type: range

4.3 场景3:Using filesort(ORDER BY无索引)

问题SQL:

sql 复制代码
-- 按create_time排序,但create_time没有索引
EXPLAIN SELECT * FROM order WHERE user_id = 100 ORDER BY create_time DESC LIMIT 10;
-- Extra: Using where; Using filesort

优化方案:加复合索引

sql 复制代码
-- 添加复合索引(注意字段顺序!)
ALTER TABLE `order` ADD INDEX idx_user_time (user_id, create_time);

-- 再次EXPLAIN
-- Extra: Using index condition
-- 索引直接有序,不需要额外排序

踩坑提醒:复合索引的字段顺序很重要!把等值查询的字段放前面,范围查询/排序的字段放后面。我见过太多人索引字段顺序写反了,结果索引只用了一半。

4.4 场景4:Using temporary(GROUP BY/DISTINCT无索引)

问题SQL:

sql 复制代码
EXPLAIN SELECT status, COUNT(*) FROM user GROUP BY status;
-- Extra: Using temporary; Using filesort

优化方案:

sql 复制代码
-- 方案1:给GROUP BY字段加索引
ALTER TABLE user ADD INDEX idx_status (status);

-- 方案2:改写为子查询(数据量大时)
SELECT status, cnt FROM (
    SELECT status, COUNT(*) AS cnt FROM user WHERE id > 0 GROUP BY status
) t;

4.5 场景5:大表JOIN效率低

问题SQL:

sql 复制代码
-- user表1000万行,order表5000万行
EXPLAIN SELECT * FROM user u 
JOIN `order` o ON u.id = o.user_id 
WHERE u.status = 1;

优化方案:小表驱动大表

sql 复制代码
-- 确保驱动表是小结果集
EXPLAIN SELECT * FROM 
(SELECT * FROM user WHERE status = 1) u 
JOIN `order` o ON u.id = o.user_id;

-- 或者使用STRAIGHT_JOIN强制驱动顺序
EXPLAIN SELECT * FROM user u 
STRAIGHT_JOIN `order` o ON u.id = o.user_id 
WHERE u.status = 1;

同时确保JOIN字段有索引:

sql 复制代码
ALTER TABLE `order` ADD INDEX idx_user_id (user_id);

五、慢查询治理体系

5.1 事前:SQL Review

代码审查时,必须检查SQL:

  • 新加的SQL是否走了索引?
  • 是否有全表扫描风险?
  • 是否在大表上做全量操作?
  • 是否用了 SELECT *

Review Checklist:

检查项 通过标准
是否用到索引 EXPLAIN的type至少为range
是否扫描过多数据 rows预估 < 10000
是否有filesort Extra不包含Using filesort
是否有temporary Extra不包含Using temporary
是否SELECT * 只查询需要的字段

5.2 事中:实时监控

搭建 Prometheus + Grafana 慢查询监控大盘:

yaml 复制代码
# Prometheus 配置
- job_name: 'mysql'
  static_configs:
    - targets: ['localhost:9104']

关键监控指标:

指标名 告警阈值 含义
mysql_global_status_slow_queries > 10/分钟 慢查询数量
mysql_global_status_threads_running > 50 正在执行的线程
mysql_global_status_innodb_row_lock_waits > 10/分钟 行锁等待次数

5.3 事后:定期分析

每周慢查询TOP10分析报告模板:

复制代码
1. 查询SQL
2. 执行次数 / 平均执行时间
3. 扫描行数 / 返回行数
4. EXPLAIN分析
5. 优化建议
6. 优化后预计提升

5.4 治理流程

复制代码
发现慢查询 → EXPLAIN分析 → 定位原因 → 优化SQL/加索引 → 验证效果 → 监控持续观察
     ↑                                                              |
     └──────────────── 定期巡检,形成闭环 ────────────────────────────┘

六、踩坑指南

坑1:EXPLAIN的rows是预估不是实际

我踩过这个坑。开发环境数据量小,EXPLAIN显示rows=100,信心满满地上线。结果生产环境800万数据,扫描了100万行。一定要在生产环境的从库上验证!
坑2:开发环境和生产环境EXPLAIN结果差异大

开发库就几百条数据,优化器可能选择全表扫描。生产库几百万数据,同样的SQL可能需要走索引。务必在数据量相近的环境验证。
坑3:索引加对了但执行计划不走索引

有时候明明有索引,EXPLAIN显示key=NULL。大概率是统计信息过期了,执行:

sql 复制代码
ANALYZE TABLE user;

更新统计信息后,优化器就会选择正确的索引。
坑4:优化器选择错误

极少数情况下,优化器会选错执行计划。可以用 FORCE INDEX 强制走索引:

sql 复制代码
SELECT * FROM user FORCE INDEX (idx_phone) WHERE phone = '13800138000';

但这只是应急手段,不建议常态化使用。


七、问题与解答

Q1:EXPLAIN和EXPLAIN ANALYZE有什么区别?

A:

EXPLAIN 只显示执行计划(预估),不真正执行SQL。

EXPLAIN ANALYZE(MySQL 8.0.18+)会真正执行SQL,显示实际执行时间和实际扫描行数。

sql 复制代码
-- 仅看执行计划
EXPLAIN SELECT * FROM user WHERE id = 1;

-- 真正执行并分析(注意:会实际跑SQL!)
EXPLAIN ANALYZE SELECT * FROM user WHERE id = 1;

生产环境慎用 EXPLAIN ANALYZE,特别是UPDATE/DELETE!

Q2:为什么加了索引,查询还是很慢?

A:

可能的原因:

  1. 索引没用到:检查EXPLAIN的key列是否为NULL
  2. 回表次数太多:SELECT * 导致大量回表,考虑覆盖索引
  3. 数据量太大:即使走索引,扫描行数还是很多,考虑分表分库
  4. 索引选择性差:比如性别字段(只有男/女),索引效果很差
  5. 服务器负载高:磁盘IO打满,CPU飙高,再好的索引也白搭

Q3:慢查询日志记录了太多数据,怎么过滤?

A:

可以通过配置过滤:

ini 复制代码
# 只记录超过10秒的慢查询
long_query_time = 10

# 不记录管理语句(如ALTER TABLE)
log_slow_admin_statements = 0

# 不记录从库的慢查询
log_slow_slave_statements = 0

或者使用 pt-query-digest 的过滤参数:

bash 复制代码
# 只分析查询时间超过5秒的
pt-query-digest --filter '$event->{Query_time} > 5' /var/log/mysql/slow.log

八、面试高频考点汇总

考点1:EXPLAIN中type列有哪些值?性能排序是怎样的?

答案:

复制代码
system > const > eq_ref > ref > range > index > ALL
  • system/const:主键或唯一索引,性能最好
  • eq_ref:JOIN时主键关联
  • ref:普通索引等值查询
  • range:索引范围扫描
  • index:全索引扫描
  • ALL:全表扫描,性能最差

考点2:Extra列中Using filesort和Using temporary代表什么?

答案:

  • Using filesort:MySQL无法利用索引完成排序,需要额外排序操作。通常是因为ORDER BY字段没有索引,或者不符合最左前缀。
  • Using temporary:需要创建临时表来保存中间结果。常见于GROUP BY、DISTINCT、UNION等操作。

两者都是性能警告,需要优化。

考点3:什么是覆盖索引?

答案:

覆盖索引是指查询的所有字段都在索引中,不需要回表查数据。

sql 复制代码
-- 索引:idx_name_age (name, age)
SELECT name, age FROM user WHERE name = '张三';
-- 只需要查索引树就能拿到所有数据,Extra显示Using index

优点:减少回表IO,大幅提升查询性能。

考点4:索引失效的常见场景有哪些?

答案:

  1. 对索引字段做函数操作(YEAR(create_time)
  2. 隐式类型转换(字符串字段传数字)
  3. like前缀模糊('%张三%'
  4. 不符合最左前缀原则
  5. 索引字段参与计算(id + 1 = 100
  6. OR条件中部分字段无索引
  7. 全表扫描比索引更快时(数据量极小)

考点5:如何分析一条慢SQL?

答案:

  1. EXPLAIN分析执行计划,看type、key、rows、Extra
  2. 查看是否走索引,没走索引就分析原因
  3. 查看扫描行数,rows是否过大
  4. 查看Extra,是否有filesort或temporary
  5. 查看慢查询日志,确认执行时间和频率
  6. 对比优化前后,用EXPLAIN验证效果

九、模拟面试官提问和参考答案

场景题1:生产环境CPU飙高,你如何判断是不是慢查询导致的?

参考答案:

  1. 先看监控,确认CPU飙高的时间点
  2. 登录MySQL,执行 SHOW PROCESSLIST,看是否有大量 "Sending data"、"Sorting result" 状态的线程
  3. 查看慢查询日志,定位该时间段的慢查询
  4. EXPLAIN 分析可疑SQL的执行计划
  5. 如果是慢查询导致,临时杀掉慢查询线程(KILL query_id),然后长期优化SQL或加索引

场景题2:有个分页查询 LIMIT 1000000, 10 很慢,怎么优化?

参考答案:

  1. 延迟关联:先查id,再JOIN取数据

    sql 复制代码
    SELECT * FROM user u
    JOIN (SELECT id FROM user ORDER BY id LIMIT 1000000, 10) t ON u.id = t.id;
  2. 覆盖索引:确保子查询只查索引字段

  3. 业务限制:不允许跳页太深,最多翻到100页

  4. 记录上次位置 :用 WHERE id > last_id LIMIT 10 替代深度分页

场景题3:表有联合索引 (a, b, c),以下SQL能否用到索引?

sql 复制代码
WHERE a = 1 AND b = 2 AND c = 3;  -- 能,全部用到
WHERE a = 1 AND b = 2;            -- 能,用到a,b
WHERE a = 1 AND c = 3;            -- 能,只用到a(c断了)
WHERE b = 2 AND c = 3;            -- 不能,最左前缀断了
WHERE a = 1 AND b > 2 AND c = 3;  -- 能,用到a,b(b是范围,c用不到)

场景题4:索引加了,但EXPLAIN显示不走索引,可能是什么原因?

参考答案:

  1. 统计信息过期 → ANALYZE TABLE 更新
  2. 数据量太小,全表扫描更快
  3. 查询条件用了函数或隐式转换,导致索引失效
  4. 索引选择性太差(如性别字段)
  5. 查询范围太大,回表成本高于全表扫描
  6. 使用了 !=<>NOT IN 等操作

场景题5:如何设计一套慢查询治理方案?

参考答案:

  1. 事前预防:SQL Review + EXPLAIN检查 + 索引规范
  2. 事中监控:Prometheus+Grafana监控慢查询数量、执行时间
  3. 事后分析:每周慢查询TOP10报告,持续跟踪优化效果
  4. 应急机制:自动告警 + 自动KILL超长查询 + 限流降级
  5. 团队规范:代码提交必须附带EXPLAIN结果 + 索引变更流程

十、互动话题

你在工作中遇到过最离谱的慢查询是什么?排查了多久才找到原因?欢迎在评论区分享你的"翻车"经历,咱们一起复盘!


十一、参考资料

  1. MySQL官方文档 - EXPLAIN输出格式
  2. MySQL官方文档 - 慢查询日志
相关推荐
Android-Flutter1 小时前
android compose shadow 阴影 使用
android·kotlin·compose
万亿少女的梦1681 小时前
基于Spring Boot的社区管理系统设计与实现
java·spring boot·mysql·vue·系统设计
luj_17681 小时前
草酸与烟酸对消化及糖代谢的影响解析
服务器·c语言·开发语言·经验分享·算法
fei_sun2 小时前
【SystemVerilog】SystemVerilog与C语言的接口
c语言·开发语言
大气的小蜜蜂2 小时前
领域层的服务
java·前端·数据库
agent8972 小时前
Spring Boot 接口超时治理:从连接池、线程池到熔断限流的完整排查思路
java·spring boot·后端
W是笔名2 小时前
python___容器类型的数据___序列
开发语言·python
Devin~Y2 小时前
抖音级短视频推荐与直播带货平台面试实战:从 Java 微服务到 RAG 智能客服全链路解析
java·spring boot·redis·spring cloud·kafka·agent·rag
☆cwlulu2 小时前
try-throw-catch异常捕获流程
开发语言·c++