【计算基础|MySQL10】【工程实战】explain 与慢查询优化

【计算基础|MySQL10】【工程实战】explain 与慢查询优化

上一篇把索引失效和设计原则讲透了。但光背口诀没用------线上一条 SQL 慢了,你总得知道它到底扫了多少行、走没走索引、卡在哪一步。这篇全是动手:先灌 100 万行数据当靶子,再开慢查询日志抓现行,最后用 EXPLAIN(以及 MySQL 8.0.18 后的 EXPLAIN ANALYZE)把七个真实优化案例的前后对比跑给你看。

所有数字都是我在 Docker MySQL 8.0.46 上实跑的,原样贴出。结论先行:慢查询优化的标准动作就五步------开慢日志找 SQL → EXPLAIN 看执行计划 → 定位瓶颈(全表扫/回表/filesort)→ 加索引或改写 SQL → 前后耗时对比验证。

环境:Docker MySQL 8.0.46,InnoDB,表 orders 100 万行(id/user_id/product_id/amount/status/create_time),users 10 万行(含 phone VARCHAR)。


一、先造一百万行测试数据

没有数据,EXPLAIN 的 rows 全是 0 或 1,看不出门道。先建表:

sql 复制代码
CREATE TABLE orders (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  product_id BIGINT NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  status TINYINT NOT NULL,
  create_time DATETIME NOT NULL
) ENGINE=InnoDB;

灌 100 万行,用 information_schema 的笛卡尔积批量生成(比存储过程循环快得多):

sql 复制代码
INSERT INTO orders(user_id,product_id,amount,status,create_time)
SELECT
  1 + FLOOR(RAND()*10000),
  1 + FLOOR(RAND()*5000),
  ROUND(10 + RAND()*990, 2),
  FLOOR(RAND()*4),
  DATE_SUB('2026-09-24', INTERVAL FLOOR(RAND()*365) DAY)
FROM information_schema.COLUMNS c1,
     information_schema.COLUMNS c2,
     information_schema.COLUMNS c3
LIMIT 1000000;

SELECT COUNT(*) FROM orders;
-- 1000000

🔴 为什么不用存储过程循环 :一行一行 INSERT 会触发 100 万次 redo log fsync,慢到你怀疑人生。批量 INSERT ... SELECT 一次提交,快一个数量级。生产灌数据同理------批量、少事务。


二、慢查询日志:先把慢 SQL 抓出来

优化的前提是知道哪条慢。慢查询日志默认是关的:

sql 复制代码
SHOW VARIABLES LIKE 'slow_query%';
-- slow_query_log        OFF
-- slow_query_log_file   /var/lib/mysql/xxxx-slow.log

SHOW VARIABLES LIKE 'long_query_time';
-- 10.000000   ← 默认超过10秒才算慢,太长了

SHOW VARIABLES LIKE 'log_queries_not_using_indexes';
-- OFF

打开它(生产环境 long_query_time 一般设 1 秒):

sql 复制代码
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;              -- 超过1秒记,含等于
SET GLOBAL log_queries_not_using_indexes = ON;  -- 没走索引的也记

⚠️ SET GLOBAL 只对新连接 生效,当前连接的 long_query_time 还是旧值。这也是我演示时发现变量显示 10 但慢 SQL 照样被记的原因------log_queries_not_using_indexes 把全表扫的 SQL 抓进来了。

跑几条 SQL 后看慢日志原文:

log 复制代码
# Time: 2026-09-24T07:35:30.943021Z
# User@Host: root[root] @ localhost []  Id:    97
# Query_time: 0.099939  Lock_time: 0.000001 Rows_sent: 2745  Rows_examined: 1000000
SET timestamp=1790235330;
SELECT * FROM orders WHERE DATE(create_time)='2026-06-15';

🔴 看日志的关键是 Rows_examined 和 Rows_sent 的比值:这条 SQL 扫描了 100 万行、只返回 2745 行------99.7% 的工作是白做的,这就是要优化的靶子。

分析慢日志:

bash 复制代码
# mysqldumpslow:按总耗时排序看 top10(官方客户端自带,官方镜像可能没装)
mysqldumpslow -s t -t 10 /var/lib/mysql/xxxx-slow.log
# -s t 按时间排序  -s c 按次数  -t 10 取前10

# pt-query-digest(Percona Toolkit,生产更常用,输出分组/统计/指纹)
pt-query-digest /var/lib/mysql/xxxx-slow.log

⚠️ 官方 mysql:8.0 镜像默认不带 mysqldumpslow(它是个 Perl 脚本),生产服务器装 MySQL client 包就有了;更强大的 pt-query-digest 属于 Percona Toolkit。


三、EXPLAIN 逐字段详解

EXPLAIN 是优化的核心工具。在 SQL 前加 EXPLAIN,它不真的执行,而是告诉你优化器打算怎么跑。逐字段拆:

字段 含义 怎么看
id 查询序号 id 相同从上到下执行;id 不同大的先执行(子查询)
select_type 查询类型 SIMPLE 简单查询 / PRIMARY 主查询 / SUBQUERY 子查询 / DERIVED 派生表
table 表名或别名 ---
type 访问类型 🔴 最重要,见下表
possible_keys 可能用到的索引 理论候选
key 实际用到的索引 NULL = 没用索引,要警惕
key_len 索引用了多少字节 反推用了联合索引几列
ref 索引与谁比较 const 或关联列
rows 估算扫描行数 越小越好,100 万和 10 天壤之别
filtered 过滤后百分比 ---
Extra 额外信息 见下表

type 从好到差:

复制代码
NULL > system > const > eq_ref > ref > range > index > ALL

🔴 至少要到 range,出现 ALL 就是全表扫描,必须优化。

Extra 常见项:

Extra 好坏 含义
Using index ✅ 好 覆盖索引,不回表
Using where 中 Server 层再过滤
Using index condition 好 ICP 索引下推
Using temporary 🔴 差 用了临时表(GROUP BY/去重)
Using filesort 🔴 差 额外排序
Using join buffer 差 关联没走索引,用了内存 buffer

3.1 EXPLAIN ANALYZE:8.0.18 之后的大杀器

普通 EXPLAIN 给的是估算 ,EXPLAIN ANALYZE 会真的执行一遍并输出每一步的真实行数和耗时。实测对比才是硬证据:

sql 复制代码
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id=123;

四、七个优化案例,前后对比

案例 1:全表扫描加索引

没索引时:

sql 复制代码
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id=123;
复制代码
-> Filter: (orders.user_id = 123)  (cost=103215 rows=99673) (actual time=1.13..145 rows=109)
    -> Table scan on orders  (cost=103215 rows=996726) (actual time=0.474..123 rows=1e+6)

🔴 Table scan(全表扫),实际扫了 100 万行 、耗时 123ms,只返回 109 行。

加索引:

sql 复制代码
ALTER TABLE orders ADD KEY idx_user_id(user_id);
sql 复制代码
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id=123;
复制代码
-> Index lookup on orders using idx_user_id (user_id=123)  (cost=55.9 rows=109) (actual time=0.21..0.214 rows=109)

✅ 变成 Index lookup,耗时 0.21ms ,扫 109 行。快了约 580 倍。

案例 2:函数导致索引失效

create_time 加索引前先反着用:

sql 复制代码
-- 失效:对列套 DATE()
EXPLAIN SELECT * FROM orders WHERE DATE(create_time)='2026-06-15';
-- type=ALL  rows=996726

改成范围查询(上一篇讲过的改法):

sql 复制代码
ALTER TABLE orders ADD KEY idx_ct(create_time);
EXPLAIN ANALYZE SELECT * FROM orders
WHERE create_time>='2026-06-15' AND create_time<'2026-06-16';
复制代码
-> Index range scan on orders using idx_ct over (...)  (cost=1664 rows=2745) (actual time=0.415..3.87 rows=2745)

✅ type=range,扫 2745 行、3.87ms,对比全表 100 万行。

案例 3:隐式类型转换

users.phone 是 VARCHAR 且有索引,传数字直接废掉:

sql 复制代码
-- 失效:传数字
EXPLAIN SELECT * FROM users WHERE phone=13800000123;
-- type=ALL  key=NULL  rows=100062

-- 正确:传字符串
EXPLAIN SELECT * FROM users WHERE phone='13800000123';
-- type=ref  key=idx_phone  key_len=83  rows=1

这种坑在 ORM 里特别常见------实体类属性是 Long,字段是 VARCHAR,自动生成的 SQL 全是隐式转换。建表类型和查询传参类型必须一致。

案例 4:深分页优化

LIMIT 900000,10 看着是取 10 条,实际上要扫描前面 90 万行再扔掉:

sql 复制代码
EXPLAIN ANALYZE SELECT * FROM orders ORDER BY id LIMIT 900000,10;
复制代码
-> Limit/Offset: 10/900000 row(s)  (actual time=118..118 rows=10)
    -> Index scan on orders using PRIMARY  (actual time=0.443..100 rows=900010)

🔴 扫了 900010 行、耗时 100ms,绝大部分是白扫。

改法一:游标分页(记住上次最大 id):

sql 复制代码
EXPLAIN ANALYZE SELECT * FROM orders WHERE id>900000 ORDER BY id LIMIT 10;
复制代码
-> Limit: 10 row(s)  (actual time=0.236..0.238 rows=10)
    -> Index range scan on orders using PRIMARY over (900000 < id)  (actual time=0.235..0.236 rows=10)

✅ 直接从主键定位到 90 万行处往后取 10 条,0.238ms,快了约 420 倍。前提是业务能接受"按 id 翻页"而不是跳页码。

改法二:延迟关联------先用子查询走主键索引查出那 10 个 id,再回表关联取整行:

sql 复制代码
SELECT * FROM orders t
JOIN (SELECT id FROM orders ORDER BY id LIMIT 900000,10) x
ON t.id = x.id;

案例 5:count(*) 到底用哪个

sql 复制代码
EXPLAIN ANALYZE SELECT COUNT(*) FROM orders;       -- actual time=46.6ms
EXPLAIN ANALYZE SELECT COUNT(1) FROM orders;       -- actual time=43.5ms
EXPLAIN ANALYZE SELECT COUNT(product_id) FROM orders;  -- 7ms(缓冲池已热)

🔴 结论 :InnoDB 里 COUNT(*) 和 COUNT(1) 性能基本一致,优化器把它们当等价处理;COUNT(列) 要逐行判断该列不为 NULL,语义不同。实测数字的差异主要来自缓冲池冷热,不是写法快慢。

⚠️ 两个关键认知:

  • MyISAM 表内部有行数计数器,COUNT(*) 秒回;InnoDB 没有(因为事务隔离,它必须数一遍真实可见的行)。
  • 大表别裸 COUNT(*),要近似值用 SHOW TABLE STATUS 估算,或自己缓存计数。

案例 6:覆盖索引

sql 复制代码
EXPLAIN SELECT user_id FROM orders WHERE user_id=123;
-- type=ref  key=idx_user_id  Extra=Using index

Using index = 要查的列 user_id 就在索引里,不用回表。如果 SELECT 还带了别的列,就会回表;把高频查询列都塞进联合索引末尾,就是覆盖索引。

案例 7:ORDER BY 消除 filesort

sql 复制代码
-- 没索引
EXPLAIN SELECT * FROM orders ORDER BY status LIMIT 10;
-- type=ALL  Extra=Using filesort

ALTER TABLE orders ADD KEY idx_status(status);
-- 加索引后
EXPLAIN SELECT * FROM orders ORDER BY status LIMIT 10;
-- type=index  key=idx_status  Extra=NULL  ← filesort 消失

五、优化前后总表

案例 优化前 优化后 提速
1. user_id 无索引 全表扫 100 万行 / 123ms ref 索引查 109 行 / 0.21ms ~580×
2. DATE() 函数 type=ALL / 99 万行 range / 2745 行 / 3.87ms ~25×
3. phone 隐式转换 type=ALL / 10 万行 ref / 1 行 ---
4. 深分页 扫 90 万行 / 100ms 游标定位 10 行 / 0.24ms ~420×
5. count(*) 全表数行 ~46ms 缓存/近似值 视场景
6. 覆盖索引 回表 Using index 不回表 ---
7. ORDER BY Using filesort type=index 无 filesort ---

六、慢查询优化 SOP(照这个流程走)

#mermaid-svg-x9sLfR1R9ePpZvST{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-x9sLfR1R9ePpZvST .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-x9sLfR1R9ePpZvST .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-x9sLfR1R9ePpZvST .error-icon{fill:#552222;}#mermaid-svg-x9sLfR1R9ePpZvST .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-x9sLfR1R9ePpZvST .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-x9sLfR1R9ePpZvST .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-x9sLfR1R9ePpZvST .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-x9sLfR1R9ePpZvST .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-x9sLfR1R9ePpZvST .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-x9sLfR1R9ePpZvST .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-x9sLfR1R9ePpZvST .marker{fill:#333333;stroke:#333333;}#mermaid-svg-x9sLfR1R9ePpZvST .marker.cross{stroke:#333333;}#mermaid-svg-x9sLfR1R9ePpZvST svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-x9sLfR1R9ePpZvST p{margin:0;}#mermaid-svg-x9sLfR1R9ePpZvST .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-x9sLfR1R9ePpZvST .cluster-label text{fill:#333;}#mermaid-svg-x9sLfR1R9ePpZvST .cluster-label span{color:#333;}#mermaid-svg-x9sLfR1R9ePpZvST .cluster-label span p{background-color:transparent;}#mermaid-svg-x9sLfR1R9ePpZvST .label text,#mermaid-svg-x9sLfR1R9ePpZvST span{fill:#333;color:#333;}#mermaid-svg-x9sLfR1R9ePpZvST .node rect,#mermaid-svg-x9sLfR1R9ePpZvST .node circle,#mermaid-svg-x9sLfR1R9ePpZvST .node ellipse,#mermaid-svg-x9sLfR1R9ePpZvST .node polygon,#mermaid-svg-x9sLfR1R9ePpZvST .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-x9sLfR1R9ePpZvST .rough-node .label text,#mermaid-svg-x9sLfR1R9ePpZvST .node .label text,#mermaid-svg-x9sLfR1R9ePpZvST .image-shape .label,#mermaid-svg-x9sLfR1R9ePpZvST .icon-shape .label{text-anchor:middle;}#mermaid-svg-x9sLfR1R9ePpZvST .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-x9sLfR1R9ePpZvST .rough-node .label,#mermaid-svg-x9sLfR1R9ePpZvST .node .label,#mermaid-svg-x9sLfR1R9ePpZvST .image-shape .label,#mermaid-svg-x9sLfR1R9ePpZvST .icon-shape .label{text-align:center;}#mermaid-svg-x9sLfR1R9ePpZvST .node.clickable{cursor:pointer;}#mermaid-svg-x9sLfR1R9ePpZvST .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-x9sLfR1R9ePpZvST .arrowheadPath{fill:#333333;}#mermaid-svg-x9sLfR1R9ePpZvST .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-x9sLfR1R9ePpZvST .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-x9sLfR1R9ePpZvST .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-x9sLfR1R9ePpZvST .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-x9sLfR1R9ePpZvST .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-x9sLfR1R9ePpZvST .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-x9sLfR1R9ePpZvST .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-x9sLfR1R9ePpZvST .cluster text{fill:#333;}#mermaid-svg-x9sLfR1R9ePpZvST .cluster span{color:#333;}#mermaid-svg-x9sLfR1R9ePpZvST div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-x9sLfR1R9ePpZvST .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-x9sLfR1R9ePpZvST rect.text{fill:none;stroke-width:0;}#mermaid-svg-x9sLfR1R9ePpZvST .icon-shape,#mermaid-svg-x9sLfR1R9ePpZvST .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-x9sLfR1R9ePpZvST .icon-shape p,#mermaid-svg-x9sLfR1R9ePpZvST .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-x9sLfR1R9ePpZvST .icon-shape .label rect,#mermaid-svg-x9sLfR1R9ePpZvST .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-x9sLfR1R9ePpZvST .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-x9sLfR1R9ePpZvST .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-x9sLfR1R9ePpZvST :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} type=ALL 全表扫
rows_examined 远大于 rows_sent
Using filesort/temporary
深分页
开慢查询日志

long_query_time=1s
mysqldumpslow / pt-query-digest

找出耗时Top SQL
EXPLAIN / EXPLAIN ANALYZE

看 type、key、rows、Extra
瓶颈在哪?
加合适索引
改写SQL: 函数挪右边/消除隐式转换
给排序/分组列加索引
改游标分页/延迟关联
再跑 EXPLAIN ANALYZE

前后耗时对比验证

辅助工具:

sql 复制代码
-- 按 SQL 指纹统计历史耗时(performance_schema 摘要)
SELECT digest_text, count_star, avg_timer_wait/1e9 AS avg_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY avg_timer_wait DESC LIMIT 10;

SHOW PROFILE 在 8.0 已废弃但还能用,用来拆分一条 SQL 各阶段耗时(starting/statistics/preparing/executing/Sending data),新版直接用 EXPLAIN ANALYZE 更直观。


总结:你真正需要记住的 7 件事

  1. 慢查询优化五步:开慢日志抓 SQL → EXPLAIN 看计划 → 定位瓶颈 → 加索引/改写 → 前后对比验证。
  2. EXPLAIN 里盯四个字段:type(至少 range)、key(NULL 要警惕)、rows(扫描行数)、Extra(Using index 好,filesort/temporary 差)。
  3. Rows_examined / Rows_sent 比值就是浪费程度,接近 1000:1 的 SQL 一定要优化。
  4. 函数、运算、隐式类型转换都会让索引失效------列要裸奔,变换挪右边。
  5. 深分页别用 LIMIT offset,n,改游标分页 WHERE id>? LIMIT n,提速几百倍。
  6. InnoDB 的 count(*) 不快(事务隔离),大表用近似值或缓存;count(*) 和 count(1) 等价。
  7. EXPLAIN ANALYZE(8.0.18+)真跑一遍给真实耗时,比估算的 EXPLAIN 更可信。

验证清单

  • 用批量 INSERT ... SELECT 灌出 100 万行,别用循环单行插
  • 开启慢查询日志,把 long_query_time 设成 1 秒
  • 抓一条慢 SQL,对着慢日志原文看 Query_time 和 Rows_examined
  • 亲手跑出 type=ALL 到 type=ref 的加索引前后对比
  • 用 EXPLAIN ANALYZE 对比优化前后的 actual time
  • 把 LIMIT 900000,10 改写成游标分页,感受 100ms→0.24ms
  • 解释为什么 count(*) 在 InnoDB 不快而 MyISAM 快

参考资源


下篇预告:《事务与隔离级别》------索引和慢查询是"性能",接下来进入"正确性"。为什么你转账时一条 UPDATE 失败不能扣了钱没加钱?为什么默认隔离级别下,一个事务内两次读同一行结果却一样?下一篇从 ACID 讲起,把脏读、不可重复读、幻读和四种隔离级别用真实事务跑给你看。

相关推荐
科小怪4 天前
ADB工具下载安装与常用命令:一条数据线连上电脑,就能给手机装应用、传文件、查日志
adb·智能手机·电脑
张小姐的猫6 天前
【MySQL】访问 & 数据流动原理
android·数据库·mysql·adb·1024程序员节
lqbzlll8 天前
MySQL 慢查询日志开了但看不懂?从 slow_query_log 到 EXPLAIN ANALYZE 一步步定位
数据库·mysql·adb
风翼靓崽8 天前
centos7以mysql为例自建yum源
数据库·mysql·adb
2401_881828329 天前
MySQL操作命令
android·mysql·adb
灵晔君10 天前
【MySQL】数据库基础
数据库·mysql·adb
在多学一点10 天前
MySQL_DDL的基本命令
数据库·mysql·adb
2501_9151063211 天前
抓包抓到乱码或压缩字节怎么解析,TraceEagle五种查看模式和一键解码
网络协议·计算机网络·网络安全·ios·adb·https·udp
cnskylee11 天前
【MySQL】麒麟V10 SP3系统安装MySQL-Commercial-8.0.46
数据库·mysql·adb
一技安身11 天前
MySQL 8.4.13 LTS 通用二进制包(Linux免安装版)完整教程
linux·mysql·adb