写在前面:说实话,做后端开发这些年,我见过太多项目上线后因为一条慢SQL把数据库拖垮的事故。有一次凌晨两点被叫醒,生产环境MySQL CPU飙到90%,一查发现一条统计SQL执行了30秒,连接池被打满,整个服务差点雪崩。那次之后我才真正重视起慢查询治理。这篇文章把我踩过的坑和积累的经验整理出来,希望能帮你少走点弯路。
文章目录
- 一、为什么慢查询是性能杀手?
- 1.1 一个真实的生产事故
- 1.2 慢查询的危害
- 1.3 生活类比:堵车
- 二、开启慢查询日志
- 2.1 MySQL配置
- 2.2 查看慢查询日志
- 2.3 慢查询日志分析要点
- 三、EXPLAIN字段逐行解读
- 3.1 EXPLAIN基本用法
- 3.2 id列:执行顺序
- 3.3 select_type列:查询类型
- 3.4 table列:访问的表
- 3.5 type列:访问类型(重点!)
- 3.6 possible_keys列:可能使用的索引
- 3.7 key列:实际使用的索引
- 3.8 key_len列:索引使用长度
- 3.9 ref列:索引匹配条件
- 3.10 rows列:预估扫描行数
- 3.11 Extra列:额外信息(重点!)
- 四、常见慢查询场景与优化
- 4.1 场景1:全表扫描(type=ALL)
- 4.2 场景2:索引失效
- 4.3 场景3:Using filesort(ORDER BY无索引)
- 4.4 场景4:Using temporary(GROUP BY/DISTINCT无索引)
- 4.5 场景5:大表JOIN效率低
- 五、慢查询治理体系
- 5.1 事前:SQL Review
- 5.2 事中:实时监控
- 5.3 事后:定期分析
- 5.4 治理流程
- 六、踩坑指南
- 七、问题与解答
- Q1:EXPLAIN和EXPLAIN ANALYZE有什么区别?
- Q2:为什么加了索引,查询还是很慢?
- Q3:慢查询日志记录了太多数据,怎么过滤?
- 八、面试高频考点汇总
- 考点1:EXPLAIN中type列有哪些值?性能排序是怎样的?
- 考点2:Extra列中Using filesort和Using temporary代表什么?
- 考点3:什么是覆盖索引?
- 考点4:索引失效的常见场景有哪些?
- 考点5:如何分析一条慢SQL?
- 九、模拟面试官提问和参考答案
- 场景题1:生产环境CPU飙高,你如何判断是不是慢查询导致的?
- 场景题2:有个分页查询 `LIMIT 1000000, 10` 很慢,怎么优化?
- 场景题3:表有联合索引 (a, b, c),以下SQL能否用到索引?
- 场景题4:索引加了,但EXPLAIN显示不走索引,可能是什么原因?
- 场景题5:如何设计一套慢查询治理方案?
- 十、互动话题
- 十一、参考资料
一、为什么慢查询是性能杀手?
1.1 一个真实的生产事故
去年双十一前夕,我们系统的订单查询接口突然超时。
监控告警狂响,MySQL CPU飙到90%,QPS从平时的2000暴跌到200。
紧急排查发现,运营同学跑了一条统计SQL:
SELECTCOUNT(*)FROMorderWHEREcreate_time>'2024-01-01'ANDstatus=1;这条SQL执行了30秒,扫描了800万行数据。
连接池被打满,其他正常请求全部排队等待,整个系统差点雪崩。
1.2 慢查询的危害
| 危害类型 | 具体表现 | 影响程度 |
|---|---|---|
| 响应延迟 | 接口响应从100ms变成10s+ | 用户体验极差 |
| 连接池打满 | 数据库连接被慢查询占满 | 系统不可用 |
| 雪崩效应 | 上游服务超时重试,流量翻倍 | 级联故障 |
| 主从延迟 | 慢查询在主库执行,从库复制滞后 | 数据不一致 |
1.3 生活类比:堵车
慢查询就像城市主干道上的严重堵车。
一条主干道堵了,整个城市的交通都受影响。
救护车、消防车过不去,后果可想而知。
数据库也是一样,一条慢SQL能把整个系统拖下水。
二、开启慢查询日志
2.1 MySQL配置
找到MySQL配置文件(通常是my.cnf或my.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:
sudosystemctl restart mysql2.2 查看慢查询日志
方式一:直接查看日志文件
# 查看最新的慢查询tail-f/var/log/mysql/slow.log方式二:使用 mysqldumpslow 工具
# 按执行时间排序,显示前10条mysqldumpslow-st-t10/var/log/mysql/slow.log# 按执行次数排序mysqldumpslow-sc-t10/var/log/mysql/slow.log方式三:使用 pt-query-digest 工具(推荐)
# 安装 Percona Toolkitsudoapt-getinstallpercona-toolkit# 分析慢查询日志,生成详细报告pt-query-digest /var/log/mysql/slow.log>slow_query_report.txtpt-query-digest输出包含:
- Rank:按查询时间占比排名
- Query ID:查询指纹
- Response time:总响应时间和单次平均时间
- Calls:执行次数
- R/Call:每次调用平均时间
- V/M:响应时间方差均值(越大越不稳定)
2.3 慢查询日志分析要点
| 指标 | 含义 | 关注重点 |
|---|---|---|
| 频率 | 多久出现一次 | 高频慢查询优先处理 |
| 执行时间 | 单次执行多久 | 超过1秒就要警惕 |
| 返回行数 | 返回了多少数据 | 返回行数远大于需要 = 浪费 |
| 扫描行数 | 扫描了多少行 | 扫描行数 / 返回行数 > 100 = 严重 |
三、EXPLAIN字段逐行解读
3.1 EXPLAIN基本用法
EXPLAINSELECT*FROMuserWHEREid=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越大越先执行。
-- id相同的情况EXPLAINSELECT*FROMuseru,orderoWHEREu.id=o.user_id;-- id都是1,先执行user表,再执行order表-- id不同的情况(子查询)EXPLAINSELECT*FROMuserWHEREid=(SELECTuser_idFROMorderWHEREid=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出现
index或ALL就要警惕了。我见过太多人看到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 |
示例:
-- 联合索引 idx_name_age (name, age)EXPLAINSELECT*FROMuserWHEREname='张三'ANDage=20;-- key_len = 82 + 4 = 86,说明两个字段都用到了EXPLAINSELECT*FROMuserWHEREname='张三';-- 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缓存 | 一般 |
示例解读:
-- 覆盖索引,性能好EXPLAINSELECTid,nameFROMuserWHEREname='张三';-- Extra: Using index-- 文件排序,性能差EXPLAINSELECT*FROMuserORDERBYage;-- Extra: Using filesort-- 使用了临时表EXPLAINSELECTstatus,COUNT(*)FROMuserGROUPBYstatus;-- Extra: Using temporary; Using filesort四、常见慢查询场景与优化
4.1 场景1:全表扫描(type=ALL)
问题SQL:
-- user表的phone字段没有索引EXPLAINSELECT*FROMuserWHEREphone='13800138000';EXPLAIN结果:
type: ALL rows: 1000000 Extra: Using where优化方案:加索引
-- 添加索引ALTERTABLEuserADDINDEXidx_phone(phone);-- 再次EXPLAINtype: refrows:1key: idx_phone4.2 场景2:索引失效
2.1 隐式类型转换
-- phone是varchar类型,传入数字EXPLAINSELECT*FROMuserWHEREphone=13800138000;-- type: ALL,索引失效!-- 正确写法EXPLAINSELECT*FROMuserWHEREphone='13800138000';-- type: ref,索引生效2.2 对索引字段做函数操作
-- 错误:对create_time做函数操作EXPLAINSELECT*FROMuserWHEREYEAR(create_time)=2024;-- type: ALL-- 正确:改写为范围查询EXPLAINSELECT*FROMuserWHEREcreate_timeBETWEEN'2024-01-01'AND'2024-12-31';-- type: range2.3 like ‘%xxx’ 前缀模糊
-- 前缀模糊,索引失效EXPLAINSELECT*FROMuserWHEREnameLIKE'%张三%';-- type: ALL-- 后缀模糊,索引生效EXPLAINSELECT*FROMuserWHEREnameLIKE'张三%';-- type: range4.3 场景3:Using filesort(ORDER BY无索引)
问题SQL:
-- 按create_time排序,但create_time没有索引EXPLAINSELECT*FROMorderWHEREuser_id=100ORDERBYcreate_timeDESCLIMIT10;-- Extra: Using where; Using filesort优化方案:加复合索引
-- 添加复合索引(注意字段顺序!)ALTERTABLE`order`ADDINDEXidx_user_time(user_id,create_time);-- 再次EXPLAIN-- Extra: Using index condition-- 索引直接有序,不需要额外排序踩坑提醒:复合索引的字段顺序很重要!把等值查询的字段放前面,范围查询/排序的字段放后面。我见过太多人索引字段顺序写反了,结果索引只用了一半。
4.4 场景4:Using temporary(GROUP BY/DISTINCT无索引)
问题SQL:
EXPLAINSELECTstatus,COUNT(*)FROMuserGROUPBYstatus;-- Extra: Using temporary; Using filesort优化方案:
-- 方案1:给GROUP BY字段加索引ALTERTABLEuserADDINDEXidx_status(status);-- 方案2:改写为子查询(数据量大时)SELECTstatus,cntFROM(SELECTstatus,COUNT(*)AScntFROMuserWHEREid>0GROUPBYstatus)t;4.5 场景5:大表JOIN效率低
问题SQL:
-- user表1000万行,order表5000万行EXPLAINSELECT*FROMuseruJOIN`order`oONu.id=o.user_idWHEREu.status=1;优化方案:小表驱动大表
-- 确保驱动表是小结果集EXPLAINSELECT*FROM(SELECT*FROMuserWHEREstatus=1)uJOIN`order`oONu.id=o.user_id;-- 或者使用STRAIGHT_JOIN强制驱动顺序EXPLAINSELECT*FROMuseru STRAIGHT_JOIN`order`oONu.id=o.user_idWHEREu.status=1;同时确保JOIN字段有索引:
ALTERTABLE`order`ADDINDEXidx_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 慢查询监控大盘:
# 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。大概率是统计信息过期了,执行:
ANALYZETABLEuser;更新统计信息后,优化器就会选择正确的索引。
坑4:优化器选择错误
极少数情况下,优化器会选错执行计划。可以用
FORCE INDEX强制走索引:SELECT*FROMuserFORCEINDEX(idx_phone)WHEREphone='13800138000';但这只是应急手段,不建议常态化使用。
七、问题与解答
Q1:EXPLAIN和EXPLAIN ANALYZE有什么区别?
A:
EXPLAIN只显示执行计划(预估),不真正执行SQL。
EXPLAIN ANALYZE(MySQL 8.0.18+)会真正执行SQL,显示实际执行时间和实际扫描行数。
-- 仅看执行计划EXPLAINSELECT*FROMuserWHEREid=1;-- 真正执行并分析(注意:会实际跑SQL!)EXPLAINANALYZESELECT*FROMuserWHEREid=1;生产环境慎用EXPLAIN ANALYZE,特别是UPDATE/DELETE!
Q2:为什么加了索引,查询还是很慢?
A:
可能的原因:
- 索引没用到:检查EXPLAIN的key列是否为NULL
- 回表次数太多:SELECT * 导致大量回表,考虑覆盖索引
- 数据量太大:即使走索引,扫描行数还是很多,考虑分表分库
- 索引选择性差:比如性别字段(只有男/女),索引效果很差
- 服务器负载高:磁盘IO打满,CPU飙高,再好的索引也白搭
Q3:慢查询日志记录了太多数据,怎么过滤?
A:
可以通过配置过滤:
# 只记录超过10秒的慢查询 long_query_time = 10 # 不记录管理语句(如ALTER TABLE) log_slow_admin_statements = 0 # 不记录从库的慢查询 log_slow_slave_statements = 0或者使用pt-query-digest的过滤参数:
# 只分析查询时间超过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:什么是覆盖索引?
答案:
覆盖索引是指查询的所有字段都在索引中,不需要回表查数据。
-- 索引:idx_name_age (name, age)SELECTname,ageFROMuserWHEREname='张三';-- 只需要查索引树就能拿到所有数据,Extra显示Using index优点:减少回表IO,大幅提升查询性能。
考点4:索引失效的常见场景有哪些?
答案:
- 对索引字段做函数操作(
YEAR(create_time)) - 隐式类型转换(字符串字段传数字)
- like前缀模糊(
'%张三%') - 不符合最左前缀原则
- 索引字段参与计算(
id + 1 = 100) - OR条件中部分字段无索引
- 全表扫描比索引更快时(数据量极小)
考点5:如何分析一条慢SQL?
答案:
- EXPLAIN分析执行计划,看type、key、rows、Extra
- 查看是否走索引,没走索引就分析原因
- 查看扫描行数,rows是否过大
- 查看Extra,是否有filesort或temporary
- 查看慢查询日志,确认执行时间和频率
- 对比优化前后,用EXPLAIN验证效果
九、模拟面试官提问和参考答案
场景题1:生产环境CPU飙高,你如何判断是不是慢查询导致的?
参考答案:
- 先看监控,确认CPU飙高的时间点
- 登录MySQL,执行
SHOW PROCESSLIST,看是否有大量 “Sending data”、“Sorting result” 状态的线程 - 查看慢查询日志,定位该时间段的慢查询
- 用
EXPLAIN分析可疑SQL的执行计划 - 如果是慢查询导致,临时杀掉慢查询线程(
KILL query_id),然后长期优化SQL或加索引
场景题2:有个分页查询LIMIT 1000000, 10很慢,怎么优化?
参考答案:
延迟关联:先查id,再JOIN取数据
SELECT*FROMuseruJOIN(SELECTidFROMuserORDERBYidLIMIT1000000,10)tONu.id=t.id;覆盖索引:确保子查询只查索引字段
业务限制:不允许跳页太深,最多翻到100页
记录上次位置:用
WHERE id > last_id LIMIT 10替代深度分页
场景题3:表有联合索引 (a, b, c),以下SQL能否用到索引?
WHEREa=1ANDb=2ANDc=3;-- 能,全部用到WHEREa=1ANDb=2;-- 能,用到a,bWHEREa=1ANDc=3;-- 能,只用到a(c断了)WHEREb=2ANDc=3;-- 不能,最左前缀断了WHEREa=1ANDb>2ANDc=3;-- 能,用到a,b(b是范围,c用不到)场景题4:索引加了,但EXPLAIN显示不走索引,可能是什么原因?
参考答案:
- 统计信息过期 →
ANALYZE TABLE更新 - 数据量太小,全表扫描更快
- 查询条件用了函数或隐式转换,导致索引失效
- 索引选择性太差(如性别字段)
- 查询范围太大,回表成本高于全表扫描
- 使用了
!=、<>、NOT IN等操作
场景题5:如何设计一套慢查询治理方案?
参考答案:
- 事前预防:SQL Review + EXPLAIN检查 + 索引规范
- 事中监控:Prometheus+Grafana监控慢查询数量、执行时间
- 事后分析:每周慢查询TOP10报告,持续跟踪优化效果
- 应急机制:自动告警 + 自动KILL超长查询 + 限流降级
- 团队规范:代码提交必须附带EXPLAIN结果 + 索引变更流程
十、互动话题
你在工作中遇到过最离谱的慢查询是什么?排查了多久才找到原因?欢迎在评论区分享你的"翻车"经历,咱们一起复盘!
十一、参考资料
- MySQL官方文档 - EXPLAIN输出格式
- MySQL官方文档 - 慢查询日志