Mysql专题五:关于JOIN的优化和子查询的优化

JOIN的含义就如英文单词“join”一样,连接两张表,大致分为内连接,外连接,右连接,左连接,自然连接。这里描述先甩出一张用烂了的图,然后插入测试数据。
在这里插入图片描述
在这里插入图片描述

一、JOIN语法

内连接
一下三种写法都是内连接:
select * from t1 join t2 on t1.a = t2.a;
select * from t1 inner join t2 on t1.a = t2.a;
select * from t1 cross join t2 on t1.a = t2.a;

左连接:
select * from t1 left join t2 on t1.a = t2.a;

右连接:
select * from t1 right join t2 on t1.a = t2.a;

二、连接的原理

不管是内连接还是左右连接,都需要一个驱动表和一个被驱动表,对于内连接来说,选取哪个表为驱动表都没关系,而外连接的驱动表是固定的,也就是说左连接的驱动表就是左边的那个表,右连接的驱动表就是右边的那个表。
连接的大致原理是:

1)选取驱动表,使用与驱动表相关的过滤条件,选取代价最低的访问形式来执行对驱动表的单表查询。
2)对上一步骤中查询驱动表得到的结果集中每一条记录,都分别到被驱动表中查找匹配的记录。
对应伪代码就是:

for each row in t1 {   //此处表示遍历满足对t1单表查询结果集中的每一条记录
    for each row in t2 {   //此处表示对于某条t1表的记录来说,遍历满足对t2单表查询结果集中的每一条记录
        // 判断是否符合join条件
    }
}

(一) 嵌套循环连接(Nested-Loop Join)

上面的过程就像是一个嵌套的循环,所以这种驱动表只访问一次,但被驱动表却可能被多次访问,访问次数取决于对驱动表执行单表查询后的结果集中的记录条数的连接执行方式称之为嵌套循环连接(Nested-Loop Join),这是最简单,也是最笨拙的一种连接查询算法;

比如对于下面这个sql:
select * from t1 join t2 on t1.a = t2.a where t1.b in (1,2);

先会执行:
在这里插入图片描述
得到三条记录。
然后分别执行;
select * from t2 where t2.a = 1;
select * from t2 where t2.a = 2;
select * from t2 where t2.a = 5;

所以实际上对于上面的步骤,实际上都是针对单表的查询,所以都可以使用索引来帮助查询。

(二)基于块的嵌套循环连接(Block Nested-Loop Join)

扫描一个表的过程其实是先把这个表从磁盘上加载到内存中,然后从内存中比较匹配条件是否满足。现实生活中的表可不像t1、t2这种只有几条记录,可能会有成千上万的数据。内存里可能并不能完全存放的下表中所有的记录,所以在扫描表前边记录的时候后边的记录可能还在磁盘上,等扫描到后边记录的时候可能内存不足,所以需要把前边的记录从内存中释放掉。我们前边又说过,采用嵌套循环连接算法的两表连接过程中,被驱动表可是要被访问好多次的,如果这个被驱动表中的数据特别多而且不能使用索引进行访问,那就相当于要从磁盘上读好几次这个表,这个I/O代价就非常大了,所以我们得想办法:尽量减少访问被驱动表的次数

当被驱动表中的数据非常多时,每次访问被驱动表,被驱动表的记录会被加载到内存中,在内存中的每一条记录只会和驱动表结果集的一条记录做匹配,之后就会被从内存中清除掉。然后再从驱动表结果集中拿出另一条记录,再一次把被驱动表的记录加载到内存中一遍,周而复始,驱动表结果集中有多少条记录,就得把被驱动表从磁盘上加载到内存中多少次。所以我们可不可以在把被驱动表的记录加载到内存的时候,一次性和多条驱动表中的记录做匹配,这样就可以大大减少重复从磁盘上加载被驱动表的代价了。
join buffer
Mysql中有一个叫做join buffer的概念,join buffer就是执行连接查询前申请的一块固定大小的内存,先把若干条驱动表结果集中的记录装在这个join buffer中,然后开始扫描被驱动表,每一条被驱动表的记录一次性和join buffer中的多条驱动表记录做匹配,因为匹配的过程都是在内存中完成的,所以这样可以显著减少被驱动表的I/O代价。
最好的情况是join buffer足够大,能容纳驱动表结果集中的所有记录,这样只需要访问一次被驱动表就可以完成连接操作了。这种加入了join buffer的嵌套循环连接算法称之为基于块的嵌套连接(Block Nested-Loop Join)算法。
这个join buffer的大小是可以通过启动参数或者系统变量join_buffer_size进行配置,默认大小为262144字节(也就是256KB),最小可以设置为128字节。当然,对于优化被驱动表的查询来说,最好是为被驱动表加上效率高的索引,如果实在不能使用索引,并且自己的机器的内存也比较大可以尝试调大join_buffer_size的值来对连接查询进行优化。
另外需要注意的是,驱动表的记录并不是所有列都会被放到join buffer中,只有查询列表中的列和过滤条件中的列才会被放到join buffer中,所以再次提醒我们,最好不要把*作为查询列表,只需要把我们关心的列放到查询列表就好了,这样可以在join buffer中放置更多的记录。

三、外连接消除

内连接的驱动表和被驱动表的位置可以相互转换,而左连接和右连接的驱动表和被驱动表是固定的。这就导致内连接可能通过优化表的连接顺序来降低整体的查询成本,而外连接却无法优化表的连接顺序。
外连接和内连接的本质区别就是:对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充;而内连接的驱动表的记录如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录会被舍弃

案例:下图中可以发现驱动表和被驱动表发生了变化,实际上加上了is not null之后被优化成了内连接,就可以利用查询优化器选择最优的连接顺序了。
在这里插入图片描述

四、关于子查询的优化

下面这些sql都含有子查询:

select * from t1 where a in (select a from t2);
select * from (select * from t1) as t;

(一)按返回的结果集区分子查询

  • 1、标量子查询
    那些只返回一个单一值的子查询称之为标量子查询。比如:
    select * from t1 where a in (select max(a) from t2);
  • 2、行子查询
    返回一条记录的子查询,不过这条记录需要包含多个列。比如:
    select * from t1 where (a, b) = (select a, b from t2 limit 1);
  • 3、列子查询
    返回一个列的数据的子查询,包含多条记录。比如:
    select * from t1 where a in (select a from t2);
  • 4、表子查询
    子查询的结果既包含很多条记录,又包含很多个列。比如:
    select * from t1 where (a, b) in (select a,b from t2);

(二)按与外层查询关系来区分子查询

  • 1、相关子查询
    如果子查询的执行需要依赖于外层查询的值,我们就可以把这个子查询称之为相关子查询。比如:
    select * from t1 where a in (select a from t2 where t1.a = t2.a);
  • 2、不相关子查询
    如果子查询可以单独运行出结果,而不依赖于外层查询的值,我们就可以把这个子查询称之为不相关子查询。前边介绍的那些子查询全部都可以看作不相关子查。

(三)子查询在MySQL中是怎么执行的

  • 1、对于不相关标量子查询或者行子查询
    比如:select * from t1 where a = (select a from t2 limit 1);
    它的执行步骤是:
    1)执行select a from t2 limit 1这个子查询。
    2)然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询select * from t1 where a = …;

  • 2、对于相关标量子查询或者行子查询
    比如:select * from t1 where b = (select b from t2 where t1.a = t2.a limit 1);
    它的执行步骤是:
    1)先从外层查询中获取一条记录,本例中也就是先从t1表中获取一条记录。
    2)然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,本例中就是从t1表中获取的那条记录中找出t1.a列的值,然后执行子查询。
    3)最后根据子查询的查询结果来检测外层查询WHERE子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。
    4)再次执行第一步,获取第二条外层查询中的记录,依次类推。。。

  • 3、IN子查询优化
    mysql对IN子查询进行了优化。
    比如:select * from t1 where a in (select a from t2);
    对于不相关的IN子查询来说,如果子查询的结果集中的记录条数很少,那么把子查询和外层查询分别看成两个单独的单表查询效率还是蛮高的,但是如果单独执行子查询后的结果集太多的话,就会导致这些问题:
    • 结果集太多,可能内存中都放不下
    • 对于外层查询来说,如果子查询的结果集太多,那就意味着IN子句中的参数特别多,这会导致:
    • 无法有效的使用索引,只能对外层查询进行全表扫描。
    • 在对外层查询执行全表扫描时,由于IN子句中的参数太多,这会导致检测一条记录是否符合和IN子句中的参数匹配花费的时间太长

在mysql中,不直接将不相关子查询的结果集当作外层查询的参数,而是将该结果集写入一个临时表里。写入临时表的过程是这样的:
1)该临时表的列就是子查询结果集中的列。
2)写入临时表的记录会被去重。IN语句是判断某个操作数在不在某个集合中,集合中的值重不重复对整个IN语句的结果并不影响,所以我们在将结果集写入临时表时对记录进行去重可以让临时表变得更小。临时表也是个表,只要为表中记录的所有列建立主键或者唯一索引就可以进行去重。
3)一般情况下子查询结果集不会特别大,所以会为它建立基于内存的使用Memory存储引擎的临时表,而且会为该表建立哈希索引。IN语句的本质就是判断某个操作数在不在某个集合里,如果集合中的数据建立了哈希索引,那么这个匹配的过程就是很快的。
4)如果子查询的结果集非常大,超过了系统变量tmp_table_size或者max_heap_table_size,临时表会转而使用基于磁盘的存储引擎来保存结果集中的记录,索引类型也对应转变为B+树索引。

这个将子查询结果集中的记录保存到临时表的过程称之为物化(Materialize)。那个存储子查询结果集的临时表称之为物化表。正因为物化表中的记录都建立了索引(基于内存的物化表有哈希索引,基于磁盘的有B+树索引),通过索引执行IN语句判断某个操作数在不在子查询结果集中变得非常快,从而提升了子查询语句的性能。

还是对于上面的那个sql:select * from t1 where a in (select a from t2);

当我们把子查询进行物化之后,假设子查询物化表的名称为materialized_table,该物化表存储的子查询结果集的列为m_val,那么这个查询其实可以从下边两种角度来看待:

• 从表t1的角度来看待,整个查询的意思其实是:对于t1表中的每条记录来说,如果该记录的a列的值在子查询对应的物化表中,则该记录会被加入最终的结果集。

• 从子查询物化表的角度来看待,整个查询的意思其实是:对于子查询物化表的每个值来说,如果能在t1表中找到对应的a列的值与该值相等的记录,那么就把这些记录加入到最终的结果集。

也就是说其实上边的查询就相当于表t1和子查询物化表materialized_table进行内连接:
select * from t1 inner join materialized_table on t1.a = m_val;

转化成内连接之后,查询优化器就可以评估不同连接顺序需要的成本是多少,选取成本最低的那种查询方式执行查询。
虽然将子查询进行物化之后再执行查询会有建立临时表的成本,但是可以将子查询转换为JOIN还是会更有效率一点的。那能不能不进行物化操作直接把子查询转换为连接呢。
我们对比下面两个sql:
select * from t1 where a in (select a from t2);
select t1.* from t1 inner join t2 on t1.a = t2.a;

这两个sql的查询结果其实很像,只是说对于第二个sql的结果集没有去重,所以IN子查询和两表连接之间并不完全等价,但是将子查询转换为连接又真的可以充分发挥优化器的作用,所以MySQL提出了一个新概念半连接(semi-join),将t1表和t2表进行半连接的意思就是:对于t1表的某条记录来说,我们只关心在t2表中是否存在与之匹配的记录是否存在,而不关心具体有多少条记录与之匹配,最终的结果集中只保留t1表的记录。semi-join只是在MySQL内部采用的一种执行子查询的方式,MySQL并没有提供面向用户的semi-join语法 。

那么怎么实现semi-join呢?

(1)Table pullout (子查询中的表上拉)
当子查询的查询列表处只有主键或者唯一索引列时,可以直接把子查询中的表上拉到外层查询的FROM子句中,并把子查询中的搜索条件合并到外层查询的搜索条件中。
比如:select * from t1 where a in (select a from t2 where t2.b = 1); – a是主键

我们可以直接把t2表上拉到外层查询的FROM子句中,并且把子查询中的搜索条件合并到外层查询的搜索条件中,上拉之后的查询就是这样的:
select * from t1 inner join t2 on t1.a = t2.a where t2.b = 1; – a是主键

(2)DuplicateWeedout execution strategy (重复值消除)
对于这个查询来说:
select * from t1 where a in (select e from t2 where t2.b = 1); – e只是一个普通字段

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

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

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

(4)LooseScan(松散索引扫描)
子查询扫描了非唯一索引,因为是非唯一索引,所以可能有相同的值,可以利用索引去重。
对于某些使用IN语句的相关子查询,比方这个查询:
select * from t1 where a in (select b from t2 where t1.b = t2.b);
它可以转换为半连接:
select * from t1 semi join t2 on t1.a = t2.a and t1.b = t2.b;

如一下几种情况就不能转换为semi-join:
• 外层查询的WHERE条件中有其他搜索条件与IN子查询组成的布尔表达式使用OR连接起来
• 使用NOT IN而不是IN的情况
• 子查询中包含GROUP BY、HAVING或者聚集函数的情况
• 子查询中包含UNION的情况

那么对于不能转为semi-join查询的子查询,有其他方式来进行优化:
• 对于不相关子查询来说,可以尝试把它们物化之后再参与查询
比如对于使用了NOT IN下面这个sql:
select * from t1 where a not in (select a from t2 where t2.a = 1);

请注意这里将子查询物化之后不能转为和外层查询的表的连接,因为用的是not in只能是先扫描t1表,然后对t1表的某条记录来说,判断该记录的a值在不在物化表中。
• 不管子查询是相关的还是不相关的,都可以把IN子查询尝试专为EXISTS子查询
其实对于任意一个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)

这样转换的好处是,转换前本来不能用到索引,但是转换后可能就能用到索引了,比如:
select * from t1 where a in (select a from t2 where t2.e = t1.e);
这个sql里面的子查询时用不到索引的,转换后变为:
select * from t1 where exists (select 1 from t2 where t2.e = t1.e and t1.a = t2.a)

转换之后t2表就能用到a字段的索引了。
所以,如果IN子查询不满足转换为semi-join的条件,又不能转换为物化表或者转换为物化表的成本太大,那么它就会被转换为EXISTS查询。

五、对于派生表的优化

select * from (select a, b from t1) as t;
上面这个sql,子查询是放在from后面的,这个子查询的结果相当于一个派生表,表的名称是t,有a,b两个字段。
对于派生表,有两种执行方式:

(一)把派生表物化

我们可以将派生表的结果集写到一个内部的临时表中,然后就把这个物化表当作普通表一样参与查询。当然,在对派生表进行物化时,使用了一种称为延迟物化的策略,也就是在查询中真正使用到派生表时才回去尝试物化派生表,而不是还没开始执行查询就把派生表物化掉。比如:
select * from (select * from t1 where a = 1) as derived1 inner join t2 on derived1.a = t2.a where t2.a =10;

如果采用物化派生表的方式来执行这个查询的话,那么执行时首先会到t1表中找出满足t1.a = 10的记录,如果找不到,说明参与连接的t1表记录就是空的,所以整个查询的结果集就是空的,所以也就没有必要去物化查询中的派生表了。

(二)将派生表和外层的表合并,也就是将查询重写为没有派生表的形式

比如下面这个sql:
select * from (select * from t1 where a = 1) as t;
和下面的sql是等价的:
select * from t1 where a = 1;

再看一些复杂一点的sql:
select * from (select * from t1 where a = 1) as t inner join t2 on t.a = t2.a where t2.b = 1;
我们可以将派生表与外层查询的表合并,然后将派生表中的搜索条件放到外层查询的搜索条件中,就像下面这样:
select * from t1 inner join t2 on t1.a = t2.a where t1.a = 1 and t2.b = 1;

这样通过将外层查询和派生表合并的方式成功的消除了派生表,也就意味着我们没必要再付出创建和访问临时表的成本了。可是并不是所有带有派生表的查询都能被成功的和外层查询合并,当派生表中有这些语句就不可以和外层查询合并:

聚集函数,比如MAX()、MIN()、SUM()啥的
DISTINCT
GROUP BY
HAVING
LIMIT
UNION 或者 UNION ALL
派生表对应的子查询的SELECT子句中含有另一个子查询

所以MySQL在执行带有派生表的时候,优先尝试把派生表和外层查询合并掉,如果不行的话,再把派生表物化掉执行查询。

以上均为鲁班学院学习资料,欢迎大家报班学习,真心推荐

发布了143 篇原创文章 · 获赞 49 · 访问量 25万+

猜你喜欢

转载自blog.csdn.net/weixin_36586564/article/details/104008195