个人主页: > for_ever_love__ <(欢迎各位大佬莅临😊)
其他栏目: > 大模型开发从0到1 <
其他栏目: > iOS项目总结大全 <
其他栏目: > 我想学python了 <
其他栏目: > iOS UI <
文章目录
- [MySQL 优化器与 OPTIMIZER_TRACE:把"为什么选这条计划"问到水落石出](#MySQL 优化器与 OPTIMIZER_TRACE:把"为什么选这条计划"问到水落石出)
-
- [一、一条 SQL 在优化器里经历了什么](#一、一条 SQL 在优化器里经历了什么)
- 二、成本模型:优化器的"账本"
-
- [2.1 成本 = IO 成本 + CPU 成本](#2.1 成本 = IO 成本 + CPU 成本)
- [2.2 这些成本常数是**可以改的**](#2.2 这些成本常数是可以改的)
- [2.3 怎么调](#2.3 怎么调)
- [三、三个升级版 EXPLAIN](#三、三个升级版 EXPLAIN)
- [四、OPTIMIZER_TRACE 上手](#四、OPTIMIZER_TRACE 上手)
-
- [4.1 开关与相关变量](#4.1 开关与相关变量)
- [4.2 trace 的骨架](#4.2 trace 的骨架)
- [五、逐个段落读懂 trace](#五、逐个段落读懂 trace)
-
- [5.1 join_preparation:看看 MySQL 把你 SQL 改成了什么样](#5.1 join_preparation:看看 MySQL 把你 SQL 改成了什么样)
- [5.2 condition_processing:条件是怎么被改写的](#5.2 condition_processing:条件是怎么被改写的)
- [5.3 ref_optimizer_key_uses:哪些索引本来可用](#5.3 ref_optimizer_key_uses:哪些索引本来可用)
- [5.4 rows_estimation → range_analysis:★ 索引选择的决定性战场](#5.4 rows_estimation → range_analysis:★ 索引选择的决定性战场)
- [5.5 considered_execution_plans:JOIN 顺序的决策现场](#5.5 considered_execution_plans:JOIN 顺序的决策现场)
- [5.6 attaching_conditions_to_tables 与 refine_plan](#5.6 attaching_conditions_to_tables 与 refine_plan)
- 六、实战案例:为什么有索引却全表扫描
-
- [6.1 现象](#6.1 现象)
- [6.2 抓 trace 找答案](#6.2 抓 trace 找答案)
- [6.3 第二个案例:range 被 heuristic 排除](#6.3 第二个案例:range 被 heuristic 排除)
- 七、实在说服不了优化器时的干预手段
-
- [7.1 optimizer_switch](#7.1 optimizer_switch)
- [7.2 传统 / 新版 hint](#7.2 传统 / 新版 hint)
- 八、三个常见误区
- 九、小结
MySQL 优化器与 OPTIMIZER_TRACE:把"为什么选这条计划"问到水落石出
EXPLAIN只能告诉你结果 :用了哪个索引、扫了多少行。但它永远不会告诉你为什么 ------ 为什么不用你建的那个索引、
为什么 join 顺序是这样、为什么我们认为很明显的路径它视而不见。
要回答"为什么",唯一的工具是
OPTIMIZER_TRACE:它把优化器做决策时的每一步、每个估算成本和每次取舍都记下来。
本文从成本模型讲到 trace 的每个关键段落,最后用两个真实感很强的案例演示怎么读它。
一、一条 SQL 在优化器里经历了什么
先建立全局视图。一条 SQL 从进来到出结果,大致走四步:
| 阶段 | 谁负责 | 做什么 |
|---|---|---|
| 解析(Parser) | Server 层 | 词法/语法分析,生成解析树 |
| 预处理(Preprocessor) | Server 层 | 语义检查:表/列是否存在、权限、展开 * |
| 优化(Optimizer) | Server 层 | 逻辑重写 + 成本估算 + 挑选执行计划 |
| 执行(Executor) | Server + 引擎 | 按计划调用引擎接口取数据 |
优化阶段内部又分为两层:
① 逻辑优化(基于规则,一定会做)
| 变换 | 例子 |
|---|---|
| 常量传播 | WHERE b = 5 AND a = b → a = 5 |
| 等值派生 | a = b AND b = c → 三者组成同一个等价集 |
| 琐碎条件移除 | WHERE 1 = 1 被删掉;WHERE 0 = 1 直接判定为空结果集 |
| 外连接化简 | LEFT JOIN + WHERE 右表列 IS NOT NULL → 可化简为 INNER JOIN |
| 派生表合并 | derived_merge 把派生表直接并进外层查询 |
② 物理优化(基于成本,挑最便宜的)
- 每张表用什么访问方式(全表、
ref、range、index scan...) - 多表 JOIN 的顺序
- 是否用索引合并、是否物化子查询、是否用临时表做排序
物理优化是穷举 + 贪心混合搜索:表数量少于某个阈值(默认受 optimizer_search_depth 影响)时近似穷举,
超过则用贪心策略。这就是为什么 10 张表以上的 JOIN 特别容易出坏计划。
二、成本模型:优化器的"账本"
2.1 成本 = IO 成本 + CPU 成本
Cost = Server Cost + Engine Cost
= CPU Cost + IO Cost
- CPU 成本:在 Server 层做事的开销,比如比较索引键值、比较记录、给结果集排序
- IO 成本:从引擎层读数据的开销,MySQL 8.0 能区分"数据在内存中"和"要读磁盘"两种成本
2.2 这些成本常数是可以改的
很多人不知道,MySQL 把成本常数放进了两张可写的系统表:
sql
SELECT * FROM mysql.server_cost;
SELECT * FROM mysql.engine_cost;
mysql.server_cost 的关键项与默认值:
| cost_name | 默认值 | 含义 |
|---|---|---|
row_evaluate_cost |
0.2 | 在 Server 层判断一条记录是否满足条件的成本 |
key_compare_cost |
0.1 | 比较一次索引键值的成本 |
memory_temptable_create_cost |
2.0 | 创建一张内存临时表 |
memory_temptable_row_cost |
0.2 | 往内存临时表写一行 |
disk_temptable_create_cost |
40.0 | 创建一张落盘临时表 |
disk_temptable_row_cost |
1.0 | 往磁盘临时表写一行 |
mysql.engine_cost:
| cost_name | 默认值 | 含义 |
|---|---|---|
io_block_read_cost |
1.0 | 从磁盘读一个页的成本 |
memory_block_read_cost |
1.0 | 从缓冲池读一个页的成本 |
读出几个重要结论:
磁盘临时表的创建成本是内存临时表的 20 倍。
所以一旦 GROUP BY / 排序把结果撑爆了
tmp_table_size,计划成本会突然暴涨一个量级 ------ 这就是很多"莫名变慢"的根因。
2.3 怎么调
SSD / NVMe 普及之后,"读一次磁盘"的成本远低于默认值 1.0,
所以全表扫描被高估了,很多实例上把 io_block_read_cost 调低反而更合理:
sql
UPDATE mysql.engine_cost
SET cost_value = 0.4
WHERE cost_name = 'io_block_read_cost' AND engine_name = 'InnoDB';
FLUSH OPTIMIZER_COSTS; -- 让它生效
-- 恢复默认:把 cost_value 置回 NULL 即可
UPDATE mysql.engine_cost SET cost_value = NULL WHERE cost_name = 'io_block_read_cost';
FLUSH OPTIMIZER_COSTS;
⚠️ 不要在没有充分压测的情况下在生产调这些值。
它会全局改变所有 SQL 的计划选择,救了一片 SQL 的同时很可能打死另一片。
三、三个升级版 EXPLAIN
在讲 trace 之前,先把能被替代掉的简单场景解决掉:
| 形式 | 版本 | 价值 | 代价 |
|---|---|---|---|
EXPLAIN FORMAT=JSON |
5.6+ | 给出 read_cost / eval_cost / prefix_cost,能定量看成本 |
只是估算 |
EXPLAIN FORMAT=TREE |
8.0.16+ | 树形展示,可读性比 JSON 好得多 | 只是估算 |
EXPLAIN ANALYZE |
8.0.18+ | 实际执行 ,给出 actual time 和真实行数 |
⚠️ 会真的跑一遍 SQL |
sql
EXPLAIN FORMAT=TREE SELECT * FROM orders o JOIN user u ON o.uid=u.id WHERE o.status=1;
-- -> Nested loop inner join (cost=1204.10 rows=980)
-- -> Index lookup on o using idx_status (status=1)
-- -> Single-row index lookup on u using PRIMARY (id=o.uid)
EXPLAIN ANALYZE SELECT * FROM orders WHERE status=1;
-- -> Index lookup on orders using idx_status (status=1)
-- (cost=430.25 rows=1024) (actual time=0.041..8.732 rows=9811 loops=1)
EXPLAIN ANALYZE 里 rows=1024 vs actual rows=9811 这个 10 倍落差,
就是统计信息失真的直接证据(那是统计信息与直方图那篇的主题)。
⚠️
EXPLAIN ANALYZE会真正执行 这条 SQL。别在生产的 INSERT / UPDATE / DELETE 上直接跑,除非你明确知道后果。
四、OPTIMIZER_TRACE 上手
4.1 开关与相关变量
sql
SET SESSION optimizer_trace = "enabled=on", end_markers_in_json=on;
SET optimizer_trace_max_mem_size = 10485760; -- 默认仅 1MB,复杂 SQL 很容易被截断
SET optimizer_trace_offset = -10, optimizer_trace_limit = 10; -- 保留最近 10 条
-- 跑你要分析的 SQL
SELECT * FROM orders WHERE shop_id=88 AND created_at > '2026-01-01';
-- 读跟踪结果
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
-- 用完务必关掉
SET SESSION optimizer_trace = "enabled=off";
| 变量 | 默认 | 说明 |
|---|---|---|
optimizer_trace |
enabled=off,one_line=off |
总开关;one_line=on 存成单行省空间但没法看 |
optimizer_trace_features |
全开 | 控制跟踪哪些优化:贪心搜索、range 优化、动态 range、重复子查询 |
optimizer_trace_limit |
1 | 最多展示几条 |
optimizer_trace_offset |
-1 | 偏移量,-1 表示最新一条(配合 limit 很像 LIMIT) |
optimizer_trace_max_mem_size |
1MB | 超了会被截断,复杂 SQL 必须调大 |
end_markers_in_json |
off | 在 JSON 右括号旁加注释方便配对阅读 |
三条注意事项:
- 只能会话级看自己的 :即使
SET GLOBAL开启,每个 session 也只能查到自己执行的语句 - 有性能开销:生产上开全局是灾难,务必 session 级用完就关
- 被截断时结果里会出现
MISSING_BYTES_BEYOND_MAX_MEM_SIZE,调大 max_mem_size 重来
4.2 trace 的骨架
一份完整的 trace 是 steps 数组,典型包含三段:
json
{
"steps": [
{ "join_preparation": { ... } }, // 准备:SQL 被规范化重写
{ "join_optimization": { ... } }, // 优化:核心,绝大多数内容都在这里
{ "join_execution": { ... } } // 执行:只有真正执行时才有
]
}
(EXPLAIN SELECT ... 语句只会有前两段,因为它不真正执行。)
五、逐个段落读懂 trace
5.1 join_preparation:看看 MySQL 把你 SQL 改成了什么样
json
"join_preparation": {
"select#": 1,
"steps": [
{ "expanded_query": "/* select#1 */ select `orders`.`id` AS `id`, ... from `orders` where ..." }
]
}
expanded_query 就是被展开、补全程限名之后的 SQL。
从这里能一眼看出:表名/别名是不是写错了、* 被展开成哪些列、视图有没有按预期 merge。
5.2 condition_processing:条件是怎么被改写的
json
"condition_processing": {
"condition": "WHERE",
"original_condition": "((`orders`.`shop_id` = 88) and (`orders`.`status` = 1))",
"steps": [
{ "transformation": "equality_propagation", "resulting_condition": "..." },
{ "transformation": "constant_propagation", "resulting_condition": "..." },
{ "transformation": "trivial_condition_removal", "resulting_condition": "..." }
]
}
这三个 transformation 是固定的三步曲。看这里能发现:
你写的 WHERE a = b AND a = 5 到底有没有被优化器推导出 b = 5。
5.3 ref_optimizer_key_uses:哪些索引本来可用
json
"ref_optimizer_key_uses": [
{ "table": "`orders`", "field": "shop_id", "equals": "88", "null_rejecting": false },
{ "table": "`orders`", "field": "status", "equals": "1", "null_rejecting": false }
]
这一步列出所有可以做等值 ref 访问 的索引列。
如果你认为某个列应该在这里却不在,说明条件写成了不可用的形式(函数、隐式转换、类型不匹配)。
5.4 rows_estimation → range_analysis:★ 索引选择的决定性战场
这是最重要的段落,也是回答"为什么不用我的索引"的地方:
json
"rows_estimation": [{
"table": "`orders`",
"range_analysis": {
"table_scan": {
"rows": 2838216,
"cost": 286799 // ← 全表扫描的成本
},
"potential_range_indexes": [
{ "index": "PRIMARY", "usable": false, "cause": "not_applicable" },
{ "index": "idx_shop_status_time", "usable": true, "key_parts": ["shop_id","status","created_at"] }
],
"skip_scan_range": {
"potential_skip_scan_indexes": [
{ "index": "idx_shop_status_time", "usable": false, "cause": "query_references_nonkey_column" }
]
},
"analyzing_range_alternatives": {
"range_scan_alternatives": [{
"index": "idx_shop_status_time",
"ranges": ["88 <= shop_id <= 88"],
"index_dives_for_eq_ranges": true,
"index_only": false,
"rows": 86,
"cost": 50.909,
"chosen": true
}],
"analyzing_roworder_intersect": {
"usable": false,
"cause": "too_few_roworder_scans"
}
},
"chosen_range_access_summary": {
"range_access_plan": { "type": "range_scan", "index": "idx_shop_status_time", "rows": 86 },
"rows_for_plan": 86,
"cost_for_plan": 50.909,
"chosen": true
}
}
}]
读法就一句话:把 table_scan.cost 和 chosen_range_access_summary.cost_for_plan 对比。
- 全表扫描 286799 < 索引 50.909 → 走索引
- 反过来若全表扫描更便宜 → 优化器合理地选了全表扫描,你的索引确实不该用
常见的 cause 取值:
| cause | 含义 |
|---|---|
not_applicable |
这个索引压根不适用于该条件 |
too_few_roworder_scans |
无法做 row order 交集(index merge) |
query_references_nonkey_column |
查询引用了索引之外的列,无法做 skip scan / 覆盖扫描 |
heuristic_index_cheaper |
已有更便宜的访问路径,这个被启发式排除了 |
not_group_by_or_distinct |
group_index_range 不适用 |
5.5 considered_execution_plans:JOIN 顺序的决策现场
json
"considered_execution_plans": [{
"plan_prefix": [],
"table": "`orders`",
"best_access_path": {
"considered_access_paths": [
{ "access_type": "ref", "index": "idx_shop", "rows": 86, "cost": 50.412, "chosen": true },
{ "access_type": "range", "range_details": { "used_index": "idx_shop" },
"chosen": false, "cause": "heuristic_index_cheaper" }
]
},
"condition_filtering_pct": 100,
"rows_for_plan": 86,
"cost_for_plan": 50.412,
"chosen": true
}]
considered_access_paths 罗列了优化器比较过的所有路径,每条都带 cost 和 chosen。
多表 JOIN 时会看到嵌套的 plan_prefix,那就是驱动表顺序的搜索过程。
condition_filtering_pct 就是 EXPLAIN 里 filtered 字段的来源。
5.6 attaching_conditions_to_tables 与 refine_plan
json
"attaching_conditions_to_tables": {
"attached_conditions_summary": [
{ "table": "`orders`", "attached": "((`orders`.`status` = 1))" }
]
},
"refine_plan": [ { "table": "`orders`" } ]
这一步确认:哪些条件跟着表走(可以 push down 到引擎),哪些留在 Server 层。
如果本该由 ICP 处理的条件没出现在 attached 里,ICP 就不会生效。
六、实战案例:为什么有索引却全表扫描
6.1 现象
sql
EXPLAIN SELECT * FROM orders WHERE created_at > '2026-09-01';
-- type: ALL, key: NULL, rows: 2800000
created_at 上明明有 idx_created_at。
6.2 抓 trace 找答案
sql
SET SESSION optimizer_trace="enabled=on", end_markers_in_json=on;
SET optimizer_trace_max_mem_size=10485760;
SELECT * FROM orders WHERE created_at > '2026-09-01';
SELECT QUERY, TRACE FROM information_schema.OPTIMIZER_TRACE\G
SET SESSION optimizer_trace="enabled=off";
在 rows_estimation.range_analysis 里会看到类似:
json
"analyzing_range_alternatives": {
"range_scan_alternatives": [{
"index": "idx_created_at",
"ranges": ["0x99b201 <= created_at"],
"rows": 950000, // ← 优化器认为要返回 95 万行
"cost": 228050,
"chosen": true
}],
...
},
"chosen_range_access_summary": null // ← 最终没选它
对照 table_scan.cost:
json
"table_scan": { "rows": 2838216, "cost": 286799 }
结论 :优化器认为范围要返回 95 万行(占全表 33%),
走索引要 95 万次回表,成本 228050 接近全表扫描的 286799,
再叠加回表的随机 IO 惩罚,最终选了全表扫描。
这时候正确的动作不是 加 FORCE INDEX,而是三选一:
| 现象 | 真正的问题 | 解法 |
|---|---|---|
| 实际只返回 5000 行,预估 95 万 | 统计信息失真 | ANALYZE TABLE / 加直方图 |
| 实际真的返回 95 万行 | 这是合理选择 | 改业务逻辑,加更强的过滤条件或做分页 |
| 确实要用索引且只取少量列 | 回表太贵 | 做成覆盖索引(见回表那篇) |
6.3 第二个案例:range 被 heuristic 排除
trace 里经常看到:
json
{ "access_type": "range", "chosen": false, "cause": "heuristic_index_cheaper" }
这不是错误。MySQL 有个启发式规则:能用 ref(等值)就用 ref,它通常比等价的 range 更便宜。
看到这个 cause 不用慌,说明优化器已经找到了更好的路。
真正要警惕的是 "chosen": false 且没有 cause,或者 potential_range_indexes 里
你期望的索引直接 usable: false ------ 那多半是条件写法有问题。
七、实在说服不了优化器时的干预手段
7.1 optimizer_switch
sql
SELECT @@optimizer_switch\G
SET SESSION optimizer_switch='derived_merge=off';
常用的几项:
| 开关 | 默认 | 什么时候要动它 |
|---|---|---|
index_condition_pushdown |
on | 排查 ICP 是否生效时临时关掉对比 |
derived_merge |
on | 派生表被过早 merge 导致慢时关掉,让它先物化 |
condition_fanout_filter |
on | 8.0 的多列级别的选择率估算,怀疑估算被它搞坏时关掉验证 |
mrr / mrr_cost_based |
on | 调 MRR 行为做对比实验 |
semijoin / materialization |
on | 子查询 SEMI JOIN 转换出问题时逐个关掉排查 |
prefer_ordering_index |
on(8.0.21+) | ORDER BY ... LIMIT 选错索引的头号嫌疑对象 |
skip_scan |
on(8.0.20+) | 不走期望索引时检查是不是它捣的乱 |
7.2 传统 / 新版 hint
sql
SELECT * FROM orders FORCE INDEX(idx_created_at) WHERE created_at > '2026-09-01';
SELECT * FROM orders IGNORE INDEX(idx_created_at) WHERE created_at > '2026-09-01';
SELECT STRAIGHT_JOIN o.*, u.name FROM orders o JOIN user u ON o.uid=u.id WHERE ...;
-- MySQL 8.0 优化器提示(写在 /*+ */ 里)
SELECT /*+ NO_ICP(orders) SET_VAR(sort_buffer_size=8M) */ * FROM orders WHERE ...;
SELECT /*+ JOIN_ORDER(orders, user) */ * FROM orders o JOIN user u ON o.uid=u.id;
SELECT /*+ MAX_EXECUTION_TIME(1000) */ * FROM orders WHERE ...; -- 超时限制,毫秒
优先级:hint > optimizer_switch > 系统变量 > 默认。
使用原则 :hint 是止血手段,不是优化方案。
只要在 SQL 里写了 hint,就等于把这个 SQL 的计划"锁死"了,
后续数据分布变化、版本升级都不会重新评估。写之前一定在注释里写明原因。
八、三个常见误区
误区 1:trace 里 chosen: false 就是优化器出错了
------绝大部分 chosen: false 是正常取舍 ,而且旁边几乎都有一个 cause 字段解释原因。
真正的信号是:你的 SQL 结果很差,但 trace 里没看到对你有利的路径被评估过。
误区 2:EXPLAIN ANALYZE 可以随便在生产跑
------它会真的执行 这条 SQL,包括 INSERT / UPDATE / DELETE。
在生产上只用它分析 SELECT,写操作请在测试环境用等价数据量复现。
误区 3:改成本常数能解决选错索引
------改 mysql.server_cost / engine_cost 是全局 行为,
影响的不仅有出问题的那条 SQL,还有所有正常 SQL。
这是最后手段,且在动之前必须先在压测环境全量回归。
九、小结
- MySQL 优化器是 CBO ,决策链路是:逻辑重写 → 物理路径成本估算 → 挑最便宜的
- 成本 = IO 成本 + CPU 成本 ,成本常数写在
mysql.server_cost/mysql.engine_cost里,可调 (改完FLUSH OPTIMIZER_COSTS) - 磁盘临时表创建成本是内存临时表的 20 倍,这是很多"莫名变慢"的根源
EXPLAIN的三件套:FORMAT=JSON(5.6+ 看 cost)、FORMAT=TREE(8.0.16+ 可读性好)、ANALYZE(8.0.18+ 真实执行,给实际行数)- OPTIMIZER_TRACE(5.6 引入) 记录优化全过程,存于
information_schema.OPTIMIZER_TRACE;务必 session 级开启、用完关闭 - 常见坑:
optimizer_trace_max_mem_size默认仅 1MB,复杂 SQL 极易被截断 - trace 主干三段:
join_preparation(SQL 被重写成什么样)、join_optimization(重点 )、join_execution join_optimization里最该看的两个地方:rows_estimation → range_analysis:对比table_scan.cost与索引方案cost_for_plan,立刻知道索引该不该用considered_execution_plans:看每条路径的cost/chosen/cause,理解 JOIN 顺序的选择
- 常见
cause:not_applicable、heuristic_index_cheaper(正常)、query_references_nonkey_column、too_few_roworder_scans chosen_range_access_summary为null+ 全表扫描更便宜 = 优化器没算错,是你的过滤条件太弱- 干预手段优先级:改 SQL / 改索引 > 修统计信息 > optimizer_switch > hint ;
FORCE INDEX只是止血 - 8.0.21 起的
prefer_ordering_index是ORDER BY ... LIMIT选错索引的头号嫌疑人