MySQL对子查询的优化

MySQL 专栏收录该内容
33 篇文章 0 订阅

子查询语法

在一个查询语句A里的某个位置也可以有另一个查询语句B,这个出现在A
语句的某个位置中的查询B就被称为子查询,A也被称之为外层查询。子查询可
以在一个外层查询的各种位置出现,

子查询出现在SELECT子句中

也就是我们平时说的查询列表中,比如这样:

SELECT (SELECT m1 FROM e1 LIMIT 1);

其中的(SELECT m1 FROM e1 LIMIT 1)就是子查询。

子查询出现在FROM子句中

比如:

SELECT m, n FROM (SELECT m2 + 1 AS m, n2 AS n FROM e2 WHERE m2 > 2) AS
 t;

这个例子中的子查询是:(SELECT m2 + 1 AS m, n2 AS n FROM e2 WHERE m2 >
2),这里可以把子查询的查询结果当作是一个表,子查询后边的AS t表明这个子
查询的结果就相当于一个名称为t的表,这个名叫t的表的列就是子查询结果中
的列,比如例子中表t就有两个列:m列和n列。这个放在FROM子句中的子查
询本质上相当于一个表,但又和我们平常使用的表有点儿不一样,MySQL 把这种
由子查询结果集组成的表称之为派生表。

子查询出现在WHERE或ON子句中

把子查询放在外层查询的WHERE子句或者ON子句中可能是我们最常用的
一种使用子查询的方式了,比如这样:

SELECT * FROM e1 WHERE m1 IN (SELECT m2 FROM e2);

这个查询表明我们想要将(SELECT m2 FROM e2)这个子查询的结果作为外层
查询的IN语句参数,整个查询语句的意思就是我们想找e1表中的某些记录,这
些记录的m1列的值能在e2表的m2列找到匹配的值。

子查询出现在ORDER BY子句、GROUP BY子句中

虽然语法支持,但没啥意义。

子查询的分类

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

因为子查询本身也算是一个查询,所以可以按照它们返回的不同结果集类型
而把这些子查询分为不同的类型:

标量子查询

那些只返回一个单一值的子查询称之为标量子查询,比如这样:

SELECT (SELECT m1 FROM e1 LIMIT 1);

或者这样:

SELECT * FROM e1 WHERE m1 = (SELECT MIN(m2) FROM e2);
SELECT * FROM e1 WHERE m1 < (SELECT MIN(m2) FROM e2);

这两个查询语句中的子查询都返回一个单一的值,也就是一个标量。这些标
量子查询可以作为一个单一值或者表达式的一部分出现在查询语句的各个地方。

行子查询

顾名思义,就是返回一条记录的子查询,不过这条记录需要包含多个列(只
包含一个列就成了标量子查询了)。比如这样:

SELECT * FROM e1 WHERE (m1, n1) = (SELECT m2, n2 FROM e2 LIMIT 1);

其中的(SELECT m2, n2 FROM e2 LIMIT 1)就是一个行子查询,整条语句的含义
就是要从e1表中找一些记录,这些记录的m1和n1列分别等于子查询结果中的
m2 和n2列。

列子查询

列子查询自然就是查询出一个列的数据喽,不过这个列的数据需要包含多条
记录(只包含一条记录就成了标量子查询了)。比如这样:

SELECT * FROM e1 WHERE m1 IN (SELECT m2 FROM e2);

其中的(SELECT m2 FROM e2)就是一个列子查询,表明查询出e2表的m2列
的值作为外层查询IN语句的参数。

表子查询

顾名思义,就是子查询的结果既包含很多条记录,又包含很多个列,比如这
样:

SELECT * FROM e1 WHERE (m1, n1) IN (SELECT m2, n2 FROM e2);

其中的(SELECT m2, n2 FROM e2)就是一个表子查询,这里需要和行子查询对
比一下,行子查询中我们用了LIMIT 1来保证子查询的结果只有一条记录,表子
查询中不需要这个限制。

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

不相关子查询

如果子查询可以单独运行出结果,而不依赖于外层查询的值,我们就可以把
这个子查询称之为不相关子查询。我们前边介绍的那些子查询全部都可以看作不
相关子查询。

相关子查询

如果子查询的执行需要依赖于外层查询的值,我们就可以把这个子查询称之
为相关子查询。比如:

SELECT * FROM e1 WHERE m1 IN (SELECT m2 FROM e2 WHERE n1 = n2);

例子中的子查询是(SELECT m2 FROM e2 WHERE n1 = n2),可是这个查询中有
一个搜索条件是n1=n2,别忘了n1是表e1的列,也就是外层查询的列,也就
是说子查询的执行需要依赖于外层查询的值,所以这个子查询就是一个相关子查
询。

[NOT] IN/ANY/SOME/ALL 子查询

对于列子查询和表子查询来说,它们的结果集中包含很多条记录,这些记录
相当于是一个集合,所以就不能单纯的和另外一个操作数使用操作符来组成布尔
表达式了,MySQL通过下面的语法来支持某个操作数和一个集合组成一个布尔表
达式:

IN或者NOT IN

具体的语法形式如下:

操作数 [NOT] IN (子查询)

这个布尔表达式的意思是用来判断某个操作数在不在由子查询结果集组成
的集合中,比如下边的查询的意思是找出e1表中的某些记录,这些记录存在于
子查询的结果集中:

SELECT * FROM e1 WHERE (m1, n1) IN (SELECT m2, n2 FROM e2);

ANY/SOME(ANY和SOME是同义词)

具体的语法形式如下:

操作数 比较符 ANY/SOME(子查询)

这个布尔表达式的意思是只要子查询结果集中存在某个值和给定的操作数
做比较操作,比较结果为TRUE,那么整个表达式的结果就为TRUE,否则整个表
达式的结果就为FALSE。比方说下边这个查询:

SELECT * FROM e1 WHERE m1 > ANY(SELECT m2 FROM e2);

这个查询的意思就是对于e1表的某条记录的m1列的值来说,如果子查询
(SELECT m2 FROM e2)的结果集中存在一个小于m1列的值,那么整个布尔表达式
的值就是TRUE,否则为FALSE,也就是说只要m1列的值大于子查询结果集中最
小的值,整个表达式的结果就是TRUE,所以上边的查询本质上等价于这个查询:

SELECT * FROM e1 WHERE m1 > (SELECT MIN(m2) FROM e2);

另外,=ANY相当于判断子查询结果集中是否存在某个值和给定的操作数相
等,它的含义和IN是相同的。

ALL

具体的语法形式如下:

操作数 比较操作 ALL(子查询)

这个布尔表达式的意思是子查询结果集中所有的值和给定的操作数做比较
操作比较结果为TRUE,那么整个表达式的结果就为TRUE,否则整个表达式的结
果就为FALSE。比方说下边这个查询:

SELECT * FROM e1 WHERE m1 > ALL(SELECT m2 FROM e2);

这个查询的意思就是对于e1表的某条记录的m1列的值来说,如果子查询
(SELECT m2 FROM e2)的结果集中的所有值都小于m1列的值,那么整个布尔表达
式的值就是TRUE,否则为FALSE,也就是说只要m1列的值大于子查询结果集中
最大的值,整个表达式的结果就是 TRUE,所以上边的查询本质上等价于这个查
询:

SELECT * FROM e1 WHERE m1 > (SELECT MAX(m2) FROM e2);

EXISTS子查询

有的时候我们仅仅需要判断子查询的结果集中是否有记录,而不在乎它的记
录具体是个啥,可以使用把EXISTS或者NOT EXISTS放在子查询语句前边,就像
这样:

SELECT * FROM e1 WHERE EXISTS (SELECT 1 FROM e2);

对于子查询(SELECT 1 FROM e2)来说,我们并不关心这个子查询最后到底查
询出的结果是什么,所以查询列表里填*、某个列名,或者其他啥东西都无所谓,
我们真正关心的是子查询的结果集中是否存在记录。也就是说只要(SELECT 1
FROM e2)这个查询中有记录,那么整个EXISTS表达式的结果就为TRUE。

子查询语法注意事项

  • 子查询必须用小括号扩起来。

  • 在SELECT子句中的子查询必须是标量子查询,如果子查询结果集中有多个
    列或者多个行,都不允许放在SELECT子句中,在想要得到标量子查询或者行子
    查询,但又不能保证子查询的结果集只有一条记录时,应该使用LIMIT 1语句来
    限制记录数量。

  • 对于[NOT] IN/ANY/SOME/ALL子查询来说,子查询中不允许有LIMIT语句,
    而且这类子查询中ORDER BY子句、DISTINCT语句、没有聚集函数以及HAVING
    子句的GROUP BY子句没有什么意义。因为子查询的结果其实就相当于一个集合,
    集合里的值排不排序等一点儿都不重要。

  • 不允许在一条语句中增删改某个表的记录时同时还对该表进行子查询。

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

想象子查询的执行方式

想象中子查询的执行方式是这样的:

如果该子查询是不相关子查询,比如下边这个查询:


SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2);

先单独执行(SELECT order_note FROM s2)这个子查询。然后在将上一步子查
询得到的结果当作外层查询的参数再执行外层查询SELECT * FROM s1 WHERE
order_note IN (…)。

如果该子查询是相关子查询,比如下边这个查询:

SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2 WHERE
 s1.order_no= s2.order_no);

这个查询中的子查询中出现了s1.order_no= s2.order_no这样的条件,意味
着该子查询的执行依赖着外层查询的值,先从外层查询中获取一条记录,本例中
也就是先从s1表中获取一条记录,然后执行子查询。
最后根据子查询的查询结果来检测外层查询WHERE子句的条件是否成立,
如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。
再次执行第一步,获取第二条外层查询中的记录,依次类推。

但真的是这样吗?其实MySQL用了一系列的办法来优化子查询的执行,大
部分情况下这些优化措施其实挺有效的,下边我们来看看各种不同类型的子查询
具体是怎么执行的。

标量子查询、行子查询的执行方式

对于不相关标量子查询或者行子查询来说,它们的执行方式很简单,比方说
下边这个查询语句:

SELECT * FROM s1 WHERE order_note = (SELECT order_note FROM s2 WHERE
 key3 = 'a' LIMIT 1);

它的执行方式和我们前面想象的一样:先单独执行(SELECT order_note FROM
s2 WHERE key3 = ‘a’ LIMIT 1)这个子查询。然后在将上一步子查询得到的结果当作
外层查询的参数再执行外层查询SELECT * FROM s1 WHERE order_note= …。

也就是说,对于包含不相关的标量子查询或者行子查询的查询语句来说,
MySQL会分别独立的执行外层查询和子查询,就当作两个单表查询就好了。

对于相关的标量子查询或者行子查询来说,比如下边这个查询:

SELECT * FROM s1 WHERE
 order_note = (SELECT order_note FROM s2 WHERE s1.order_no=
 s2.order_no LIMIT 1);

事情也和我们前面想象的一样,它的执行方式就是这样的:

先从外层查询中获取一条记录,本例中也就是先从 s1 表中获取一条记录。
然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,本例中就是
从s1表中获取的那条记录中找出s1.order_no列的值,然后执行子查询。
最后根据子查询的查询结果来检测外层查询WHERE子句的条件是否成立,
如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。
再次执行第一步,获取第二条外层查询中的记录,依次类推。

也就是说对于两种使用标量子查询以及行子查询的场景中,MySQL优化器的
执行方式并没有什么新鲜的。

MySQL对IN子查询的优化

物化表

对于不相关的IN子查询,比如这样:

SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2 WHERE
 order_no = 'a');

我们最开始的感觉就是这种不相关的IN子查询和不相关的标量子查询或者
行子查询是一样一样的,都是把外层查询和子查询当作两个独立的单表查询来对
待。但是MySQL为了优化IN子查询下了很大力气,所以整个执行过程并不像我
们想象的那么简单。

对于不相关的IN子查询来说,如果子查询的结果集中的记录条数很少,那
么把子查询和外层查询分别看成两个单独的单表查询效率很高,但是如果单独执
行子查询后的结果集太多的话,就会导致这些问题:

  • 结果集太多,可能内存中都放不下。

  • 对于外层查询来说,如果子查询的结果集太多,那就意味着IN子句中的
    参数特别多,这就导致:无法有效的使用索引,只能对外层查询进行全表扫描。

在对外层查询执行全表扫描时,由于IN子句中的参数太多,这会导致检测
一条记录是否符合和IN子句中的参数匹配花费的时间太长。

比如说IN子句中的参数只有两个:

SELECT * FROM tbl_name WHERE column IN (a, b);

这样相当于需要对tbl_name表中的每条记录判断一下它的column列是否符
合column = a OR column = b。在IN子句中的参数比较少时这并不是什么问题,
如果IN子句中的参数比较多时,比如这样:

SELECT * FROM tbl_name WHERE column IN (a, b, c ..., ...);

那么这样每条记录需要判断一下它的column列是否符合column = a OR
column = b OR column = c OR …,这样性能耗费可就多了。

MySQL的改进是不直接将不相关子查询的结果集当作外层查询的参数,而是
将该结果集写入一个临时表里。写入临时表的过程是这样的:

1、该临时表的列就是子查询结果集中的列。

2、写入临时表的记录会被去重,临时表也是个表,只要为表中记录的所有
列建立主键或者唯一索引。

一般情况下子查询结果集不会大的离谱,所以会为它建立基于内存的使用
Memory存储引擎的临时表,而且会为该表建立哈希索引。

如果子查询的结果集非常大,超过了系统变量tmp_table_size或者
max_heap_table_size,临时表会转而使用基于磁盘的存储引擎来保存结果集中的
记录,索引类型也对应转变为B+树索引。

MySQL把这个将子查询结果集中的记录保存到临时表的过程称之为物化(Materialize)。为了方便起见,我们就把那个存储子查询结果集的临时表

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

物化表转连接

事情到这就完了?我们还得重新审视一下最开始的那个查询语句:

SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2 WHERE
 order_no = 'a');

当我们把子查询进行物化之后,假设子查询物化表的名称为
materialized_table,该物化表存储的子查询结果集的列为m_val,那么这个查询
就相当于表s1和子查询物化表materialized_table进行内连接:

SELECT s1.* FROM s1 INNER JOIN materialized_table ON order_note = m_val;

转化成内连接之后就有意思了,查询优化器可以评估不同连接顺序需要的成
本是多少,选取成本最低的那种查询方式执行查询。

将子查询转换为 semi-join

虽然将子查询进行物化之后再执行查询都会有建立临时表的成本,但是不管
怎么说,我们见识到了将子查询转换为连接的强大作用,MySQL继续开脑洞:能
不能不进行物化操作直接把子查询转换为连接呢?让我们重新审视一下上边的
查询语句:

SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2 WHERE
 order_no = 'a');

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

SELECT s1.* FROM s1 INNER JOIN s2
 ON s1.order_note = s2.order_note
 WHERE s2.order_no= 'a';

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

情况一:对于s1表的某条记录来说,s2表中没有任何记录满足s1.order_note = s2.order_note这个条件,那么该记录自然也不会加入到最后的结果集。

情况二:对于s1表的某条记录来说,s2 表中有且只有1条记录满足
s1.order_note = s2.order_note这个条件,那么该记录会被加入最终的结果集。

情况三:对于s1表的某条记录来说,s2 表中至少有2条记录满足
s1.order_note = s2.order_note这个条件,那么该记录会被多次加入最终的结果集。

对于s1表的某条记录来说,由于我们只关心s2表中是否存在记录满足
s1.order_no = s2.order_note这个条件,而不关心具体有多少条记录与之匹配,又
因为有情况三的存在,我们上边所说的IN子查询和两表连接之间并不完全等价。

但是将子查询转换为连接又真的可以充分发挥优化器的作用,所以MySQL在这
里提出了一个新概念 — 半连接(semi-join)。

将s1表和s2表进行半连接的意思就是:对于 s1 表的某条记录来说,我们
只关心在 s2 表中是否存在与之匹配的记录,而不关心具体有多少条记录与之匹
配,最终的结果集中只保留s1表的记录。为了让大家有更直观的感受,我们假
设MySQL内部是这么改写上边的子查询的:

SELECT s1.* FROM s1 SEMI JOIN s2
 ON s1.order_note = s2.order_note
 WHERE order_no= 'a';

注意:semi-join只是在 MySQL 内部采用的一种执行子查询的方式,MySQL
并没有提供面向用户的semi-join语法。

概念是有了,怎么实现这种所谓的半连接呢?MySQL准备了好几种办法。

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

当子查询的查询列表处只有主键或者唯一索引列时,可以直接把子查询中的
表上拉到外层查询的FROM子句中,并把子查询中的搜索条件合并到外层查询的
搜索条件中,比如假设s2中存在这个一个key2列,列上有唯一性索引:

SELECT * FROM s1
 WHERE key2 IN (SELECT key2 FROM s2 WHERE key3 = 'a');

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

SELECT s1.* FROM s1 INNER JOIN s2
 ON s1.key2 = s2.key2
 WHERE s2.key3 = 'a';

为啥当子查询的查询列表处只有主键或者唯一索引列时,就可以直接将子查
询转换为连接查询呢?因为主键或者唯一索引列中的数据本身就是不重复的嘛!
所以对于同一条s1表中的记录,你不可能找到两条以上的符合s1.key2=s2.key2
的记录。

DuplicateWeedout execution strategy (重复值消除)

对于这个查询来说:

SELECT * FROM s1 WHERE order_note IN (SELECT order_note FROM s2 WHERE
 order_no= 'a');

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

CREATE TABLE tmp (
id PRIMARY KEY
);

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

LooseScan execution strategy (松散扫描)

大家看这个查询:

SELECT * FROM s1 WHERE order_note IN (SELECT order_no FROM s2 WHERE
 order_no> 'a' AND order_no< 'b');

在子查询中,对于s2表的访问可以使用到order_no列的索引,而恰好子查
询的查询列表处就是order_no列,这样在将该查询转换为半连接查询后,如果
将s2作为驱动表执行查询的话,那么执行过程就是这样:

在s2表的idx_order_no索引中,值为’aa’的二级索引记录一共有3条,那么
只需要取第一条的值到s1表中查找s1.order_note= 'aa’的记录,如果能在s1 表中
找到对应的记录,那么就把对应的记录加入到结果集。依此类推,其他值相同的
二级索引记录,也只需要取第一条记录的值到s1表中找匹配的记录,这种虽然
是扫描索引,但只取值相同的记录的第一条去做匹配操作的方式称之为松散扫描。

当然除了我们上面所说的,MySQL中的半连接方式还有好几种,比如
Semi-join Materializationa 半连接物化、FirstMatch execution strategy (首次匹配)
等等,我们就不更深入的讨论了。

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:

  • 该子查询必须是和 IN 语句组成的布尔表达式,并且在外层查询的 WHERE 或
    者 ON 子句中出现。

  • 外层查询也可以有其他的搜索条件,只不过和 IN 子查询的搜索条件必须使
    用 AND 连接起来。

  • 该子查询必须是一个单一的查询,不能是由若干查询由 UNION 连接起来的
    形式。

  • 该子查询不能包含 GROUP BY 或者 HAVING 语句或者聚集函数。

MySQL对不能转为semi-join查询的子查询优化

1、对于不相关子查询来说,可以尝试把它们物化之后再参与查询
比如我们上边提到的这个查询:

SELECT * FROM s1 WHERE order_note NOT IN (SELECT order_note FROM s2
 WHERE order_no= 'a')

先将子查询物化,然后再判断order_note是否在物化表的结果集中可以加
快查询执行的速度。

2、不管子查询是相关的还是不相关的,都可以把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 s1 WHERE order_no IN (SELECT order_no FROM s2 where
 s1.order_note = s2.order_note) OR insert_time >2021-03-22 18:28:28;

这个查询中的子查询是一个相关子查询,而且子查询执行的时候不能使用到
索引,但是将它转为EXISTS子查询后却可以使用到索引:

SELECT * FROM s1 WHERE EXISTS (SELECT 1 FROM s2 where s1.order_note =
 s2.order_note AND s2.order_no= s1.order_no) OR insert_time >2021-03-22
 18:28:2800;

转为EXISTS子查询时便可能使用到s2表的idx_order_no索引了。
需要注意的是,如果IN子查询不满足转换为semi-join的条件,又不能转换
为物化表或者转换为物化表的成本太大,那么它就会被转换为EXISTS查询。
在MySQL5.5以及之前的版本没有引进semi-join和物化的方式优化子查询时,
优化器都会把IN子查询转换为EXISTS子查询,所以当时好多声音都是建议大家
把子查询转为连接,不过随着MySQL的发展,最近的版本中引入了非常多的子
查询优化策略,内部的转换工作优化器会为大家自动实现。

  • 1
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 代码科技 设计师:Amelia_0503 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值