【计算基础|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 讲起,把脏读、不可重复读、幻读和四种隔离级别用真实事务跑给你看。

相关推荐
2501_915909062 天前
抓包工具断点拦截是什么?如何半路拦截修改 HTTP 请求
网络协议·计算机网络·网络安全·adb·https·udp
misty youth4 天前
Navicat与PHPstudy小皮
mysql·adb·php·navicat
for_ever_love__5 天前
MySQL 8.0 数据类型选型指南:INT、DECIMAL、VARCHAR 与时间字段怎么选
mysql·adb·数据类型·varchar·长度·字段类型选择
yeflx6 天前
Ubuntu22.04安装Mysql Workbench
数据库·mysql·adb
夜雪一千6 天前
MySQL 自增 ID 的底层原理与坑点
android·mysql·adb
Dxy12393102167 天前
MySQL JSON 生成列索引原理、实战与坑点
mysql·adb·json
远方 hi7 天前
MySQL 管理
adb
夜雪一千8 天前
MySQL 删除 JSON 类型的数据实战
mysql·adb·json