一、前言
子查询,用白话解释就是查询语句中嵌套着另一个查询语句。相信用过MySQL的同学都知道甚至用过子查询,但是具体它是怎样实现的,查询效率如何,恐怕好多人就不太知道了,下面咱们一起探索一下。
二、准备内容
这里我们需要用到3个表,这3个表都有一个主键索引 id 和一个索引 a,字段 b 上无索引。存储过程 idata() 往表 t1 里插入的是 100 行数据,表 t2、t3 里插入了 1000 行数据。建表语句如下:
CREATE TABLE `t1` (
`id` INT ( 11 ) NOT NULL,
`t1_a` INT ( 11 ) DEFAULT NULL,
`t1_b` INT ( 11 ) DEFAULT NULL,
PRIMARY KEY ( `id` ),
KEY `idx_a` ( `t1_a` )) ENGINE = INNODB;
CREATE TABLE `t2` (
`id` INT ( 11 ) NOT NULL,
`t2_a` INT ( 11 ) DEFAULT NULL,
`t2_b` INT ( 11 ) DEFAULT NULL,
PRIMARY KEY ( `id` ),
KEY `idx_a` ( `t2_a` )) ENGINE = INNODB;
CREATE TABLE `t3` (
`id` INT ( 11 ) NOT NULL,
`t3_a` INT ( 11 ) DEFAULT NULL,
`t3_b` INT ( 11 ) DEFAULT NULL,
PRIMARY KEY ( `id` ),
KEY `idx_a` ( `t3_a` )) ENGINE = INNODB;
-- 向t1添加100条数据
-- drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=100)do
insert into t1 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
-- 向t2添加1000条数据
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=101;
while(i<=1100)do
insert into t2 values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
-- 向t2添加1000条数据,且t3_a列的值为倒叙
drop procedure idata;
delimiter ;;
create procedure idata()
begin
declare i int;
set i=101;
while(i<=1100)do
insert into t3 values(i, 1101-i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
三、子查询的语法形式和分类
1. 语法形式
子查询的语法规定,子查询可以在一个外层查询的各种位置出现,这里我们只介绍常用的几个:
<h4 id="h6-1.1. <code>FROM1.1.FROM
子句中
<h4 id="h6-1.2.<code>WHERE</code>或<code>IN1.2.如:
SELECT m, n FROM (SELECT m2 + 1 AS m, n2 AS n FROM t2 WHERE m2 > 2) AS t;
这个例子中的子查询是:
(SELECT m2 + 1 AS m, n2 AS n FROM t2 WHERE m2 > 2)
,这个放在FROM
子句中的子查询相当于一个表
,但又和我们平常使用的表有点儿不一样,这种由子查询结果集组成的表称之为派生表
。
WHERE
或IN
子句中
如:
SELECT * FROM t1 WHERE m1 = (SELECT MIN(m2) FROM t2);
SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2);
其他的还有 SELECT
子句中,ORDER BY
子句中,GROUP BY
子句中,虽然语法支持,但没啥意义,就不唠叨这些情况了。
2. 分类
2.1 按返回的结果集区分
- 标量子查询,只返回一个单一值的子查询称之为
标量子查询
,比如:SELECT * FROM t1 WHERE m1 = (SELECT m1 FROM t1 LIMIT 1);
- 行子查询,就是只返回一条记录的子查询,不过这条记录需要包含多个列(只包含一个列就成了标量子查询了)。比如:
SELECT * FROM t1 WHERE (m1, n1) = (SELECT m2, n2 FROM t2 LIMIT 1);
- 列子查询,就是只返回一个列的数据,不过这个列的数据需要包含多条记录(只包含一条记录就成了标量子查询了)。比如:
SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2);
- 表子查询,就是子查询的结果既包含很多条记录,又包含很多个列,比如:
SELECT * FROM t1 WHERE (m1, n1) IN (SELECT m2, n2 FROM t2);
其中的(SELECT m2, n2 FROM t2)
就是一个表子查询,这里需要和行子查询对比一下,行子查询中我们用了LIMIT 1
来保证子查询的结果只有一条记录。
2.2 按与外层查询关系来区分
- 不相关子查询,就是子查询可以单独运行出结果,而不依赖于外层查询的值,我们就可以把这个子查询称之为
不相关子查询
。 - 相关子查询,就是需要依赖于外层查询的值的子查询称之为
相关子查询
。比如:SELECT * FROM t1 WHERE m1 IN (SELECT m2 FROM t2 WHERE n1 = n2);
四、子查询在MySQL中是怎么执行的
1. 标量子查询、行子查询的执行方式
1.1 不相关子查询
如下边这个查询语句:
mysql root@localhost:test> explain select * from t1 where t1_a = (select t2_a from t2 limit 1);
+----+-------------+-------+-------+---------------+-------+---------+--------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+-------+---------------+-------+---------+--------+------+-------------+
| 1 | PRIMARY | t1 | ref | idx_a | idx_a | 5 | const | 1 | Using where |
| 2 | SUBQUERY | t2 | index | <null> | idx_a | 5 | <null> | 1000 | Using index |
+----+-------------+-------+-------+---------------+-------+---------+--------+------+-------------+
它的执行方式:
- 先单独执行
(select t2_a from t2 limit 1)
这个子查询。 - 然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询
select * from t1 where t1_a = ...
。
也就是说,对于包含不相关的标量子查询或者行子查询的查询语句来说,MySQL会分别独立的执行外层查询和子查询,就当作两个单表查询就好了。
1.2 相关的子查询
比如下边这个查询:
mysql root@localhost:test> explain select * from t1 where t1_a = (select t2_a from t2 where t1.t1_b=t2.t2_b limit 1);
+----+--------------------+-------+------+---------------+--------+---------+--------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+--------------------+-------+------+---------------+--------+---------+--------+------+-------------+
| 1 | PRIMARY | t1 | ALL | <null> | <null> | <null> | <null> | 100 | Using where |
| 2 | DEPENDENT SUBQUERY | t2 | ALL | <null> | <null> | <null> | <null> | 1000 | Using where |
+----+--------------------+-------+------+---------------+--------+---------+--------+------+-------------+
它的执行方式就是这样的:
- 先从外层查询中获取一条记录,本例中也就是先从
t1
表中获取一条记录。 - 然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,就是
t1
表中找出t1.t1_b
列的值,然后执行子查询。 - 最后根据子查询的查询结果来检测外层查询
WHERE
子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。 - 然后重复以上步骤,直到
t1
中的记录全部匹配完。
2. IN子查询
2.1 物化
如果子查询的结果集中的记录条数很少,那么把子查询和外层查询分别看成两个单独的单表查询效率还是蛮高的,但是如果单独执行子查询后的结果集太多的话,就会导致这些问题:
- 结果集太多,可能内存中都放不下~
- 对于外层查询来说,如果子查询的结果集太多,那就意味着
IN
子句中的参数特别多,这就导致:- 无法有效的使用索引,只能对外层查询进行全表扫描。
- 在对外层查询执行全表扫描时,由于
IN
子句中的参数太多,这会导致检测一条记录是否符合和IN
子句中的参数匹配花费的时间太长。
于是就有:不直接将不相关子查询的结果集当作外层查询的参数,而是将该结果集写入一个临时表里。写入临时表的过程是这样的:
- 该临时表的列就是子查询结果集中的列。
- 写入临时表的记录会被去重,让临时表变得更小,更省地方。
- 一般情况下子查询结果集不大时,就会为它建立基于内存的使用
Memory
存储引擎的临时表,而且会为该表建立哈希索引。如果子查询的结果集非常大,超过了系统变量tmp_table_size
或者max_heap_table_size
,临时表会转而使用基于磁盘的存储引擎来保存结果集中的记录,索引类型也对应转变为B+
树索引。
这个将子查询结果集中的记录保存到临时表的过程称之为物化
(Materialize
)。为了方便起见,我们就把那个存储子查询结果集的临时表称之为物化表
。正因为物化表中的记录都建立了索引(基于内存的物化表有哈希索引,基于磁盘的有B+树索引),通过索引执行IN
语句判断某个操作数在不在子查询结果集中变得非常快,从而提升了子查询语句的性能。
mysql root@localhost:test> explain select * from t3 where t3_a in (select t2_a from t2);
+----+--------------+-------------+--------+---------------+------------+---------+--------------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+--------------+-------------+--------+---------------+------------+---------+--------------+------+-------------+
| 1 | SIMPLE | t3 | ALL | idx_a | <null> | <null> | <null> | 1000 | Using where |
| 1 | SIMPLE | <subquery2> | eq_ref | <auto_key> | <auto_key> | 5 | test.t3.t3_a | 1 | <null> |
| 2 | MATERIALIZED | t2 | index | idx_a | idx_a | 5 | <null> | 1000 | Using index |
+----+--------------+-------------+--------+---------------+------------+---------+--------------+------+-------------+
其实上边的查询就相当于表t3
和子查询物化表进行内连接:
mysql root@localhost:test> explain select * from t3 left join t2 on t3.t3_a=t2.t2_a;
+----+-------------+-------+------+---------------+--------+---------+--------------+------+--------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+--------+---------+--------------+------+--------+
| 1 | SIMPLE | t3 | ALL | <null> | <null> | <null> | <null> | 1000 | <null> |
| 1 | SIMPLE | t2 | ref | idx_a | idx_a | 5 | test.t3.t3_a | 1 | <null> |
+----+-------------+-------+------+---------------+--------+---------+--------------+------+--------+
此时MySQL
查询优化器会通过运算来选择成本更低的方案来执行查询。
虽然,上面通过物化表的方式,将IN子查询转换成了联接查询,但还是会有建立临时表的成本,能不能不进行物化操作直接把子查询转换为连接呢?直接转换肯定不行。
-- 这里我们先构造了3条记录,其实也是构造不唯一的普通索引
+------+------+------+
| id | t2_a | t2_b |
+------+------+------+
| 1100 | 1000 | 1000 |
| 1101 | 1000 | 1000 |
| 1102 | 1000 | 1000 |
+------+------+------+
-- 加限制条件where t2.id>=1100是为了减少要显示的数据
mysql root@localhost:test> select * from t3 where t3_a in (select t2_a from t2 where t2.id>=1100);
+-----+------+------+
| id | t3_a | t3_b |
+-----+------+------+
| 101 | 1000 | 101 |
+-----+------+------+
1 row in set
Time: 0.016s
mysql root@localhost:test> select * from t3 left join t2 on t3.t3_a=t2.t2_a where t2.id>=1100;
+-----+------+------+------+------+------+
| id | t3_a | t3_b | id | t2_a | t2_b |
+-----+------+------+------+------+------+
| 101 | 1000 | 101 | 1100 | 1000 | 1000 |
| 101 | 1000 | 101 | 1101 | 1000 | 1000 |
| 101 | 1000 | 101 | 1102 | 1000 | 1000 |
+-----+------+------+------+------+------+
3 rows in set
Time: 0.018s
所以说IN
子查询和表联接之间并不完全等价。而我们需要的是另一种叫做半联接(semi-join
)的联接方式 :对于t3
表的某条记录来说,我们只关心在t2
表中是否存在与之匹配的记录,而不关心具体有多少条记录与之匹配,最终的结果集中也只保留t3
表的记录。
注意:semi-join只是在MySQL内部采用的一种执行子查询的方式,MySQL并没有提供面向用户的semi-join语法。
2.2 半联接的实现:
- Table pullout (子查询中的表上拉)
当子查询的查询列表处只有主键或者唯一索引列时,可以直接把子查询中的表上拉
到外层查询的FROM
子句中,并把子查询中的搜索条件合并到外层查询的搜索条件中,比如这个:
mysql root@localhost:test> select * from t3 where t3_a in (select t2_a from t2 where t2.id=999)
+-----+------+------+
| id | t3_a | t3_b |
+-----+------+------+
| 102 | 999 | 102 |
+-----+------+------+
1 row in set
Time: 0.024s
mysql root@localhost:test> select * from t3 join t2 on t3.t3_a=t2.t2_a where t2.id=999;
+-----+------+------+-----+------+------+
| id | t3_a | t3_b | id | t2_a | t2_b |
+-----+------+------+-----+------+------+
| 102 | 999 | 102 | 999 | 999 | 999 |
+-----+------+------+-----+------+------+
1 row in set
Time: 0.028s
mysql root@localhost:test> explain select * from t3 where t3_a in (select t2_a from t2 where t2.id=999)
+----+-------------+-------+-------+---------------+---------+---------+-------+------+--------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+-------+---------------+---------+---------+-------+------+--------+
| 1 | SIMPLE | t2 | const | PRIMARY,idx_a | PRIMARY | 4 | const | 1 | <null> |
| 1 | SIMPLE | t3 | ref | idx_a | idx_a | 5 | const | 1 | <null> |
+----+-------------+-------+-------+---------------+---------+---------+-------+------+--------+
- FirstMatch execution strategy (首次匹配)
FirstMatch
是一种最原始的半连接执行方式,跟相关子查询的执行方式是一样的,就是说先取一条外层查询的中的记录,然后到子查询的表中寻找符合匹配条件的记录,如果能找到一条,则将该外层查询的记录放入最终的结果集并且停止查找更多匹配的记录,如果找不到则把该外层查询的记录丢弃掉;然后再开始取下一条外层查询中的记录,重复上边这个过程。mysql root@localhost:test> explain select * from t3 where t3_a in (select t2_a from t2 where t2.t2_a=1000) +----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------+ | 1 | SIMPLE | t3 | ref | idx_a | idx_a | 5 | const | 1 | <null> | | 1 | SIMPLE | t2 | ref | idx_a | idx_a | 5 | const | 4 | Using index; FirstMatch(t3) | +----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------+
- DuplicateWeedout execution strategy (重复值消除)
转换为半连接查询后,t3
表中的某条记录可能在t2
表中有多条匹配的记录,所以该条记录可能多次被添加到最后的结果集中,为了消除重复,我们可以建立一个临时表,并设置主键id,每当某条t3
表中的记录要加入结果集时,就首先把这条记录的id
值加入到这个临时表里,如果添加成功,说明之前这条t2
表中的记录并没有加入最终的结果集,是一条需要的结果;如果添加失败,说明之前这条s1
表中的记录已经加入过最终的结果集,直接把它丢弃。
- LooseScan execution strategy (松散扫描)这种虽然是扫描索引,但只取值相同的记录的第一条去做匹配操作的方式称之为
松散扫描
。
2.3 半联接的适用条件
当然,并不是所有包含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
语句或者聚集函数。
EXISTS
子查询
不管子查询是相关的还是不相关的,都可以把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)
当然这个过程中有一些特殊情况,比如在outer_expr
或者inner_expr
值为NULL
的情况下就比较特殊。因为有NULL
值作为操作数的表达式结果往往是NULL
,比方说:
mysql root@localhost:test> SELECT NULL IN (1, 2, 3);
+-------------------+
| NULL IN (1, 2, 3) |
+-------------------+
| <null> |
+-------------------+
1 row in set
而EXISTS
子查询的结果肯定是TRUE
或者FASLE
。但是现实中我们大部分使用IN
子查询的场景是把它放在WHERE
或者ON
子句中,而WHERE
或者ON
子句是不区分NULL
和FALSE
的,比方说:
mysql root@localhost:test> SELECT 1 FROM s1 WHERE NULL;
+---+
| 1 |
+---+
0 rows in set
Time: 0.016s
mysql root@localhost:test> SELECT 1 FROM s1 WHERE FALSE;
+---+
| 1 |
+---+
0 rows in set
Time: 0.033s
所以只要我们的IN
子查询是放在WHERE
或者ON
子句中的,那么IN -> EXISTS
的转换就是没问题的。说了这么多,为啥要转换呢?这是因为不转换的话可能用不到索引,比方说下边这个查询:
mysql root@localhost:test> explain select * from t3 where t3_a in (select t2_a from t2 where t2.t2_a>=999) or t3_b > 1000;
+----+-------------+-------+-------+---------------+--------+---------+--------+------+--------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+-------+---------------+--------+---------+--------+------+--------------------------+
| 1 | PRIMARY | t3 | ALL | <null> | <null> | <null> | <null> | 1000 | Using where |
| 2 | SUBQUERY | t2 | range | idx_a | idx_a | 5 | <null> | 107 | Using where; Using index |
+----+-------------+-------+-------+---------------+--------+---------+--------+------+--------------------------+
但是将它转为EXISTS
子查询后却可以使用到索引:
mysql root@localhost:test> explain select * from t3 where exists (select 1 from t2 where t2.t2_a>=999 and t2.t2_a=t3.t3_a) or t3_b > 1000;
+----+--------------------+-------+------+---------------+--------+---------+--------------+------+--------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+--------------------+-------+------+---------------+--------+---------+--------------+------+--------------------------+
| 1 | PRIMARY | t3 | ALL | <null> | <null> | <null> | <null> | 1000 | Using where |
| 2 | DEPENDENT SUBQUERY | t2 | ref | idx_a | idx_a | 5 | test.t3.t3_a | 1 | Using where; Using index |
+----+--------------------+-------+------+---------------+--------+---------+--------------+------+--------------------------+
需要注意的是,如果
IN
子查询不满足转换为semi-join
的条件,又不能转换为物化表或者转换为物化表的成本太大,那么它就会被转换为EXISTS
查询。
五、总结
- 如果
IN
子查询符合转换为semi-join
的条件,查询优化器会优先把该子查询转换为semi-join
,然后再考虑下边执行半连接的策略中哪个成本最低:- Table pullout
- DuplicateWeedout
- LooseScan
- FirstMatch
- 如果
IN
子查询不符合转换为semi-join
的条件,那么查询优化器会从下边两种策略中找出一种成本更低的方式执行子查询:- 先将子查询物化之后再执行查询
- 执行
IN to EXISTS
转换。