我们平时写完一个SQL语句后,就扔给MySQL去执行了,在聊MySQL的基础逻辑架构的时候谈到过优化器,其实我们扔给MySQL执行的SQL语句不会立马执行,而是先交给优化器根据一些规则把这条SQL进行充分的优化以后才执行。下面就来聊聊优化器一些比较重要的优化规则。
一、条件优化
1.1 移除不必要的括号
有时候表达式里有许多无用的括号,比如这样:
((a = 5 AND b = c) OR ((a > c) AND (c < 5)))
看着就很烦,优化器会把那些用不到的括号给干掉,就是这样:
(a = 5 and b = c) OR (a > c AND c < 5)
1.2 常量传递
有时候某个表达式是某个列和某个常量做等值匹配,比如这样:
a = 5
当这个表达式和其他涉及列a
的表达式使用AND
连接起来时,可以将其他表达式中的a
的值替换为5
,比如这样:
a = 5 AND b > a
就可以被转换为:
a = 5 AND b > 5
1.3 等值传递
有时候多个列之间存在等值匹配的关系,比如这样:
a = b and b = c and c = 5
这个表达式可以被简化为:
a = 5 and b = 5 and c = 5
1.4 移除没用的条件
对于一些明显永远为TRUE
或者FALSE
的表达式,优化器会移除掉它们,比如这个表达式:
(a < 1 and b = b) OR (a = 6 OR 5 != 5)
很明显,b = b
这个表达式永远为TRUE
,5 != 5
这个表达式永远为FALSE
,所以简化后的表达式就是这样的:
(a < 1 and TRUE) OR (a = 6 OR FALSE)
可以继续被简化为
a < 1 OR a = 6
1.5 表达式计算
在查询开始执行之前,如果表达式中只包含常量的话,它的值会被先计算出来,比如这个:
a = 5 + 1
因为5 + 1
这个表达式只包含常量,所以就会被化简成:
a = 6
但是这里需要注意的是,如果某个列并不是以单独的形式作为表达式的操作数时,比如出现在函数中,出现在某个更复杂表达式中,就像这样:
ABS(a) > 5
或者:
-a < -8
优化器是不会尝试对这些表达式进行化简的。我们前边说过只有搜索条件中索引列和常数使用某些运算符连接起来才可能使用到索引,所以如果可以的话,最好让索引列以单独的形式出现在表达式中。
1.6 HAVING子句和WHERE子句的合并
如果查询语句中没有出现诸如SUM
、MAX
等等的聚集函数以及GROUP BY
子句,优化器就把HAVING
子句和WHERE
子句合并起来。
1.7 常量表检测
设计MySQL
的大叔觉得下边这两种查询运行的特别快:
-
查询的表中一条记录没有,或者只有一条记录。
-
使用主键等值匹配或者唯一二级索引列等值匹配作为搜索条件来查询某个表。
设计MySQL
的大叔觉得这两种查询花费的时间特别少,少到可以忽略,所以也把通过这两种方式查询的表称之为常量表
(英文名:constant tables
)。优化器在分析一个查询语句时,先首先执行常量表查询,然后把查询中涉及到该表的条件全部替换成常数,最后再分析其余表的查询成本,比方说这个查询语句:
SELECT * FROM table1 INNER JOIN table2
ON table1.column1 = table2.column2
WHERE table1.primary_key = 1;
很明显,这个查询可以使用主键和常量值的等值匹配来查询table1
表,也就是在这个查询中table1
表相当于常量表
,在分析对table2
表的查询成本之前,就会执行对table1
表的查询,并把查询中涉及table1
表的条件都替换掉,也就是上边的语句会被转换成这样:
SELECT table1表记录的各个字段的常量值, table2.* FROM table1 INNER JOIN table2
ON table1表column1列的常量值 = table2.column2;
二、外连接消除
我们前边说过,内连接
的驱动表和被驱动表的位置可以相互转换,而左(外)连接
和右(外)连接
的驱动表和被驱动表是固定的。这就导致内连接
可能通过优化表的连接顺序来降低整体的查询成本,而外连接
却无法优化表的连接顺序。为了故事的顺利发展,我们还是把之前介绍连接原理时用过的t1
和t2
表请出来,为了防止大家早就忘掉了,我们再看一下这两个表的结构:
CREATE TABLE t1 (
m1 int,
n1 char(1)
) Engine=InnoDB, CHARSET=utf8;
CREATE TABLE t2 (
m2 int,
n2 char(1)
) Engine=InnoDB, CHARSET=utf8;
mysql> SELECT * FROM t1;
+------+------+
| m1 | n1 |
+------+------+
| 1 | a |
| 2 | b |
| 3 | c |
+------+------+
3 rows in set (0.00 sec)
mysql> SELECT * FROM t2;
+------+------+
| m2 | n2 |
+------+------+
| 2 | b |
| 3 | c |
| 4 | d |
+------+------+
3 rows in set (0.00 sec)
外连接和内连接的本质区别就是:对于外连接的驱动表的记录来说,如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录仍然会被加入到结果集中,对应的被驱动表记录的各个字段使用NULL值填充;而内连接的驱动表的记录如果无法在被驱动表中找到匹配ON子句中的过滤条件的记录,那么该记录会被舍弃。查询效果就是这样:
mysql> SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2;
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
| 3 | c | 3 | c |
+------+------+------+------+
2 rows in set (0.00 sec)
mysql> SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2;
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
| 3 | c | 3 | c |
| 1 | a | NULL | NULL |
+------+------+------+------+
3 rows in set (0.00 sec)
对于上边例子中的(左)外连接来说,由于驱动表t1
中m1=1, n1='a'
的记录无法在被驱动表t2
中找到符合ON
子句条件t1.m1 = t2.m2
的记录,所以就直接把这条记录加入到结果集,对应的t2
表的m2
和n2
列的值都设置为NULL
。
- PS:右(外)连接和左(外)连接其实只在驱动表的选取方式上是不同的,其余方面都是一样的,所以优化器会首先把右(外)连接查询转换成左(外)连接查询。我们后边就不再唠叨右(外)连接了。
我们知道WHERE
子句的杀伤力比较大,凡是不符合WHERE子句中条件的记录都不会参与连接。只要我们在搜索条件中指定关于被驱动表相关列的值不为NULL
,那么外连接中在被驱动表中找不到符合ON
子句条件的驱动表记录也就被排除出最后的结果集了,也就是说:在这种情况下:外连接和内连接也就没有什么区别了!比方说这个查询:
mysql> SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2 WHERE t2.n2 IS NOT NULL;
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
| 3 | c | 3 | c |
+------+------+------+------+
2 rows in set (0.01 sec)
由于指定了被驱动表t2
的n2
列不允许为NULL
,所以上边的t1
和t2
表的左(外)连接查询和内连接查询是一样一样的。当然,我们也可以不用显式的指定被驱动表的某个列IS NOT NULL
,只要隐含的有这个意思就行了,比方说这样:
mysql> SELECT * FROM t1 LEFT JOIN t2 ON t1.m1 = t2.m2 WHERE t2.m2 = 2;
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
+------+------+------+------+
1 row in set (0.00 sec)
在这个例子中,我们在WHERE
子句中指定了被驱动表t2
的m2
列等于2
,也就相当于间接的指定了m2
列不为NULL
值,所以上边的这个左(外)连接查询其实和下边这个内连接查询是等价的:
mysql> SELECT * FROM t1 INNER JOIN t2 ON t1.m1 = t2.m2 WHERE t2.m2 = 2;
+------+------+------+------+
| m1 | n1 | m2 | n2 |
+------+------+------+------+
| 2 | b | 2 | b |
+------+------+------+------+
1 row in set (0.00 sec)
我们把这种在外连接查询中,指定的WHERE
子句中包含被驱动表中的列不为NULL
值的条件称之为空值拒绝
(英文名:reject-NULL
)。在被驱动表的WHERE子句符合空值拒绝的条件后,外连接和内连接可以相互转换。这种转换带来的好处就是查询优化器可以通过评估表的不同连接顺序的成本,选出成本最低的那种连接顺序来执行查询。
三、子查询优化
CREATE TABLE single_table (
id INT NOT NULL AUTO_INCREMENT,
key1 VARCHAR(100),
key2 INT,
key3 VARCHAR(100),
key_part1 VARCHAR(100),
key_part2 VARCHAR(100),
key_part3 VARCHAR(100),
common_field VARCHAR(100),
PRIMARY KEY (id),
KEY idx_key1 (key1),
UNIQUE KEY idx_key2 (key2),
KEY idx_key3 (key3),
KEY idx_key_part(key_part1, key_part2, key_part3)
) Engine=InnoDB CHARSET=utf8;
为了方便,我们假设有两个表s1
、s2
与这个single_table
表的构造是相同的,而且这两个表里边儿有10000条记录,除id列外其余的列都插入随机值。下边正式开始我们的表演。
3.1 小白们眼中子查询的执行方式
在我还是一个单纯无知的少年时,觉得子查询的执行方式是这样的:
-
如果该子查询是不相关子查询,比如下边这个查询:
SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2);
我年少时觉得这个查询是的执行方式是这样的:
-
先单独执行
(SELECT common_field FROM s2)
这个子查询。 -
然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询
SELECT * FROM s1 WHERE key1 IN (...)
。
-
-
如果该子查询是相关子查询,比如下边这个查询:
SELECT * FROM s1 WHERE key1 IN (SELECT common_field FROM s2 WHERE s1.key2 = s2.key2);
这个查询中的子查询中出现了
s1.key2 = s2.key2
这样的条件,意味着该子查询的执行依赖着外层查询的值,所以我年少时觉得这个查询的执行方式是这样的:-
先从外层查询中获取一条记录,本例中也就是先从
s1
表中获取一条记录。 -
然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,本例中就是从
s1
表中获取的那条记录中找出s1.key2
列的值,然后执行子查询。 -
最后根据子查询的查询结果来检测外层查询
WHERE
子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。 -
再次执行第一步,获取第二条外层查询中的记录,依次类推~
-
告诉我不只是我一个人是这样认为的,这样认为的同学请举起你们的双手~~~ 哇唔,还真不少~
其实设计MySQL
的大叔想了一系列的办法来优化子查询的执行,大部分情况下这些优化措施其实挺有效的,但是保不齐有的时候马失前蹄,下边我们详细唠叨各种不同类型的子查询具体是怎么执行的。
3.2 标量子查询、行子查询的执行方式
我们经常在下边两个场景中使用到标量子查询或者行子查询:
-
SELECT
子句中,我们前边说过的在查询列表中的子查询必须是标量子查询。 -
子查询使用
=
、>
、<
、>=
、<=
、<>
、!=
、<=>
等操作符和某个操作数组成一个布尔表达式,这样的子查询必须是标量子查询或者行子查询。
对于上述两种场景中的不相关标量子查询或者行子查询来说,它们的执行方式是简单的,比方说下边这个查询语句:
SELECT * FROM s1
WHERE key1 = (SELECT common_field FROM s2 WHERE key3 = 'a' LIMIT 1);
它的执行方式和年少的我想的一样:
-
先单独执行
(SELECT common_field FROM s2 WHERE key3 = 'a' LIMIT 1)
这个子查询。 -
然后在将上一步子查询得到的结果当作外层查询的参数再执行外层查询
SELECT * FROM s1 WHERE key1 = ...
。
也就是说,对于包含不相关的标量子查询或者行子查询的查询语句来说,MySQL会分别独立的执行外层查询和子查询,就当作两个单表查询就好了。
对于相关的标量子查询或者行子查询来说,比如下边这个查询:
SELECT * FROM s1 WHERE
key1 = (SELECT common_field FROM s2 WHERE s1.key3 = s2.key3 LIMIT 1);
事情也和年少的我想的一样,它的执行方式就是这样的:
-
先从外层查询中获取一条记录,本例中也就是先从
s1
表中获取一条记录。 -
然后从上一步骤中获取的那条记录中找出子查询中涉及到的值,本例中就是从
s1
表中获取的那条记录中找出s1.key3
列的值,然后执行子查询。 -
最后根据子查询的查询结果来检测外层查询
WHERE
子句的条件是否成立,如果成立,就把外层查询的那条记录加入到结果集,否则就丢弃。 -
再次执行第一步,获取第二条外层查询中的记录,依次类推~
也就是说对于一开始唠叨的两种使用标量子查询以及行子查询的场景中,MySQL
优化器的执行方式并没有什么新鲜的。
3.3 IN子查询优化
物化表的提出
对于不相关的IN
子查询,比如这样:
SELECT * FROM s1
WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = 'a');
我们最开始的感觉就是这种不相关的IN
子查询和不相关的标量子查询或者行子查询是一样一样的,都是把外层查询和子查询当作两个独立的单表查询来对待,可是很遗憾的是设计MySQL
的大叔为了优化IN
子查询倾注了太多心血(毕竟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
的大叔想了一个招:不直接将不相关子查询的结果集当作外层查询的参数,而是将该结果集写入一个临时表里。写入临时表的过程是这样的:
-
该临时表的列就是子查询结果集中的列。
-
写入临时表的记录会被去重。
我们说
IN
语句是判断某个操作数在不在某个集合中,集合中的值重不重复对整个IN
语句的结果并没有啥子关系,所以我们在将结果集写入临时表时对记录进行去重可以让临时表变得更小,更省地方 -
一般情况下子查询结果集不会大的离谱,所以会为它建立基于内存的使用
Memory
存储引擎的临时表,而且会为该表建立哈希索引。 -
如果子查询的结果集非常大,超过了系统变量
tmp_table_size
或者max_heap_table_size
,临时表会转而使用基于磁盘的存储引擎来保存结果集中的记录,索引类型也对应转变为B+
树索引。
设计MySQL
的大叔把这个将子查询结果集中的记录保存到临时表的过程称之为物化
(英文名:Materialize
)。为了方便起见,我们就把那个存储子查询结果集的临时表称之为物化表
。正因为物化表中的记录都建立了索引(基于内存的物化表有哈希索引,基于磁盘的有B+树索引),通过索引执行IN
语句判断某个操作数在不在子查询结果集中变得非常快,从而提升了子查询语句的性能。
物化表转连接
事情到这就完了?我们还得重新审视一下最开始的那个查询语句:
SELECT * FROM s1
WHERE key1 IN (SELECT common_field FROM s2 WHERE key3 = 'a');
当我们把子查询进行物化之后,假设子查询物化表的名称为materialized_table
,该物化表存储的子查询结果集的列为m_val
,那么这个查询其实可以从下边两种角度来看待:
- 从表
s1
的角度来看待,整个查询的意思其实是:对于s1
表中的每条记录来说,如果该记录的key1
列的值在子查询对应的物化表中,则该记录会被加入最终的结果集。画个图表示一下就是这样:
- 从子查询物化表的角度来看待,整个查询的意思其实是:对于子查询物化表的每个值来说,如果能在
s1
表中找到对应的key1
列的值与该值相等的记录,那么就把这些记录加入到最终的结果集。画个图表示一下就是这样:
也就是说其实上边的查询就相当于表s1
和子查询物化表materialized_table
进行内连接:
SELECT s1.* FROM s1 INNER JOIN materialized_table ON key1 = m_val;
转化成内连接之后就有意思了,查询优化器可以评估不同连接顺序需要的成本是多少,选取成本最低的那种查询方式执行查询。我们分析一下上述查询中使用外层查询的表s1
和物化表materialized_table
进行内连接的成本都是由哪几部分组成的:
-
如果使用
s1
表作为驱动表的话,总查询成本由下边几个部分组成:-
物化子查询时需要的成本
-
扫描
s1
表时的成本 -
s1表中的记录数量 × 通过
m_val = xxx
对materialized_table
表进行单表访问的成本(我们前边说过物化表中的记录是不重复的,并且为物化表中的列建立了索引,所以这个步骤显然是非常快的)。
-
-
如果使用
materialized_table
表作为驱动表的话,总查询成本由下边几个部分组成:-
物化子查询时需要的成本
-
扫描物化表时的成本
-
物化表中的记录数量 × 通过
key1 = xxx
对s1
表进行单表访问的成本(非常庆幸key1
列上建立了索引,所以这个步骤是非常快的)。
-
MySQL
查询优化器会通过运算来选择上述成本更低的方案来执行查询。
来源:掘金小册子《MySQL 是怎样运行的:从根儿上理解 MySQL》