查询的生命周期的下一步是将一个SQL转换成一个执行计划,MySQL再按照这个执行计划和存储引擎进行交互。这包括多个子阶段:解析SQL、预处理、优化SQL执行计划。这个过程中任何错误(例如语法错误)都可能终止查询。
语法解析器和预处理
首先,MySQL通过关键字将SQL语句进行解析,并生成一颗对应的“解析树”。MySQL解析器将使用MySQL语法规则验证和解析查询。例如,它将验证是否使用错误的关键字,或者使用关键字的顺序是否正确等,再或者它还会验证引号是否能前后正确匹配。
预处理器则根据一些MySQL规则进一步检查解析树是否合法,例如,这里将检查数据表和数据列是否存在,还会解析名字和别名,看看它们是否有歧义。
下一步预处理器会验证权限。这通常很快,除非服务器上有非常多的权限配置。
查询优化器
现在语法树被认为是合法的了,并且由优化器将其转化成执行计划。一条查询可以有很多种执行方式,最后都返回相同的结果。优化器的作用就是找到这其中最好的执行计划。
MySQL使用基于成本的优化器,它将尝试预测一个查询使用某种执行计划时的成本,并选择其中成本最小的一个。最初,成本的最小单位是随机读取一个4K数据页的成本,后来(成本计算公式)变得更加复杂,并且引入了一些“因子”来估算某些操作的代价,如当执行一次WHERE条件比较的成本。可以通过查询当前会话的Last_query_cost的值来得知MySQL计算的当前查询的成本。
1 | SELECT SQL_NO_CACHE COUNT(*) FROM sakila.file_actor; |
有很多原因会导致MySQL优化器选择错误的执行计划
- 统计信息不准确。MySQL以来存储引擎提供的统计信息来评估成本,但是有的存储引擎提供的信息是准确的,有的偏差可能非常大。例如,InnoDB因为其MVCC的架构,并不能维护一个数据表的行数的精确统计信息。
- 执行计划中的成本估算不等同于实际执行的成本。所以即使统计信息精准,优化器给出的执行计划也可能不是最优的。例如有时候某个执行计划虽然需要读取更多的页面,但是它的成本却更小。因为如果这些页面都是顺序读或者这些页面都已经在内存中的话,那么它的访问成本将很小。MySQL层面并不知道哪些页面在内存中,哪些在磁盘上,所以查询实际执行过程中到底需要多少次物理I/O是无法得知的。
- MySQL的最优可能和你想的最优不一样。你可能希望执行时间尽可能的短,但是MySQL只是基于其成本模型选择最优的执行计划,而有些时候这并不是最快的执行方式。所以这里我们看到根据执行成本来选择执行计划并不是完美的模型。
- MySQL从不考虑其他并发执行的查询,这可能会影响到当前查询的速度。
- MySQL也并不会任何时候都是基于成本的优化。有时也会基于一些固定的规则,例如,如果存在全文搜索的MATCH()子句,则在存在全文所以的时候就使用全文索引。即使有时候使用别的索引和WHERE条件可以远比这种方式要快,MySQL也仍然会使用对应的全文索引。
- MySQL不会考虑不受其控制的操作的成本,例如执行存储过程或者用户自定义函数的成本。
- 后面我们还会看到,优化器有时候无法去估算所有可能的执行计划,所以它可能错过实际上最优的执行计划。
静态优化和动态优化
MySQL的查询优化器是一个非常复杂的部件,它使用了很多优化策略来生成一个最优的执行计划。优化策略可以简单地分为两种,一种是静态优化,一种是动态优化。
- 静态优化可以直接对解析树进行分析,并完成优化。例如,优化器可以通过一些简单的代数变换将WHERE条件转换成另一种等价形式。静态优化不依赖于特别的数值,如WHERE条件中带入的一些常数等。静态优化在第一次完成后就一直有效,即使使用不同的参数重复执行查询也不会发生变化。可以认为这是一种“编译时优化”。
- 动态优化则和查询的上下文有关,也可能和很多其他因素相关,例如WHERE条件中的取值、索引中条目对应的数据行数等。这需要在每次查询的时候都重新评估,可以认为这是“运行时优化”。
在执行语句和存储过程的时候,动态优化和静态优化的区别非常重要。MySQL对查询的静态优化只需要一次,但对查询的动态优化则在每次执行时都需要重新评估。有时候甚至在查询的执行过程中也会重新优化(例如,在关联操作中,范围检查在执行计划会针对每一行重新评估索引)。
下面是一些MySQL能够处理的优化类型
重新定义关联表的顺序
数据表的关联并不总是按照在查询中指定的顺序进行。决定关联的顺序是优化器很重要的一部分功能。
将外连接转化成内连接
并不是所有的OUTER JOIN语句都必须以外连接的方式执行。诸多因素,例如WHERE条件、库表结构都可能会让外连接等价于一个内连接。MySQL能够识别这点并重写查询,让其可以调整关联顺序。
使用等价变换规则
MySQL可以使用一些等价变换来简化并规范化表达式。它可以合并和减少一些比较,还可以移除一些恒成立和一些恒不成立的判断。例如:(5=5 AND a>5)
将被改写为a>5
。类似的,如果有(a<b AND b=c) AND a=5
则会改写为b>5 AND b=c AND a=5
。
优化COUNT()、MIN()和MAX()
索引和列是否可为空通常可以帮助MySQL优化这类表达式。例如,要找到某一列的最小值,只需要查询对应B-Tree索引最左端的记录,MySQL可以直接获取索引的第一行记录。在优化器生成执行计划的时候就可以利用这一点,在B-Tree索引中,优化器会将这个表达式作为一个常数对待。类似的,如果要查找一个最大值,也只需读取B-Tree索引的最后一条记录。如果MySQL使用了这种类型的优化,那么EXPLAIN中就可以看到“Select tables optimized away”。从字面意思可以看出,它表示优化器已经从执行计划中移除了该表,并以一个常数取而代之。
类似的,没有任何WHERE条件的COUNT(*)
查询通常也可以使用存储引擎提供的一些优化(例如,MyISAM维护了一个变量来存放数据表的行数)。
预估并转化为常数表达式
当MySQL检测到一个表达式可以转化为常数的时候,就会一直把该表达式作为常数进行优化处理。例如,一个用户自定义变量在查询中没有发生变化时就可以转换为一个常数。
在优化阶段,有时候甚至一个查询也能转化为一个常数。一个例子是在索引列上执行MIN()函数。甚至是主键或者唯一键查找语句也可以转换为常数表达式。如果WHERE子句中使用了该类索引的常数条件,MySQL可以在查询开始阶段就先查找到这些值,这样优化器就能够直到并转换为常数表达式。
覆盖索引扫描
当索引中的列包含所有查询中需要使用的列的时候,MySQL就可以使用索引返回需要的数据,而无须查询对应的数据行。
子查询优化
MySQL在某些情况下可以将子查询转换一种效率更高的形式,从而减少多个查询多次对数据进行访问。
提前终止查询
在发现已经满足查询需求的时候,MySQL总是能够立刻终止查询。一个典型的例子就是当使用了LIMIT子句的时候。除此之外,MySQL还有几类情况也会提前终止查询,例如发现了一个不成立的条件,这时MySQL可以立刻返回一个空结果。除此之外,MySQL在执行过程中,如果发现某些特殊的条件,则会提前终止查询
等值传播
如果两个列的值通过等式关联,那么MySQL能够把其中一个列的WHERE条件传递到另一列上。
列表IN()的比较
在很多数据库系统中,IN()完全等同于多个OR条件的子句,因为这两者是完全等价的。在MySQL中这点是不成立的,MySQL将IN()列表中的数据先进行排序,然后通过二分查找的方式来确定列表中的值是否满足条件,这是一个O(log n)复杂度的操作,等价地转换成OR查询的复杂度为O(n),对于IN()列表中有大量取值的时候,MySQL的处理速度将会更快。
数据和所有的统计信息
在服务器层有查询优化器,却没有保存数据和索引的统计信息。统计信息由存储引擎实现,不同的存储引擎可能会存储不同的统计信息(也可以按照不同的格式存储统计信息)。某些引擎,例如Archive引擎,则根本就没有存储任何统计信息!
因为服务器层没有任何统计信息,所以MySQL查询优化器在生成查询的执行计划时,需要向存储引擎获取相应的统计信息。存储引擎则提供给优化器对应的统计信息,包括:每个表或者索引有多少个页面、每个表的每个索引的基数是多少、数据行和索引长度、索引的分布信息等。优化器根据这些信息来选择一个最优的执行计划。
MySQL如何执行关联查询
MySQL中“关联”一词所包含的意义比一般意义上理解的要更广泛。总的来说,MySQL认为任何一个查询都是一次“关联”——并不仅仅是一个查询需要到两个表匹配才叫关联,所以在MySQL中,每一个查询,每一个片段(包括子查询、甚至基于单表的SELECT)都可能是关联。
当前MySQL关联执行的策略很简单:MySQL对任何关联都执行嵌套循环关联操作,即MySQL先在一个表中循环取出单条数据,然后在嵌套循环到下一个表中寻找匹配的行,依次下去,直到找到所有表总匹配的行为止。然后根据各个表匹配的行,返回查询中需要的各个列。MySQL会尝试在最后一个关联表中找到所有匹配的行,如果最后一个关联表无法找到更多的行以后,MySQL返回到上一层次关联表,看是否能够找到更多的匹配记录,一次类推迭代执行。
执行计划
和很多其他关系数据库不同,MySQL并不会生成查询字节码来执行查询。MySQL生成查询的一颗指令树,然后通过存储引擎执行完成这颗指令树并返回结果。最终的执行计划包含了重构查询的全部信息。
MySQL执行计划是一颗左侧深度优先的树。
关联查询优化器
MySQL优化器最重要的一部分就是关联查询优化,它决定了都个表关联时的顺序。
排序优化
无论如何排序都是一个成本很高的操作,所以从性能角度考虑,应尽可能避免排序或者尽可能避免对大量数据进行排序。
当不能使用索引生成排序结果的时候,MySQL需要自己进行排序,如果数据量小则在内存中进行,如果数据量大则需要使用磁盘,不过MySQL将这个过程统一称为文件排序(filesort),即使完全是内存排序不需要任何磁盘文件时也是如此。
如果需要排序的数据量小于“排序缓冲区”,MySQL使用内存进行“快速排序”操作。如果内存不够排序,那么MySQL会先将数据分块,对每个独立的块使用“快速排序”进行排序,并将各个块的排序结果存放到磁盘上,然后将各个排好序的块进行合并(merge),最后返回排序结果。
MySQL有如下两种排序法:
两次传输排序(旧版本使用)
读取行指针和需要排序的字段,对其进行排序,然后再根据排序结果读取所需要的数据行。
这需要进行两次数据传输,即需要从数据表中读取两次数据,第二次读取数据的时候,因为是读取排序列进行排序后的所有记录,这会产生大量的随机I/O,所以两次数据传输的成本非常高。当使用的是MyISAM表的时候,成本可能会更高,因为MyISAM使用系统调用进行数据的读取(MyISAM非常依赖操作系统对数据的缓存)。不过这样做的优点是,在排序的时候存储尽可能少的数据,这就让“排序缓冲区”中可能容纳尽可能多的行数进行排序。
单次传输排序(新版本使用)
先读取查询所需要的所有列,然后在根据给定列进行排序,最后直接返回排序结果。这个算法只在MySQL 4.1和后续更新的版本才引入。因为不再需要从数据表中读取两次数据,对于I/O密集型的应用,这样做的效率高了很多。另外,相比两次传输排序,这个算法只需要一次顺序I/O读取所有的数据,而无须任何的随机I/O。缺点是,如果需要返回的列非常多、非常大,会额外占用大量的空间,而这些列对排序操作本身来说是没有任何作用的。因为单条排序记录很大,所以可能会有更多的排序块需要合并。
很难说哪个算法效率更高,两种算法都有各自最好和最糟的场景。当查询需要所有列的总长度不超过参数max_length_for_sort_data时,MySQL使用“单次传输排序”,可以通过调整这个参数来影响MySQL排序算法的选择。