MySQL 优化器与 OPTIMIZER_TRACE:把‘为什么选这条计划‘问到水落石出

个人主页: > 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 右括号旁加注释方便配对阅读

三条注意事项:

  1. 只能会话级看自己的 :即使 SET GLOBAL 开启,每个 session 也只能查到自己执行的语句
  2. 有性能开销:生产上开全局是灾难,务必 session 级用完就关
  3. 被截断时结果里会出现 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 选错索引的头号嫌疑人
相关推荐
j7~1 小时前
【仿 RabbitMQ 项目】发布订阅消息队列 (篇九):队列数据管理,内存镜像 + SQLite 持久化实现
数据库·c++·分布式·sqlite·rabbitmq·队列数据管理
l1t1 小时前
DeepSeek总结的PostgreSQL中TABLESAMPLE 如何挑选行
数据库·sql
qyyyyy5702 小时前
WhatsApp 联系人表格怎么清洗?Excel 文本类型、重复号码与姓名冲突处理
数据库·自然语言处理·excel·hash
云贝贝贝2 小时前
Oracle 认证实战指南
数据库·oracle
靠沿2 小时前
MySQL数据库基础——事务
java·数据库·mysql
AIFQuant2 小时前
Python港股实时行情API接入:恒生股票数据获取与AH股溢价监控实战
开发语言·数据库·python·金融·实时数据api
夜雪一千2 小时前
MySQL 中查询和操作JSON类型的数据
数据库·mysql·json
达梦数据2 小时前
达梦数据复制软件DMDRS搭建部署示例:源数据库DMDPC到目标数据库DM8一对一数据同步
数据库·oracle
AIgorithmGEEK10 小时前
MySQL 索引全解(下):建、查、删实战与知识速查表
数据库·mysql·b+树·索引