MySQL 优化器 深度解析

数据结构 · 核心功能 · 工作流程 · 代价模型 · 优化策略 · 实战调优

🧠 优化器是什么

MySQL 优化器(Optimizer)是介于解析器执行器之间的决策引擎——它拿到 SQL 的"解析树"后,在真正执行前,花时间思考"最好的执行方式"是什么。
定位
SQL 引擎中间层,负责制定最优查询执行计划
输入
Parser 输出的查询语法树(AST) + 统计信息
输出
一条或多条执行计划,每条附代价评分
核心矛盾
计划搜索空间(指数级)vs 优化时间(必须快)
Parser 解析器 Optimizer 优化器 ★ 决策中枢 基于代价 / 规则 / 统计信息 Executor 执行器 Storage Engine InnoDB / MyISAM 统计信息存储 innodb_table_stats mysql.server_cost
关键认知:优化器并不真正执行查询,它只做"规划"——相当于地图导航规划路线,而 Executor 是实际开车的司机。优化时间通常只有几毫秒,但这几毫秒的决策决定了查询执行几分钟还是几秒。

⚖️ 两种优化模式:RBO vs CBO

✅ RBO — 基于规则的优化器(Rule-Based)

  • 按固定规则优先级决定执行顺序,不关心数据分布
  • 规则示例:索引扫描 > 范围扫描 > 全表扫描
  • MySQL 5.5 及之前主要使用
  • 简单、稳定,但无法适应数据倾斜
  • 可预测:相同 SQL → 相同计划

✅ CBO — 基于代价的优化器(Cost-Based)MySQL 5.6+ 默认

  • 枚举多个执行计划,计算每个计划的 IO+CPU 代价
  • 依赖表/索引统计信息(行数、基数、页数)
  • 能感知数据倾斜:区分度高→选索引,低→扫全表
  • 可能选错计划:统计信息过时时会出 bug
  • MySQL 8.0 引入了直方图进一步精确代价估算
RBO — 固定规则优先级 Rule 1 主键/唯一索引 等值查询 成本 1 Rule 2 非唯一索引 范围扫描 成本 2 Rule 3 索引 全扫描 成本 3 Rule N 全表扫描 ALL 成本 N 按优先级直接选规则,不比较实际代价 CBO — 枚举计划 · 代价最小 Plan A idx_status 范围扫描 → 500行 cost=0.35 ✅ Plan B 全表扫描 → 100万行 cost=4200 Plan C idx_name 全扫描 → 100万行 cost=3800 枚举所有计划,选取总代价最低的方案 选中!

🗂️ 优化器的数据结构

MySQL 优化器内部围绕三个核心对象展开:Query_block(查询块)→ JOIN(连接)→ JOIN_TAB(连接表),每层封装不同粒度的优化信息。

Query_block(查询块) SELECT / FROM / WHERE / GROUP BY / HAVING / ORDER BY 8.0 前称 SELECT_LEX,SQL 解析后生成 JOIN(连接对象) • tables_count(参与表数) • best_read(最优代价)/ best_rowcount(最优行数) • best_ref[](最优 JOIN 顺序)/ const_table_map(常量表) JOIN_TAB[0] — 驱动表 • table:TABLE* 表对象 • type:访问类型 ALL/ref/range • key_use:候选索引列表 records / read_time / condition JOIN_TAB[1] — 被驱动表 • table:TABLE* 表对象 • type:访问类型 eq_ref/ref • key_use:候选索引列表 records / read_time / condition JOIN_TAB[N-1] — 被驱动表 • table:TABLE* 表对象 • type:访问类型 ref • key_use:候选索引列表 records / read_time / condition 统计信息 ha_rows records double read_time

关键数据结构一览

// sql/sql_optimizer.h — 核心数据结构

// 查询块:代表一个 SELECT 语句的所有信息
struct Query_block {
    LEX           *lex;            // 词法分析上下文指针
    TABLE_LIST      *leaf_tables;    // 叶子表(去掉派生表的真实表)
    Item            *where_cond;     // WHERE 条件 Item 树
    ORDER           *group_list;     // GROUP BY 链表
    ORDER           *order;          // ORDER BY 链表
    JOIN            *join;           // 指向关联优化对象
    Key_usage       *key_usage_array;// 所有可用的索引访问方式
    uint            select_num;      // 查询块序号(1,2,3...)
};

// JOIN:整个查询的连接对象,管理所有表的优化决策
struct JOIN {
    THD             *thd;            // 线程句柄
    JOIN_TAB        *join_tab;       // 所有表的 JOIN_TAB 数组
    uint            tables;         // 参与 JOIN 的表总数
    uint            const_tables;   // 被判定为常量(最多1行)的表数
    double          best_read;       // 最优计划的估算总代价
    ha_rows         best_rowcount;   // 最优计划估算的总行数
    JOIN_TAB        *best_ref[];     // 最优连接顺序的表数组
};

// JOIN_TAB:每张参与 JOIN 的表在优化器中的抽象
struct JOIN_TAB {
    TABLE           *table;          // 关联的物理表对象
    Key_use         *keyuse;        // 此表可用的所有索引访问方式
    ha_rows          records;       // 估算结果行数(来自统计信息)
    double           read_time;     // 此表访问代价估算
    enum join_type  type;         // ALL / ref / range / eq_ref...
    Key_AND         *const_keys;    // 可用常量等值连接的索引
    Item             *condition;    // WHERE 过滤条件
    enum enum_impossible read_cost; // 是否可直接排除
};

🔄 优化器完整工作流程

MySQL 优化器的工作分为预处理(逻辑优化)和代价优化(物理优化)两大阶段,中间穿插多次改写。
【解析层】 SQL 解析 词法 + 语法 → Query_block 【逻辑优化】 ① 预处理 条件化简 · 常量折叠 · 视图合并 ② 谓词下推 WHERE 条件下推到存储引擎层 ③ 子查询改写 IN/EXISTS → JOIN,半连接优化 ④ 派生表合并 FROM (SELECT...) → 内联到主查询 【物理优化 · 代价计算】 ⑤ 访问路径枚举 每张表:全表 / 索引扫描 / Range ⑥ 常量表识别 主键/唯一索引等值 → const 标记 ⑦ JOIN 顺序优化 DP 穷举 / 贪心搜索最优连接顺序 ⑧ 代价计算 IO_cost + CPU_cost → best_read 【输出】 ⑨ 执行计划 最优 JOIN 顺序 • 每张表的访问路径 • 使用的索引 • 每步估算行数 • 排序/聚合方式 best_read = 总代价 【验证】 计划验证 EXPLAIN 输出 Executor 执行器 返回结果集 图例 逻辑优化 物理优化 输出/执行

各阶段详解

预处理 · Const Reuction

WHERE id = 5 这类主键等值条件在优化前就"物化"为常量,节省后续优化时间。如果找不到任何行,直接标记 Impossible 跳过执行。

谓词下推(Predicate Pushdown)

将 WHERE 条件下推到离数据源最近的地方执行,减少中间结果集行数。例如 JOIN 时把 t2 的条件直接写入 t2 的扫描层,而非等到 JOIN 后再过滤。

子查询改写

MySQL 5.6+ 会将 IN (SELECT...) 转为半连接(Semi Join),避免子查询结果物化导致的性能退化。无法改写的子查询则用 EXISTS 策略执行。

访问路径枚举(Access Path)

对每张表枚举所有可能的访问方式:全表扫描 / 索引扫描 / 索引范围扫描,计算每种方式的 read_time。通过 records/key_len/condition 估算。

JOIN 顺序优化 — DP 算法

当表数量 ≤ optimizer_search_depth(默认 7)时,使用动态规划穷举所有排列,选最优。当超过时退化为贪心算法,每步选使总代价下降最多的表作为下一张表。

⚙️ 核心模块解析

对每张表枚举可能的索引访问方式,计算每种方式的读取代价:

访问方式触发条件代价估算公式优先级
const 主键/唯一索引 等值,返回 ≤1 行 ≈ 1(一次性读取,无需扫描) ⭐⭐⭐ 最高
eq_ref 被驱动表,JOIN 时唯一索引等值 外层行数 × 1次唯一查找 ⭐⭐⭐ 高
ref 非唯一索引等值,可返回多行 外层行数 × 索引扫描 + 回表 ⭐⭐ 中高
range BETWEEN / IN / > < 等范围条件 范围行数 × 索引读取 + 回表 ⭐⭐ 中
index 全索引扫描(不回表) 索引页数 × IO_cost ⭐ 低
ALL 无索引可用时 表总页数 × IO_cost + 行数 × CPU_cost ❌ 最高代价

JOIN 顺序是影响性能最显著的因素之一。优化器通过穷举/贪心找出最优连接顺序:

JOIN 顺序枚举 — 3 表 DP 示例 A JOIN B JOIN C 3张表,6种排列组合 (3-1)! = 3! / 3 = 6 DP 动态规划表 OPT({A,B}) + C / OPT({A,C}) + B / OPT({B,C}) + A 每项 = min(连接代价 + 子问题最优) 最优顺序 B → A → C cost=min(所有排列) 排列组合爆炸(需要枚举的计划数):
2 表
1
1 种顺序
3 表
3
3 种顺序
4 表
12
12 种顺序
5 表
60
60 种顺序
7 表
6,720
6720 种顺序
10 表
1,812,800
181 万种!
optimizer_search_depth ≤ 表数时:穷举 DP 穷举所有排列,取代价最小者。保证最优解,但表数多时耗时大 optimizer_search_depth 默认值:62(MySQL 8.0),62表以上强制贪心 optimizer_search_depth > 表数时:贪心算法 每步选使当前代价最小的表加入,逐步构建连接顺序 不保证全局最优,但速度快。复杂查询推荐调高该参数

优化器决策高度依赖统计信息,主要来源:

innodb_table_stats
表级别统计:总行数、数据页数、索引页数、修改时间
innodb_index_stats
索引级别统计:索引树深度、叶子节点数、基数(Cardinality)
STATISTICS 表
SHOW INDEX FROM t 的底层存储,Cardinality 是索引选择的关键
直方图(Histograms)
MySQL 8.0+,单列数据分布(等宽/等高/JSON),精确估算过滤后行数
-- 查看表统计信息
SELECT table_name, n_rows, clustered_index_size, sum_of_other_index_sizes
FROM mysql.innodb_table_stats
WHERE database_name = 'your_db' AND table_name = 'orders';

-- 查看索引基数(Cardinality)— 索引选择决策的核心
SHOW INDEX FROM orders;

-- 手动重新收集统计信息(统计信息过期时使用)
ANALYZE TABLE orders;

-- MySQL 8.0 直方图
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, create_time;

-- 查看直方图
SELECT * FROM information_schema.COLUMN_STATISTICS;
⚠️ 统计信息过期是优化器选错计划最常见的原因! 大批量 INSERT/UPDATE/DELETE 后,需要 ANALYZE TABLE 重新收集。

MySQL 代价模型将每次 I/O 读写和每次 CPU 处理量化为数值:

-- 优化器代价常量存储在 MySQL 内部
SELECT * FROM mysql.server_cost;    -- 服务器级代价
SELECT * FROM mysql.engine_cost;     -- 引擎级代价(InnoDB / MyISAM 可分别配置)

-- 代价计算示例
总代价 = IO_cost + CPU_cost
       = (页读取数 × page_io_cost) + (行处理数 × row_cpu_cost)
       + (key比较数 × key_cpu_cost)

-- 默认代价常量(可调整)
page_io_cost  = 1.00    -- 读一个数据页的 IO 代价
row_cpu_cost  = 0.10    -- 处理一行的 CPU 代价
key_cpu_cost  = 0.05    -- 一次 key 比较的 CPU 代价
memory_temp   = 1.00    -- 内存临时表页代价
disk_temp      = 5.00     -- 磁盘临时表页代价(5倍内存)
代价调优:通过调整 server_cost / engine_cost 表中的参数,可以让优化器更倾向索引扫描(提高 row_cpu_cost)或更倾向全表扫描(降低 page_io_cost)。

🎯 优化器核心策略图谱

优化器内置了数十种优化规则,这里展示最核心的 12 种策略及其执行时机:

CBO 优化器 代价最小化 ① ICP 索引条件下推 Index Condition Pushdown WHERE 下推到索引层过滤 ② MRR 多范围读取 Multi-Range Read 批量排序主键后回表,顺序IO ③ BKA 批KEY访问 Batched Key Access JOIN 时批量查驱动表结果集 ④ 子查询半连接 Semi Join Transformation IN(SELECT...) → JOIN,避免物化 ⑤ 派生表合并 Derived Table Merge FROM (SELECT...) 内联化 ⑥ 谓词下推 Predicate Pushdown WHERE 推到数据源层过滤 ⑦ OR 扩展 OR 到 UNION ALL 改写 WHERE col=1 OR col=2 → UNION ALL ⑧ 常量折叠 Constant Folding WHERE 1+1=2 → 提前计算为常量 ⑨ 表消除 Table Elimination SELECT t1.id FROM t1 LEFT JOIN t2 ⑩ 覆盖索引识别 Covering Index SELECT 列全在索引 → Using index ⑪ Hash Join(8.0+) Hash Join 内存构建哈希表,无索引时备选 ⑫ 窗口函数优化 Window Function Optimization ROW_NUMBER() OVER 分区剪枝

🎛️ 优化器 Hint 提示系统

当优化器选错计划时,可用 Hint 强制指定执行策略。Hint 优先级高于优化器决策,但如果 Hint 引用的索引不存在则会被忽略。
Hint作用示例
USE INDEX / FORCE INDEX / IGNORE INDEX 强制使用 / 忽略指定索引 SELECT * FROM t USE INDEX(idx_name) WHERE...
STRAIGHT_JOIN 按 SQL 书写顺序固定 JOIN 顺序 SELECT STRAIGHT_JOIN * FROM t1 JOIN t2...
ORDER BY / GROUP BY 直接使用索引满足 ORDER BY / GROUP BY,避免排序 建复合索引覆盖列顺序
SET optimizer_switch 全局开关各优化策略(ICP/MRR/BKA等) SET optimizer_switch='mrr=on,index_condition_pushdown=on'
SET optimizer_switch='batched_key_access=off' 关闭 BKA(出 bug 时临时关闭) 关闭后回退到 Nested Loop Join
SET optimizer_hint MySQL 8.0 Hint API(更精细化控制) SET optimizer_hint='max_seeks_for_key=1000'

optimizer_switch 开关一览

index_condition_pushdown
索引条件下推(ICP)
mrr
多范围读取优化(MRR)
batched_key_access
批 KEY 访问(BKA)
materialization
子查询物化(关闭后子查询用 EXISTS)
semijoin
子查询半连接优化
derived_merge
派生表合并到主查询
hash_join
Hash Join(MySQL 8.0+)
subquery_to_derived
子查询转派生表(改写)

💡 点击开关可交互模拟(实际效果需在 MySQL 中执行 SET 语句)

🔧 调优实战 Checklist

❌ 症状:type = ALL
全表扫描,扫描行数 = 表总行数。
→ 优先检查 WHERE 列是否有索引
→ 检查统计信息是否过期
⚠️ 症状:Using filesort
ORDER BY 无法利用索引顺序。
→ 建复合索引覆盖 WHERE + ORDER BY 列
→ 或建足够小的索引让排序在内存完成
⚠️ 症状:Using temporary
GROUP BY / DISTINCT 产生临时表。
→ GROUP BY 列建索引
→ 考虑在应用层聚合
❌ 症状:rows 数值巨大
EXPLAIN rows 远大于实际返回行数。
→ 统计信息过时,ANALYZE TABLE
→ 或建直方图(MySQL 8.0+)
🔍 症状:key = NULL 但 possible_keys 非空
优化器考虑了索引但没选。
→ 可能是统计信息偏差
→ 或回表代价高于全扫
→ 用 FORCE INDEX 强制测试
✅ 目标状态
type ≥ ref/range
key 显示实际使用的索引
rows 尽量小
Extra 无 filesort/temporary

诊断黄金命令

-- 1. 查看执行计划
EXPLAIN SELECT ...;

-- 2. 真实执行分析(MySQL 8.0+)— 会真正执行并返回实际时间
EXPLAIN ANALYZE SELECT ...;

-- 3. JSON 格式,代价分解(MySQL 5.7+)
EXPLAIN FORMAT=JSON SELECT ...;

-- 4. 查看优化器最终选择的 JOIN 顺序
EXPLAIN FORMAT=JSON SELECT o.*, u.name
FROM orders o
JOIN users u ON o.user_id = u.id;

-- 5. 强制使用某个索引(测试用)
SELECT * FROM orders FORCE INDEX(idx_user_id) WHERE user_id = 123;

-- 6. 查看统计信息状态
SHOW TABLE STATUS FROM your_db LIKE 'orders';
SHOW INDEX FROM orders;

-- 7. 重新收集统计信息
ANALYZE TABLE orders;

-- 8. 查看优化器开关状态
SELECT @@optimizer_switch;

-- 9. 查看搜索深度(超过此值使用贪心)
SELECT @@optimizer_search_depth;

-- 10. 临时修改优化器参数(会话级)
SET @@session.optimizer_search_depth = 7;
SET @@session.optimizer_switch = 'mrr=off';