暂无图片
暂无图片
1
暂无图片
暂无图片
暂无图片

MySQL之执行计划详解(二)

GrowthDBA 2022-01-06
900

这是2022年的第一篇文章,新年新征程,祝大家心想事成、健康暴富。接上文MySQL之执行计划详解(一),我们继续学习解读执行计划。执行计划的输出列中,就剩下Extra。今天就来学习一下Extra输出内容的详细含义。

在MySQL官方文档中:https://dev.mysql.com/doc/refman/5.7/en/explain-output.html#explain_extra,Extra提供的额外信息有很多,我们只学习工作中常用的额外信息。大家感兴趣的话可以去官网学习测试。

执行计划输出各列的含义详解



Extra

Extra列是用来说明一些额外信息,可以通过这些额外信息来更准确的理解MySQL到底将如何执行给定的查询语句。
  • No tables used

当查询语句没有FROM子句时会提示该额外信息。

DESC SELECT 1;
复制

  • Impossible WHERE

WHERE子句永远为FALSE时将会提示该额外信息。

EXPLAIN SELECT * FROM t1 WHERE 1 != 1;
复制

  • No matching min/max row

当查询列表处有MIN或者MAX聚集函数,但是并没有符合WHERE子句中的搜索条件的记录时,将会提示该额外信息。

EXPLAIN SELECT MIN(key1) FROM t1 WHERE key1 = 'abcdefg';
复制

  • Using index

当查询列表以及搜索条件中只包含属于某个索引的列,也就是在可以使用索引覆盖的情况下,在Extra列将会提示该额外信息。

EXPLAIN SELECT key1 FROM t1 WHERE key1 = 'a';
复制

  • Using index condition

有些搜索条件中虽然出现了索引列,但却不能使用到索引:

DESC SELECT * FROM t1 WHERE key1 > 'z' AND key1 LIKE '%a';
复制

其中的key1 > 'z'可以使用到索引,但是key1 LIKE '%a'却无法使用到索引,在以前版本的MySQL中,是按照下边步骤来执行这个查询的:

1、先根据key1 > 'z'这个条件,从二级索引idx_key1中获取到对应的二级索引记录。

2、根据上一步骤得到的二级索引记录中的主键值进行回表,找到完整的用户记录再检测该记录是否符合key1 LIKE '%a'这个条件,将符合条件的记录加入到最后的结果集。

但是虽然key1 LIKE '%a'不能组成范围区间参与range访问方法的执行,但这个条件毕竟只涉及到了key1列,所以MySQL将上面的步骤改进了一下:

1、先根据key1 > 'z'这个条件,定位到二级索引idx_key1中对应的二级索引记录。

2、对于指定的二级索引记录,先不着急回表,而是先检测一下该记录是否满足key1 LIKE '%a'这个条件,如果这个条件不满足,则该二级索引记录没必要回表。

3、对于满足key1 LIKE '%a'这个条件的二级索引记录执行回表操作。

回表操作其实是一个随机IO,比较耗时,所以上述修改虽然只改进了一点点,但是可以省去好多回表操作的成本。MySQL把这个改进称之为索引条件下推(英文名:Index Condition Pushdown)

如果在查询语句的执行过程中将要使用索引条件下推这个特性,在Extra列中将会显示Using index condition。

DESC SELECT * FROM t1 WHERE key1 > 'z' AND key1 LIKE '%b';
复制

  • Using where

当我们使用全表扫描来执行对某个表的查询,并且该语句的WHERE子句中有针对该表的搜索条件时,在Extra列中会提示上述额外信息。

DESC SELECT * FROM t1 WHERE common_field = 'a';
复制

当使用索引访问来执行对某个表的查询,并且该语句的WHERE子句中有除了该索引包含的列之外的其他搜索条件时,在Extra列中也会提示上述额外信息。比如下边这个查询虽然使用idx_key1索引执行查询,但是搜索条件中除了包含key1的搜索条件key1 = 'a',还有包含common_field的搜索条件,所以Extra列会显示Using where的提示:

DESC SELECT * FROM t1 WHERE key1 = 'a' AND common_field = 'a';
复制

  • Using join buffer (Block Nested Loop)

在连接查询执行过程中,当被驱动表不能有效的利用索引加快访问速度,MySQL一般会为其分配一块名叫join buffer的内存块来加快查询速度,也就是我们所讲的基于块的嵌套循环算法(这个只是大家应该很熟悉了,哈哈,这回就把知识串起来了)。

DESC SELECT * FROM t1 INNER JOIN t2 ON t1.common_field = t2.common_field;
复制

对t2表的执行计划的Extra列显示了两个提示:

  • Using join buffer (Block Nested Loop):这是因为对表t2的访问不能有效利用索引,只好退而求其次,使用join buffer来减少对t2表的访问次数,从而提高性能。

  • Using where:可以看到查询语句中有一个t1.common_field = t2.common_field条件,因为t1是驱动表,t2是被驱动表,所以在访问t2表时,t1.common_field的值已经确定下来了,所以实际上查询t2表的条件就是t2.common_field = 一个常数,所以提示了Using where额外信息。

  • Not exists

使用左(外)连接时,如果WHERE子句中包含要求被驱动表的某个列等于NULL值的搜索条件,而且那个列又是不允许存储NULL值的,那么在该表的执行计划的Extra列就会提示Not exists额外信息。

DESC SELECT * FROM t1 LEFT JOIN t2 ON t1.key1 = t2.key1 WHERE t2.id IS NULL;
复制

上述查询中t1表是驱动表,t2表是被驱动表,t2.id列是不允许存储NULL值的,而WHERE子句中又包含t2.id IS NULL的搜索条件,这意味着必定是驱动表的记录在被驱动表中找不到匹配ON子句条件的记录才会把该驱动表的记录加入到最终的结果集,所以对于某条驱动表中的记录来说,如果能在被驱动表中找到1条符合ON子句条件的记录,那么该驱动表的记录就不会被加入到最终的结果集,也就是说我们没有必要到被驱动表中找到全部符合ON子句条件的记录,这样可以稍微节省一点性能。

  • Using intersect(...)、Using union(...)和Using sort_union(...)

如果执行计划的Extra列出现了Using intersect(...)提示,说明准备使用Intersect索引合并的方式执行查询,括号中的...表示需要进行索引合并的索引名称;如果出现了Using union(...)提示,说明准备使用Union索引合并的方式执行查询;出现了Using sort_union(...)提示,说明准备使用Sort-Union索引合并的方式执行查询。

DESC SELECT * FROM t1 WHERE key1 = '61' AND key3 = 'f05';
复制

Extra列显示了Using intersect(idx_key3,idx_key1),表明MySQL即将使用idx_key3和idx_key1这两个索引进行Intersect索引合并的方式执行查询。

  • Zero limit

LIMIT子句的参数为0时,表示不打算从表中读出任何记录,将会提示该额外信息。

EXPLAIN SELECT * FROM t1 LIMIT 0;
复制

  • Using filesort

有一些情况下对结果集中的记录进行排序是可以使用到索引的。

DESC SELECT * FROM t1 ORDER BY key1 LIMIT 10;
复制

这个查询语句可以利用idx_key1索引直接取出key1列的10条记录,然后再进行回表操作就好了。但是很多情况下排序操作无法使用到索引,只能在内存中(记录较少的时候)或者磁盘中(记录较多的时候)进行排序,MySQL把这种在内存中或者磁盘上进行排序的方式统称为文件排序(英文名:filesort)。如果某个查询需要使用文件排序的方式执行查询,就会在执行计划的Extra列中显示Using filesort提示。

DESC SELECT * FROM t1 ORDER BY common_field LIMIT 10;
复制

小提示
如果查询中需要使用filesort的方式进行排序的记录非常多,那么这个过程是很耗费性能的,我们最好想办法将使用文件排序的执行方式改为使用索引进行排序
  • Using temporary

在许多查询的执行过程中,MySQL可能会借助临时表来完成一些功能,比如去重、排序之类的,比如我们在执行许多包含DISTINCT、GROUP BY、UNION等子句的查询过程中,如果不能有效利用索引来完成查询,MySQL很有可能寻求通过建立内部的临时表来执行查询。如果查询中使用到了内部的临时表,在执行计划的Extra列将会显示Using temporary提示。

DESC SELECT DISTINCT common_field FROM t1;
复制

DESC SELECT common_field, COUNT(*) AS amount FROM t1 GROUP BY common_field;
复制

上述执行计划的Extra列不仅仅包含Using temporary提示,还包含Using filesort提示,可是我们的查询语句中明明没有写ORDER BY子句呀?这是因为MySQL会在包含GROUP BY子句的查询中默认添加上ORDER BY子句,也就是说上述查询其实和下边这个查询等价:
DESC SELECT common_field, COUNT(*) AS amount FROM t1 GROUP BY common_field ORDER BY common_field;
复制

如果我们并不想为包含GROUP BY子句的查询进行排序,需要我们显式的写上ORDER BY NULL。
DESC SELECT common_field, COUNT(*) AS amount FROM t1 GROUP BY common_field ORDER BY NULL;
复制

这回执行计划中就没有Using filesort的提示了,也就意味着执行查询时可以省去对记录进行文件排序的成本了。

另外,执行计划中出现Using temporary并不是一个好的征兆,因为建立与维护临时表要付出很大成本的,所以我们最好能使用索引来替代掉使用临时表,比方说下边这个包含GROUP BY子句的查询就不需要使用临时表:

DESC SELECT key1, COUNT(*) AS amount FROM t1 GROUP BY key1;
复制

从Extra的Using index的提示里我们可以看出,上述查询只需要扫描idx_key1索引就可以搞定了,不再需要临时表了。
  • Start temporary, End temporary

当查询中包含子查询,查询优化器会优先尝试将IN子查询转换成semi-join,而semi-join又有好多种执行策略,当执行策略为DuplicateWeedout时,也就是通过建立临时表来实现为外层查询中的记录进行去重操作时,驱动表查询执行计划的Extra列将显示Start temporary提示,被驱动表查询执行计划的Extra列将显示End temporary提示。
  • LooseScan

在将IN子查询转为semi-join时,如果采用的是LooseScan执行策略,则在驱动表执行计划的Extra列就是显示LooseScan提示。
DESC SELECT * FROM t1 WHERE key3 IN (SELECT key1 FROM t2 WHERE key1 > 'z');
复制

  • FirstMatch(table_name)
在将In子查询转为semi-join时,如果采用的是FirstMatch执行策略,则在被驱动表执行计划的Extra列就是显示FirstMatch(table_name)提示。
DESC SELECT * FROM t1 WHERE common_field IN (SELECT key1 FROM t2 where t1.key3 = t2.key3);
复制




其他格式的执行计划



JSON格式的执行计划

EXPLAIN语句输出中缺少了一个衡量执行计划好坏的重要属性 —— 成本。不过MySQL贴心的为我们提供了一种查看某个执行计划花费的成本的方式:

EXPLAIN(DESC或DESCRIBE)
和真正的查询语句中间加上FORMAT=JSON
。这样我们就可以得到一个json格式的执行计划,里边儿包含该计划花费的成本。

mysql> DESC FORMAT=JSON SELECT * FROM t1 INNER JOIN t2 ON t1.key1 = t2.key2 WHERE t1.common_field = 'a'\G*************************** 1. row ***************************EXPLAIN: {  "query_block": {    "select_id": 1,    "cost_info": {      "query_cost": "3294.12"    },    "nested_loop": [      {        "table": {          "table_name": "t1",          "access_type": "ALL",          "possible_keys": [            "idx_key1"          ],          "rows_examined_per_scan": 9991,          "rows_produced_per_join": 999,          "filtered": "10.00",          "cost_info": {            "read_cost": "1895.38",            "eval_cost": "199.82",            "prefix_cost": "2095.20",            "data_read_per_join": "2M"          },          "used_columns": [            "id",            "key1",            "key2",            "key3",            "key_part1",            "key_part2",            "key_part3",            "common_field"          ],          "attached_condition": "((`test_cost`.`t1`.`common_field` = 'a') and (`test_cost`.`t1`.`key1` is not null))"        }      },      {        "table": {          "table_name": "t2",          "access_type": "ref",          "possible_keys": [            "uq_key2"          ],          "key": "uq_key2",          "used_key_parts": [            "key2"          ],          "key_length": "5",          "ref": [            "test_cost.t1.key1"          ],          "rows_examined_per_scan": 1,          "rows_produced_per_join": 999,          "filtered": "100.00",          "index_condition": "(`test_cost`.`t1`.`key1` = `test_cost`.`t2`.`key2`)",          "cost_info": {            "read_cost": "999.10",            "eval_cost": "199.82",            "prefix_cost": "3294.12",            "data_read_per_join": "2M"          },          "used_columns": [            "id",            "key1",            "key2",            "key3",            "key_part1",            "key_part2",            "key_part3",            "common_field"          ]        }      }    ]  }}1 row in set, 2 warnings (0.01 sec)
复制

输出的内容有些多,我们来一起看一下:

输出的内容‍‍‍描述‍‍‍

整个查询语句只有1个SELECT关键字,该关键字对应的id号为1

整个查询的执行成本预计为3294.12

几个表之间采用嵌套循环连接算法执行,以下是参与嵌套循环连接算法的各个表的信息

t1表是驱动表
访问方法为ALL,意味着使用全表扫描访问
可能使用的索引:idx_key1

查询一次t1表大致需要扫描9991条记录
驱动表t1的扇出是999
condition filtering代表的百分比:10%

单次查询t1表总共的成本为2095.20
读取的数据量2M(2×1024×1024)
执行查询中涉及到的列

对t1表访问时针对单表查询的条件:"((`test_cost`.`t1`.`common_field` = 'a') and (`test_cost`.`t1`.`key1` is not null))"

t2表是被驱动表
访问方法为ref,意味着使用索引等值匹配的方式访问
可能使用的索引:uq_key2
使用到的索引列:key2
key_len:5
与key2列进行等值匹配的对象:test_cost.t1.key1

查询一次t2表大致需要扫描1条记录
被驱动表t2的扇出是999(由于后边没有多余的表进行连接,所以这个值也没啥用)
condition filtering代表的百分比:100%

t2表使用索引进行查询的搜索条件:"(`test_cost`.`t1`.`key1` = `test_cost`.`t2`.`key2`)"

单次查询t1、多次查询t2表总共的成本3294.12
读取的数据量2M(2×1024×1024)

执行查询中涉及到的列

上述的输出中,我们有一个部分没有说明。下面先来看一下t1表的"cost_info"部分:

"cost_info": {  "read_cost": "1895.38",  "eval_cost": "199.82",  "prefix_cost": "2095.20",  "data_read_per_join": "2M"}
复制
  • read_cost的组成
1、IO成本
2、检测rows × (1 - filter)条记录的CPU成本
小提示
rows和filter都是我们前边介绍执行计划的输出列,在JSON格式的执行计划中,rows相当于rows_examined_per_scan,filtered名称不变
  • eval_cost的组成
检测rows × filter条记录的成本
  • prefix_cost就是单独查询t1表的成本
prefix_cost = read_cost + eval_cost
  • data_read_per_join
表示在此次查询中需要读取的数据量

再来看下t2表的"cost_info"部分:

"cost_info": {  "read_cost": "999.10",  "eval_cost": "199.82",  "prefix_cost": "3294.12",  "data_read_per_join": "2M"}
复制

由于t2表是被驱动表,所以可能被读取多次,这里的read_cost和eval_cost是访问多次t2表后累加起来的值,主要关注prefix_cost的值代表的是整个连接查询预计的成本,也就是单次查询t1表和多次查询t2表后的成本的和,也就是:

prefix_cost(t1表) + read_cost(t2表) + eval_cost(t2表) = 总成本2095.20 + 999.10 + 199.82 = 3294.12
复制

所以最终求得执行查询的总成本是:3294.12。

TREE格式的执行计划

同样地,在EXPLAIN(DESC或DESCRIBE)和真正的查询语句中间加上FORMAT=TREE。这样我们就可以得到一个tree格式的执行计划,里边儿包含该计划花费的成本。但是TREE格式的执行计划只有MySQL 8.0后才支持。
mysql> DESC FORMAT=TREE SELECT * FROM student t1 INNER JOIN student2 t2 ON t1.address = t2.address WHERE t1.address LIKE '北京市%'\G*************************** 1. row ***************************EXPLAIN: -> Filter: (t2.address = t1.address) (cost=4.95 rows=3)    -> Inner hash join (<hash>(t2.address)=<hash>(t1.address)) (cost=4.95 rows=3)        -> Table scan on t2 (cost=0.23 rows=16)        -> Hash            -> Filter: (t1.address like '北京市%') (cost=1.85 rows=2)                -> Table scan on t1 (cost=1.85 rows=16)1 row in set (0.00 sec)
复制

TREE格式的执行计划将查询的每一个步骤都分解成树型进行显示,由上可知,执行这个查询的总成本是:4.95。




执行计划扩展信息




我们在使用EXPLAIN(DESC或DESCRIBE)查看某个查询的执行计划后,还可以使用SHOW WARNINGS语句查看与这个查询的执行计划有关的一些扩展信息。

EXPLAIN SELECT t1.key1, t2.key1 FROM t1 LEFT JOIN t2 ON t1.key1 = t2.key1 WHERE t2.common_field IS NOT NULL;SHOW WARNINGS\G
复制

可以看到SHOW WARNINGS展示出来的信息有三个字段,分别是Level、Code、Message。我们最常见的就是Code为1003的信息,当Code值为1003时,Message字段展示的信息类似于查询优化器将我们的查询语句重写后的语句。比如我们上边的查询本来是一个左(外)连接查询,但是有一个t2.common_field IS NOT NULL的条件,就会导致查询优化器把左(外)连接查询优化为内连接查询,从SHOW WARNINGS的Message字段也可以看出来,原本的LEFT JOIN已经变成了JOIN。

小提示
Message字段展示的信息类似于查询优化器将我们的查询语句重写后的语句,并不是等价于。也就是说Message字段展示的信息并不是标准的查询语句,在很多情况下重写后的语句并不能直接运行,它只能作为帮助我们理解MySQL将如何执行查询语句的一个参考依据而已


小结




通过两篇文章的篇幅详细学习了如何解读执行计划。通过执行计划,我们可以快速获取到一个查询的执行过程,并且可以快速定位到SQL的性能瓶颈所在,通过创建合理的索引和适当的改写,以达到优化SQL的目的。同时,我们还学习了FORMAT=JSON格式的执行计划,JSON格式的执行计划为我们提供了一种查看某个执行计划花费的成本的方式,使执行计划查看起来更加直观。同时我也简单介绍了一下TREE格式的执行计划和执行计划的扩展信息,为帮助我们理解MySQL将如何执行查询语句的一个参考依据。站在巨人肩膀上,每天进步一点点。



 参考资料 




  • https://dev.mysql.com/doc/refman/5.7/en/explain-output.html#explain_extra

  • 小孩子4919《MySQL是怎样运行的:从根上理解MySQL》

end


文章转载自 GrowthDBA,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论