暂无图片
暂无图片
1
暂无图片
暂无图片
暂无图片

MySQL之基于规则的优化特性(三)

GrowthDBA 2022-01-20
692
今天继续来学习关于MySQL优化器的一些优化规则、特性。
第三篇了,也是MySQL基于规则优化特性的最后一篇。前两篇文章MySQL之基于规则的优化特性(一)MySQL之基于规则的优化特性(二),我们从optimizer_switch入手,学习了索引合并(Index Merge)、索引条件下推(ICP)、基于块的嵌套循环连接(Block Nested-Loop Join,BNL)、Multi-Range Read(MRR)、Batched Key Access(BKA)、嵌套循环连接(Simple Nested-Loop Join/Nested-Loop Join)、基于索引的嵌套循环连接(Index Nested-Loop Join,NLJ)、条件化简、子查询的注意事项和物化表的提出对IN子查询的优化特性。
今天继续来学习有关子查询的优化特性,开始吧。


子查询优化



IN子查询优化

物化表转连接

「derived_merge=on(视图/派生表合并,需要配合好Auto_key)」
再来看一下之前的一个查询语句:
SELECT * FROM t1 WHERE key1 IN (SELECT common_field FROM t2 WHERE key3 = 'a');
当我们把子查询进行物化之后,假设子查询物化表的名称为materialized_table,该物化表存储的子查询结果集的列为m_val,那么这个查询其实可以从下边两种角度来看待:
1、从表t1的角度来看待,整个查询的意思其实是:对于t1表中的每条记录来说,如果该记录的key1列的值在子查询对应的物化表中,则该记录会被加入最终的结果集。
2、从子查询物化表的角度来看待,整个查询的意思其实是:对于子查询物化表的每个值来说,如果能在t1表中找到对应的key1列的值与该值相等的记录,那么就把这些记录加入到最终的结果集。
翻译一下上面的话就是:上边的查询就相当于表t1和子查询物化表materialized_table进行内连接
SELECT t1.* FROM t1 INNER JOIN materialized_table ON key1 = m_val;
转化成内连接之后,查询优化器可以评估不同连接顺序需要的成本是多少,选取成本最低的那种查询方式执行查询。
  • 使用t1表作为驱动表的话,总查询成本由下边几个部分组成:
1、物化子查询时需要的成本;
2、扫描t1表时的成本;
3、t1表中的记录数 × 通过m_val = xxx对materialized_table表进行单表访问的成本(我们前边说过物化表中的记录是不重复的,并且为物化表中的列建立了索引,所以这个步骤显然是非常快的)。
  • 使用materialized_table表作为驱动表的话,总查询成本由下边几个部分组成:
1、物化子查询时需要的成本;
2、扫描物化表时的成本;
3、物化表中的记录数量 × 通过key1 = xxx对t1表进行单表访问的成本(非常庆幸key1列上建立了索引,所以这个步骤是非常快的)。
MySQL查询优化器会通过运算来选择上述成本更低的方案来执行查询。

视图/派生表合并(Derived merge)

视图合并就是我们上面说的物化表转连接的另外一种叫法
  • Derived作为FROM后面子查询生成的派生表/衍生表,生成在内存或者临时表空间。

  • 含有派生表的查询,MySQL提供了两种执行策略:派生表物化——将派生表的结果集写到一个内部的临时表中,然后就把这个物化表当作普通表一样参与查询。当然,在对派生表进行物化时,MySQL使用了一种称为延迟物化的策略,也就是在查询中真正使用到派生表时才回去尝试物化派生表,而不是还没开始执行查询呢就把派生表物化掉;派生表和外层的表合并,也就是将查询重写为没有派生表的形式。通过将外层查询和派生表合并的方式成功的消除了派生表,也就意味着我们没必要再付出创建和访问临时表的成本了。

  • 如果Derived表作为驱动表,需减少数据量为目的。

  • 执行计划中出现<derived>当作被驱动表的时候,可能会产生auto_key(是在内存中的索引)【对tmp_table_size、max_heap_table_size依赖较大】,生命周期就是这个SQL的执行时间,并且创建临时索引也需要消耗时间、资源。产生auto_key索引也是要以减少数据量为目的。

  • derived_merge=on,可以把简单的Subquuery转换成Join。on时,被驱动表的连接条件需要有索引,off时,被驱动表的结果集要小。

小提示

1、使用limit语法可以阻止视图合并;(即阻止物化生成Derived表)

2、视图合并慢是因为视图合并因为被驱动表中没有索引。

3、为什么不先JOIN,然后再把最终结果及排序?答:就是处理结果集的面积大小问题。(结果集的行数量和列数量的乘积)

4、视图合并是5.7的新特性,建议使用,使用的前提是被驱动表的关联字段必须有索引(视图合并慢是因为视图合并因为被驱动表中没有索引),5.6是没有视图合并的,会先生成一个derived表,并创建derived表的auto_key,其实视图合并的优势在于不用生成一个derived表的过程,被驱动表关联列上加上索引就相当于5.6中的auto_key(只适用于小结果集,如果结果集所在的中间结果集大,会产生磁盘IO)效果了。

5、Derived表不能合并(Derived表不能转换为Join)的情况:

  • union/union all

  • group by

  • distinct

  • 聚合函数,MIN()、MAX()、SUM()、COUNT()...(我的理解此条为需要对结果集二次计算的情况)

  • limit

  • having

  • @分配给用户变量

  • SELECT List中的子查询

  • 仅引用文字值

将子查询转换为semi-join

半连接(semi-join)

「semijoin=on(半连接)loosescan=on(半连接—松散扫描)firstmatch=on(半连接—首次匹配)duplicateweedout=on(半连接—重复值消除)」

虽然将子查询进行物化之后再执行查询都会有建立临时表的成本,但是不管怎么说,我们见识到了将子查询转换为连接的强大作用,MySQL继续优化:能不能不进行物化操作直接把子查询转换为连接呢?(就是再次缩减物化生成临时表的成本)

SELECT * FROM t1 WHERE key1 IN (SELECT common_field FROM t2 WHERE key3 = 'a');

这个查询可以理解成:对于t1表中的某条记录,如果我们能在t2表(准确的说是执行完WHERE t2.key3 = 'a'之后的结果集)中找到一条或多条记录,这些记录的common_field的值等于t1表记录的key1列的值,那么该条t1表的记录就会被加入到最终的结果集。这个过程其实和把t1和t2两个表连接起来的效果很像:

SELECT t1.* FROM t1 INNER JOIN t2 ON t1.key1 = t2.common_field WHERE t2.key3 = 'a';

我们不能保证对于t1表的某条记录来说,在t2表(准确的说是执行完WHERE t2.key3 = 'a'之后的结果集)中有多少条记录满足t1.key1 = t2.common_field这个条件,不过我们可以分三种情况讨论:

1、对于t1表的某条记录来说,t2表中没有任何记录满足t1.key1 = t2.common_field这个条件,那么该记录自然也不会加入到最后的结果集。

2、对于t1表的某条记录来说,t2表中有且只有1条记录满足t1.key1 = t2.common_field这个条件,那么该记录会被加入最终的结果集。

3、对于t1表的某条记录来说,t2表中至少有2条记录满足t1.key1 = t2.common_field这个条件,那么该记录会被多次加入最终的结果集。

对于t1表的某条记录来说,由于我们只关心t2表中是否存在记录满足t1.key1 = t2.common_field这个条件,而不关心具体有多少条记录与之匹配,又因为有情况三的存在,我们上边所说的IN子查询和两表连接之间并不完全等价。但是将子查询转换为连接又真的可以充分发挥优化器的作用,MySQL提出了一个新概念——半连接(英文名:semi-join)

将t1表和t2表进行半连接的意思就是:对于t1表的某条记录来说,我们只关心在t2表中是否存在与之匹配的记录,而不关心具体有多少条记录与之匹配,最终的结果集中只保留t1表的记录。为了让大家有更直观的感受,我们假设MySQL内部是这么改写上边的子查询的:
SELECT t1.* FROM t1 SEMI JOIN t2 ON t1.key1 = t2.common_field WHERE key3 = 'a';
小提示
semi-join只是在MySQL内部采用的一种执行子查询的方式,MySQL并没有提供面向用户的semi-join语法,所以我们不需要,也不能尝试把上边这个语句放到黑框框里运行,只是想说明一下上边的子查询在MySQL内部会被转换为类似上边半连接语句。

半连接(semi-join)的实现

01

Table pullout (子查询中的表上拉)

当子查询的查询列表处只有主键或者唯一索引列时,可以直接把子查询中的表上拉到外层查询的FROM子句中,并把子查询中的搜索条件合并到外层查询的搜索条件中。
SELECT * FROM t1 WHERE key2 IN (SELECT key2 FROM t2 WHERE key3 = 'a');

由于key2列是t2表的唯一二级索引列,所以我们可以直接把t2表上拉到外层查询的FROM子句中,并且把子查询中的搜索条件合并到外层查询的搜索条件中,上拉之后的查询就是这样的。

SELECT t1.* FROM t1 INNER JOIN t2 ON t1.key2 = t2.key2 WHERE t2.key3 = 'a';
因为主键或者唯一索引列中的数据本身不重复的特性,对于同一条t1表中的记录,你不可能找到两条以上的符合t1.key2 = t2.key2的记录,所以当子查询的查询列表处只有主键或者唯一索引列时,就可以直接将子查询转换为连接查询

02

DuplicateWeedout execution strategy(重复值消除)

对于这个查询:

SELECT * FROM t1 WHERE key1 IN (SELECT common_field FROM t2 WHERE key3 = 'a');
转换为半连接查询后,t1表中的某条记录可能在t2表中有多条匹配的记录,所以该条记录可能多次被添加到最后的结果集中,为了消除重复,我们可以建立一个临时表,比方说这个临时表长这样:
CREATE TABLE tmp (    id PRIMARY KEY);

这样在执行连接查询的过程中,每当某条t1表中的记录要加入结果集时,就首先把这条记录的id值加入到这个临时表里,如果添加成功,说明之前这条t1表中的记录并没有加入最终的结果集,现在把该记录添加到最终的结果集;如果添加失败,说明之前这条t1表中的记录已经加入过最终的结果集,这里直接把它丢弃就好了,这种使用临时表消除semi-join结果集中的重复值的方式称之为DuplicateWeedout

03

LooseScan execution strategy(松散扫描)

这个查询:

SELECT * FROM t1 WHERE key3 IN (SELECT key1 FROM t2 WHERE key1 > 'a' AND key1 < 'b');
对于t2表的访问可以使用到key1列的索引,而恰好子查询的查询列表处就是key1列,这样在将该查询转换为半连接查询后,如果将t2作为驱动表执行查询的话。在t2表的idx_key1索引中,值重复的二级索引记录一共有N条,那么只需要取第一条的值到t1表中查找t1.key3 = '[重复值]'的记录,如果能在t1表中找到对应的记录,那么就把对应的记录加入到结果集。依此类推,其他值相同的二级索引记录,也只需要取第一条记录的值到t1表中找匹配的记录,这种虽然是扫描索引,但只取值相同的记录的第一条去做匹配操作的方式称之为松散扫描

04

Semi-join Materialization execution strategy

之前介绍的先把外层查询的IN子句中的不相关子查询进行物化,然后再进行外层查询的表和物化表的连接本质上也算是一种semi-join,只不过由于物化表中没有重复的记录,所以可以直接将子查询转为连接查询。

05

FirstMatch execution strategy(首次匹配)

FirstMatch是一种最原始的半连接执行方式,跟我们常规认知中的相关子查询的执行方式是一样的,先取一条外层查询的中的记录,然后到子查询的表中寻找符合匹配条件的记录,如果能找到一条,则将该外层查询的记录放入最终的结果集并且停止查找更多匹配的记录,如果找不到则把该外层查询的记录丢弃掉;然后再开始取下一条外层查询中的记录,重复上边这个过程。

对于某些使用IN语句的相关子查询。

SELECT * FROM t1 WHERE key1 IN (SELECT common_field FROM t2 WHERE t1.key3 = t2.key3);

可以很方便的转为半连接,转换后的语句类似这样:

SELECT t1.* FROM t1 SEMI JOIN t2 ON t1.key1 = t2.common_field AND t1.key3 = t2.key3;
然后就可以使用上边的DuplicateWeedout、LooseScan、FirstMatch等半连接执行策略来执行查询,当然,如果子查询的查询列表处只有主键或者唯一二级索引列,还可以直接使用table pullout的策略来执行查询,但是需要大家注意的是,由于相关子查询并不是一个独立的查询,所以不能转换为物化表来执行查询

半连接(semi-join)的使用场景

并不是所有包含IN子查询的查询语句都可以转换为semi-join,只有形如这样的查询才可以被转换为semi-join:
SELECT ... FROM outer_tables WHERE expr IN (SELECT ... FROM inner_tables ...) AND ...SELECT ... FROM outer_tables WHERE (oe1, oe2, ...) IN (SELECT ie1, ie2, ... FROM inner_tables ...) AND ...
符合下边这些条件的子查询才可以被转换为semi-join:
1、该子查询必须是和IN语句组成的布尔表达式,并且在外层查询的WHERE或者ON子句中出现。
2、外层查询也可以有其他的搜索条件,只不过和IN子查询的搜索条件必须使用AND连接起来。
3、该子查询必须是一个单一的查询,不能是由若干查询由UNION连接起来的形式。
4、该子查询不能包含GROUP BY或者HAVING语句或者聚集函数

不适用半连接(semi-join)的情况

以下情况不能将子查询转换为semi-join:

1、外层查询的WHERE条件中有其他搜索条件与IN子查询组成的布尔表达式使用OR连接起来。

SELECT * FROM t1 WHERE key1 IN (SELECT common_field FROM t2 WHERE key3 = 'a') OR key2 > 100;

2、使用NOT IN而不是IN的情况。

SELECT * FROM t1 WHERE key1 NOT IN (SELECT common_field FROM t2 WHERE key3 = 'a');

3、在SELECT子句中的IN子查询的情况。

SELECT key1 IN (SELECT common_field FROM t2 WHERE key3 = 'a') FROM t1 ;

4、子查询中包含GROUP BY、HAVING或者聚集函数的情况。

SELECT * FROM t1 WHERE key2 IN (SELECT COUNT(*) FROM t2 GROUP BY key1);

5、子查询中包含UNION的情况。

SELECT * FROM t1 WHERE key1 IN (    SELECT common_field FROM t2 WHERE key3 = 'a'     UNION    SELECT common_field FROM t2 WHERE key3 = 'b');

不能转为semi-join查询的子查询MySQL还是做了一些优化工作

  • 对于不相关子查询来说,可以尝试把它们物化之后再参与查询。先将子查询物化,然后再判断字段值是否在物化表的结果集中可以加快查询执行的速度。

  • 不管子查询是相关的还是不相关的,都可以把IN子查询尝试转为EXISTS子查询。

outer_expr IN (SELECT inner_expr FROM ... WHERE subquery_where)/* 可以转换为 */EXISTS (SELECT inner_expr FROM ... WHERE subquery_where AND outer_expr=inner_expr)

当然这个过程中有一些特殊情况,比如在outer_expr或者inner_expr值为NULL的情况下就比较特殊。有NULL值作为操作数的表达式结果往往是NULL。

SELECT NULL IN (1, 2, 3);SELECT 1 IN (1, 2, 3);SELECT NULL IN (NULL);

而EXISTS子查询的结果肯定是TRUE或者FASLE:
SELECT EXISTS (SELECT 1 FROM t1 WHERE NULL = 1);SELECT EXISTS (SELECT 1 FROM t1 WHERE 1 = NULL);SELECT EXISTS (SELECT 1 FROM t1 WHERE NULL = NULL);

大部分使用IN子查询的场景是把它放在WHERE或者ON子句中,而WHERE或者ON子句是不区分NULL和FALSE的。

SELECT 1 FROM t1 WHERE NULL;SELECT 1 FROM t1 WHERE FALSE;

只要我们的IN子查询是放在WHERE或者ON子句中的,那么IN → EXISTS的转换就是没问题的。转换的根本原因就是尽可能使用到索引。
SELECT * FROM t1    WHERE key1 IN (SELECT key3 FROM t2 where t1.common_field = t2.common_field)         OR key2 > 1000;/* 转换为 */SELECT * FROM t1    WHERE EXISTS (SELECT 1 FROM t2 where t1.common_field = t2.common_field AND t2.key3 = t1.key1)         OR key2 > 1000;
这个查询中的子查询是一个相关子查询,而且IN子查询执行的时候不能使用到索引,但是将它转为EXISTS子查询后却可以使用到索引(t2表的idx_key3索引)。
小提示

※ 果IN子查询不满足转换为semi-join的条件,又不能转换为物化表或者转换为物化表的成本太大,那么它就会被转换为EXISTS查询

※ 如果果IN子查询符合转换为semi-join的条件,查询优化器会优先把该子查询转换为semi-join,然后从下边5种执行半连接的策略中选择成本最低的那种执行策略来执行子查询:Table pullout、DuplicateWeedout、LooseScan、Materialization、FirstMatch

※ 如果IN子查询不符合转换为semi-join的条件,那么查询优化器会从下边两种策略中找出一种成本更低的方式执行子查询:先将子查询物化之后再执行查询、执行IN to EXISTS转换

ANY/ALL子查询优化

如果ANY/ALL子查询是不相关子查询的话,它们在很多场合都能转换成我们熟悉的方式去执行。

[NOT] EXISTS子查询的执行

如果[NOT] EXISTS子查询是不相关子查询,可以先执行子查询,得出该[NOT] EXISTS子查询的结果是TRUE还是FALSE,并重写原先的查询语句。

SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE key1 = 'a') OR key2 > 100;
因为这个语句里的子查询是不相关子查询,所以优化器会首先执行该子查询,假设该EXISTS子查询的结果为TRUE,那么接着优化器会重写查询为:
SELECT * FROM t1 WHERE TRUE OR key2 > 100;/* 进一步简化后 */SELECT * FROM t1 WHERE TRUE;

对于相关的[NOT] EXISTS子查询来说,比如这个查询:

SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE t1.common_field = t2.common_field);

这个查询只能按照我们常规认知中的那种执行相关子查询的方式来执行。不过如果[NOT] EXISTS子查询中如果可以使用索引的话,那查询速度也会加快不少,比如:

SELECT * FROM t1 WHERE EXISTS (SELECT 1 FROM t2 WHERE t1.common_field = t2.key1);

这个EXISTS子查询中可以使用idx_key1来加快查询速度。



小结




今天的内容很重要,篇幅较长,就不做小结了,因为我感觉通篇几乎都是知识点,虽然是偏理论的原理性文章,但是背后的本质和处理逻辑还是需要我们掌握的。刚开始学习的时候,我也懵懵懂懂,但是有一句话:书读百遍,其义自见。我觉得这句话对我非常有用,一个难以理解的知识点,看一遍不会,那就看第二遍,两遍不会,再看第三遍,以此类推,直到理解看会,总会有彻悟的一天。

纵观整个MySQL的优化特性,优化的本质一直都是减少扫描行数、减少随机IO、随机IO优化成顺序IO、能够使用到索引、将常规认知中的子查询执行方式转换成连接查询...等等。MySQL的开发人员对业务使用场景较多的子查询有较大篇幅的优化算法设计,不愧为仅次于Oracle数据库的优秀数据库软件,汇聚了很多开发人员的心血,作为使用者的我们,更应该知其然,知其所以然,才不会飘飘然。

站在巨人的肩膀上,每天进步一点点,大道至简,贵在坚持!~




 参考资料 




  • 小孩子4919《MySQL是怎样运行的:从根上理解MySQL》

end


文章转载自 GrowthDBA,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论