三、查询优化器
对于一个 SQL 语句,查询优化器先看是不是能转换成 JOIN ,再将 JOIN 进行优化。
优化步骤:
- 条件优化
- 计算全表扫描成本
- 找出所有能用到的索引
- 针对每个索引计算不同的访问方式的成本
- 选出成本最小的索引以及访问方式
开启查询优化器日志
1 |
|
理解查询优化器日志
1. join_preparation 准备阶段
这步主要是进行准备工作,翻译语句工作,美化 SQL ,格式化等,如:将 SELECT * FROM t1;
转换为 SELECT t1.a, t1.b, ...
等。
2. join_optimization 优化阶段
最重要的一步,对 SQL 进行优化。
2.1 条件传递(condition_processing)
等值传递(equality_propagation)
优化相等变量的的值。
1 |
|
上面这个 SQL 可以转换为:
1 |
|
常量传递(constant_propagation)
优化常量,简化条件。
1 |
|
上面这个 SQL 可以转换为:
1 |
|
移除无用的条件(trivial_condition_removal)
1 |
|
上面这个 SQL 可以转换为:
1 |
|
2.2 替换虚拟生成列(substitute_generated_columns)
2.3 表依赖详情(table_dependencies)
分析表之间的依赖关系。
2.4 列出可用索引(ref_optimizer_key_uses)
列出可用的索引,包括主键聚簇索引、二级索引等。
2.5 行估算成本(rows_estimation)
一个查询可以有不同的执行方案,可以选择某个索引进行查询,也可以选择全表扫描,查询优化器会选择其中成本最低的方案去执行查询。
I/O 成本
InnoDB 存储引擎都是将数据和索引都存储到磁盘上的,当我们想查询表中的记录时,需要先把数据或者索引加载到内存中然后再操作。这个从磁盘到内存这个加载的过程损耗的时间称之为 I/O 成本。
CPU 成本
读取以及检测记录是否满足对应的搜索条件、对结果集进行排序等这些操作损耗的时间称之为 CPU 成本。
InnoDB 存储引擎规定 读取一个页花费的成本默认是 1.0,读取及检测一条记录是否符合搜索条件的成本默认是 0.2。
2.6 对比执行计划(considered_execution_plans)
对比各个执行计划的开销,选择最优的执行计划。
2.7 附加条件(attaching_conditions_to_tables)
基于 considered_execution_plans 中选择的最优执行计划,改造原有 WHERE 条件,并针对表增加适当的附加条件,以便于单表数据的筛选。
2.8 最终优化(finalizing_table_conditions)
2.9 改善执行计划(refine_plan)
3. join_explain 优化结果展示阶段
生成优化器优化后的结果。
优化步骤
在一条单表查询语句真正执行之前,MySQL 的查询优化器会找出执行该语句所有可能使用的方案,对比之后找出成本最低的方案,
这个成本最低的方案就是所谓的 执行计划,之后才会调用存储引擎提供的接口真正的执行查询。
下面以一个实例来分析这些优化步骤,单表查询语句如下:
1 |
|
1. 根据搜索条件,找出所有可能使用的索引
emp_no > '10101'
,这个搜索条件可以使用主键索引PRIMARY
。to_date = '1991-10-10'
,这个搜索条件可以使用二级索引idx_titles_to_date
。
上边的查询语句可能用到的索引,也就是 possible keys 只有 PRIMARY
和 idx_titles_to_date
。
2. 计算全表扫描的成本
对于 InnoDB 存储引擎来说,全表扫描就是把 聚簇索引 中的记录依次 与 给定的搜索条件 做比较,把符合搜索条件的记录加入到结果集,
所以需要将聚簇索引对应的页面加载到内存中,然后再检测记录是否符合搜索条件。
查询成本 = I/O 成本 + CPU 成本
所以计算全表扫描的代价需要两个信息:
- 聚簇索引占用的页面数
- 该表中的记录数
MySQL 为每个表维护了一系列的统计信息,查看表的统计信息:
1 |
|
Rows
表中的记录条数
对于使用 MyISAM 存储引擎的表来说,该值是准确的。
对于使用 InnoDB 存储引擎的表来说,该值是一个估计值。
Data_length
表占用的存储空间字节数
对于使用 MyISAM 存储引擎的表来说,该值就是数据文件的大小。
对于使用 InnoDB 存储引擎的表来说,该值就相当于聚簇索引占用的存储空间大小,也就是说可以这样计算该值的大小:
1 |
|
计算案例中的全表扫描成本
titles 使用默认 16KB 的页面大小,根据上边查询结果显示 Data_length 的值是 20512768,所以可以反向推导出聚簇索引的页面数量为:
1 |
|
现已得到聚簇索引占用的页面数量以及该表记录数的估计值,所以就可以计算全表扫描成本了。但是 MySQL 在真实计算成本时会进行一些微调。
1 |
|
1252 指的是聚簇索引占用的页面数,1.0 指的是加载一个页面的成本常数。
1 |
|
442070 指的是统计数据中表的记录数,对于 InnoDB 存储引擎来说是一个估计值,0.2 指的是访问一条记录所需的成本常数。
1 |
|
综上所述,对于 titles 的全表扫描所需的总成本就是 89666。
表中的记录其实都存储在聚簇索引对应 B+树的叶子节点中,所以只要通过根节点获得了最左边的叶子节点,就可以沿着叶子节点组成的双向链表把所有记录都查看一遍。
也就是说全表扫描这个过程其实有的 B+树内节点是不需要访问的,但是 MySQL 在计算全表扫描成本时直接使用聚簇索引占用的页面数作为计算 I/O 成本的依据,是不区分内节点和叶子节点的。
3. 计算 PRIMARY 需要的成本
计算 PRIMARY 需要多少成本的关键问题是:需要预估出根据对应的 WHERE
条件在主键索引 B+树中存在多少条符合条件的记录。
范围区间数
当从索引中查询记录时,不管是 =、in、>、<
这些操作都需要从索引中确定一个范围,不论这个范围区间的索引占用了多少页面,查询优化器都粗略的认为读取索引的一个范围区间的 I/O 成本和读取一个页面是相同的。
本例中使用 PRIMARY
的范围区间只有一个:(10101, 20000)
,所以相当于访问这个范围区间的索引付出的 I/O 成本就是:
1 |
|
预估范围内的记录数
优化器需要计算索引的某个范围区间到底包含多少条记录,对于本例来说就是要计算 PRIMARY
在 (10101, 20000)
这个范围区间中包含多少条数据记录,计算过程:
- 先根据
emp_no > 10101
这个条件访问一下PRIMARY
对应的 B+树索引,找到满足emp_no > 10101
这个条件的第一条记录,把这条记录称为 区间最左记录。 - 再根据
emp_no < 20000
这个条件继续从PRIMARY
对应的 B+树索引中找出第一条满足这个条件的记录,把这条记录称为 区间最右记录。 - 如果 区间最左记录 和 区间最右记录 相隔不太远(只要相隔不大于 10 个页面即可),那就可以精确统计出满足
emp_no > '10101' AND emp_no < '20000'
条件的记录条数。
否则只沿着 区间最左记录 向右读 10 个页面,计算平均每个页面中包含多少记录,然后用这个平均值乘以 区间最左记录 和 区间最右记录 之间的页面数量就可以了。
怎么估计 区间最左记录 和 区间最右记录 之间有多少个页面呢?
计算它们父节点中对应的目录项记录之间隔着几条记录就可以了。
根据上面的步骤可以算出来 PRIMARY
索引的记录条数,所以读取记录的 CPU 成本:
1 |
|
26808 是预估的需要读取的数据记录条数,0.2 是读取一条记录成本常数。
总成本
1 |
|
4. 计算二级索引需要的成本
因为通过二级索引查询需要 回表 ,所以计算二级索引需要的成本时还要加上回表的成本,而回表的成本就相当于下面这个 SQL 执行:
1 |
|
所以 二级索引的成本 = 辅助索引的查询成本 + 回表查询的成本
5. 比较并选出最优
选择成本最小的索引。
6. NULL 值的处理
对于 NULL
值,有三种理解:
- NULL 值代表一个不确定的值,每个 NULL 值都是独一无二的,在统计列不重复的时候应该都当做是独立的。
- NULL 值在业务上就是代表没有,所有的 NULL 值代表的意义是一样的,所以所有的 NULL 值都一样,在统计列不重复值的时候应该只算一个。
- NULL 完全没有意义,在统计列不重复值的时候应该忽略 NULL。
InnoDB 提供了一个系统变量:
1 |
|
这个变量有三个值:
nulls_equal
:认为所有 NULL 值都是相等的。这个值也是innodb_stats_method
的默认值。如果某个索引列中 NULL 值特别多的话,
这种统计方式会让优化器认为某个列中平均一个值重复次数特别多,所以倾向于不使用索引进行访问。nulls_unequal
:认为所有 NULL 值都是不相等的。如果某个索引列中 NULL 值特别多的话,这种统计方式会让优化器认为某个列中平均一个值重复次数特别少,所以倾向于使用索引进行访问。nulls_ignored
:直接把 NULL 值忽略掉。
最好不在索引列中存放 NULL 值才是正解。
7. 控制执行计划
Index Hints
USE INDEX
:限制索引的使用范围,数据表里有很多索引,当 MySQL 对索引进行选择时,这些索引都在考虑的范围内。但有时我们希望 MySQL 只考虑几个索引,而不是全部的索引,
这就需要用到USE INDEX
对查询语句进行设置。IGNORE INDEX
:限制不使用索引的范围。FORCE INDEX
:我们希望 MySQL 必须使用某一个索引(由于 MySQL 在查询时只能使用一个索引,因此只能强迫 MySQL 使用一个索引)。这就需要使用FORCE INDEX
来完成这个功能。
基本语法格式:
1 |
|