MySQL之查询性能优化(三)
MySQL查询优化器的局限性
MySQL的万能“嵌套循环”并不是对每种查询都是最优的。不过还好,MySQL查询优化只对少部分查询不适用,而且我们往往可以通过改写查询让MySQL高效地完成工作。
关联子查询
MySQL的子查询实现都非常糟糕。最糟糕的一类查询是WHERE条件中包含IN()的子查询语句。例如,我们希望找到Sakila数据库中,演员Penlope Guiness(他的actor_id为1)参演的所有影片信息。很自然的,我们会按照下面的方式用子查询实现:
SELECT * FROM sakila.film WHERE film_id IN (SELECT film_id FROM sakila.film_actor WHERE actor_id = 1);
因为MySQL对IN()列表中的选项有专门的优化策略,一般会认为MySQL会先执行子查询返回锁包含actor_id为1的film_id。一般来说,IN()列表查询速度很快,所以我们会认为上面的查询会这样执行:
-- SELECT GROUP_CONCAT(film_id) FROM sakila.film_actor WHERE actor_id = 1; -- Result: 1,23,25,106,140,166,277,361,438,499,506,509,605,635,749,832,939,970,980 SELECT * FROM sakila.film WHERE film_id IN(1,23,25,106,140,166,277,361,438,499,506,509,605,635,749,832,939,970,980);
很不幸,MySQL不是这样做的。MYSQL会将相关的外层表压到子查询中,它认为这样可以更高效率地查找到数据行。也就是说,MySQL会将查询改写成下面的样子:
SELECT * FROM sakila.film WHERE EXISTS (SELECT * FROM sakila.film_actor WHERE actor_id = 1 AND film_actor.film_id = film.film_id);
这时,子查询需要根据film_id来关联外部表film,因为需要film_id字段,所以MySQL认为无法先执行这个子查询。通过EXPLAIN我们可以看到子查询是一个相关子查询:
mysql> EXPLAIN SELECT * FROM sakila.film WHERE EXISTS (SELECT * FROM sakila.film_actor WHERE actor_id = 1 AND film_actor.film_id = film.film_id)\G *************************** 1. row *************************** id: 1 select_type: PRIMARY table: film partitions: NULL type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 1000 filtered: 100.00 Extra: Using where *************************** 2. row *************************** id: 2 select_type: DEPENDENT SUBQUERY table: film_actor partitions: NULL type: eq_ref possible_keys: PRIMARY,idx_fk_film_id key: PRIMARY key_len: 4 ref: const,sakila.film.film_id rows: 1 filtered: 100.00 Extra: Using index 2 rows in set, 2 warnings (0.00 sec)
根据EXPLAIN的输出我们可以看到,MySQL先选择对film表进行全表扫描,然后根据返回的film_id逐个执行子查询。如果是一个很小的表,这个查询糟糕的性能可能不会引起注意,但如果外层的表是一个非常大的表,那么这个查询的性能会非常糟糕。当然我们很容易用下面的办法来重写这个查询:
SELECT film.* FROM sakila.film INNER JOIN sakila.film_actor USING(film_id) WHERE actor_id = 1;
另一个优化的办法是使用函数GROUP_CONCAT()在IN()中构造一个由逗号分隔的列表。有时这比上面的使用关联改写更快。因为使用IN()加子查询,性能经常会非常糟,所以建议使用EXISTS()等效的改写查询来获取更好的效率。下面是另一种改写IN()加子查询的办法:
SELECT film.* FROM sakila.film WHERE EXISTS(SELECT * FROM sakila.film_actor WHERE actor_id = 1 AND film_actor.film_id = film.film_id);
如何用好关联子查询
并不是所有关联子查询的性能都很差,是否很差需要有自己的测试和判断。很多时候,关联子查询是一种非常合理、自然,甚至是性能最好的写法。我们来看看下面的例子:
mysql> EXPLAIN SELECT film_id, language_id FROM sakila.film WHERE NOT EXISTS(SELECT * FROM sakila.film_actor WHERE film_actor.film_id = film.film_id)\G *************************** 1. row *************************** id: 1 select_type: PRIMARY table: film partitions: NULL type: index possible_keys: NULL key: idx_fk_language_id key_len: 1 ref: NULL rows: 1000 filtered: 100.00 Extra: Using where; Using index *************************** 2. row *************************** id: 2 select_type: DEPENDENT SUBQUERY table: film_actor partitions: NULL type: ref possible_keys: idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: sakila.film.film_id rows: 5 filtered: 100.00 Extra: Using index 2 rows in set, 2 warnings (0.00 sec)
一般会建议使用左外连接(LEFT OUTER JOIN)重写该查询,以代替子查询。理论上,改写后MySQL的执行计划完全不会改变。我们来看这个例子:
mysql> EXPLAIN SELECT film_id, language_id FROM sakila.film LEFT OUTER JOIN sakila.film_actor USING(film_id) WHERE film_actor.film_id IS NULL\G *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film partitions: NULL type: index possible_keys: NULL key: idx_fk_language_id key_len: 1 ref: NULL rows: 1000 filtered: 100.00 Extra: Using index *************************** 2. row *************************** id: 1 select_type: SIMPLE table: film_actor partitions: NULL type: ref possible_keys: idx_fk_film_id key: idx_fk_film_id key_len: 2 ref: sakila.film.film_id rows: 5 filtered: 100.00 Extra: Using where; Not exists; Using index 2 rows in set, 1 warning (0.00 sec)
可以看到,这里的执行计划基本上一样,下面是一些微小的区别:
- 表film_actor的访问类型一个是DEPENDENT SUBQUERY,而另一个是SIMPLE。这个不同是由于语句的写法不同导致的,一个是普通査询,一个是子査询。这对底层存储引擎接口来说,没有任何不同。
- 对film表,第二个査询的Extra中没有 “Using where”,但这不重要,第二个査询的USING子句和第一个査询的WHERE子句实际上是完全一样的。
- 在第二个表 film_actor的执行计划的Extra列有“Not exists”。这是我们前面章节中提到的提前终止算法(early-termination algorithm),MySQL通过使用“Not exists”优化来避免在表film_actor的索引中读取任何额外的行。这完全等效于直接编写NOT EXISTS子査询,这个执行计划中也是一样,一旦匹配到一行数据,就立刻停止扫描。
所以,从理论上讲,MySQL将使用完全相同的执行计划来完成这个查询。每个具体的案例会各有不同,有时候子查询写法也会快些。例如,当返回结果中只有一个表中的某些列的时候。听起来,这种情况对于关联查询效率也会很好。具体情况具体分析,例如下面的关联,我们希望返回所有包含同一个演员参演的电影,因为一个电影会有很多演员参演,所以可能会返回一些重复的记录:
SELECT film.film_id FROM sakila.film INNER JOIN sakila.film_actor USING(film_id);
我们需要使用DISTINCT和GROUP BY来移除重复的记录:
SELECT DISTINCT film.film_id FROM sakila.film INNER JOIN sakila.film_actor USING(film_id);
但是,回头看看这个查询,到底这个查询返回的结果集意义是什么?至少这样的写法会让SQL的意义不明显。如果使用EXISTS则很容易表达“包含同一个演员”的逻辑,而且不需要DISTINCT和GROUP BY,也不会产生重复的结果集,我们知道一旦使用DISTINCT和GROUP BY,那么在查询的执行过程中,通常需要产生临时中间表。下面我们用子查询的写法替换上面的写法:
SELECT film_id FROM sakila.film WHERE EXISTS(SELECT * FROM sakila.film_actor WHERE film.film_id = film_actor.film_id);
UNION的限制
有时,MySQL无法将限制条件从外层下推到内层,这使得原本能够限制部分返回结果的条件无法应用到内层查询的优化上。
如果希望UNION的各个子句能够根据LIMIT只取部分结果集,或者希望能够先排好序再合并结果集的话,就需要在UNION的各个子句中分别使用这些子句。例如,想将这两个子查询结果联合起来,然后再取前二十条记录,那么MySQL会将两个表都存放到同一个临时表中,然后再取出前二十行记录:
(SELECT first_name, last_name FROM sakila.actor ORDER BY last_name) UNION ALL (SELECT first_name, last_name FROM sakila.customer ORDER BY last_name) LIMIT 20;
这条查询将会把actor中的200条记录和customer表中的599条记录放在一个临时表中,然后再从临时表中取出前二十条。可以通过在UNION的两个子查询中分别加上一个LIMIT 20来减少临时表中的数据:
(SELECT first_name, last_name FROM sakila.actor ORDER BY last_name LIMIT 20) UNION ALL (SELECT first_name, last_name FROM sakila.customer ORDER BY last_name LIMIT 20) LIMIT 20;
现在中间临时表只包含四十条记录了,除了性能考虑之外,这里还需要考虑一点:从临时表中取出数据的顺序并不是一定的,所以如果想获得正确的顺序,还需要加上一个全局的ORDER BY和LIMIT操作。
索引合并优化
在5.0和更新版本中,当WHERE子句中包含多个复杂条件的时候,MySQL能够访问单个表的多个索引以合并和交叉过过滤的方式来定位需要查找的行。
等值传递
某些时候,等值传递会带来一些意想不到的额外消耗。例如,有一个非常大的IN()列表,而MySQL优化器发现存在WHERE、ON和USING子句,将这个列表的值和另一个表的某个列相关联。那么优化器会将IN()列表都复制应用到关联的各个表中。通常,因为各个表新增了过滤条件,优化器可以更高效地从存储引擎过滤记录。但如果这个列表非常大,则会导致优化和执行都会变慢。
并行执行
MySQL无法利用多核特性来并行执行查询,即一条查询可能分很多个步骤来执行,但必须是串行执行,而不能并行,但MySQL暂时做不到。
松散索引扫描
由于历史原因,MySQL并不支持松散索引扫描,也就无法按照一个不连续的方式扫描一个索引。通常,MySQL的索引扫描要先定义一个起点和终点,即使需要的数据只是这段索引中很少数的几个,MySQL仍需要扫描这段索引中每一个条目。下面,我们通过一个示例说明这点,假设我们有索引(a,b),有下面的查询:
SELECT …… FROM tb1 WHERE b BETWEEN 2 AND 3;
因为索引的前导字段是列a,但是在查询中只指定了字段b,MySQL无法使用这个索引,从而只能通过全表扫描找到匹配的行,如图1-5所示:
图1-5 MySQL通过全表扫描找到需要的记录
了解索引的物理结构的话,不难发现还有一个更快的办法执行上面的查询。索引的物理结构使得可以先扫描a列第一个值对应的b列的范围,然后再跳到a列第二个不同值扫描对应的b列的范围。如图1-6展示了如果由MySQL来实现这个过程会怎样?
图1-6 使用松散索引扫描效率会更高,但MySQL现在还不支持这么做
注意到,这时就无须再使用WHERE子句过滤,因为松散索引扫描已经跳过了所有不需要的记录。
上面是一个简单的例子,除了松散索引扫描,新增一个合适的索引当然也可以优化上述查询。但对于某些场景,增加索引是没用的,例如,对于第一个索引列是范围条件,第二个索引列是等值条件的查询,靠增加索引就无法解决问题。
MySQL5.0之后的版本,在某些特殊的场景下是可以使用松散索引扫描的,例如,在一个分组查询中需要找到分组的最大值和最小值:
mysql> EXPLAIN SELECT actor_id, MAX(film_id) FROM sakila.film_actor GROUP BY actor_id\G *************************** 1. row *************************** id: 1 select_type: SIMPLE table: film_actor partitions: NULL type: range possible_keys: PRIMARY,idx_fk_film_id key: PRIMARY key_len: 2 ref: NULL rows: 201 filtered: 100.00 Extra: Using index for group-by 1 row in set, 1 warning (0.09 sec)
在EXPLAIN中的Extra字段显示“Using index for group-by”,表示这里将使用松散哈希索引扫描。在MySQL很好地支持松散索引扫描之前,一个简单的绕过问题的办法就是给前面的列加上可能的常数值。
最大值和最小值优化
对于MIN()和MAX()查询,MySQL的优化做得不够好,这里有个例子:
SELECT MIN(actor_id) FROM actor WHERE first_name = 'PENELOPE';
因为first_name字段上并没有索引,因此MySQL将会进行一次全表扫描。如果MySQL能够进行主键扫描,那么理论上,当MySQL读到第一个满足条件的记录的时候,就是我们需要找的最小值了,因为主键严格按照actor_id字段的大小顺序排列的。但是MySQL这时只会做全表扫描,我们可以通过查看SHOW STATUS的全表扫描计数器来验证这一点。一个曲线的优化办法就是移除MIN(),然后使用LIMIT来讲查询重写如下:
SELECT actor_id FROM actor USE INDEX(PRIMARY) WHERE first_name = 'PENELOPE' LIMIT 1;
这个策略可以让MySQL扫描尽可能少的记录数,但这个SQL很难一眼看出我们所想表达的本意,但有时在一些对性能有要求的环境下,我们不得不放弃一些原则。
在同一个表查询和更新
MySQL不允许对同一张表同时进行查询和更新。这其实并不是优化器的限制,如果清楚MySQL是如何执行查询的,就可以避免这种情况。下面是一个无法运行的SQL虽然这是一个符合标准的SQL语句,这个SQL语句尝试将两个表中相似行的数量记录到字段cnt中:
UPDATE tb1 AS outer_tb1 SET cnt = (SELECT COUNT(*) FROM tb1 AS inner_tb1 WHERE inner_tb1.type = outer_tb1.type);
可以通过使用生成表的形式来绕过上面的限制,因为MySQL只会把这个表当做一个临时表来处理。实际上,这执行了两个查询,一个是子查询的SELECT语句,另一个是多表关联UPDATE,只是关联的表是一个临时表。子查询会在UPDATE语句打开表之前就完成,所以下面的查询将会正常执行:
UPDATE tb1 INNER JOIN(SELECT type, COUNT(*) AS cnt FROM tb1 GROUP BY type) AS der USING(type) SET tb1.cnt = der.cnt;