第8章-4 查询性能优化2

发布于:2025-05-10 ⋅ 阅读:(13) ⋅ 点赞:(0)

        上一篇:《第8章-3 查询性能优化1

MySQL如何执行联接查询

        MySQL中使用的术语“联接”(对应英文为Join)的范围可能比你熟悉的更广泛。总的来说,MySQL认为每一个查询都是联接——不仅是匹配两张表中对应行的查询,而是每一个查询、每一个片段(包括子查询,甚至基于单表的SELECT)都是联接。因此,理解MySQL如何执行联接查询是非常重要的。

        所以,理解MySQL如何执行UNION查询至关重要。我们先来看一个UNION查询的例子。对于UNION查询,MySQL先将一系列的单个查询结果放到一个临时表中,然后再重新读出临时表中的数据来完成UNION查询。在MySQL的概念中,每个查询都是一次联接,所以读取临时表的结果也是一次联接。

        当前MySQL的联接执行策略很简单:MySQL对任何联接都执行嵌套循环联接操作,即MySQL先在一个表中循环取出单条数据,然后再嵌套循环到下一个表中寻找匹配的行,依次下去,直到找到所有表中匹配的行为止。最后根据各个表匹配的行,返回查询中需要的各列。

        MySQL会尝试在最后一个联接表中找到所有匹配的行,如果最后一个联接表无法找到更多的行,MySQL返回到上一层次的联接表,看是否能够找到更多的匹配记录,依此类推,迭代执行(MySQL的执行计划并不是这里描述的这么简单,过程中有非常多的细节优化,使得整个过程是一个非常复杂的过程)。

        在MySQL 8.0.20版本之后,已经不再使用基于块的嵌套循环联接操作,取而代之的是哈希连接(参见链接33 https://oreil.ly/WdIQm)。这让联接操作性能变得更好,特别是当数据集可以全部存储在内存时。

执行计划

        和很多其他关系数据库不同,MySQL并不会生成查询字节码来执行查询。MySQL生成查询的一棵指令树 (可以通过在EXPLAIN语句中新增FORMAT=TREE关键字来查看树形结构,mysql要8.0版本支持) ,然后通过查询执行引擎执行完成这棵指令树并返回结果。最终的执行计划包含了重构查询的全部信息。如果你对某个查询执行EXPLAIN EXTENDED后,再执行SHOW WARNINGS,就可以看到重构出的查询(MySQL根据执行计划生成输出。这和原查询有完全相同的语义,但是查询语句可能并不完全相同)。

        任何多表查询都可以使用一棵树来表示,例如,可以按照图8-2执行一个四表的联接操作。

                图8-2:多表联接的一种方式

        在计算机科学中,这被称为一棵平衡树。但是,这并不是MySQL执行查询的方式。正如我们在前面章节中介绍的,MySQL总是从一个表开始,一直嵌套循环、回溯完成所有表联接。所以,MySQL的执行计划总是如图8-3所示,是一棵左侧深度优先的树。

                        图8-3:MySQL如何实现多表联接

联接查询优化器

        MySQL查询优化器最重要的一部分就是联接查询优化器,它决定了多个表联接时的顺序。通常多表联接的时候,可以有多种不同的联接顺序来获得相同的执行结果。联接查询优化器通过评估不同顺序时的成本来选择一个成本最低的联接顺序。

下面的查询可以通过不同顺序的联接最后获得相同的结果:

SELECT f.film_id, f.title, f.release_year, 
a.actor_id, a.first_name, a.last_name
FROM  film f
INNER JOIN film_actor USING(film_id)
INNER JOIN actor a USING(actor_id);

        很容易看出,可以通过一些不同的执行计划来完成上面的查询。例如,MySQL可以从film表开始,使用film_actor表的索引film_id来查找对应的actor_id值,然后再根据actor表的主键找到对应的记录。Oracle用户会用下面的术语描述:“film表作为驱动表先查找file_actor表,然后以此结果为驱动表再查找actor表”。这样做效率应该很高,我们再使用EXPLAIN看看MySQL将如何执行这个查询:

EXPLAIN SELECT f.film_id, f.title, f.release_year, 
a.actor_id, a.first_name, a.last_name
FROM  film f
INNER JOIN film_actor USING(film_id)
INNER JOIN actor a USING(actor_id);

        这和我们前面给出的执行计划完全不同。MySQL从actor表开始(从上面的EXPLAIN结果的第一行输出可以看出这一点),然后与我们前面的计划按照相反的顺序进行联接。这样是否效率更高呢?我们来看看。我们先使用STRAIGHT_JOIN关键字,按照之前的顺序执行,下面是对应的EXPLAIN输出结果:

EXPLAIN SELECT STRAIGHT_JOIN f.film_id, f.title, f.release_year, 
a.actor_id, a.first_name, a.last_name
FROM  film f
INNER JOIN film_actor USING(film_id)
INNER JOIN actor a USING(actor_id);

        这说明了MySQL为什么要反转联接顺序:反转后可以使查询在第一个表中检查更少的行。在这两种情况下,都能够在第二个和第三个表中执行快速索引查找,不同的是,需要执行的索引查找次数不一样。将film表作为第一个表需要检查大约1000行记录(参见rows字段),每一行都是一个探针,用于针对film_actor和actor表进行索引查找。如果MySQL先扫描actor表,则只需要对后面的表进行200次索引查找。换句话说,反转联接顺序会让查询进行更少的回溯和重读操作。

这个简单的例子主要想说明MySQL是如何选择合适的联接顺序来让查询执行的成本尽可能低的。重新定义联接的顺序是优化器非常重要的一项功能。不过有的时候,优化器给出的并不是最优的联接顺序。这时可以使用STRAIGHT_JOIN关键字重写查询,让优化器按照你认为的最优的联接顺序执行——不过老实说,人的判断很难那么精准。绝大多数时候,优化器做出的选择都比普通人的判断要更准确。

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

        不过,糟糕的是,n个表的联接可能有n的阶乘种联接顺序,我们称之为所有可能的查询计划的“搜索空间”。搜索空间的增长速度非常块,例如,若是10个表的联接,那么共有3628800种不同的联接顺序!当搜索空间非常大的时候,优化器不可能逐一评估每一种联接顺序的成本。这时,优化器选择使用“贪婪”搜索的方式查找“最优”的联接顺序。实际上,当需要联接的表超过optimizer_search_depth的限制的时候,就会选择“贪婪”搜索模式了(optimizer_search_depth参数可以根据需要指定大小)。

        在MySQL这些年的发展过程中,优化器积累了很多“启发式”的优化策略来加速执行计划的生成。在绝大多数情况下这都是有效的,但因为不会去计算每一种联接顺序的成本,所以偶尔也会选择不是最优的执行计划。

        有时查询不能重新排序,联接优化器可以利用这一点通过消除选择来减小搜索空间。左联接(LEFT JOIN)和相关子查询都是很好的例子(稍后将详细介绍子查询)。这是因为,一个表的结果依赖于另外一个表中检索的数据,这种依赖关系通常可以帮助联接优化器通过消除选择来减少搜索空间。

排序优化

        无论如何排序都是一个成本很高的操作,所以从性能角度考虑,应尽可能避免排序或者尽可能避免对大量数据进行排序。

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

        如果需要排序的数据量小于“排序缓冲区”,MySQL使用内存进行快速排序操作。如果内存不够排序,那么MySQL会先将数据分块,对每个独立的块使用“快速排序”进行排序,并将各个块的排序结果存放在磁盘上,然后将各个排好序的块进行合并(merge),最后返回排序结果。

MySQL有如下两种排序算法。

两次传输排序(旧版本使用)

        读取行指针和需要排序的字段,对其进行排序,然后再根据排序结果读取所需要的数据行。

        这需要进行两次数据传输,即需要从数据表中读取两次数据,第二次读取数据的时候,因为是读取排序列进行排序后的所有记录,这会产生大量的随机I/O,所以两次传输排序的成本非常高。

单次传输排序(新版本使用)

        先读取查询所需要的所有列,然后再根据给定列进行排序,最后直接返回排序结果。因为不再需要从数据表中读取两次数据,对于I/O密集型的应用来说,这样做的效率高了很多。另外,相比两次传输排序,这个算法只需要一次顺序I/O就可读取所有的数据,而无须任何的随机I/O。然而,这种方式可能占用更多空间,因为会保存查询中每一行所需要的列,而不仅仅是进行排序操作所需要的列。这意味着更少的元组可以放入排序缓冲区,使得文件排序(filesort)操作必须执行更多的排序合并过程。

        MySQL在进行文件排序时需要使用的临时存储空间可能会比想象的要大得多。原因在于MySQL在排序时,对每一个排序记录都会分配一个足够长的定长空间来存放。这个定长空间必须足够长才能容纳其中最长的字符串,例如,如果是VARCHAR列,则需要分配其完整长度;如果使用utf8mb4字符集,那么MySQL将会为每个字符预留4字节。我们曾经在一个库表结构设计不合理的案例中看到,排序消耗的临时空间比磁盘上的原表要大很多倍。

        在联接查询的时候如果需要排序,MySQL会分两种情况来处理这样的文件排序。如果ORDER BY子句中的所有列都来自联接的第一个表,那么MySQL在联接处理第一个表的时候就进行文件排序。如果是这样,那么在MySQL的EXPLAIN结果中可以看到Extra字段会有“Using filesort”字样。除此之外的所有情况,MySQL都会先将联接的结果存放到一个临时表中,然后在所有的联接都结束后,再进行文件排序。在这种情况下,在MySQL的EXPLAIN结果的Extra字段可以看到“Using temporary;Using filesort”字样。如果查询中有LIMIT的话,LIMIT也会在文件排序之后应用,所以即使需要返回较少的数据,临时表和需要排序的数据量仍然会非常大。

查询执行引擎

        在解析和优化阶段,MySQL将生成查询对应的执行计划,MySQL的查询执行引擎会根据这个执行计划来完成整个查询。这里的执行计划是一个数据结构,而不是和很多其他的关系数据库那样生成对应的可执行的字节码。

        相对于查询优化阶段,查询执行阶段不是那么复杂:MySQL只是简单地根据执行计划给出的指令逐步执行。在根据执行计划逐步执行的过程中,有大量的操作需要通过调用存储引擎实现的接口来完成,这些接口也就是我们称为“handler API”的接口。查询中的每一个表都由一个handler的实例表示。如果一个表在查询中出现了三次,服务器会创建三个handler对象。前面我们有意忽略了这一点,实际上,MySQL在优化阶段就为每个表创建了一个handler实例,优化器根据这些实例的接口可以获取表的相关信息,包括表的所有列名、索引统计信息,等等。

        存储引擎接口有着非常丰富的功能,但是底层接口却只有几十个,这些接口像“搭积木”一样能够完成查询的大部分操作。例如,有一个查询某个索引的第一行的接口,其还有查询某个索引条目的下一个条目的功能,有了这两个功能就可以完成全索引扫描的操作了。这种简单的接口模式,让MySQL的存储引擎的插件式架构成为可能,但是正如前面的讨论,这也给优化器带来了一定的限制。

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

为了执行查询,MySQL只需要重复执行计划中的各个操作,直到完成所有的数据查询。

将结果返回给客户端

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

        MySQL将结果集返回客户端是一个增量且逐步返回的过程。例如,我们回头看看前面的联接操作,一旦服务器处理完最后一个联接表,开始生成第一条结果时,MySQL就可以开始向客户端逐步返回结果集了。这样处理有两个好处:服务器端无须存储太多的结果,也就不会因为要返回太多结果而消耗太多内存。另外,这样的处理也可让MySQL客户端第一时间获得返回的结果(可以通过一些办法来影响这个行为,例如,可以使用SQL_BUFFER_RESULT。参考MySQL官方手册中的“Optimizer Hints”一节可获得更多信息)。 结果集中的每一行都会以一个满足MySQL客户端/服务器通信协议的封包发送,再通过TCP协议进行传输,在TCP传输的过程中,可能对MySQL的封包进行缓存,然后批量传输。

MySQL查询优化器的局限性

        MySQL所实现的查询执行方式并不是对每种查询都是最优的。不过还好,MySQL查询优化器只对少部分查询不适用,而且我们往往可以通过改写查询让MySQL高效地完成工作。

UNION的限制

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

        如果希望UNION的各个子句能够根据LIMIT只取部分结果集,或者希望能够先排好序再合并结果集的话,就需要在UNION的各个子句中分别使用这些子句。例如,想将两个子查询结果联合起来,然后再取前20条记录,那么MySQL会将两个表存放到同一个临时表中,然后再取出前20行记录:

(SELECT first_name, last_name
 FROM actor
 ORDER BY last_name)
UNION ALL
(SELECT first_name, last_name
 FROM customer
 ORDER BY last_name)
LIMIT 20;

        这条查询将会把actor表中的200条记录和customer表中的599条记录存放在一个临时表中,然后再从临时表中取出前20条。可以通过在UNION的两个子查询中分别加上一个LIMIT 20来减少临时表中的数据:

(SELECT first_name, last_name
 FROM actor
 ORDER BY last_name
 LIMIT 20)
UNION ALL
(SELECT first_name, last_name
 FROM customer
 ORDER BY last_name
 LIMIT 20)
LIMIT 20;

        现在临时表只包含40条记录了,除了考虑性能之外,在这里还需要注意一点:从临时表中取出数据的顺序并不是一定的,所以如果想获得正确的顺序,还需要在最后的LIMIT操作前加上一个全局的ORDER BY操作。

等值传递

        某些时候,等值传递会带来一些意想不到的额外消耗。例如,考虑一列上的巨大IN()列表,优化器知道它将等于其他表中的一些列,这是由于WHERE、ON或USING子句使列彼此相等。

        优化器通过将列表复制到所有相关表中的相应列来“共享”列表。通常,因为各个表新增了过滤条件,所以优化器可以更高效地从存储引擎过滤记录。但是如果这个列表非常大,则会导致优化和执行都会变慢。在写作本书的时候,除了修改MySQL源代码,目前还没有什么办法能够绕过该问题(不过这个问题很少会碰到)。

并行执行

        MySQL无法利用多核特性来并行执行查询。很多其他的关系数据库能够提供这个特性,但是MySQL做不到。这里特别指出是想告诉读者不要花时间去尝试寻找并行执行查询的方法。

在同一个表中查询和更新

        MySQL不允许对一张表同时进行查询和更新。这其实并不是优化器的限制,如果你清楚MySQL是如何执行查询的,就可以避免这种情况。下面是一段无法运行的SQL语句,尽管这是一段符合标准的SQL语句。这个查询会将表中每一行的c字段值更新为和该行的type字段值相同的行数量:

UPDATE tbl AS outer_tbl
SET c = (
SELECT COUNT(*) FROM tbl AS inner_tbl
WHERE inner_tbl.type = outer_tbl.type );


ERROR 1093 (HY000): You can't specify target table 'outer_tbl'
for update in FROM clause

        可以使用生成表的形式来绕过上面的限制,因为MySQL只会把这个表当作一个临时表来处理。实际上,这执行了两个查询:一个是子查询中的SELECT语句,另一个是多表UPDATE查询,其中包含原表和子查询的联接结果。子查询会在UPDATE语句打开表之前就完成,所以下面的查询将会正常执行:

UPDATE tbl
INNER JOIN(
  SELECT type, count(*) AS c
  FROM tbl
  GROUP BY type
  ) AS der USING(type)
SET tbl.c = der.c;

优化特定类型的查询

        这一节,我们将介绍如何优化特定类型的查询。在本书的其他部分会分散介绍这些优化技巧,不过这里将会汇总一下,以便参考和查阅。

        本节介绍的多数优化技巧都和特定的版本有关,所以对于未来MySQL的版本未必适用。毫无疑问,某一天优化器自己也会实现这里列出的部分或者全部优化技巧。

优化COUNT()查询

        COUNT()聚合函数,以及如何优化使用了该函数的查询,很可能是MySQL中最容易被误解的前10个话题之一。你在网上随便搜索一下就能看到很多错误的理解,可能比我们想象中的要多得多。

在做优化之前,先来看看COUNT()函数真正的作用是什么。

COUNT()的作用

        COUNT()是一个特殊的函数,有两种非常不同的作用:它可以统计某列的值的数量,也可以统计行数。在统计列值时要求列值是非空的(不统计NULL)。如果在COUNT()的括号中指定了列或者列的表达式,则统计的就是这个表达式有值的结果数。因为很多人对NULL理解有问题,所以这里很容易产生误解。如果你想了解更多关于SQL语句中NULL的含义,建议阅读一些关于SQL语句基础的书籍。(关于这个话题,互联网上的一些信息是不够准确的。)

        COUNT()的另一个作用是统计结果集的行数。当MySQL确认括号内的表达式值不可能为空时,实际上就是在统计行数。最简单的就是当我们使用COUNT(*)的时候,这种情况下通配符*并不会像我们猜想的那样扩展成所有的列,实际上,它会忽略所有的列而直接统计所有的行数。

        我们发现最常见的错误之一是,当需要统计行数时,在COUNT()函数的括号内指定了列名。如果想要知道结果中的行数,应该始终使用COUNT(*),这样可以更清晰地传达意图,避免糟糕的性能表现。

简单优化

        通常会看到这样的问题:如何在一个查询中统计同一列的不同值的数量,以减少查询的语句量。例如,假设可能需要通过一个查询返回各种不同颜色的商品数量,此时不能使用OR语句(比如,SELECT COUNT(color='blue'OR color='red')FROM items;),因为这样做无法区分不同颜色的商品数量;也不能在WHERE条件中指定颜色(比如,SELECTCOUNT(*)FROM items WHERE color='blue'AND color='RED';),因为颜色的条件是互斥的。下面的查询可以在一定程度上解决这个问题:(也可以写成这样的SUM()表达式:

SUM(color='blue'),SUM(color='red'))

SELECT SUM(IF(color = 'blue', 1, 0)) AS blue,
SUM(IF(color = 'red', 1, 0)) AS red 
FROM items;

        也可以使用COUNT()而不是SUM()实现同样的目的,只需要将满足条件设置为真,不满足条件设置为NULL即可:

SELECT COUNT(color = 'blue' OR NULL) AS blue, 
 COUNT(color = 'red' OR NULL) AS red 
FROM items;  

使用近似值

        有时候,某些业务场景并不要求完全精确的统计值,此时可以用近似值来代替。EXPLAIN出来的优化器估算的行数就是一个不错的近似值,执行EXPLAIN并不需要真正地去执行查询,所以成本很低。

        很多时候,计算精确值非常复杂,而计算近似值则非常简单。曾经有一个客户希望我们统计他的网站的当前活跃用户数是多少,这个活跃用户数保存在缓存中,过期时间为30分钟,所以每隔30分钟需要重新计算并放入缓存。这个活跃用户数本身就不是精确值,所以使用近似值代替是可以接受的。另外,如果要精确统计在线人数,使用WHERE条件会很复杂,一方面需要剔除当前非活跃用户,另一方面还要剔除系统中某些特定ID的“默认”用户,去掉这些约束条件对总数的影响很小,但却可能提升该查询的性能。更进一步的优化则可以尝试删除DISTINCT这样的约束来避免文件排序。这样重写过的查询比原来精确统计的查询快很多,而返回的结果则几乎相同。

更复杂的优化

        通常来说,COUNT()查询需要扫描大量的行(意味着要访问大量数据)才能获得精确的结果,因此是很难优化的。除了前面提到的方法,在MySQL层面还能做的就只有索引覆盖扫描了。如果这还不够,那就需要考虑修改应用的架构,可以增加类似Memcached这样的外部缓存系统。不过,可能很快你就会陷入一个熟悉的困境:“快速、精确和实现简单”。三者永远只能满足其二,必须舍掉一个。

优化联接查询

这个话题基本上整本书都在讨论,这里需要特别提到以下几点。

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

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

        ●  当升级MySQL的时候需要注意:联接语法、运算符优先级等其他可能会发生变化的地方。因为以前是普通联接的地方可能会变成笛卡儿积,不同类型的联接可能会生成不同的结果,甚至会产生语法错误。

使用WITH ROLLUP优化GROUP BY

        分组查询的一个变种就是要求MySQL对返回的分组结果再做一次超级聚合。可以使用WITH ROLLUP子句来实现这种逻辑,但可能优化得不够。可以通过EXPLAIN来观察其执行计划,特别要注意分组是否是通过文件排序或者临时表实现的。然后再去掉WITHROLLUP子句来看执行计划是否相同。也可以通过本节前面介绍的优化器提示来强制执行计划。

        很多时候,如果可以,在应用程序中做超级聚合是更好的,虽然这需要给客户端返回更多的结果。也可以在FROM子句中嵌套使用子查询,或者是通过一个临时表存放中间数据,然后和临时表执行UNION来得到最终结果。

        最好的办法是尽可能地将WITH ROLLUP功能转移到应用程序中处理。

优化LIMIT和OFFSET子句

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

        一个非常常见又令人头疼的问题是,在偏移量非常大的时候,例如,可能是LIMIT1000,20这样的查询,这时MySQL需要查询10020条记录然后只返回最后20条,前面10000条记录都将被抛弃,这样的代价非常高。如果所有的页面被访问的频率都相同,那么这样的查询平均需要访问半个表的数据。要优化这种查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能

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

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

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

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

        这种“延迟联接”之所以有效,是因为它允许服务器在不访问行的情况下检查索引中尽可能少的数据,然后,一旦找到所需的行,就将它们与整个表联接,以从该行中检索其他列。

类似的技术也适用于带有LIMIT子句的联接。

        有时候也可以将LIMIT查询转换为已知位置的查询,让MySQL通过范围扫描获得对应的结果。例如,如果在一个位置列上有索引,并且预先计算出了边界值,上面的查询就可以改写为:

SELECT * FROM film
SELECT film_id, description FROM film
WHERE position_id BETWEEN 50 AND 54 ORDER BY position_id;   

        对数据进行排名的问题也与此类似,但往往还会同时和GROUP BY混合使用。在这种情况下通常需要预先计算并存储排名信息。

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

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

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

优化SQL CALC FOUND ROWS

        分页的时候,另一个常用的技巧是在LIMIT语句中加上SQL_CALC_FOUND_ROWS提示(hint),这样就可以获得去掉LIMIT以后满足条件的行数,因此可以作为分页的总数。

        看起来,MySQL做了一些非常“高深”的优化,像是通过某种方法预测了总行数。但实际上,MySQL只有在扫描了所有满足条件的行以后,才会知道行数,所以加上这个提示以后,不管是否需要,MySQL都会扫描所有满足条件的行,然后再抛弃掉不需要的行,而不是在满足LIMIT的行数后就终止扫描。所以该提示的代价可能非常高。

        一个更好的设计是将具体的页数换成“下一页”按钮,假设每页显示20条记录,那么我们每次查询时都是用LIMIT返回21条记录并只显示20条,如果第21条存在,那么就显示“下一页”按钮,否则就说明没有更多的数据,也就无须显示“下一页”按钮了。

        另一种做法是先获取并缓存较多的数据——例如,缓存1000条——然后每次分页都从这个缓存中获取。这样做可以让应用程序根据结果集的大小采取不同的策略,如果结果集小于1000,就可以在页面上显示所有的分页链接,因为数据都在缓存中,所以这样做不会对性能造成影响。如果结果集大于1000,则可以在页面上设计一个额外的“找到的结果多于1000条”之类的按钮。这两种策略都比每次生成全部结果集再抛弃不需要的数据的效率高很多。

        有时候也可以考虑使用EXPLAIN的结果中的rows列的值来作为结果集总数的近似值(实际上,Google的搜索结果总数也是一个近似值)。当需要精确结果的时候,再单独使用COUNT(*)来满足需求,这时如果能够使用索引覆盖扫描则通常也会比SQL_CALC_FOUND_ROWS快得多。

优化UNION查询

        MySQL总是通过创建并填充临时表的方式来执行UNION查询,因此很多优化策略在UNION查询中都没法很好地被使用。经常需要手工地将WHERE、LIMIT、ORDER BY等子句“下推”到UNION的各个子查询中,以便优化器可以充分利用这些条件进行优化(例如,直接将这些子句冗余地写一份到各个子查询)。

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

小结

        如果把创建高性能应用程序比作一个环环相扣的“难题”,除了前面介绍的schema、索引和查询语句设计之外,查询优化应该是解开“难题”的最后一步。要想写一个好的查询,你必须理解schema设计、索引设计等,反之亦然。

        理解查询是如何被执行的以及时间都消耗在哪些地方,依然是前面我们介绍的响应时间的一部分。如果再加上一些诸如解析和优化过程的知识,就可以更进一步地理解我们在第7章中讨论的MySQL如何访问表和索引的内容了。这也可从另一个维度帮助你理解MySQL在访问表和索引时查询和索引的关系。
        优化通常需要三管齐下:不做、少做、快速地做。

         上一篇:《第8章-3 查询性能优化1


网站公告

今日签到

点亮在社区的每一天
去签到