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

MySQL之连接查询成本

GrowthDBA 2021-12-25
838

上篇文章MySQL之单表查询成本我们学习了单表查询成本的计算过程,今天我们来学习一下连接查询成本的相关知识,话不多说,Let's GO。


Condition Filtering介绍




因为要做连接查询,所以我们创建一张和table_query_cost表一模一样的表table_query_cost2。
CREATE TABLE `table_query_cost` (    `id` INT(11) NOT NULL AUTO_INCREMENT,    `key1` VARCHAR(100),    `key2` INT(11) ,    `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 uq_key2 (`key2`),    KEY idx_key3 (`key3`),    KEY idx_key_part(`key_part1`, `key_part2`, `key_part3`)) Engine=InnoDB;
MySQL中连接查询采用的是嵌套循环连接算法,驱动表会被访问一次,被驱动表可能会被访问多次,所以对于两表连接查询来说,它的查询成本由下边两个部分构成:
  • 单次查询驱动表的成本
  • 多次查询被驱动表的成本(具体查询多少次取决于对驱动表查询的结果集中有多少条记录)
对驱动表进行查询后得到的记录条数称之为驱动表的扇出(英文名:fanout)。很显然驱动表的扇出值越小,对被驱动表的查询次数也就越少,连接查询的总成本也就越低。计算整个连接查询所使用的成本时,就需要首先计算出驱动表的扇出值,有的时候扇出值的计算是很容易的,比如下面的查询:
e.g. 1
SELECT * FROM table_query_cost AS t1 INNER JOIN table_query_cost2 AS t2;

假设t1表是驱动表,对驱动表的单表查询只能使用全表扫描,有多少条记录扇出值就是多少。根据之前统计信息中t1表的Rows是10056,所以驱动表t1的扇出值就是10056。

e.g. 2
SELECT * FROM table_query_cost AS t1 INNER JOIN table_query_cost2 AS t2   WHERE t1.key2 > 99 AND t1.key2 < 999;

同样假设t1表是驱动表,对驱动表的单表查询可以使用uq_key2索引。uq_key2范围区间(99,999)的记录数就是扇出值,根据之前计算的uq_key2范围区间(99,999)的记录数是86,所以此情况下驱动表t1的扇出值就是86。

上述这两种情况是比较简单、理想的场景,但是有的时候,为满足业务上的需求,SQL会写成下面的情况:

e.g. 3

SELECT * FROM table_query_cost AS t1 INNER JOIN table_query_cost2 AS t2   WHERE t1.common_field > 'a6c5a45';

e.g. 1类似,在内连接的基础上,多了一个t1.common_field > 'a6c5a45'的搜索条件。查询优化器不会真正去执行,只能这10056行记录里有多少条记录满足t1.common_field > 'a6c5a45'这个条件。

e.g. 4

SELECT * FROM table_query_cost AS t1 INNER JOIN table_query_cost2 AS t2   WHERE t1.key2 > 99 AND t1.key2 < 999   AND t1.common_field > 'a6c5a45';
e.g. 2类似,对驱动表t1也多了一个t1.common_field > 'a6c5a45'的搜索条件,但因可以使用到uq_key2索引,所以只需要从符合二级索引范围区间的记录中有多少条记录符合t1.common_field > 'a6c5a45'条件,也就是从86条记录中有多少复合t1.common_field > 'a6c5a45'这个条件。
e.g. 5
SELECT * FROM table_query_cost AS t1 INNER JOIN table_query_cost2 AS t2   WHERE t1.key2 > 99 AND t1.key2 < 999   AND t1.key1 IN ('e4', '2f', 'd7')   AND t1.common_field > 'a6c5a45';
e.g. 2类似,在驱动表t1选取uq_key2索引执行查询后,优化器需要从符合二级索引范围区间中猜出有多少条记录复合t1.key1 IN ('e4', '2f', 'd7')、t1.common_field > 'a6c5a45'这两个条件,也就是说需要猜86条记录中有多少记录符合这两个条件。
列出这么多情况,只想表达一个事情:在这两种情况下计算驱动表扇出值时需要靠猜:
  • 如果使用的是全表扫描的方式执行的单表查询,那么计算驱动表扇出时需要猜满足搜索条件的记录到底有多少条
  • 如果使用的是索引执行的单表扫描,那么计算驱动表扇出的时候需要猜满足除使用到对应索引的搜索条件外的其他搜索条件的记录有多少条
MySQL把这个猜的过程称之为condition filtering。当然,这个过程可能会使用到索引,也可能使用到统计数据,也可能就是设计MySQL时单纯的瞎猜,整个评估过程挺复杂的,技术深度未达,就先跳过了。


两表连接的成本分析



连接查询的成本计算公式:
连接查询总成本 = 单次访问驱动表的成本 + 驱动表扇出数 x 单次访问被驱动表的成本

对于左(外)连接和右(外)连接查询来说,驱动表固定,想要得到最优的查询方案只需要:

  • 分别为驱动表和被驱动表选择成本最低的访问方法。

  • 可是对于内连接来说,驱动表和被驱动表的位置是可以互换的,需要考虑两个方面的问题:

1、不同的表作为驱动表最终的查询成本可能是不同的,也就是需要考虑最优的表连接顺序。

2、然后分别为驱动表和被驱动表选择成本最低的访问方法。

综上,计算内连接查询成本的方式更复杂,下面以内连接为例看看如何计算出最优的连接查询方案。
看一个查询:
SELECT * FROM table_query_cost AS t1 INNER JOIN table_query_cost2 AS t2   ON t1.key1 = t2.common_field   WHERE t1.key2 > 99 AND t1.key2 < 999   AND t2.key2 > 999 AND t2.key2 < 1999;

可以选择两种连接顺序:

1、t1连接t2,t1为驱动表,t2为被驱动表;

2、t2连接t1,t2为驱动表,t1为被驱动表。

查询优化器需要分别考虑这两种情况下的最优查询成本,然后选取那个成本更低的连接顺序以及该连接顺序下各个表的最优访问方法作为最终的查询计划。

  • t1作为驱动表的情况
  • 分析对于驱动表的成本最低执行方案:涉及驱动表t1的搜索条件有t1.key2 > 99 AND t1.key2 < 999,这个查询可以用到uq_key2索引,和全表扫描相比,使用uq_key2索引执行查询成本更低。

  • 分析对于被驱动表的成本最低的执行方案:涉及被驱动表t2的搜索条件有两个:

①t2.common_field = 常数(因为对驱动表t1结果集中的每一条记录,都需要进行一次被驱动表t2的访问,此时那些涉及两表的条件现在相当于只涉及被驱动表t2)。
②t2.key2 > 999 AND t2.key2 < 1999,明显看出由于t2.common_field没有用到索引,使用uq_key2索引执行查询成本更低。
所以,使用t1作为驱动表的总成本就是(暂时忽略join buffer影响):
使用uq_key2访问t1的成本 + t1的扇出 × 使用uq_key2访问t2的成本
  • t2作为驱动表的情况

  • 分析对于驱动表的成本最低执行方案:涉及对驱动表t2的搜索条件有t2.key2 >999 AND t2.key2 <1999,查询可能使用到uq_key2索引,和全表扫描相比,使用uq_key2索引执行查询成本更低。

  • 分析对于被驱动表的成本最低的执行方案:涉及被驱动表t1的搜索条件有两个:

①t1.key1 = 常数。
②t1.key2 > 99 AND t1.key2 < 999。
这时情况就变得有意思了,使用idx_key1可以使用ref方式访问,使用uq_key2可以使用range方式访问。这时优化器就需要从全表扫描、idx_key1、uq_key2这几个方案中选出成本最低的方案。
uq_key2的范围区间是确定的(99,999),可以通过之前目录项记录方式来计算成本,但是t1.key1 = 常数中的常数我们不确定,如何衡量成本呢?其实直接使用索引统计数据就可以了(索引列平均一个值重复多少次)。下面一起来看下:
SHOW INDEX FROM test_cost.table_query_cost;

属性有点多,介绍一下:

属性名‍‍‍描述‍‍‍
Table索引所属的表名
Non_unique索引列的值是否唯一,聚簇索引和唯一二级索引的值为0,普通二级索引的值为1
Key_name索引的名称
Seq_in_index索引列在索引中的位置,从1开始计数。比如复合索引idx_key_part,key_part1、key_part2、key_part3对应的位置分别是1、2、3
Column_name索引列的名称
Collation索引列中值的排序方式,A代表ASC升序存放,NULL为降序存放
Cardinality索引列中不重复值的数量
Sub_part对于存储字符串或者字节串的列来说,我们只会将这些串的前N个字符或字节创建索引,这个属性就是那个N值,NULL表示对完整列建立了索引
Packed索引列如何被压缩,NULL表示未压缩
Null该索引列是否允许存储NULL值
Index_type使用的索引类型,常见的就是BTREE,就是B+树
Comment索引列注释信息
Index_comment索引信息

上述属性大家应该都能看懂。我们最关注的就是Cardinality属性,Cardinality直译过来就是基数,表示索引列中不重复值的个数。如表中有10W行记录,Cardinality属性值为10W,表示这个字段没有重复值,如果Cardinality属性值为1的话,那就意味着该字段全是重复值。结合Rows统计数据,我们可以针对索引列,计算出平均一个值重复多少次。

一个值的重复次数 ≈ Rows ÷ Cardinality

回到如何衡量t1.key1= 常数的成本问题上,以table_query_cost表的idx_key1索引为例,Rows值为10056,Cardinality属性值为256,可以计算得到key1列的平均单个值的重复次数就是:

10056 ÷ 256 ≈ 39(条)

一般情况下,ref的访问方式要比range成本更低,这里假设使用idx_key1进行对t1的访问。

所以,使用t2作为驱动表的总成本就是:

使用uq_key2访问t2的成本 + t2的扇出 × 使用idx_key1访问t1的成本

最后优化器会比较这两种方式的最优访问成本,选取那个成本更低的连接顺序去真正的执行查询。从上边的计算过程也可以看出来,连接查询成本占大头的其实是驱动表扇出数 x 单次访问被驱动表的成本,所以我们的优化重点其实是下边这两个部分:

  • 尽量减少驱动表的扇出

  • 对被驱动表的访问成本尽量低

这一点对于我们实际书写连接查询语句时十分有用,我们需要尽量在被驱动表的连接列上建立索引,这样就可以使用ref访问方法来降低访问被驱动表的成本了。如果可以,被驱动表的连接列最好是该表的主键或者唯一二级索引列,这样就可以把访问被驱动表的成本降到更低了。



多表连接的成本分析




真实使用过程中,我们可能会进行多表连接,分析一下多表连接查询的成本。首先要考虑的就是多表连接可能产生多少种连接顺序:
  • 两表,比如A表和B表连接:

共有AB、BA两种连接顺序,相当于2 × 1 = 2种连接顺序。

  • 三表,比如A表、B表和C表连接:

共有ABC、ACB、BAC、BCA、CAB、CBA六种连接顺序,相当于3 × 2 × 1 = 6中连接顺序。

  • 四表,则有4 × 3 × 2 × 1 = 24种连接顺序。

  • 对于n张表,则有 n × (n-1) × (n-2) × ··· × 1种连接顺序,就是n的阶乘种连接顺序,也就是n!

如果有n个表进行连接,MySQL查询优化器要将每一种连接顺序的查询成本都计算一遍吗?那样的话代价就太大了,业务怎么能接受花费大量的时间来决定SQL怎么执行上。所以,MySQL想了很多办法来减少计算非常多种连接顺序的成本的方法
1、提前结束某种顺序的成本评估。MySQL在计算各种链接顺序的成本之前,会维护一个全局的变量,这个变量表示当前最小的连接查询成本。如果在分析某个连接顺序的成本时,该成本已经超过当前最小的连接查询成本,那就压根儿不对该连接顺序继续往下分析了。比方说A、B、C三个表进行连接,已经得到连接顺序ABC是当前的最小连接成本,比方说10.0,在计算连接顺序BCA时,发现B和C的连接成本就已经大于10.0时,就不再继续往后分析BCA这个连接顺序的成本了。
2、系统变量optimizer_search_depth。为了防止无穷无尽的分析各种连接顺序的成本,MySQL提出了optimizer_search_depth系统变量,如果连接表的个数小于该值,那么就继续穷举分析每一种连接顺序的成本,否则只对与optimizer_search_depth值相同数量的表进行穷举分析。很显然,该值越大,成本分析的越精确,越容易得到好的执行计划,但是消耗的时间也就越长,否则得到不是很好的执行计划,但可以省掉很多分析连接成本的时间。
3、根据某些规则不考虑某些连接顺序。即使是有上边两条规则的限制,但是分析多个表不同连接顺序成本花费的时间还是会很长,所以MySQL干脆提出了一些所谓的启发式规则(就是根据以往经验指定的一些规则),凡是不满足这些规则的连接顺序压根儿就不分析,这样可以极大的减少需要分析的连接顺序的数量,但是也可能造成错失最优的执行计划。他们提供了一个系统变量optimizer_prune_level来控制到底是不是用这些启发式规则。


调节成本常数




除了MySQL之单表查询成本介绍的成本常数外,MySQL还支持好多。
SHOW TABLES FROM mysql LIKE '%cost%';

MySQL分为Server层和存储引擎层,在server层进行连接管理、查询缓存、语法解析、查询优化等操作,在存储引擎层执行具体的数据存取操作。也就是说一条语句在server层中执行的成本是和它操作的表使用的存储引擎是没关系的,所以关于这些操作对应的成本常数就存储在了server_cost表中,而依赖于存储引擎的一些操作对应的成本常数就存储在了engine_cost表中。

mysql.server_cost表

SELECT * FROM mysql.server_cost;

  • cost_name:成本常数的名称。

  • cost_value:成本常数对应的值。如果该列的值为NULL的话,意味着对应的成本常数会采用默认值。

  • last_update:表示最后更新记录的时间。

  • comment:注释。

目前在Server层的一些操作对应的成本常数有以下几种:

成本常数名称‍‍‍默认值‍‍‍描述‍‍‍
disk_temptable_create_cost40.0创建基于磁盘的临时表的成本,如果增大这个值的话会让优化器尽量少的创建基于磁盘的临时表。
disk_temptable_row_cost1.0向基于磁盘的临时表写入或读取一条记录的成本,如果增大这个值的话会让优化器尽量少的创建基于磁盘的临时表。
key_compare_cost0.1两条记录做比较操作的成本,多用在排序操作上,如果增大这个值的话会提升filesort的成本,让优化器可能更倾向于使用索引完成排序而不是filesort。
memory_temptable_create_cost2.0创建基于内存的临时表的成本,如果增大这个值的话会让优化器尽量少的创建基于内存的临时表。
memory_temptable_row_cost0.2向基于内存的临时表写入或读取一条记录的成本,如果增大这个值的话会让优化器尽量少的创建基于内存的临时表。
row_evaluate_cost0.2这个就是我们之前一直使用的检测一条记录是否符合搜索条件的成本,增大这个值可能让优化器更倾向于使用索引而不是直接全表扫描。
小提示
MySQL在执行诸如DISTINCT查询、分组查询、Union查询以及某些特殊条件下的排序查询都可能在内部先创建一个临时表,使用这个临时表来辅助完成查询(比如对于DISTINCT查询可以建一个带有UNIQUE索引的临时表,直接把需要去重的记录插入到这个临时表中,插入完成之后的记录就是结果集了)。在数据量大的情况下可能创建基于磁盘的临时表,也就是为该临时表使用MyISAM、InnoDB等存储引擎,在数据量不大时可能创建基于内存的临时表,也就是使用Memory存储引擎

这些成本常数在server_cost中的初始值都是NULL,意味着优化器会使用它们的默认值来计算某个操作的成本,如果我们想修改某个成本常数的值的话,需要做两个步骤:

1、对我们感兴趣的成本常数做更新操作,比方说我们想把检测一条记录是否符合搜索条件的成本增大到0.4,那么就可以这样写更新语句:

UPDATE mysql.server_cost     SET cost_value = 0.4    WHERE cost_name = 'row_evaluate_cost';

2、让系统重新加载这个表的值:

FLUSH OPTIMIZER_COSTS;

在你修改完某个成本常数后想把它们再改回默认值的话,可以直接把cost_value的值设置为NULL,再使用FLUSH OPTIMIZER_COSTS语句让系统重新加载它即可。

mysql.engine_cost表

SELECT * FROM mysql.engine_cost;

与server_cost相比,engine_cost多了两个列:

  • engine_name:成本常数适用的存储引擎名称。如果该值为default,意味着对应的成本常数适用于所有的存储引擎。

  • device_type:存储引擎使用的设备类型,这主要是为了区分常规的机械硬盘和固态硬盘。

目前支持的存储引擎成本常数只有两个:

成本常数名称‍‍‍默认值‍‍‍描述‍‍‍
io_block_read_cost1.0从磁盘上读取一个块对应的成本。请注意我使用的是块,而不是页这个词儿。对于InnoDB存储引擎来说,一个页就是一个块,不过对于MyISAM存储引擎来说,默认是以4096字节作为一个块的。增大这个值会加重I/O成本,可能让优化器更倾向于选择使用索引执行查询而不是执行全表扫描。
memory_block_read_cost1.0与上一个参数类似,只不过衡量的是从内存中读取一个块对应的成本。

在MySQL目前的实现中,并不能准确预测某个查询需要访问的块中有哪些块已经加载到内存中,有哪些块还停留在磁盘上,所以MySQL很粗暴的认为不管这个块有没有加载到内存中,使用的成本都是1.0。

与更新server_cost表中的记录一样,我们也可以通过更新engine_cost表中的记录来更改关于存储引擎的成本常数,我们也可以通过为engine_cost表插入新记录的方式来添加只针对某种存储引擎的成本常数:

1、插入针对某个存储引擎的成本常数,比如我们想增大InnoDB存储引擎页面I/O的成本,书写正常的插入语句即可:

INSERT INTO mysql.engine_cost    VALUES ('InnoDB', 0, 'io_block_read_cost', 2.0,    CURRENT_TIMESTAMP, 'increase Innodb I/O cost');

2、让系统重新加载这个表的值:

FLUSH OPTIMIZER_COSTS;


小结




今天我们学习了关于连接查询成本的分析过程,偏向原理,理解起来也比较简单。实际工作的过程我们需要尽量在被驱动表的连接列上建立索引,这样就可以使用ref访问方法来降低访问被驱动表的成本。成本常数一般我们也不会手动修改的,大家了解即可。又是站在巨人肩膀进步一点点的一天。
今天是平安夜,明天是圣诞节,祝大家平平安安,快快乐乐!




 参考资料 




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

end


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

评论