【计算基础|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,表
orders100 万行(id/user_id/product_id/amount/status/create_time),users10 万行(含 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 件事
- 慢查询优化五步:开慢日志抓 SQL → EXPLAIN 看计划 → 定位瓶颈 → 加索引/改写 → 前后对比验证。
- EXPLAIN 里盯四个字段:
type(至少 range)、key(NULL 要警惕)、rows(扫描行数)、Extra(Using index 好,filesort/temporary 差)。 Rows_examined / Rows_sent比值就是浪费程度,接近 1000:1 的 SQL 一定要优化。- 函数、运算、隐式类型转换都会让索引失效------列要裸奔,变换挪右边。
- 深分页别用
LIMIT offset,n,改游标分页WHERE id>? LIMIT n,提速几百倍。 - InnoDB 的
count(*)不快(事务隔离),大表用近似值或缓存;count(*)和count(1)等价。 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 快
参考资源
- MySQL 8.0 Reference Manual --- EXPLAIN: https://dev.mysql.com/doc/refman/8.0/en/explain-output.html
- MySQL 8.0 Reference Manual --- EXPLAIN ANALYZE: https://dev.mysql.com/doc/refman/8.0/en/explain.html
- MySQL 8.0 Reference Manual --- Slow Query Log: https://dev.mysql.com/doc/refman/8.0/en/slow-query-log.html
下篇预告:《事务与隔离级别》------索引和慢查询是"性能",接下来进入"正确性"。为什么你转账时一条 UPDATE 失败不能扣了钱没加钱?为什么默认隔离级别下,一个事务内两次读同一行结果却一样?下一篇从 ACID 讲起,把脏读、不可重复读、幻读和四种隔离级别用真实事务跑给你看。