MySQL 执行原理深度剖析:查询成本计算与优化器内幕

关键词:MySQL, 查询优化器, 执行原理, 成本计算, Explain, 子查询优化, 物化表, 性能优化


前言

你是否好奇过 MySQL 是如何在众多执行方案中选择最优路径的?

为什么有时候明明有索引,MySQL 却选择了全表扫描?

为什么子查询性能如此糟糕,而改成 JOIN 后速度飞升?

本文将带你深入 MySQL 查询优化器的核心内幕,揭秘查询成本计算的原理、执行计划生成的全过程,以及子查询优化的神奇机制。掌握这些底层原理,你将能够写出更高效、更符合 MySQL"口味"的 SQL 语句。


目录

  1. [MySQL 查询成本详解](#MySQL 查询成本详解)
  2. 单表查询成本分析实战
  3. [Explain 与查询成本](#Explain 与查询成本)
  4. 连接查询的成本分析
  5. 调节成本常数
  6. [MySQL 查询重写规则](#MySQL 查询重写规则)
  7. 子查询优化内幕
  8. 总结

一、MySQL 查询成本详解

1.1 什么是查询成本

MySQL 执行一个查询可以有多种不同的执行方案,它会选择其中成本最低的那种方案去真正执行查询。在 MySQL 中,一条查询语句的执行成本由两个方面组成:

💾 I/O 成本

表的数据和索引都存储在磁盘上,查询时需要先把数据或索引加载到内存中。这个从磁盘到内存的加载过程损耗的时间称之为 I/O 成本

⚙️ CPU 成本

读取以及检测记录是否满足搜索条件、对结果集进行排序等操作损耗的时间称之为 CPU 成本

1.2 成本常数

MySQL 默认的成本常数:

成本类型 默认值 说明
读取一个页面 1.0 I/O 成本,InnoDB 中页是磁盘和内存交互的基本单位
检测一条记录 0.2 CPU 成本,无论是否需要检测搜索条件

注意:不管读取记录时需不需要检测是否满足搜索条件,哪怕是空数据,其成本都算是 0.2。


二、单表查询成本分析实战

在一条单表查询语句真正执行之前,MySQL 的查询优化器会:

  1. 找出执行该语句所有可能使用的方案
  2. 对比之后找出成本最低的方案(即执行计划
  3. 调用存储引擎提供的接口真正执行查询

2.1 实战案例

sql 复制代码
SELECT * FROM order_exp 
WHERE order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S') 
AND expire_time > '2021-03-22 18:28:28' 
AND expire_time <= '2021-03-22 18:35:09' 
AND insert_time > expire_time 
AND order_note LIKE '%7****排1%' 
AND order_status = 0;

2.2 步骤一:找出所有可能使用的索引

分析各个搜索条件:

搜索条件 可能使用的索引 说明
order_no IN (...) idx_order_no ✓ 可以使用二级索引
expire_time 范围 idx_expire_time ✓ 可以使用二级索引
insert_time > expire_time - ✗ 索引列未与常数比较
order_note LIKE '%...' - ✗ 通配符开头无法使用索引
order_status = 0 - ✗ 不符合最左前缀原则

可能使用的索引(possible_keys)idx_order_no, idx_expire_time

2.3 步骤二:计算全表扫描的代价

需要两个统计信息:

  • 聚簇索引占用的页面数
  • 该表中的记录数

查看表的统计信息:

sql 复制代码
SHOW TABLE STATUS LIKE 'order_exp'\G

关键字段解释:

  • Rows:表中的记录条数(InnoDB 是估计值)
  • Data_length:表占用的存储空间字节数

计算聚簇索引的页面数量:

复制代码
聚簇索引页面数 = Data_length ÷ 16 ÷ 1024 = 97
全表扫描成本计算:

I/O 成本

复制代码
97 × 1.0 + 1.1 = 98.1

(97 是页面数,1.0 是加载一个页面的成本,1.1 是微调值)

CPU 成本

复制代码
10350 × 0.2 + 1.0 = 2071

(10350 是估计的记录数,0.2 是检测一条记录的成本)

总成本

复制代码
98.1 + 2071 = 2169.1

2.4 步骤三:计算使用不同索引的成本

使用 idx_expire_time 的成本分析

范围区间:('2021-03-22 18:28:28', '2021-03-22 18:35:09')

I/O 成本

复制代码
1.0(范围区间数量)+ 39 × 1.0(预估回表次数)= 40.0

CPU 成本

复制代码
39 × 0.2(读取二级索引)+ 0.01 + 39 × 0.2(回表后检测)= 15.61

总成本

复制代码
40.0 + 15.61 = 55.61
使用 idx_order_no 的成本分析

3 个单点区间,需要回表的记录数为 58。

I/O 成本

复制代码
3.0 + 58 × 1.0 = 61.0

CPU 成本

复制代码
58 × 0.2 + 58 × 0.2 + 0.01 = 23.21

总成本

复制代码
61.0 + 23.21 = 84.21

2.5 步骤四:对比成本,选择最优方案

执行方案 成本
全表扫描 2169.1
使用 idx_expire_time 55.61
使用 idx_order_no 84.21

结论 :选择 idx_expire_time 执行查询,因为它的成本最低。


三、Explain 与查询成本

3.1 查看执行计划的 JSON 格式成本

MySQL 提供了查看执行计划成本的方式,在 EXPLAIN 后加上 FORMAT=JSON

sql 复制代码
EXPLAIN FORMAT=JSON 
SELECT * FROM order_exp 
WHERE order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S') 
AND expire_time > '2021-03-22 18:28:28' 
AND expire_time <= '2021-03-22 18:35:09'\G

JSON 输出中包含 query_cost 字段,显示该执行计划的总成本。

3.2 Optimizer Trace:查看优化器全过程

MySQL 5.6+ 提供了 optimizer trace 功能,可以查看优化器生成执行计划的整个过程。

开启 Optimizer Trace
sql 复制代码
-- 查看当前状态
SHOW VARIABLES LIKE 'optimizer_trace';

-- 开启(session 级别)
SET optimizer_trace = 'enabled=on';

-- 关闭
SET optimizer_trace = 'enabled=off';

⚠️ 注意:开启 trace 会影响 MySQL 性能,只能临时分析 SQL 使用,用完立即关闭。

使用 Optimizer Trace
sql 复制代码
-- 1. 开启 trace
SET optimizer_trace = 'enabled=on';

-- 2. 执行要分析的查询
SELECT * FROM order_exp 
WHERE order_no IN ('DD00_6S', 'DD00_9S', 'DD00_10S')
AND expire_time > '2021-03-22 18:28:28' 
AND insert_time > '2021-03-22 18:35:09' 
AND order_note LIKE '%7****排1%';

-- 3. 查看优化过程
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
优化过程的三个阶段
阶段 说明
prepare 准备工作
optimize 基于成本的优化(主要关注此阶段)
execute 执行

optimize 阶段:

  • 单表查询关注 "rows_estimation" 过程
  • 多表连接查询关注 "considered_execution_plans" 过程

四、连接查询的成本分析

4.1 Condition Filtering(条件过滤)

MySQL 连接查询采用嵌套循环连接算法

  • 驱动表被访问一次
  • 被驱动表可能被访问多次

驱动表的扇出(fanout):对驱动表查询后得到的记录条数。

扇出值越小,对被驱动表的查询次数越少,连接查询的总成本越低。

扇出值计算示例

示例 1

sql 复制代码
SELECT * FROM order_exp AS s1 INNER JOIN order_exp2 AS s2;
  • 使用全表扫描,扇出值 = 驱动表的记录数

示例 2

sql 复制代码
SELECT * FROM order_exp AS s1 
INNER JOIN order_exp2 AS s2 
WHERE s1.expire_time > '2021-03-22 18:28:28' 
AND s1.expire_time <= '2021-03-22 18:35:09';
  • 使用索引,扇出值 = 范围区间中的记录数

示例 3

sql 复制代码
SELECT * FROM order_exp AS s1 
INNER JOIN order_exp2 AS s2 
WHERE s1.order_note > 'xyz';
  • 优化器只能猜测满足条件的记录数(condition filtering)

4.2 两表连接的成本公式

复制代码
连接查询总成本 = 单次访问驱动表的成本 + 驱动表扇出数 × 单次访问被驱动表的成本

对于内连接,驱动表和被驱动表的位置可以互换,需要考虑:

  1. 不同的表作为驱动表,查询成本可能不同
  2. 分别为驱动表和被驱动表选择成本最低的访问方法

4.3 多表连接的成本分析

n 表连接有 n! 种连接顺序。

优化策略

策略 1:提前结束高成本方案

维护一个全局变量表示当前最小成本,如果某连接顺序的成本已经超过该值,就不再继续分析。

策略 2:optimizer_search_depth

sql 复制代码
SHOW VARIABLES LIKE 'optimizer_search_depth';
  • 如果连接表个数小于该值:穷举分析所有连接顺序
  • 否则:只对与该值相同数量的表进行穷举分析

值越大,成本分析越精确,但消耗时间越长。

策略 3:启发式规则

MySQL 提供了一些经验规则,不满足规则的连接顺序直接不分析。

sql 复制代码
SHOW VARIABLES LIKE 'optimizer_prune_level';

控制是否使用启发式规则来减少需要分析的连接顺序数量。


五、调节成本常数

5.1 成本常数存储表

MySQL 将成本常数存储在两个表中:

sql 复制代码
SHOW TABLES FROM mysql LIKE '%cost%';
表名 说明
server_cost Server 层操作的成本常数
engine_cost 存储引擎层操作的成本常数

5.2 mysql.server_cost 表

sql 复制代码
SELECT * FROM mysql.server_cost;
成本常数 默认值 说明
disk_temptable_create_cost 40.0 创建磁盘临时表的成本
disk_temptable_row_cost 1.0 向磁盘临时表读写一条记录的成本
key_compare_cost 0.1 两条记录比较操作的成本(用于排序)
memory_temptable_create_cost 2.0 创建内存临时表的成本
memory_temptable_row_cost 0.2 向内存临时表读写一条记录的成本
row_evaluate_cost 0.2 检测一条记录是否符合搜索条件的成本

修改成本常数

sql 复制代码
-- 更新成本常数
UPDATE mysql.server_cost 
SET cost_value = 0.5 
WHERE cost_name = 'row_evaluate_cost';

-- 重新加载
FLUSH OPTIMIZER_COSTS;

-- 恢复默认值
UPDATE mysql.server_cost 
SET cost_value = NULL 
WHERE cost_name = 'row_evaluate_cost';

FLUSH OPTIMIZER_COSTS;

5.3 mysql.engine_cost 表

sql 复制代码
SELECT * FROM mysql.engine_cost;
成本常数 默认值 说明
io_block_read_cost 1.0 从磁盘读取一个块的成本
memory_block_read_cost 1.0 从内存读取一个块的成本

说明:目前 MySQL 不能准确预测块是否在内存中,所以默认值相同。


六、MySQL 查询重写规则

MySQL 查询优化器会竭尽全力将糟糕的语句转换成高效执行的形式,这个过程称为查询重写

6.1 条件化简

1. 移除不必要的括号
sql 复制代码
-- 原始
((a = 5 AND b = c) OR ((a > c) AND (c < 5)))

-- 化简
(a = 5 and b = c) OR (a > c AND c < 5)
2. 常量传递(Constant Propagation)
sql 复制代码
-- 原始
a = 5 AND b > a

-- 化简
a = 5 AND b > 5
3. 等值传递(Equality Propagation)
sql 复制代码
-- 原始
a = b AND b = c AND c = 5

-- 化简
a = 5 AND b = 5 AND c = 5
4. 移除没用的条件
sql 复制代码
-- 原始
(a < 1 AND b = b) OR (a = 6 OR 5 != 5)

-- 化简(b = b 永远为 TRUE,5 != 5 永远为 FALSE)
(a < 1 AND TRUE) OR (a = 6 OR FALSE)

-- 继续化简
a < 1 OR a = 6
5. 表达式计算
sql 复制代码
-- 原始
a = 5 + 1

-- 化简
a = 6

注意:如果列出现在函数中或复杂表达式中,不会进行化简。

6. 常量表检测

MySQL 认为以下查询运行特别快:

  • 使用主键等值匹配
  • 使用唯一二级索引列等值匹配

这类表称为常量表(constant tables),优化器会先执行常量表查询,将其条件替换为常数。

6.2 外连接消除

外连接内连接的本质区别:

  • 外连接:驱动表记录无法在被驱动表中找到匹配记录,仍加入结果集(被驱动表字段填充 NULL)
  • 内连接:驱动表记录无法在被驱动表中找到匹配记录,该记录被舍弃
空值拒绝(Reject-NULL)

如果在 WHERE 子句中指定了被驱动表相关列不为 NULL 的条件,外连接可以转换为内连接。

sql 复制代码
-- 左外连接
SELECT * FROM e1 LEFT JOIN e2 ON e1.m1 = e2.m2 
WHERE e2.n2 IS NOT NULL;

-- 等价于内连接
SELECT * FROM e1 INNER JOIN e2 ON e1.m1 = e2.m2 
WHERE e2.n2 IS NOT NULL;

好处:查询优化器可以评估不同连接顺序的成本,选择成本最低的方案。


七、子查询优化内幕

7.1 子查询分类

按返回结果集区分
类型 说明 示例
标量子查询 返回单一值 SELECT (SELECT m1 FROM e1 LIMIT 1)
行子查询 返回一条记录(多列) WHERE (m1, n1) = (SELECT m2, n2 FROM e2 LIMIT 1)
列子查询 返回一个列的数据 WHERE m1 IN (SELECT m2 FROM e2)
表子查询 返回多行多列 WHERE (m1, n1) IN (SELECT m2, n2 FROM e2)
按与外层查询关系区分
类型 说明
不相关子查询 可以单独运行,不依赖外层查询的值
相关子查询 执行需要依赖外层查询的值

7.2 IN 子查询的优化

不相关标量子查询的执行方式
sql 复制代码
SELECT * FROM s1 
WHERE order_note = (SELECT order_note FROM s2 WHERE key3 = 'a' LIMIT 1);

执行步骤:

  1. 单独执行子查询
  2. 将结果作为外层查询的参数
  3. 执行外层查询
相关标量子查询的执行方式
sql 复制代码
SELECT * FROM s1 
WHERE order_note = (
    SELECT order_note FROM s2 
    WHERE s1.order_no = s2.order_no LIMIT 1
);

执行步骤:

  1. 从外层查询获取一条记录
  2. 执行子查询
  3. 检测条件是否成立,决定保留或丢弃记录
  4. 重复步骤 1

7.3 物化表(Materialization)

问题背景

不相关 IN 子查询如果结果集太多,会导致:

  1. 结果集太大,内存放不下
  2. 无法有效使用索引,只能全表扫描
  3. IN 参数太多,检测记录是否匹配耗时太长
MySQL 的解决方案:物化

将子查询结果集写入临时表(物化表):

物化过程

  1. 临时表的列就是子查询结果集中的列
  2. 写入临时表的记录会被去重(建立主键或唯一索引)
  3. 结果集不大时:使用 Memory 存储引擎,建立哈希索引
  4. 结果集很大时:使用磁盘存储引擎(InnoDB/MyISAM),建立 B+ 树索引

物化表的优势

  • 通过索引执行 IN 语句判断非常快
  • 大幅提升了子查询语句的性能

7.4 物化表转连接

转换原理
sql 复制代码
-- 原始子查询
SELECT * FROM s1 
WHERE order_note IN (
    SELECT order_note FROM s2 WHERE order_no = 'a'
);

物化后相当于内连接

sql 复制代码
-- 转换后的连接查询
SELECT s1.* FROM s1 
INNER JOIN materialized_table ON order_note = m_val;
成本评估

MySQL 会评估两种连接顺序的成本:

方案 1:s1 作为驱动表

复制代码
总成本 = 物化子查询成本 + 扫描 s1 成本 + 
         s1 记录数 × 通过 m_val 访问物化表成本

方案 2:物化表作为驱动表

复制代码
总成本 = 物化子查询成本 + 扫描物化表成本 + 
         物化表记录数 × 通过 order_note 访问 s1 成本

优化器会选择成本更低的方案执行查询。


八、总结

本文深入剖析了 MySQL 查询优化器的核心原理:

核心知识点

  1. 查询成本计算

    • I/O 成本 + CPU 成本
    • 全表扫描 vs 索引扫描的成本对比
    • 优化器如何选择最优执行方案
  2. Explain 与 Optimizer Trace

    • EXPLAIN FORMAT=JSON 查看执行计划成本
    • optimizer_trace 查看优化全过程
  3. 连接查询优化

    • 扇出值(fanout)的概念
    • 多表连接的成本计算策略
    • 提前结束高成本方案、启发式规则
  4. 成本常数调节

    • server_cost:Server 层操作成本
    • engine_cost:存储引擎层操作成本
  5. 查询重写规则

    • 条件化简(常量传递、等值传递、移除无用条件)
    • 外连接消除(空值拒绝)
  6. 子查询优化

    • 物化表(Materialization)
    • 物化表转连接
    • 大幅提升 IN 子查询性能

实战建议

场景 建议
子查询性能差 尝试改写为 JOIN,或确认是否使用了物化优化
优化器选择不当 使用 optimizer_trace 分析原因
多表连接慢 关注驱动表的扇出值,考虑调整连接顺序
成本估算不准 考虑调节 row_evaluate_cost 等成本常数

优化口诀

复制代码
物化子查询转连接,成本计算要搞懂
常量传递化简快,外连接消除省开销
扇出值小效率好,trace 分析真奇妙

如果你觉得这篇文章对你有帮助,欢迎 点赞、收藏、转发!有任何问题可以在评论区留言交流。

标签:MySQL, 查询优化器, 执行原理, 成本计算, Explain, 子查询优化, 物化表, 性能优化, 数据库原理

相关推荐
candyTong1 小时前
Claude Code 每次调用 API 时,上下文是怎么"拼"出来的?
javascript·后端·架构
java_cj1 小时前
数据库范式化设计与性能优化全攻略
数据库·后端·性能优化·架构·开源
Noushiki1 小时前
MySQL索引优化实战:高效查询的黄金法则
数据库·sql·mysql
TDengine (老段)2 小时前
TDengine Commit 与 Flush 机制 — 从内存到磁盘的数据落盘全流程
大数据·数据库·物联网·架构·时序数据库·iot·tdengine
雪隐2 小时前
AI股票小助手01-量化交易基础概念
人工智能·后端·python
alwaysrun2 小时前
Rust之代数数据类型Enum
后端·rust·编程语言
前端市界2 小时前
拒绝纸上谈兵!Docker 一键全线打通 DevOps 金三角实战
后端
罗工_有bug2 小时前
label-studio 踩坑:一个环境变量引发的 bool 转换错误
后端
Dxy12393102162 小时前
Python 操作 MySQL 事务:从入门到避坑
android·python·mysql