EXPLAIN与查询优化
一条在线上跑了三个月没问题的 SQL,为什么突然开始拖垮数据库?本章建立一套完整的"发现慢→定位慢→验证优化"工作流:慢查询日志与 pt-query-digest 负责发现,EXPLAIN 与 EXPLAIN ANALYZE 负责定位和验证,成本模型与统计信息帮你理解优化器为什么"不听话"。覆盖 EXPLAIN 逐字段精讲、key_len 计算、JOIN/排序/分页/count 的专项优化,以及用 EXPLAIN 复核索引失效的实战方法。
前置阅读:索引的机理(B+ 树、最左前缀、回表代价)在 04-索引原理与实战.md,本章不重复讲"为什么索引会失效",而是讲怎么用 EXPLAIN 把失效发现和验证出来。
1. 慢查询工作流:先定义"慢",再分层选工具
1.1 什么叫慢
没有业务基线的"慢"是伪命题。经验做法:
- 以接口视角定标准:比如"订单列表页 P99 < 50ms",那么单条 SQL 的预算通常只有 10~20ms(要给网络、序列化、多次查询留余量)。
long_query_time的阈值不等于"业务上的慢"。默认 10s 是审计口径,不是性能口径;生产上常见设 0.5s 甚至 0.1s,配合采样开关控制日志量。- 一条 SQL 平均 5ms 但每秒执行 1 万次,也是性能事故——平均值正常不代表总耗时不重要,这要靠第 2 节的聚合工具看总耗时占比,不是看单条耗时。
1.2 工具链分层
| 层 | 解决什么问题 | 常用工具 |
|---|---|---|
| 发现 | 哪些 SQL 在消耗资源 | 慢查询日志 + pt-query-digest;performance_schema 的 events_statements_summary_by_digest;sys schema 视图 |
| 定位 | 这一条 SQL 为什么慢 | EXPLAIN / EXPLAIN FORMAT=TREE |
| 验证 | 优化后是不是真的快了 | EXPLAIN ANALYZE(真实执行,含实际行数与耗时) |
顺序不能反:先看全局再逐条优化。凭感觉挑一条 SQL 优化,往往花了半天把最慢的一条从 3s 优化到 800ms,而真正压垮数据库的是另外 20 条各跑 200ms、每秒执行几百次的高频 SQL。
2. 慢查询日志与 pt-query-digest
2.1 开启与参数
-- 动态开启(my.cnf 中配置同名参数持久化)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5; -- 单位秒,支持小数
SET GLOBAL log_output = 'FILE'; -- 'FILE' 或 'TABLE'(或 'FILE,TABLE')
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';各参数要点:
long_query_time可精确到微秒。慢日志里每条记录还带Query_time与Lock_time两个独立字段(后者是等待锁的时间),两者口径的历史演进有过调整,排障时结合看两个数,不要死抠"等待时间算不算进阈值"这一条——以你自己的压测复现结果为准。log_output=TABLE时慢日志写入mysql.slow_log表,方便 SQL 方式聚合(SELECT query_time, sql_text FROM mysql.slow_log ORDER BY query_time DESC),但这张表是 CSV 引擎、写入不能走索引,量大了会很慢,适合临时排查不适合长期开。log_queries_not_using_indexes:慎用。它会把"全表扫小表"这种完全无害的查询(配置表、字典表可能就 20 行)也记下来,日志噪音大到淹没真问题。折中方案是同时设置:
SET GLOBAL log_queries_not_using_indexes = ON;
SET GLOBAL min_examined_row_limit = 10000; -- 扫描行数不足 1 万的不记录- 慢日志里
Rows_examined是判断"扫描/返回比"的关键:返回 20 行却扫描 50 万行,就是典型的低效查询。
2.2 pt-query-digest 报告怎么读
pt-query-digest /var/log/mysql/slow.log > report.txt报告头部给全局画像(总查询数、总耗时、时间分布直方图),下面按**指纹(fingerprint,参数归一化后的 SQL 模板)**分组,每组给:出现次数、单次平均/中位耗时、总耗时占比(Rank)。读报告的顺序:
- 只看排序第一的 3~5 项,它们的
[total time]占比之和通常已超 80%——这就是"优化谁"的答案。 - 对每组看
Query 1:23. 1.5k rows Examined这类行,Rows examined / Rows sent比值大于 1000:1 的,优先怀疑索引缺失或深分页。 - 同一指纹出现两种形态(比如 IN 列表一个很长一个很短),说明 SQL 模板不稳定,需要先从应用层收敛。
pt-query-digest 是 Percona Toolkit 的第三方工具,不在 MySQL 发行版内,需单独安装 percona-toolkit。不想装外部工具时,performance_schema.events_statements_summary_by_digest 按 SUM_TIMER_WAIT 排序是等价替代品(口径略有差异,且受 performance_schema 上限淘汰策略影响)。
3. EXPLAIN 逐字段精讲
EXPLAIN SELECT o.id, o.status FROM orders o WHERE o.user_id = 42 ORDER BY o.created_at DESC LIMIT 20;| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | o | NULL | ref | idx_user_id,idx_created | idx_user_id | 8 | const | 137 | 100.00 | Using where; Using index; Using filesort |
以下逐个字段讲透。
3.1 id
id 表示 SELECT 子句的编号,规律(经典格式下):
- id 相同:属于同一执行层级,按从上到下(书写顺序)大致理解为执行顺序,具体连接次序以优化器和 FORMAT=TREE 为准。
- id 不同:id 越大越先执行——先算子查询,再外层用它。比如 WHERE 里的非相关子查询会被拆成独立 id。
- id 为 NULL:出现在 UNION 中,表示对前面若干 id 的结果做合并(
UNION RESULT)。 - 相关子查询(内层引用了外层列):select_type 是
DEPENDENT SUBQUERY,内层 id 通常与外层相同或需结合 UNION 一起看,规律在不同 MySQL 版本上呈现有差异,不要在文档/代码注释里把这个编号当强承诺。UNION 中第二个 SELECT 标UNION、后续合并行标UNION RESULT,加了deduplicated/materialized修饰词表示去重或物化方式。
3.2 select_type 常见值
| 值 | 含义 |
|---|---|
| SIMPLE | 无子查询、无 UNION 的简单查询 |
| PRIMARY | 最外层查询 |
| SUBQUERY | 非相关子查询中的第一个 SELECT |
| DEPENDENT SUBQUERY | 相关子查询(外层每行都要重算,性能红线) |
| DERIVED | FROM 里的派生表(子查询物化) |
| UNION / UNION RESULT | UNION 的第二+个分支 / 合并结果行 |
| MATERIALIZED | 物化子查询 |
看到 DEPENDENT SUBQUERY 就要警觉:它意味着子查询被执行 N 遍。改写为 JOIN 或让优化器做半连接(semi-join,5.6+ 会自动尝试)通常是第一板斧。
3.3 type:访问类型的等级阶梯
从好到坏排一条阶梯,生产上出现 range 及更差档位的高频 SQL 都要审:
| 档位 | 触发条件 | 例子(orders 表,PK=id,idx_user_id(user_id),idx_created(created_at)) |
|---|---|---|
| system | 表只有一行(如系统表特例) | SELECT * FROM dummy_single_row |
| const | 主键/唯一索引 = 常量,最多命中一行 | SELECT * FROM orders WHERE id = 1001 |
| eq_ref | JOIN 时被驱动表用主键/唯一索引等值关联,每行驱动只需回表一次 | JOIN users u ON u.id = o.user_id |
| ref | 非唯一索引等值匹配 | WHERE user_id = 42 |
| fulltext / ref_or_null / index_merge / unique_subquery / index_subquery | 特殊访问方式:全文、含 NULL 的 ref、多索引合并结果、子查询走唯一/普通索引 | WHERE user_id=42 OR id=7 可能 index_merge |
| range | 索引上的范围:between、in、>、<、前缀 like |
WHERE created_at BETWEEN '2026-10-01' AND '2026-10-31' |
| index | 全索引扫描(扫整棵二级索引叶子,通常为了取有序性或覆盖) | 无 where、只 select 索引列且需要顺序 |
| ALL | 全表扫描 | WHERE status = 1 且 status 无索引、选择性差 |
两个易混点:index 不等于 range,前者没有过滤条件、扫的行数≈全表行数(只是扫的是更窄的索引而非聚簇记录);range 也不是万能钥匙,WHERE id IN (...) 和 WHERE created_at > x 都是 range,但前者每段可做 index dive 估得很准,后者可能估偏(见第 5 节)。
3.4 key 与 possible_keys
possible_keys:候选索引清单(优化器"考虑过")。key:实际选中的。为 NULL 即没用索引。possible_keys有值但key为 NULL:优化器算下来用索引更贵(回表太多/选择性太差),或统计信息失真。先ANALYZE TABLE更新统计再看,仍不用则参考第 5 节成本模型判断是不是"真不该用"。key显示<primaryKey>之类或UNIQUE_SUBQUERY是特殊标记,含义见官方手册的 show_explain 一节。
3.5 key_len:到底用了几列、多少字节
key_len 是"本次执行实际用到的索引前缀字节数",反推它能确认联合索引用到了第几列。计算规则:
key_len = Σ(用到的每列的宽度) + 索引中可空列每个 +1 字节(NULL 标记)
+ 用到的变长列每个再加 2 字节长度前缀(EXPLAIN 口径一律按 2 算)字符集每字符字节数(算列宽时按字符集峰值字节数):
| 字符集/类型 | 每字符/每列字节 |
|---|---|
| ascii / latin1 | 1 |
| gbk | 2 |
| utf8mb3 | 3 |
| utf8mb4 | 4 |
| TINYINT/SMALLINT/INT/BIGINT | 1/2/4/8 |
| DATE / TIME / DATETIME / TIMESTAMP | 3/3/5/4(DATETIME/TIME 带小数秒再加 1~3) |
| CHAR(n)/VARCHAR(n) | n × 字符集字节数(VARCHAR 每个变长列另加 2 字节长度前缀) |
三道联合索引例题(自己先算,再看答案):
题 1:users 表 INDEX idx_name (name),name VARCHAR(50) CHARACTER SET utf8mb4 NOT NULL,查询 WHERE name = '张三',key_len = ?
答案:50×4 + 2(长度前缀)= 202;若 name 可空再加 1,为 203。注意:有些速查资料把 InnoDB 磁盘记录格式里"变长列最大字节 ≤255 时长度表用 1 字节"的规则套到 key_len 上、算出 201,这是张冠李戴——EXPLAIN 的 key_len 按列的最大可能字节数 + 2 口径计算,与磁盘实际存 1 还是 2 字节无关。官方手册自己的例子(
author VARCHAR(20) NULLutf8 计 63 = 20×3+2+1)就是 +2;实测印证:单列表 utf8mb4VARCHAR(20)key_len=82、VARCHAR(63)(252 字节,磁盘格式该用 1 字节前缀的临界内侧)key_len=254,一律 +2。
题 2:orders 表 INDEX idx_user_created (user_id INT NOT NULL, created_at DATETIME NOT NULL)。① WHERE user_id = 42;② WHERE user_id = 42 AND created_at > '2026-10-01'。两次 key_len 各是?
答案:① 只用到第一列,4 = 4;② 两列都用到,4 + 5 = 9。key_len 从 4 变 9,说明范围条件确实吃进了索引第二列。若 created_at 可空,则第二列另加 1(注意 NULL 标记只给可空列,非末列同样要加)。
题 3:products 表 INDEX idx_cat_name (category_id INT NOT NULL, name VARCHAR(30) CHARACTER SET utf8mb4 NULL)。WHERE category_id = 7 与 WHERE category_id = 7 AND name LIKE 'Pro%' 的 key_len?
答案:前者 4;后者 4 + (30×4 + 2 长度前缀 + 1 可空) = 4 + 123 = 127。LIKE ‘Pro%’ 是前缀范围,能继续用第二列。若写成
LIKE '%Pro%',第二列用不上,key_len 回到 4——这是验证"最左前缀断在哪一列"最直接的手段。
3.6 rows 与 filtered
rows:估算的"该访问方式预计要检查的行数",不是精确值,更不是结果行数。InnoDB 靠采样统计页(见 5.2),数据倾斜时能偏差几倍。filtered:估算经 where 过滤后剩余行的百分比。rows × filtered%大致是传给上一层/客户端的行数。- 判断"优化器有没有被统计信息骗",核心动作就是拿
EXPLAIN ANALYZE的实际 rows 与这里的估算 rows 对比(第 4 节)。
3.7 Extra 高频项清单
| Extra 项 | 含义 | 信号 |
|---|---|---|
| Using index | 覆盖索引,不回表 | 好,能出现尽量争取 |
| Using index condition | ICP(索引条件下推),二级索引先过滤再回表 | 好,见 04 章机理 |
| Using index condition; Using MRR | ICP + 多范围读。MRR 优化器自 8.0.21 起被移除,新版本不会再出现 | 旧版本兼容提醒 |
| Using where | 拿到行之后还要在 server 层过条件 | 中性;覆盖索引场景下常表示"过非索引列条件" |
| Using filesort | 需要额外排序(内存或磁盘归并) | 高频出现要处理,见第 7 节 |
| Using temporary | 需要临时表(DISTINCT/GROUP BY/物化派生表等) | 高频出现要处理 |
| Using join buffer (hash join) | 被驱动表 join 列无可用索引,8.0.18+ 走 hash join 兜底 | 索引设计缺陷的明确信号 |
| Using join buffer (Block Nested Loop) | 8.0.18 之前同类场景的 BNL 字样;新基线版本已看不到 | 读旧工单/旧教程时的对照 |
| Select tables optimized away | 聚合能直接从索引取极值,不扫表 | 好,MAX(id)、MIN(created_at) 常触发 |
| Impossible WHERE | 条件恒假,直接返回空 | 应用 bug 排查线索 |
| No tables used | SELECT 1 之类 |
正常 |
| Full scan on NULL key | 子查询里外层值可能为 NULL 导致每次全扫 | 危险,通常改写法 |
| Using index for group-by | GROUP BY 走松散索引扫描 | 好 |
4. FORMAT=TREE 与 EXPLAIN ANALYZE:估算 vs 真实
4.1 树形视图
经典 EXPLAIN 是一堆行,连接次序、物化关系要靠脑补;FORMAT=TREE(8.0.16+)把执行计划画成树,且带 cost 与行数估算:
EXPLAIN FORMAT=TREE
SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.status = 'PAID' ORDER BY o.created_at DESC LIMIT 20;-> Limit: 20 row(s)
-> Sort: o.created_at DESC, limit input to 20 row(s) per chunk
-> Stream results
-> Inner join on u.id = o.user_id
-> Index lookup on o using idx_status (status='PAID') (cost=8213.40 rows=24180)
-> Single-row index lookup on u using PRIMARY (id=o.user_id) (cost=7254.05 rows=24180)逐字段读法:每个节点括注 cost=(累计估算成本,无单位、只用于比较)与 rows=(估算行数)。缩进越深越先执行(子节点先产出数据)。上面这个计划的问题是 status='PAID' 命中 2.4 万行再排序取 20——如果建 (status, created_at) 联合索引,计划会变成"沿索引反向扫、取满 20 行即停",Sort 节点直接消失。对比两棵树的形状比看 cost 数字更有价值。
4.2 EXPLAIN ANALYZE:真实执行
EXPLAIN ANALYZE(8.0.18+)会真的把 SQL 跑完,在每个节点标注 (actual time=X..Y rows=N loops=M):
actual time单位毫秒,X..Y是首行到末行的耗时区间。rows是实际产出行数——和估算 rows 对比,就是诊断统计信息失真的铁证。loops是该节点被执行的次数(嵌套循环里被驱动侧 loops=驱动侧行数)。真实总耗时 ≈ Y×loops,别只看单节点数字。
注意 两个红线:其一,它会真实执行,SELECT 之外别对 UPDATE/DELETE/INSERT 用 EXPLAIN ANALYZE(语句会真改数据);其二,大查询在高峰期跑 ANALYZE 等于给线上再压一遍同样的负载。只在预发/从库/低峰用。另外它与
ANALYZE TABLE(更新统计信息的语句)名字像但完全无关,别混。
4.3 命名撞车提醒
本章出现两个 Analyze:EXPLAIN ANALYZE=真执行拿实测行数;ANALYZE TABLE orders;=重新采样索引统计信息。5.2 节会用到后者。
5. 优化器为什么"不选你建的索引"
索引在那里,优化器不用——这不是 bug,是成本模型算出来的结论。要让它听话,得先理解它怎么算账。
5.1 成本模型:IO + CPU 的抽象计价
优化器把每种访问方式的代价折算成 cost 数字再比大小,主要成本项(SEcost/server_costing_parameters 可调):
SHOW VARIABLES LIKE '%block_read_cost%';
-- memory_block_read_cost = 0.25 内存页读一次
-- io_block_read_cost = 1.0 磁盘页读一次粗略地说:走二级索引的代价 ≈ 命中的索引条目数(排序、扫叶子)+ 回表次数(每次回表是聚簇索引上的一次独立 B+ 树查找);全表扫的代价 ≈ 聚簇索引的页数(顺序读)。所以当范围条件命中超过约总行数 20%~30%(经验值,数据在缓冲池比例高时阈值更低)时,回表成本超过顺序扫全表,优化器放弃索引是正确决策。典型例证:
-- is_deleted 选择性 99%(绝大多数行都是 0),单列索引基本不会被选中
EXPLAIN SELECT * FROM users WHERE is_deleted = 0;
-- key=NULL, type=ALL:不是索引白建了,是这列就不配建单列索引真正要区分的是两种情况:优化器算得对(选择性差、回表多),还是优化器算错了(统计信息失真)。后者见下。
5.2 统计信息采样偏差
InnoDB 对每张表维护总行数估算、对每个索引维护"不同键前缀数"等采样值(持久化在内部表 mysql.innodb_table_stats / mysql.innodb_index_stats,后者每索引一行 n_diff_pfx* 记录),来源是 innodb_stats_persistent 持久化统计,默认只采样 20 个页:
SHOW VARIABLES LIKE 'innodb_stats_persistent_sample_pages'; -- 默认 2020 页对 5GB 的 orders 表意味着极小样本,数据倾斜(比如某个 user_id 有 100 万条订单)时估算可以偏出一个数量级。修正手段按代价从小到大:
ANALYZE TABLE orders; -- 重新采样(8.0.28 起不再刷新表定义缓存,抖动很小;更早版本有短暂抖动,注意窗口)
SET innodb_stats_persistent_sample_pages = 100; -- 提高采样页数:更准,但 ANALYZE 更慢
ANALYZE TABLE orders; -- 调完参数再采样批量导入大改数据后、以及每天凌晨对核心大表跑一次 ANALYZE TABLE,是很多团队的例行动作(注意 innodb_stats_auto_recalc 开启时,表变更超过约 10% 行会后台自动重算,多数场景够用,不必手动刷)。
5.3 eq_range_index_dive_limit:IN 列表的两种估算
等值范围(IN/多个 OR =)的估行,优化器分两种做法:
- 范围段数 <
eq_range_index_dive_limit(默认 200):逐段做 index dive(真实下探 B+ 树数行),估得准但费 CPU; - 段数 ≥ 200:改用统计信息估算,防止 1 万个值的 IN 把优化器 CPU 耗光——但统计信息本身有采样偏差,于是出现"IN 300 个值反而选错计划"的经典现象。
排查口诀:同一条 SQL,IN (少值) 走 range、IN (多值) 退化 ALL,先想这个阈值。确认手段是对比两种写法的 rows 估算,必要时把大 IN 拆分或临时调低阈值压测对比。
5.4 直方图:给无索引列补数据分布
8.0+ 支持列值直方图,专治"没索引、但有明显分布倾斜"的列(估算过滤率用):
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, amount WITH 32 BUCKETS; -- 上限 1024
SELECT column_name, histogram FROM information_schema.column_statistics; -- JSON,含每桶端点与累积频率
-- 删除:ANALYZE TABLE orders DROP HISTOGRAM ON status;两个限制要记牢:数据变了直方图不会自动更新(需要重跑,可挂定时任务);同一列有索引统计时直方图不参与(有索引就用索引统计)。直方图救的是 WHERE status='REFUNDED' 这类选择性极差但确实需要估准的过滤列,让优化器在"走不走某个其他索引"的决策上不跑偏。
5.5 optimizer_trace:抓一次优化决策的完整心路
想知道优化器为什么选了 A 不选 B,开 trace 看它的候选清单和逐项 cost:
SET optimizer_trace = 'enabled=on';
SELECT /* 目标 SQL */ o.id FROM orders o WHERE o.user_id = 42 ORDER BY o.created_at DESC LIMIT 20;
SELECT trace FROM information_schema.optimizer_trace\G
SET optimizer_trace = 'enabled=off';输出 JSON 很长,只盯这几处关键行:
"rows_after_filtering": 137,
"considered_execution_plans": [
{ "plan_prefix": [], "table": "`o`",
"best_access_path": { "considered_access_paths": [
{ "access_type": "ref", "index": "idx_user_id", "rows": 137, "cost": 52.1 },
{ "access_type": "scan", "rows": 5184213, "cost": 528340.2 }
]},
...trace 里能看到所有被评估并淘汰的方案(包括各候选索引、排序代价、是否 filesort)。线上排障一次只看一条 SQL 的 trace,别全局常开。trace 是性能_schema 之外唯一"白盒"入口,也是本章把 5.1~5.4 串起来的验证工具。
6. JOIN 优化
6.1 驱动表选择的两条原则
- 小结果集做驱动表:不是"表小",而是"经 where/索引过滤后产出行数少"的那一侧。驱动表每产出一行,被驱动表就要做一次查找,所以驱动侧的行数被当作"乘数"。
- 被驱动表的 join 列必须有索引:N 行驱动 × 被驱动表一次 B+ 树 O(logN) 查找 vs N 次全表扫,天壤之别。这条是 JOIN 优化的第一检查项。
-- 好:过滤后只有 20 行的 orders 做驱动,users 主键 eq_ref 点查 20 次
EXPLAIN SELECT u.name FROM orders o JOIN users u ON u.id = o.user_id
WHERE o.status = 'PAID' AND o.created_at > '2026-10-19' LIMIT 20;
-- 期望:o 侧 ref/range,u 侧 type=eq_ref, key=PRIMARY优化器会自己按成本选驱动表;只有统计失真把它带偏时才用 STRAIGHT_JOIN 或 /*+ JOIN_ORDER(o,u) */ 强制(第 12 节)。
6.2 嵌套循环家族三代
MySQL 的等值 JOIN 本质都是嵌套循环,差别在被驱动侧"内层循环"怎么省:
| 代际 | 机制 | 一句话适用场景 |
|---|---|---|
| Simple Nested Loop | 驱动每行全扫被驱动表 | 优化器基本不选,只在无索引小表兜底时近似出现 |
| Index Nested Loop (INL) | 被驱动 join 列有索引,内层变成 B+ 树点查 | 标准形态,99% 的快 JOIN 都是它 |
| Block Nested Loop (BNL) → Hash Join | 攒一批驱动行再扫被驱动:8.0.18 前是 BNL(整批 × 整表比对,O(M×N));8.0.18 起被 hash join 取代(建哈希表后一次探测,接近 O(M+N)) | 被驱动 join 列无索引时的兜底。Extra 出现 Using join buffer (hash join) 就是在告诉你"这里缺一个索引" |
hash join 比 BNL 快一个量级,但它只是"止痛药":内存不够时仍需分桶落盘(Grace 形态),成本依旧显著高于 INL。看到 hash join 的处方仍然是:给被驱动表 join 列建索引。
6.3 join 列字符集/排序规则不一致的代价
orders.user_id 是 utf8mb4、users.id 是 utf8,或两者 collation 不同(utf8mb4_general_ci vs utf8mb4_0900_ai_ci),后果是 join 列上索引失效(被驱动侧退化为扫描/hash join),甚至直接报错 1267(Illegal mix of collations)。建表期就把全库字符集排序规则统一(本手册基线建议 utf8mb4 + 统一 collation),详见 03-数据类型与建表.md。这是 EXPLAIN 里"明明两边都有索引却不用"的高频真因。
6.4 子查询改 JOIN:何时有效何时更差
IN (SELECT ...)非相关子查询:5.6+ 优化器会自动做半连接(semi-join)转成 join 形态,手写成 JOIN 常常没有收益,若原语义需要去重而改写后丢了DISTINCT,还可能出错。WHERE EXISTS (相关子查询):改 JOIN 通常有效,尤其是老版本/优化器没做物化时。FROM (子查询) t派生表:8.0 支持派生表合并与条件下推,很多情况能自动展平;但子查询里带LIMIT、复杂聚合时会被物化成临时表,此时改写成 JOIN 或拆两步才见效。- 结论:先 EXPLAIN 看它有没有自动改写,没改写再动手改 SQL。用 FORMAT=TREE 确认形状变化,用 ANALYZE 确认实际 rows 没有变差。
7. ORDER BY 与 GROUP BY
7.1 filesort:单遍还是双遍
需要排序且索引给不了顺序时,MySQL 在 sort_buffer_size(默认 256KB,按排序操作分配、一次查询里多个排序就有多份)内存中排序,放不下走磁盘归并(Extra: Using filesort)。排序策略有两种形态:一次装入全部列(单遍),或只装排序列+主键指针、排完再回表取整行(双遍)。传统上由行宽与 max_length_for_sort_data(默认 4096)决定走哪种;8.0 后实现细节有调整(对含大字段列的处理更激进地走"排完再取"路径),该参数是否仍严格决定路径在 8.4 上表述要谨慎——实践上只要记住:SELECT * + 大字段 + 排序 = 排序成本显著上涨,能只选必要列就只选必要列。
优化优先级:索引顺序 > 减少排序行数(先过滤再排)> 减少排序列宽 > 调 sort_buffer_size(谨慎全局调,会话级临时调即可,它按排序操作分配,全局调大容易被并发查询放大成内存事故)。
7.2 用联合索引直接消灭 filesort(设计法)
排序走向"Using index for order by / 无 filesort"的模式是索引设计的一部分(机理见 04,这里给验证视角):
-- 索引 (user_id, created_at)
WHERE user_id = 42 ORDER BY created_at DESC -- 索引内 created_at 已按 user_id 分段有序,直接倒序读,无 filesort
-- 索引 (status, created_at)
ORDER BY status, created_at -- 前缀+后缀都沿索引,无 filesort
ORDER BY status ASC, created_at DESC -- 方向混合:普通索引给不了,8.0 降序索引(DESC 列)才能消灭 filesort
WHERE user_id = 42 ORDER BY amount -- amount 不在索引里,必然 filesort验证时看两点:经典格式 Extra 里 Using filesort 消失;FORMAT=TREE 里 Sort: 节点消失(变成 Index lookup ... ; order by ... 形态或直接 Limit 截断)。
7.3 GROUP BY 与临时表
GROUP BY 的实现要么靠索引天然有序直接分组(Extra 出现 Using index for group-by 是理想态,松散索引扫描:无聚合函数或仅 MIN/MAX、分组列是索引前缀时才可能命中),要么建临时表(Using temporary)再排序返回。Using temporary; Using filesort 组合出现在高频 SQL 上,通常是聚合查询没有按"分组列在前、聚合排序列在后"设计索引。
注意:8.0 取消隐式排序(从 5.7 升级的最大行为坑之一) 5.7 及以前,
GROUP BY city会隐式按 city 排序输出;8.0 起不再保证。下面这个写法在 5.7 里"碰巧"有序,升 8.0 后顺序随机:SELECT city, COUNT(*) FROM users GROUP BY city; -- 8.0: 顺序不保证! SELECT city, COUNT(*) FROM users GROUP BY city ORDER BY city; -- 正确:显式 ORDER BY升级前全量扫描代码里的 GROUP BY,把需要的顺序补成显式 ORDER BY。
ORDER BY ... ASC旧版可省略方向、以及"GROUP BY 后还能引用别名"等细节变化一并见13-常见陷阱与最佳实践.md。
8. 分页与深分页
LIMIT 1000000, 20 的真实代价:按当前顺序扫过并丢弃 100 万行,再取 20 行返回。EXPLAIN 的 rows 会诚实地写约一百万——offset 是逐行计费的。三种标准解法:
方案一:延迟关联(覆盖索引先在索引上翻完丢弃段)
-- 内层子查询只碰二级索引(Extra: Using index),不回表扫宽行;外层按 20 个主键点查
SELECT o.*
FROM orders o
JOIN (SELECT id FROM orders WHERE user_id = 42 ORDER BY id LIMIT 1000000, 20) t
ON o.id = t.id;把"100 万次回表"降为"100 万次索引顺序扫 + 20 次回表"。翻页请求仍随页码线性变慢,只是常数小很多。
方案二:游标式 seek(推荐用于"下一页")
-- 前端只带上一页最后一行的 id,不传 offset
SELECT * FROM orders
WHERE user_id = 42 AND id > 20451008 -- 上一页末行游标
ORDER BY id LIMIT 20; -- 无论"第几页"都是恒定开销,走索引直接定位代价是失去"跳转到第 N 页"的能力,适合无限滚动/上下页场景。
方案三:限制最大页码 + 引导用筛选代替翻页:搜索引擎"只放前 100 页"的思路;配合业务侧强制时间范围/状态筛选把候选集先缩小。
三者共同前提:排序键稳定(ORDER BY 的列要有确定性,建议补唯一列做 tie-breaker,否则翻页会出现重复/漏行)。
9. count 家族
9.1 InnoDB 为什么不维护实时行数
MVCC 下每个事务看到的行数不同:一个事务在插入未提交时,它自己数得到这行、别的数不到——不存在一个"全局正确"的计数器可维护。所以 InnoDB 不缓存总行数,SELECT COUNT(*) FROM orders 必须扫某个索引数一遍。机理展开见 06-MVCC.md,这里只给选型结论。
9.2 三种写法的等价性
SELECT COUNT(*) FROM orders; -- 语义:数"行数",NULL 也数。优化器选最小的可用索引扫
SELECT COUNT(1) FROM orders; -- 与 COUNT(*) 完全等价(常量非 NULL,逐行返回常数),无性能差异
SELECT COUNT(id) FROM orders; -- id 非空时结果相同,但可能强制扫 id 所在索引(主键宽)而非更窄的二级索引,不占优
SELECT COUNT(price) FROM order_items; -- 不等价:跳过 NULL,语义是"非 NULL 计数"官方口径(8.0 手册)明确 COUNT(*)、COUNT(1)、COUNT(NON NULL列) 优化等价;COUNT(*) 不返回列值、不检查 NULL,是标准写法。结论:统一写 COUNT(*)。有 WHERE 时按索引选择性正常走索引,与无 WHERE 的"选最窄索引"是两条规则,别混。
9.3 大表计数的取舍
- 精确计数:只能真扫。5000 万行的 orders 全表 count 在从库跑可以接受(指
10-主从复制与高可用.md的读写分离),在主库高频跑则是事故源。 - 估算:
EXPLAIN SELECT * FROM orders的 rows(即统计信息)或information_schema.TABLES.TABLE_ROWS,同源、误差可到数倍,适合"页面显示约 xx 万条"。 - 业务化:计数表(写订单的同时
UPDATE shop_stat SET order_num=order_num+1,事务保证一致)或缓存层预聚合(Redis 计数,见12-分库分表.md的统计方案讨论)。先问产品"这个数需要精确吗",多数答案是不需要。
10. IN / OR 与批量操作
10.1 IN 列表长度与执行行为
IN 本质是多个等值 range,命中索引时按序做 index dive 后归并。3.5/5.3 节两个结论合起来用:200 个值以内估算精度好、超过后切统计口径可能跑偏。工程约束另有两条:IN 列表受 max_allowed_packet 间接限制(超长 SQL 直接报错),且应用层拼接有注入与执行计划缓存抖动问题——批量值走临时表 JOIN 是更稳的写法。
10.2 OR 改 UNION ALL 的条件
WHERE indexed_a = ? OR indexed_b = ?:同表两个不同列各有索引时,优化器通常用 index_merge(union)自动合并;但 index_merge 的历史实现偶发选错、成本估算粗糙时也可能退化成全表扫。手动改写:
(SELECT * FROM orders WHERE user_id = 42)
UNION ALL
(SELECT * FROM orders WHERE order_no = 'N20261020001' AND user_id <> 42) -- 补互斥条件防止重复行
ORDER BY created_at DESC;条件清单:① 两个谓词各自有可用索引;② 必须人工保证不重复(补 AND NOT(另一条件)),否则 UNION ALL 会输出重复行;③ 各自需要单独排序时外层 ORDER BY 仍要 filesort,收益要实测。不满足就不如信任优化器(index_merge 可用时)。
10.3 批量 UPDATE 分批模板
一把 UPDATE orders SET status=3 WHERE status=1 命中 300 万行:长事务、巨量 undo、行锁范围大到阻塞业务、binlog 单事务巨物把从库打成复制延迟(锁机理指 05-事务与锁.md,binlog 影响指 07-日志体系.md)。标准分批:
-- 应用层循环执行,每轮小事务;affected_rows=0 即退出
START TRANSACTION;
UPDATE orders SET status = 3
WHERE status = 1 AND id <= 50000000
ORDER BY id LIMIT 1000; -- UPDATE 不支持 ORDER BY+LIMIT 的组合在部分版本/语法下受限,稳妥写法:先 SELECT id 再按主键更新(见下注)
COMMIT;
DO SLEEP(0.2); -- 给从库追赶与业务流量让路更稳的两步式(避免对 UPDATE+LIMIT 语义的依赖):
SELECT id FROM orders WHERE status=1 ORDER BY id LIMIT 1000; -- 取一批主键
UPDATE orders SET status=3 WHERE id IN (/* 上一批 1000 个 */); -- 按主键小范围更新
-- 循环 + 每轮间 sleep;监控 Seconds_Behind_Source 动态调节节奏每批 1000 左右、批间 100~300ms 是常见起步值,核心目标是单事务锁持有时间 < 1s 与从库延迟不爬升。
11. 索引失效的 EXPLAIN 复核
04-索引原理与实战.md 讲清了每个失效场景的机理;本节只练一个动作:如何用 EXPLAIN 的前后对照证明它确实失效了、以及改写后确实恢复了。六个高频场景(表结构:users(name VARCHAR(50), phone VARCHAR(20), city VARCHAR(30), age INT, created_at DATETIME),索引 idx_name(name)、idx_city_age(city, age)、idx_created(created_at)):
-- 场景 1:最左前缀断了
EXPLAIN SELECT * FROM users WHERE age = 30;
-- key=NULL type=ALL。改写 WHERE city='Hangzhou' AND age=30 → key=idx_city_age, key_len=127(30×4 + 2 长度前缀 + 1 可空 + 4)
-- 场景 2:前导通配符
EXPLAIN SELECT * FROM users WHERE name LIKE '%张%';
-- key=NULL type=ALL。改前缀 '张%' → key=idx_name type=range
-- 业务必需模糊搜索:全文索引/ngram(03 章)或外部检索引擎,不要在 LIKE 上硬撑
-- 场景 3:列上套函数
EXPLAIN SELECT * FROM users WHERE DATE(created_at) = '2026-10-20';
-- key=NULL。改区间:created_at >= '2026-10-20' AND created_at < '2026-10-21' → key=idx_created type=range
-- 场景 4:隐式类型转换(phone 是 varchar,传入数字)
EXPLAIN SELECT * FROM users WHERE phone = 13800138000;
-- key=NULL type=ALL:varchar 列 = 数字按数字比较,索引列侧被转换。改 '13800138000' → key=idx_phone
-- 反向不成立:数字列=字符串仍可用索引(转换发生在常量侧),但别留这种地雷
-- 场景 5:ORDER BY 列不在索引路径里
EXPLAIN SELECT id FROM users WHERE city='Hangzhou' ORDER BY created_at;
-- key=idx_city_age, Extra 含 Using filesort。改索引 (city, created_at) 后 filesort 消失(第 7.2 节)
-- 场景 6:NOT / != / IS NULL 选择性差
EXPLAIN SELECT * FROM users WHERE city != 'Hangzhou';
-- 多数情况 key=NULL type=ALL:!= 命中 99% 的行,走索引回表是亏的(第 5.1 节成本模型)——不是索引失效,是不值得用两个验证工具:
-- 隐形索引:把线上可疑索引"逻辑下架"观察计划,比直接 DROP 安全得多(8.0+)
ALTER TABLE users ALTER INDEX idx_city_age INVISIBLE;
EXPLAIN SELECT ...; -- 观察没有它之后计划是否反而更好/不变
ALTER TABLE users ALTER INDEX idx_city_age VISIBLE; -- 随时恢复
-- 长期无人使用的候选索引,确认后 DROP(回滚只需重建索引,但大表重建成本见 09 章)possible_keys 非空而 key 为空 → 先查选择性(第 5.1 节)与统计(第 5.2 节);possible_keys 为空 → 才是真正的"失效"(本节的 1~4 场景)。这个分叉是复核时第一眼看的东西。
12. 其他技巧
12.1 覆盖改写验证
优化高频查询的最后一步经常是"把索引改覆盖":SELECT 只取需要的列,联合索引把这几列补齐(尾列 INCLUDE 思路在 MySQL 里就是多放几列进索引)。验证标准动作:Extra 出现 Using index,FORMAT=TREE 节点写 Index lookup ... (covering) 形态、且不再有回表相关的 rows 放大。代价:索引变宽、写放大增加——只给高频读、低写压力的核心链路做。
12.2 优化器提示(hint)
-- 旧式索引提示:作用于单个表引用
SELECT * FROM orders FORCE INDEX (idx_status) WHERE status = 'PAID'; -- FORCE:不用也要用
SELECT * FROM orders IGNORE INDEX (idx_created) ORDER BY created_at;
-- 8.0 优化器 hint 语法(/*+ ... */ 注释式 hint,粒度更细、可命名作用域 QB_NAME)
SELECT /*+ JOIN_ORDER(o, u) NO_INDEX(o idx_status) */ o.id, u.name
FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'PAID';常用 8.0 hint:JOIN_ORDER(t1,t2)(指定连接次序)、INDEX(t idx)/NO_INDEX(t idx)(按 hint 语法选/弃索引,替代旧式 FORCE/IGNORE 的更可控写法)、SEMIJOIN/NO_SEMIJOIN(子查询改写策略)、MAX_EXECUTION_TIME(1000)(只读查询超时保护,强烈建议配给报表 SQL)。原则:hint 是给优化器纠错的临时手段,根治靠统计信息与索引设计;hint 进了代码就要防它随数据分布变化变成枷锁,加注释说明"当时为什么"。
12.3 查询缓存:旧教程残留物
注意 MySQL 8.0 起查询缓存(query cache)已彻底移除,
query_cache_type、show_status like 'Qcache%'都不存在了。大量旧教程、旧面试题仍把它当调优项。如果你在 8.x 的 my.cnf 里写这些参数,服务会起不来(unknown variable)。8.0 的等价物在应用层/中间件(Redis、CDN)或上层缓存体系解决,见12-分库分表.md与 redis 手册。
13. 诊断速查表与优化方法论
13.1 现象 → 先看哪里
| 现象 | 第一步看什么 | 对应小节 |
|---|---|---|
| CPU 高、整体变慢 | 慢日志聚合 / events_statements_summary_by_digest 按总耗时排序 | 1、2 |
| 明明有索引不用,possible_keys 非空 key 空 | 选择性 → ANALYZE TABLE 重采样 → optimizer_trace | 5.1、5.2、5.5 |
| IN 少时快、IN 多时慢 | eq_range_index_dive_limit 口径切换 | 5.3、10.1 |
| 两边都有索引,JOIN 很慢 | join 列字符集/collation 是否一致 | 6.3 |
Extra 有 Using join buffer (hash join) |
被驱动表 join 列加索引 | 6.2 |
高频 Using filesort / Using temporary |
索引顺序能否覆盖 ORDER BY/GROUP BY | 7 |
| 翻页越翻越慢 | 改延迟关联/游标 seek | 8 |
| count 慢 | 问是否要精确;从库/估算/计数表 | 9 |
| 批量 UPDATE 后业务卡、从库延迟飙升 | 分批+小事务+睡眠,监控延迟调节奏 | 10.3 |
| 升级后 SQL 时快时慢/结果顺序变了 | GROUP BY 隐式排序取消、统计信息漂移 | 7.3、5.2 |
| 某条 SQL 一直没事突然变慢 | 数据分布漂移导致 cost 反转:重跑 ANALYZE/直方图 | 5.2、5.4 |
13.2 方法论收尾
- 先测量后动手:没有 EXPLAIN(最好 ANALYZE)前后对照的"优化"等于没做——你无法证明它变快了,甚至无法证明它没变慢。留存每条优化前后的计划截图/文本,归档到工单。
- 一次只改一个变量:同时改索引+改 SQL+调参数,出问题回滚时你不知道是哪个的功劳/锅。
- 统计先行:怀疑计划诡异,先
ANALYZE TABLE花两秒重采样,多数"优化器抽风"在这里就好了,再考虑 trace 深挖。 - 为成本结构而优化:扫描行数/回表次数/排序行数这三个乘数项,砍任何一个都比调 buffer 参数收益大且副作用小。
- 把验证做成习惯:上线新 SQL 前在预发用真实量级数据跑
EXPLAIN FORMAT=TREE;大改动(删索引、改驱动次序)在影子库灰度对比。索引怎么建、DDL 怎么在线变更,分别接着看04与09章。
实战建议:把本章工作流压成三句话贴在工位上——聚合找大头,EXPLAIN 看形状,ANALYZE 对行数。慢日志与 pt-query-digest 决定"优化谁",FORMAT=TREE 决定"改哪里"(缺索引、回表、排序、深分页四件套),EXPLAIN ANALYZE 的 actual rows vs estimated rows 决定"统计信息还是 SQL 本身的锅"。所有优化动作做完,都要求留下改前/改后的两份执行计划,这是这一行里"我优化过了"的唯一凭证。