关键词:MySQL, 查询优化器, 执行原理, 成本计算, Explain, 子查询优化, 物化表, 性能优化
前言
你是否好奇过 MySQL 是如何在众多执行方案中选择最优路径的?
为什么有时候明明有索引,MySQL 却选择了全表扫描?
为什么子查询性能如此糟糕,而改成 JOIN 后速度飞升?
本文将带你深入 MySQL 查询优化器的核心内幕,揭秘查询成本计算的原理、执行计划生成的全过程,以及子查询优化的神奇机制。掌握这些底层原理,你将能够写出更高效、更符合 MySQL"口味"的 SQL 语句。
目录
- [MySQL 查询成本详解](#MySQL 查询成本详解)
- 单表查询成本分析实战
- [Explain 与查询成本](#Explain 与查询成本)
- 连接查询的成本分析
- 调节成本常数
- [MySQL 查询重写规则](#MySQL 查询重写规则)
- 子查询优化内幕
- 总结
一、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 的查询优化器会:
- 找出执行该语句所有可能使用的方案
- 对比之后找出成本最低的方案(即执行计划)
- 调用存储引擎提供的接口真正执行查询
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 两表连接的成本公式
连接查询总成本 = 单次访问驱动表的成本 + 驱动表扇出数 × 单次访问被驱动表的成本
对于内连接,驱动表和被驱动表的位置可以互换,需要考虑:
- 不同的表作为驱动表,查询成本可能不同
- 分别为驱动表和被驱动表选择成本最低的访问方法
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);
执行步骤:
- 单独执行子查询
- 将结果作为外层查询的参数
- 执行外层查询
相关标量子查询的执行方式
sql
SELECT * FROM s1
WHERE order_note = (
SELECT order_note FROM s2
WHERE s1.order_no = s2.order_no LIMIT 1
);
执行步骤:
- 从外层查询获取一条记录
- 执行子查询
- 检测条件是否成立,决定保留或丢弃记录
- 重复步骤 1
7.3 物化表(Materialization)
问题背景
不相关 IN 子查询如果结果集太多,会导致:
- 结果集太大,内存放不下
- 无法有效使用索引,只能全表扫描
- IN 参数太多,检测记录是否匹配耗时太长
MySQL 的解决方案:物化
将子查询结果集写入临时表(物化表):
物化过程:
- 临时表的列就是子查询结果集中的列
- 写入临时表的记录会被去重(建立主键或唯一索引)
- 结果集不大时:使用 Memory 存储引擎,建立哈希索引
- 结果集很大时:使用磁盘存储引擎(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 查询优化器的核心原理:
核心知识点
-
查询成本计算
- I/O 成本 + CPU 成本
- 全表扫描 vs 索引扫描的成本对比
- 优化器如何选择最优执行方案
-
Explain 与 Optimizer Trace
EXPLAIN FORMAT=JSON查看执行计划成本optimizer_trace查看优化全过程
-
连接查询优化
- 扇出值(fanout)的概念
- 多表连接的成本计算策略
- 提前结束高成本方案、启发式规则
-
成本常数调节
server_cost:Server 层操作成本engine_cost:存储引擎层操作成本
-
查询重写规则
- 条件化简(常量传递、等值传递、移除无用条件)
- 外连接消除(空值拒绝)
-
子查询优化
- 物化表(Materialization)
- 物化表转连接
- 大幅提升 IN 子查询性能
实战建议
| 场景 | 建议 |
|---|---|
| 子查询性能差 | 尝试改写为 JOIN,或确认是否使用了物化优化 |
| 优化器选择不当 | 使用 optimizer_trace 分析原因 |
| 多表连接慢 | 关注驱动表的扇出值,考虑调整连接顺序 |
| 成本估算不准 | 考虑调节 row_evaluate_cost 等成本常数 |
优化口诀
物化子查询转连接,成本计算要搞懂
常量传递化简快,外连接消除省开销
扇出值小效率好,trace 分析真奇妙
如果你觉得这篇文章对你有帮助,欢迎 点赞、收藏、转发!有任何问题可以在评论区留言交流。
标签:MySQL, 查询优化器, 执行原理, 成本计算, Explain, 子查询优化, 物化表, 性能优化, 数据库原理