MySQL高级 —— 查询性能优化

 引言

承接《MySQL高级 —— 高性能索引》,本篇博客将围绕《高性能MySQL(第三版)》第六章内容进行总结和概括。

与索引的部分一样,SQL优化也是广大程序员深入MySQL的又一条必经之路。希望通过本篇博客的总结,能够为我们成为“高级工程师”添砖加瓦。

本博客会从查询设计的一些基本原则开始,然后介绍一些更加深入的查询优化技巧,并介绍一些MySQL优化器的内部机制

一、查询变慢的原因

MySQL执行查询时会有很多子任务,通常来说,查询的生命周期大致可以分为:

从客户端,到服务器,然后在服务器上进行解析,生成执行计划,执行,并返回结果给客户端。

其中最重要的一环是“执行”,这其中包含了大量为检索数据到存储引擎的调用以及调用后的数据处理,包括排序、分组等。

查询性能低下最根本的原因是访问的数据太多。

某些查询不可避免地需要筛选大量的数据,但大多数情况,性能低下的查询都可以通过减少访问数据量的方式进行优化。

对于性能低下的查询,一个有效的手段是通过下面两步来进行分析:

1、确认应用程序是否在检索大量超过需要的数据。通常意味着访问太多的行,但也会有时候访问太多的列。

2、确认MySQL服务器层是否在分析大量超过需要的数据行。

请求不需要的数据会造成多项资源的浪费,如网络开销、CPU以及内存资源等。一定不要有SELECT * 这样的语句。

有时候,需要判断MySQL是否做了大量额外的扫描,有三个指标衡量其开销:

1、响应时间   

响应时间 = 服务时间 + 排队时间。服务时间是查询真正花费的时间,排队时间是由于资源竞争,如IO或锁,等待执行的时间。目前的MySQL无法精确计算各个部分的时间,因此,在不同类型的应用压力下,响应时间没有统一的规律或公式。

2、扫描行数   3、返回行数

分析扫描行数和返回行数,在一定程度上说明了查询的效率高不高。

理想状态,扫描行数和返回行数是相同的,但更常见的情况是,扫描的行数和返回的行数比值在 1:1 到 10:1之间。有时会更大。

这三个指标大致反映了MySQL内部执行查询需要访问多少数据。三个指标都会记录在MySQL慢日志中,所以检查慢日志记录可以找出扫描行数过多的查询。

1.1 扫描的行数和访问类型

EXPLAIN 中的 type 列反应了访问类型(参考《MySQL 优化 —— EXPLAIN 执行计划详解》)。

type 列有:全表扫描(ALL)、索引扫描(index)、范围扫描(range)、唯一索引查询(eq_ref)、常数引用(const)等。依次从慢到快,扫描的行数从多到少。

rows 列表示了MySQL预计执行查询需要扫描的行数。比如,如果 type = ALL ,那么rows 列就是全表的行数。

Extra 列的“Using where” 表示MySQL 将通过 where 条件来筛选存储引擎返回的记录。

一般 MySQL 能够使用如下三种方式应用 WHERE 条件,性能从好到坏依次是:

1、在索引中使用 WHERE 条件来过滤不匹配的记录,在存储引擎层完成。

2、使用索引覆盖扫描(Extra 出现“Using index”),直接在索引中过滤不需要的记录并返回命中结果。这是在MySQL服务层完成的,但无须再回表查询。

3、从表中返回数据,然后过滤不满足条件的记录(Extra 出现“Using where”)。这个操作在MySQL服务层完成,MySQL需要先从表中读取记录,然后过滤。

一个合适的索引,可以让查询使用合适的访问类型,尽可能只扫描需要的数据行。

当我们发现了查询需要扫描大量的数据但是只返回了少数的行,那么通常可以尝试下面的技巧去优化它:

1、使用索引覆盖扫描。把所有需要的列都放到索引中,这样存储引擎无须回表获取对应行就可以返回结果了。

2、改变库表结构。例如使用单独的汇总表

3、重写这个复杂的查询。

二、重构查询的方式

在优化有问题的查询时,目标应该是找到一个更优的方法获取实际需要的结果,而不一定总是需要从MySQL获取一模一样的结果集。

设计查询时,一个需要考虑的重要问题是,复杂查询多个简单查询。传统实现中,总是强调在数据库中完成尽可能多的工作,原因在于减少网络通信、查询解析和优化等被认为是代价很高的事情。在其他条件都相同的情况下,尽量减少查询当然是好的。但有时候,将一个大查询分解为多个小查询也是必要的。

2.1 切分查询

切分查询是指,将大查询切分成小查询,每个查询功能完全一样,只完成一小部分,每次只返回一小部分查询结果

例如,DELETE删除旧数据,如果一次删除大量的旧数据,则可能需要一次锁住很多数据,占满整个事务日志,耗尽系统资源,阻塞很多小的但很重要的查询。

DELETE FROM messages WHERE created < DATE_SUB(NOW(), INTERVAL 3 MONTH);

如果切分成多个小的执行语句,并设置适当的时间间隔,就可以大大降低对服务器的影响。

2.2 分解关联查询

很多高性能的应用都会对关联查询进行分解。

简单的说,就是每一个表进行一次单表查询,然后将结果在应用程序中进行关联

这样做的好处有以下几点

1、让缓存的效率更高。

2、执行单个查询可以减少锁的竞争。

3、在应用层做关联,可以更容易对数据库进行拆分,更容易做到高性能和可扩展。

4、查询本身效率也可能有所提升。

5、可以减少冗余记录的查询。应用层做关联,意味着对某些记录只需要查询一次,而在数据库中做关联,则可能需要反复地访问这些记录。

6、这样做相当于在应用中实现了哈希关联,而不是使用MySQL的嵌套循环关联

使用场景:

1、应用能够方便地缓存单个查询的结果。

2、数据分布于多个MySQL服务器上。

3、能够使用IN()的方式代替关联查询。形如 select xxx from x where id in (select..) 这样的子查询。

4、当查询中使用同一个数据表。自关联。

分解关联查询举例: 

SELECT * FROM tag
    JOIN tag_post ON tag_post.tag_id = tag.id
    JOIN post ON tag_post.post_id = post.id
WHERE tag.tag = 'mysql';

可以分解为:

SELECT * FROM tag WHERE tag = 'mysql';
SELECT * FROM tag_post WHERE tag_id = 1234; 
SELECT * FROM post WHERE post.id IN(123, 456, 567, 9098, 8904);

三、查询执行的工作原理

本节介绍MySQL是如何优化和执行查询的,这有助于优化具体的查询语句。

这是MySQL在收到查询请求到返回结果的整个流程图,以下是各个步骤的概括性描述:

1、客户端发送一条查询请求。

2、MySQL服务器先检查查询缓存,如果命中了缓存,则立刻返回缓存中的结果,否则进入下一步。

3、服务器进行SQL解析,预处理,再由优化器生成执行计划

4、MySQL根据优化器生成的执行计划,调用存储引擎的API 来执行查询

5、返回结果给客户端。

其实,上面的每一步都比想象要复杂,其中,查询优化器是重难点。另外还有一些例外情况,例如,当查询使用绑定变量后,执行路径会有所不同。

3.1 MySQL 客户端/服务器通信协议

MySQL客户端和服务器之间的通信协议是“半双工”的,也就是说,在任何一个时刻,两个动作不能同时发生。要么是由服务器向客户端发送数据,要么是由客户端向服务器发送数据。

这种协议让MySQL通信简单快速,但也会有一些限制,例如,无法进行流量控制。一旦一端开始发送消息,另一端要接收完整个消息才能响应它。就像来回抛接球的游戏。

客户端用一个单独的数据包将查询传给服务器。这也是为什么当查询的语句很长的时候,参数max_allowed_packet 就特别重要了。一旦客户端发送了请求,它能做的事情就只是等待结果了。但是,服务器响应给用户的数据通常很多,由多个数据包组成。客户端必须完整地接收整个返回结果,中间无法中断。

3.2 查询状态

对于一个MySQL连接,或者说一个线程,任何时刻都有一个状态,该状态表示了MySQL当前正在做什么。

最简单的查询状态的方法是:

SHOW FULL PROCESSLIST; -- 结果的 Command 列就表示当前的状态

常见状态:

Sleep:线程正在等待客户端发送新的请求。

Query:线程正在查询或正在将结果发送给客户端。

Locked:在MySQL服务器层,该线程正在等待表锁。在存储引擎级别实现的锁,例如InnoDB的行锁,并不会体现在线程状态中。对于MyISAM来说,这是一个非常典型的状态,但在其他没有行锁的引擎中也经常会出现。

Analyzing and statistics:线程正在收集存储引擎的统计信息,并生成查询的执行计划。

Copying to tmp table [on disk]:线程正在执行查询,并且将其结果集都复制到一个临时表中,这种状态一般要么是在做GROUP BY操作,要么是文件排序操作,或者是UNION操作。如果这个状态后面还有 “on disk”标记,那表示MySQL正在将一个内存临时表放到磁盘上。

Sorting result:线程正在对结果集进行排序。

Sending data:可能表示多种情况,线程可能在多个状态之间传送数据,或者在生成结果集,或者在向客户端发送数据。

3.3 查询缓存

在解析一个查询语句之前,如果查询缓存是打开的 ,那么MySQL会优先检查这个查询是否命中查询缓存中的数据。

这个检查是通过一个对大小写敏感的哈希查找实现的。即使只有一个字节不同,那也不会匹配缓存结果。

如果当前的查询恰好命中查询缓存,那么在返回查询结果之前MySQL会检查一次用户权限。这个操作仍然无须解析查询SQL语句,查询缓存中已经存放了当前查询需要访问的表信息。如果权限无问题,MySQL就会直接返回结果,查询不会解析,不会生成执行计划,SQL不会被执行。

3.4 查询优化处理

该步骤将SQL转换为一个执行计划,包括多个子阶段:解析SQL、预处理、优化SQL执行计划。MySQL会依照这个执行计划和存储引擎进行交互

3.4.1 语法解析和预处理

首先,MySQL通过关键字将SQL语句进行解析,并生成一棵对应的“解析树”。这一步骤主要是通过解析器预处理器两个关键性组件验证一系列查询语句的合法性。

解析器将使用MySQL语法规则验证和解析查询。例如,验证关键字,包括正确性、顺序等,引号是否前后正确匹配等。

预处理器则根据一些MySQL规则进一步检查解析树是否合法。例如,检查数据表和数据列是否存在,解析名字和别名。

3.4.2 查询优化器

语法解析预处理执行完成后,MySQL就会将解析树交给优化器,由它生成执行计划

注意,一条查询可以有很多种执行方式,最后都返回相同的结果,优化器的作用就是找到这其中最好的执行计划。目前的MySQL都是使用基于计算成本的优化器

它将尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。最初,成本的最小单位是随机读取一个4K数据页的成本,后来,成本计算公式变得更加复杂、合理,并引入了一些“因子”来预估某些操作的代价。

可以通过查询当前会话的 Last_query_cost 的值来得知MySQL计算当前查询的成本。

上图表示MySQL的优化器认为大约需要做1040个数据页的随机查找才能完成上面的查询。

它是由一系列统计信息计算得来的:每个表或索引的页面个数、索引的基数(索引中不同值的数量)、索引和数据行的长度、索引分布情况等。优化器在评估成本的时候并不考虑任何层面的缓存,它假设读取任何数据都需要一次磁盘IO。

很多原因会导致MySQL优化器选择错误的执行计划

1、统计信息不准确。MySQL依赖存储引擎提供的统计信息来评估成本,但有的存储引擎提供的信息是准确的,有的可能偏差很大。例如,InnoDB因为其MVCC(多版本并发控制)的架构,并不能维护一个数据表的行数的精确统计信息。

2、执行计划中的成本估算不等同于实际执行的成本。即使统计信息精确,优化器给出的执行计划也可能不是最优的。

3、成本最优并不是时间最短。由于MySQL是基于“成本模型”择优执行计划,因此,有时候这样的执行计划并不一定是最快的,因此这样的模型也并不是最完美的模型。

4、MySQL从不考虑其他并发执行的查询,这可能会影响到当前查询的速度。

5、MySQL也并不是任何时候都是基于成本的优化。有时候会基于一些固定的规则,例如,如果存在全文搜索的MATCH()子句,则在存在全文索引的时候就使用全文索引。即使有时候使用别的索引或WHERE条件可以远比这种方式更快,MySQL也仍然会使用对应的全文索引。

6、MySQL不会考虑不受其控制的操作成本,例如执行存储过程或用户自定义的函数的成本。

7、优化器有时候无法估算所有可能的执行计划,所以它可能错过实际上最优的执行计划。

MySQL优化器是一个非常复杂的部件,它使用了很多优化策略生成一个最优的执行计划。这些优化策略分为两类:静态优化、动态优化

静态优化:可以直接对解析树进行分析,完成优化。例如,优化器可以通过一些简单的代数变换将WHERE 条件转换为另一种等价形式。静态优化不依赖于特定的数值(如WHERE中带入的一些常量等),静态优化在第一次完成后就一直有效。即使使用不同的参数重复执行查询也不会发生变化。可以认为是一种“编译时优化”。

动态优化:和查询上下文有关,也可能和很多其他因素有关,例如WHERE条件中的取值、索引中条目对应的数据行数等。这需要在每次查询的时候重新评估,有时候甚至在查询的执行过程中也会重新优化,可以认为是“运行时优化”。

下面是一些MySQL能够处理的常见优化场景:

1、重新定义关联表的顺序。表的关联并不总是按照在查询中指定的顺序进行。决定关联的顺序是优化器很重要的一部分功能。

2、将外连接转化为内连接。并不是所有的OUTER JOIN 语句都必须以外连接的方式执行。诸多因素,例如WHERE条件、库表结构都可能会让外连接等价于一个内连接。MySQL能识别这点并重写查询,让其可以调整关联顺序。

3、使用等价变换规则。MySQL可以使用一些等价变换规则来简化并规范表达式。它会合并和减少一些比较,还可以移除一些恒成立或恒不成立的判断。例如,(1 = 1 AND a > 5)将被改写为a > 5。类似的,还有(a < b AND b = c) AND a = 5,会被改写为 b > 5 AND b = c AND a = 5。这些规则对于我们编写条件语句很有用。

4、优化COUNT()、MIN()、MAX()。索引和列是否可以为空通常可以帮助MySQL优化这类表达式。例如,找到某一列的最小值,只需要查询对应B-Tree索引最左端的记录,MySQL可以直接获取索引的第一行记录。在B-Tree索引中,优化器会将这个表达式MIN()当做一个常数对待,MAX()也是类似的。如果MySQL使用这种类型的优化,那么在EXPLAIN 中就可以看到“Select tables optimized away”,它表示优化器已经从执行计划中移除了该表,并以一个常数取而代之。

5、预估并转化为常数表达式。当MySQL检测到一个表达式可以转化为常数的时候,就会一直把该表达式作为常数进行优化处理。例如,一个用户自定义变量在查询中没有发生变化时,就可以转化为一个常数。数学表达式就是一个典型的例子。另外,刚刚提到的在索引列上查询MIN、MAX,甚至是主键或唯一键查找语句,如果WHERE中使用了该类索引的常数条件,那么MySQL可以在查询开始阶段就先找到这些值,这样优化器就能够知道并转化为常数表达式。例如:

详解:MySQL分两步执行上面的查询(EXPLAIN的两行输出)。第一步从film中找到需要的行。因为在film_id上有主键索引,所以MySQL优化器知道这只会返回一条数据,优化器在生成执行计划的时候,就已经通过索引信息知道将返回多少行数据。因为优化器已经明确知道有多少个值(WHERE条件中的值)需要做索引查询,所以访问类型type = const。

第二步,MySQL将第一步中返回的film_id列当做一个已知取值的列来处理。因为优化器清楚在第一步执行完成后,该值就会是明确的了。因此,和第一步中一样,使用film_actor字段对表的访问类型也是const。

6、覆盖索引扫描。当索引中的列包含所有查询中需要使用的列时,MySQL就可以使用索引返回需要的数据,而无需查询对应的数据行。

7、子查询优化。MySQL在某些情况下可以将子查询转化为一种更高效的形式,从而减少多个查询多次对数据的访问。

8、提前终止查询。在发现已经满足查询需求时,MySQL总是能立刻终止查询。一个典型是,使用了LIMIT子句。除此之外,MySQL还有几种情况,例如发现一个不成立的条件,MySQL就会立刻返回空结果。如果发现某些特殊条件,则会提前终止查询。当存储引擎需要检索“不同取值”或判断存在性的时候。例如:

这个查询会过滤掉所有有演员的电影。当查询发现电影中只要有一个演员,就会继续查询下一条数据。类似这种“不同值/不存在”的优化一般可以用于DISTINCT、NOT EXIST()或者LEFT JOIN 类型的查询。

7、等值传播。如果两个列的值通过等式关联,那么MySQL能够把其中一个列的WHERE条件传递到另一个列上。例如:

这里使用了film_id字段进行等值关联,MySQL会知道这里的WHERE子句不仅适用于film表,同样也适用于film_actor表。

8、列表IN()的比较。在很多数据库系统中,IN()完全等同于多个OR条件的子句,因为这两者是完全等价的。但MySQL中二者并不等价。MySQL会将IN()列表中的数据先进行排序,然后通过二分查找的方式来确定列表中的值是否满足条件,时间复杂度是O(logn),而OR 的操作时间复杂度是O(n),因此,对于IN()列表中有大量取值的时候,MySQL的处理速度将会更快。

3.4.3 数据和索引的统计信息

MySQL架构由多个层次组成。在服务器层有查询优化器,却没有保存数据和索引的统计信息。统计信息由存储引擎实现,不同的存储引擎可能会存储不同的统计信息。某些引擎,例如Archive引擎,则根本没有存储任何统计信息。

因为服务器层没有任何统计信息,所以MySQL查询优化器在生成查询的执行计划时,需要向存储引擎获取相应的统计信息

存储引擎提供给优化器统计信息,包括每个表或者索引有多少个页面、每个表的每个索引的基数是多少、数据行和索引长度、索引分布信息等。优化器根据这些信息选择一个最优的执行计划。

3.4.4 MySQL如何执行关联查询

MySQL中“关联”一词包含的意义比一般意义上理解的更广泛。认为任何一个查询都是一次“关联”,而并不仅仅是一个查询要到两个表匹配才叫关联。所以在MySQL中,每一个查询、每一个片段(包括子查询,甚至是单边的select)都可能是关联。

对于UNION查询,MySQL先将一系列的单个查询结果放到一个临时表中,然后再重新读出临时表数据来完成UNION查询

在MySQL的概念中,每个查询都是一次关联,所以读取结果临时表也是一次关联。

目前MySQL关联执行的策略很简单:MySQL对任何关联都执行嵌套循环关联操作,即MySQL先在一个表中循环取出单条数据,然后再嵌套循环到下一个表中寻找匹配的行,依次下去,直到找到所有表中匹配的行为止。然后根据各个表匹配的行,返回查询中需要的各个列。MySQL会尝试在最后一个关联表中找到所有匹配的行,如果最后一个关联表无法找到更多的行,MySQL就会返回上一层关联表,看是否能够找到更多的匹配记录,以此类推迭代执行。

SELECT tb1.col1, tb2.col2 
FROM tb1 
    INNER JOIN tb1 USING(col3)
WHERE tb1.col1 IN(5, 6);

从本质上来说,MySQL对所有的类型的查询都以同样的方式运行。例如,MySQL在from子句中遇到子查询时,先执行子查询并将结果放到一个临时表中,然后将这个临时表当做一个普通表对待。注意:MySQL 的临时表是没有任何索引的,在编写复杂的子查询和关联查询的时候需要注意这一点,对UNION查询也是一样。

MySQL在执行UNION 查询时也使用类似的临时表,在遇到右外连接的时候,MySQL将其改写成等价的左外连接。简而言之,当前版本的MySQL会将所有查询类型都转换成类似的执行计划。

3.4.5 执行计划

MySQL生成查询的一颗指令树,然后通过存储引擎执行完成这颗指令树并返回结果。最终的执行计划包含了重构查询的全部信息。执行 SHOW WARNINGS,就可以看到重构出的查询。

任何多表关联都可以使用一颗树表示,例如,在MySQL中,由于MySQL总是使用“嵌套循环关联”的方式执行多表查询,因此它的树结构应该是如下图所示的,是一颗左侧深度优先树

3.4.6 关联查询优化器

MySQL优化器最重要一部分就是关联查询优化,它决定了多个表关联时的顺序。通常多表关联,可以有多种不同的关联顺序获得相同的结果。优化器会评估不同顺序时的成本,最后选择一个代价最小的关联顺序。

重新定义关联顺序是优化器非常重要的一部分功能。不过有时候,优化器给出的并不是最优的关联顺序,这时可以使用STRAIGHT_JOIN关键字重写查询,让优化器按照你认为的最优关联顺序执行。绝大多数情况,优化器做出的选择都比较合理。

关联优化器会尝试在所有的关联顺序中选择一个成本最小的来生成执行计划。如果可能,优化器会遍历每一个表,然后逐个做嵌套循环计算每一颗可能的执行计划树的成本,最后返回一个最优的执行计划

如果有超过n个表的关联,需要检查n的阶乘种关联顺序。称之为执行计划的“搜索空间”,而这个搜索空间的增长速度非常快,例如,如果10表关联,共有3628800种。当搜索空间非常大的时候,优化器就不可能逐个评估关联顺序的成本,那么优化器就会使用“贪婪”搜索的方式查找“最优”的关联顺序。当需要关联的表数量超过optimizer_search_depth的限制,就会选择“贪婪搜索”模式。

有时候,查询中的顺序是存在一定的固定的,这时优化器可以根据这些固定的查询规则,大幅减少搜索空间。例如,左连接、相关子查询。这是因为,后面表的查询需要依赖前面表的查询结果。这种依赖关系通常可以帮助优化器大大减少需要扫描的执行计划数量

3.4.7 排序优化

当不能使用索引生成排序结果的时候,MySQL就需要自己进行排序。如果数量小,则在内存中进行;如果数量大则需要使用磁盘不过,MySQL将这个过程统一成为“filesort”——文件排序,即使完全是内存排序不需要任何磁盘文件时也是如此

如果需要排序的数据量小于“排序缓冲区”,MySQL使用内存进行“快速排序”操作。

如果内存不够排序,那么MySQL会将数据分块,对每个独立的块进行“快速排序”进行排序,并将各个块的排序结果存放在磁盘上,然后将各个排好序的块进行合并(merge),最后返回排序结果。

MySQL有两种排序算法:

1、两次传输排序(旧版本使用):读取行指针和需要排序的字段,对其进行排序,然后再根据排序结果读取所需要的行。这需要进行两次数据传输,即需要从数据表中读取两次数据。第二次读取数据的时候,因为是读取排序后的所有记录,这会产生大量的随机IO,所以两次数据传输的成本非常高。

2、单次传输排序(新版本使用):先读取查询所需的所有列,然后再根据给定列进行排序,最后直接返回排序结果。MySQL4.1 引入,对IO密集型应用效率非常高。其优点是无需任何随机IO。缺点是,如果返回的列非常大、非常多,会额外占用大量空间,而这些列对排序操作本身没有任何作用。当查询需要所有列的总长度不超过参数“max_length_for_sort_data”,MySQL就会使用“单次传输排序”。

MySQL在进行文件排序时需要使用的临时存储空间可能会非常大。因为在排序时,MySQL会对每个排序记录分配一个足够长的定长空间来存放。如果表结构设计的不合理,排序消耗的临时空间就可能比磁盘上的原表大很多倍。

关联查询时如果需要排序,MySQL会分两种情况来处理。

如果ORDER BY 子句的所有列都来自关联的第一个表,那么在关联处理第一个表的时候就进行文件排序。EXPLAIN的 Extra 会出现“Using filesort”。

除此之外的所有情况,MySQL都会将关联结果放入一个临时表中,然后在所有关联都结束后,再进行文件排序。EXPLAIN 的 Extra 会显示“Using temporary;Using filesort”。

3.5 查询执行引擎

在解析和优化阶段,MySQL将生成查询对应的执行计划。MySQL的查询执行引擎则根据这个执行计划来完成整个查询。

执行计划是一种数据结构,而不是和很多其他的关系型数据库那样会生成对应的字节码。

查询执行阶段并不复杂,MySQL只是简单地根据执行计划给出的指令逐步执行。在执行过程中,有大量的操作需要通过调用存储引擎实现的接口来完成,这些接口称为“handler API”。

查询中的每一个表由一个handler的实例表示。实际上,MySQL在优化阶段就为每个表创建了一个handler实例,优化器根据这些实例的接口可以获取表的相关信息,包括表的所有列名、索引统计信息等

注意,并不是所有的操作都由handler完成。例如,当MySQL需要进行表锁的时候。handler可能会实现自己级别的、更细粒度的锁,如InnoDB就实现了自己的行基本锁。但这并不能代替服务器层的表锁。因为所有存储引擎共有的特性则由服务器层实现,比如时间日期函数、视图、触发器等。

3.6 返回结果给客户端

查询执行的最后一个阶段是将结果返回给客户端。即使查询不需要返回结果给客户端,MySQL仍然会返回这个查询的一些信息,比如影响的行数。

如果查询可以被缓存,那么MySQL在这个阶段会将结果存放到查询缓存中。

MySQL将结果集返回给客户端是一个增量、逐步返回的过程。例如关联操作,一旦服务器处理完最后一个关联表,开始生成一条结果时,MySQL就开始向客户端逐步返回结果集了。

这样处理的好处有两点:

1、服务器端无需存储太多的结果,也就不会因为要返回太多结果而消耗太多内存。

2、可以让MySQL客户端第一时间获得返回的结果。

结果集中的每一行都会以一个满足MySQL客户端/服务器通信协议的封包发送,再通过TCP协议进行传输。在TCP传输的过程中,可能对MySQL的封包进行缓存然后批量传输。

四、查询优化的局限性

4.1 关联子查询

MySQL 的子查询实现非常糟糕,其中 WHERE 子句中的 IN() 子查询尤为突出。

如果希望查询 actor_id = 1的演员参演的所有电影:

SELECT * FROM film
WHERE film_id IN(
    SELECT film_id FROM film_actor WHERE actor_id = 1
);

按照正常逻辑,我们一般认为,先执行 IN() 中的子查询,然后再由查询出的 film_id 列表来作为筛选,过滤 film 中的数据。

但在 MySQL 5.6 之前不是这样做的。MySQL会将相关的外层表压到子查询中,官方的描述是“rewrites a noncorrelated subquery as a correlated subquery” ,如下所示:

SELECT * FROM film
WHERE EXISTS(
    SELECT * FROM film_actor WHERE actor_id = 1
    AND film_actor.film_id = film.film_id
);

这时,对表 film_actor 的子查询就需要根据 film_id 字段来关联外部表 film,因为需要 film_id 字段,所以MySQL认为无法先执行这个子查询。EXPLAIN 的 select_type 会出现 “DEPENDENT SUBQUERY” 关联的子查询

这样一来,本可以仅执行一次的子查询,却由于关联的原因MySQL 会对 film 进行全表扫描,然后根据返回的 film_id 逐个执行子查询!

一种重构的方法是使用 JOIN :

SELECT * FROM film
    INNER JOIN film_actor USING(film_id)
WHERE actor_id = 1;

另一种重构的方式是,保持原来使用子查询的方式,但是通过GROUP_CONCAT() 在IN() 中构造一个由逗号分隔的列表。有时这比使用关联的方式更快:

SELECT * FROM film
WHERE film_id IN(
    SELECT GROUP_CONCAT(film_id) FROM film_actor WHERE actor_id = 1
);

但在MySQL 5.6 增加了一个新的特性,叫做 Subquery materialization——物化子查询。可以参考:使用实体化优化子查询

materialization 意为“物化、实体化、具体化”,Subquery materialization 就是将子查询实体化为一张表。简单来说,MySQL5.6 加入的这个实体化子查询的优化器特性,是将子查询的结果组织成一张临时表存储到内存中,如果这个临时表太大,还会退居磁盘。这样一来,子查询就可以仅执行一次,在后续执行过程中,直接引用临时表中的数据即可。同时,优化器还可以在这个临时表上使用哈希索引以尽可能提高性能。

另外,一般这类查询都可以通过左外连接来重写:

EXPLAIN SELECT student.* FROM student
LEFT JOIN teacher
	ON teacher.`class_id` = student.`class_id`
WHERE lesson = 1;

由于MySQL并不是总是使用实体化来优化这类子查询,因此,优化器有时候还是会以关联子查询的形式去执行查询,所以,使用 LEFT JOIN 是一个不错的选择。

其实在执行计划中最后一步的 Extra 中不论是物化子查询还是 LEFT JOIN 查询,都出现了“Using join buffer”,而且select_type 也都是两个 SIMPLE,换句话说,物化子查询实际上就是将子查询作为一个临时表,然后执行关联操作,这和直接使用LEFT JOIN 进行关联本质上是相同的,虽然物化子查询比直接LEFT JOIN 的方式多了一步“内存物化”的操作,但却可以使用哈希索引提高子查询性能,因此,究竟哪种改写方式更好,还需要以实际执行速度为准。

4.2 UNION 的限制

MySQL有时候无法将外层限制条件“下推”到内层,使得原本能够限制部分返回结果的条件无法应用到内层查询的优化上。

如果希望UNION 的各个子句能够根据LIMIT只取部分结果集,或者希望能够先排好序再进行合并的话,就需要在UNION的各个子句中分别使用这些子句。

4.3 等值传递

某些时候,等值传递会有额外消耗。例如,有一个非常大的 IN() 列表,而MySQL优化器发现WHERE 、ON 或 USING 子句,将这个列表的值和另一个表的某个列相关联,那么优化器会将IN()列表都复制应用到关联的各个表中

因为各个表新增了过滤条件,优化器通常可以更高效地从存储引擎过滤记录。但是如果IN()列表非常大,则会导致优化和执行都非常慢。这种情况可能需要通过修改代码来绕过它。

4.4 并行执行

MySQL无法利用多核特性来并行执行查询。其他的关系型数据库能够提供这个特性,但MySQL做不到。不要尝试寻找并行执行查询的方法。

4.5 哈希关联

MySQL并不支持哈希关联,MySQL的所有关联都是嵌套循环关联。

五、查询优化器的提示

MySQL提供了提示(hint)功能,可以在查询中加入相应的提示,来控制最终的执行计划。常用的有如下一些。

HIGH_PRIORITY 和 LOW_PRIORITY:这个提示告诉MySQL,当多个语句同时访问某一个表的时候,哪些语句优先级高或低些。HIGH_PRIORITY可以用于SELECT和INSERT,LOW_PRIORITY在SELECT INSERT UDATE DELETE中都可以使用。

DELAYED:这个提示对INSERT 和 REPLACE有效。该提示会让语句立刻返回,将即将入库的数据先暂存缓冲区,待表空闲时批量写入。一般可以用于日志系统。但并不是所有存储引擎都支持,并且该提示会导致LAST_INSERT_ID()无法正常工作。

STRAIGHT_JOIN:该提示用于SELECT关键字之后,也可以放置在任何两个关联表的名字之间。第一个用法是让查询中所有的表按照在语句中出现的顺序进行关联。第二个用法是为了固定前后两个表的关联顺序。

SQL_SMALL_RESULT和SQL_BIG_RESULT:用于SELECT中。SMALL告诉优化器结果集很小,可以将结果集放在内存中的索引临时表,避免排序。如果是BIG,则告诉优化器,结果集很大,建议使用磁盘临时表做排序操作。

SQL_BUFFER_RESULT:告诉优化器将查询结果放入到一个临时表,然后尽可能快的释放表锁。

SQL_CACHE和SQL_NO_CACHE:告诉MySQL这个结果集是否应该缓存在查询缓存中。第七章详细介绍。

SQL_CALC_FOUND_ROWS:让MySQL返回的结果集包含更多信息。查询中使用它,让MySQL计算除去LIMIT子句后这个查询要返回的结果集的总数,而实际上只返回LIMIT要求的结果集。可以通过函数FOUNT_ROW()获取这个值。(后面优化部分有介绍)

FOR UPDATE和LOCK IN SHARE MODE:这不是真正的优化器提示。它们主要控制SELECT语句的锁机制,但只对实现了行级锁的存储引擎有效,如InnoDB。

USE INDEX 、IGNORE INDEX 和 FORCE INDEX:告诉优化器使用或不使用哪些索引来查询记录。

optimizer_search_depth:控制优化器在穷举执行计划的限度。如果查询长时间处于“Statistics”状态,可以考虑降低该参数。

optimizer_prune_level:该参数默认打开,让优化器会根据需要扫描的行数来决定是否跳过某些执行计划。

optimizer_switch:包含了一些开启/关闭优化器特性的标志位。

六、优化特定类型的查询

6.1 优化COUNT()查询

COUNT()是一个非常特殊的函数,有两种非常不同的作用:它可以统计某个列值的数量,也可以统计行数。

统计列值时要求该列是非空的(不统计NULL)。

统计结果集的行数时,当MySQL确认括号内的表达式值不可能为空时,实际上就是在统计行数。最简单的就是COUNT(*)。这里的通配符“*”并不会扩展成所有的列,实际上,它会忽略所有的列值而直接统计所有的行数

最常见的错误是,在COUNT的括号内指定了一个列,却希望统计结果集的行数。如果希望知道的是结果集的行数,最好使用COUNT(*),这样写意义清晰,性能也会很好。

对于MyISAM,一个误解是:MyISAM的COUNT()总是很快。但是有前提条件:即只要没有任何WHERE条件的COUNT(*)才非常快。在MyISAM中,如果MySQL知道某列col不可能为NULL,那么MySQL内部会将COUNT(col)表达式优化为COUNT(*)。

当统计带WHERE子句的结果集的行数,可以是统计某个列值的数量时,MyISAM的COUNT()和其他存储引擎没有任何不同。

利用MyISAM表COUNT(*)全表非常快的特性,优化下面的查询:

SELECT COUNT(*) FROM city WHERE id > 5;

这个SQL无法利用MyISAM的这个特性,但是可以通过改写来成如下,减少扫描行数:

SELECT (SELECT COUNT(*) FROM city) - COUNT(*) 
FROM city WHERE id <= 5;

另一个案例,在同一个查询中统计同一个列的不同值的数量。例如通过一个查询返回各种不同颜色的商品的数量。有两种写法:

SELECT 
    SUM(IF(color = ‘blue’, 1, 0)) AS blue, 
    SUM(IF(color = ‘red’, 1, 0)) AS red 
FROM items;
SELECT 
    COUNT(color = ‘blue’ OR NULL) AS blue, 
    COUNT(color = ‘red’ OR NULL) AS red 
FROM tiems;

在某些不需要精确COUNT的场景,可以使用近似值来代替。

EXPLAIN出来的优化器估算的行数就是一个不错的近似值,执行EXPLAIN 并不需要真正地执行查询,所以成本很低。

通常,COUNT()都需要扫描大量的行(意味着要访问大量数据),因此很难优化。除了前面的优化方法,MySQL层还能做的就只有索引覆盖扫描,如果这样还不够,那么就需要考虑修改应用架构,可以增加汇总表或者增加外部缓存系统。

那么实际上可以发现,“快速”、“精确”、“实现简单”三者永远只能满足其二,必须舍掉其中一个

6.2 优化关联查询

在优化关联查询的时候需要注意的几点是:

1、确保ON 或 USING子句中的列上有索引。在创建索引的时候就要考虑到关联的顺序。当表A和表B用列c关联的时候,如果优化器的关联顺序是B、A,那么就不需要在B表的对应列上建立索引。没有用到的索引只会带来额外的负担。一般来说,除非有其他原因,否则只需要在关联顺序中的第二个表的相应列上创建索引。

2、确保任何的GROUP BY 和 ORDER BY 中的表达式只涉及到一个表中的列,这样MySQL才能有可能使用索引来优化这个过程。

3、当升级MySQL的时候需要注意:关联语法、运算符优先级等其他可能发生变化的地方。

6.3 优化子查询

关于子查询优化,我们给出的最重要的优化建议就是尽可能使用关联查询代替。但是根据前面的介绍,“尽可能使用关联”并不是绝对的,如果使用的是5.6或更新的版本,可以忽略子查询的优化。

6.4 优化 GROUP BY 和 DISTINCT

在很多场景下,MySQL都使用同样的办法优化这两种查询,事实上,MySQL优化器会在内部处理的时候相互转化这两类查询。他们都可以使用索引来优化,这也是最有效的优化办法。

MySQL中,当无法使用索引的时候,GROUP BY 使用两种策略来完成:使用临时表或文件排序来做分组

这部分优化其实在目前普遍 MySQL5.7 的版本已经有松散索引扫描来完成,具体参考官网:

如果需要对关联查询做分组(GROUP BY),并且是按照查找表中的某个列进行分组,那么通常采用查找表的标识列分组的效率会比其他列更高

6.5 优化 LIMIT 分页

通常使用LIMIT加上偏移量的办法来实现系统对分页操作的要求。配合上合适的ORDER BY 子句,如果有对应的索引,通常效率会不错,否则,MySQL需要做大量的文件排序操作。

一个很头疼的问题是,当偏移量非常大的时候,例如LIMIT 10000, 20这样的查询,这时MySQL需要查询10020条记录然后只返回最后20条,前面的10000条记录都将被抛弃。

要优化这样的查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能。

优化此类分页查询的一个最简单的办法就是尽可能的使用索引覆盖扫描,而不是查询所有的列。然后根据需要做一次关联操作再返回所需的列。对于偏移量很大的时候,这样做的效率会提升非常大。

SELECT film_id, description 
    FROM film 
    ORDER BY title 
    LIMIT 50, 5;

如果这个表非常大,那么这个查询最好改写成下面的样子:

SELECT film.film_id, film.description
FROM film
INNER JOIN (
    SELECT film_id FROM film
    ORDER BY title 
    LIMIT 50, 5
) AS lim USING(film_id);

这里的“延迟关联”将大大提升查询效率,它让MySQL扫描尽可能少的页面。这个技术也可以用于优化关联查询中的LIMIT。

LIMIT和 OFFSET的问题,其实是后者的问题,它会导致扫描大量不需要的行然后再抛弃掉。如果可以使用书签记录上次取数据的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用OFFSET。

例如,如果需要按照租借记录做翻页,那么可以根据最新一条租借记录向后追溯,这种做法可行是因为租借记录的主键是单调增长的。

首先使用下面的查询获取第一组结果:

SELECT * FROM rental
ORDER BY rental_id DESC
LIMIT 20;

假设返回了主键为16049到16030的租借记录,那么下一页查询就可以从16030这个点开始:

SELECT * FROM rental
WHERE rental_id < 16030
ORDER BY rental_id DESC
LIMIT 20;

该技术的的好处是无论翻页到多么后面,性能都会很好。

其他优化技术还包括使用预先计算的汇总表,或者关联到一个冗余表,冗余表只包含主键列和需要做排序的数据列。

6.6 优化 UNION 查询

MySQL总是通过创建并填充临时表的方式来执行UNION查询。因此很多优化策略在UNION查询中都没法很好的使用。经常需要手动将WHERE LIMIT ORDER BY 等子句“下推”到UNION的各个子查询中。

除非确实需要服务器消除重复的行,否则就一定要使用UNION ALL,这非常重要。如果没有ALL关键字,MySQL会给临时表加上DISTINCT选项,这会导致对整个临时表的数据做唯一性检查,代价非常高。即使有ALL关键字,MySQL仍然会使用临时表来存储结果。事实上,MySQL总是将结果放入临时表,然后再读出,再返回给客户端。

6.7 使用用户自定义变量

用户自定义变量是一个容易被遗忘的MySQL特性,但是如果能够用好,发挥其潜力,在某些场景可以写出非常高效的查询语句。

单纯的关系查询将所有的东西都当成无序的数据集合,并且一次性操作它们。

用户自定义变量是一个用来存储内容的临时容器,在连接MySQL的整个过程都存在。可以使用如下的SET和SELECT 语句来定义他们:

然后在任何可以使用表达式的地方使用这些自定义变量:

SELECT ... WHERE col <= @last_week;

使用自定义变量的一些注意事项:

1、使用自定义变量的查询,无法使用查询缓存。

2、不能在使用常量或标识符的地方使用自定义变量,例如表名、列名、LIMIT子句中。

3、自定义变量的生命周期在一个连接中有效,所以不能用它们来做连接间的通信。

4、如果使用连接池或持久化连接,自定义变量可能让看起来毫无关系的代码发生交互(如果这样,通常是代码bug或连接池bug,这类情况确有发生)。

5、MySQL5.0之前的版本,是大小写敏感的。注意版本兼容问题。

6、不能显式地声明自定义变量的类型,且MySQL的用户自定义变量是一个动态类型,也就是说用户自定义变量的类型在赋值时会随之改变。如果你希望变量是整数类型,那么最好在初始化的时候就赋值0,浮点型就赋值0.0,字符串就赋值’’。

7、MySQL优化器在某些场景下可能会将这些变量优化掉,这可能导致代码不按预期的方式运行。

8、赋值的顺序和赋值的时加点并不总是固定的,这依赖于优化器的决定。

9、赋值符号:=的优先级非常低,所以需要注意,赋值表达式应该使用明确的括号。

10、使用未定义变量不会产生任何语法错误,一旦发生问题会很难排查。要避免使用未定义变量。

 

已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页