高性能MySQL第六章
高性能MySQL第六章-查询性能优化
为什么查询速度会慢
如果把查询看作一个任务,那么它是由一系列子任务组成的。优化查询任务实际上就是优化子任务,要么消除一些子任务,要么减少子任务的运行时间,执行次数。
查询的生命周期大致可以分为:
- 客户端连接服务端
- 服务器解析SQL,生成执行计划
- 执行计划
- 返回结果给客户端
慢查询基础:优化数据访问
从两个角度来分析:
- 确认应用程序是否在检索大量超过需要的数据。
- 确认MySQL服务器是否在分析大量超过需要的数据行。
是否向数据库请求了不需要的数据
一般有以下几种情况:
- 查询不需要的记录:假如网站上只显示10条数据,你却要把整个数据库都查出来。或者分页操作时把全部数据查询出来,自己在业务端进行强行分页操作。这些操作都是不好的。
- 多表关联时返回全部列:连表查询的时候不要用*,哪怕你用表.*都可以。连表的时候大多数应该都用不到所有字段吧
- 总是取出全部列:
SELECT *
少用,尽量一些查询就返回需要的行就行。 - 重复查询相同的数据:有一些需要重复使用的数据,应该查出来放入缓存中,而不是重复查询数据库。
MySQL是否在扫描额外的记录
衡量开销的三个指标:
- 响应时间:服务时间+排队时间=响应时间。响应时间可以用之前的服务器性能剖析和MySQL基准测试来确定是服务器的问题还是单个查询的问题。
- 扫描的行数:理想情况下扫描的行数和返回的行数应该是相同的。不过现实一般不可能这样,一般在1:1和10:1之间。
- 返回的行数:EXPLAIN语句中的type列反映了访问类型。还有扫描的行数,当Extra字段出现
Using Where
表示讲通过WHERE条件筛选存储引擎返回的记录(这样的话数据实际已经查出来了,只是通过WHERE来筛选以后返回而已)。有些查询需要读取几千行,却只返回200行。可以使用索引覆盖扫描,这样会在存储引擎层面减少查询的数量,或是改变库表结构,使用单独的汇总表。
检查慢查询日志是找出扫描行数过多的查询的好办法。
重构查询的方式
优化有问题的查询时,应该时找到一个更优的方法获得实际需要的结果,而不一定总是要获得一模一样的结果集。
一个复杂查询还是多个简单查询
MySQL从设计上让连接和断开连接都很轻量级,而且现在的网络速度比以前还要快很多。即使在一个通用服务器上,也能够运行每秒超过10w的查询,即使时一个千兆网卡也能轻松满足每秒超过2000次查询。
MySQL内部每秒能扫描内存中上百万行数据,相比之下,响应数据给客户端就慢得多了。但是有时,把一个大查询分解为多个小查询是很有必要的
切分查询
假如我们需要每个月运行一次下面的查询
1 |
|
可以用类似的下面的方法来完成同样的工作:
1 |
|
一次删除一万行数据一般来说是比较高效的。但是如果是事务型存储引擎,很多时候小的事务更高效。同时可以把一次性的压力分散到一个很长的时间段中,就可以大大降低对服务器的影响,还可以减少删除时锁的持有时间。
分解关联查询
可以把一个复杂的多表查询拆分为单表查询。
1 |
|
有时候这样做是因为:
- 让缓存效率更高,假如缓存中存储了其中某个id的信息就可以直接返回,而不用联表查
- 执行单个查询可以减少锁的竞争
- 减少冗余记录的查询,联表查可能需要重复地访问一部分数据。
查询执行的基础
弄清楚MySQL 是如何优化和执行查询对我们理解如果优化是非常关键的。
- 客户端发送查询给服务器
- 检查缓存,如果命中则直接返回
- 服务器端进行SQL解析、预处理,由优化器生成对应的执行计划
- MySQL根据执行计划,调用存储引擎的API来执行查询
- 返回结果集给客户端
和一开始的流程差距不大。不过其中每一步都非常复杂,我们只是简化地说是这样的。
MySQL客户端和服务通信协议
MySQL客户端和服务器之间的通信协议是“半双工”,这意味着任何时刻,上传和下载只有一个在执行。就像扔皮球一样,只有等待球回来才能扔出去。
这个球就是我们的数据,通常来说,查询语句可能不会很大,但是也有存在查询语句非常长的情况。当查询语句非常长的时候,参数max_allowed_packet(指mysql服务器端和客户端在一次传送数据包的过程当中最大允许的数据包大小)就特别重要了。
相反,返回值通常很多。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 | 这表示多种情况:线程可能在多个状态之间传送数据,或者在生成结果集,或者在向客户端返回数据。 |
查询缓存
查询缓存的检查时通过一个对大小写敏感的哈希查找实现的。所以查询和缓存中即使只有一个字节不同都无法匹配。匹配成功以后还会检查用户权限。如果都通过以后就会跳过其他阶段直接返回数据。
查询优化处理
这一步时将一个SQL转换成一个执行计划:解析SQL、预处理、优化SQL执行计划
语法解析器和预处理
通过关键字将SQL语句执行解析,并生成一颗对应的解析树(验证关键字的拼写和顺序是否正确)。预处理则根据一些MySQL规则进一步检查解析树是否合法(检查数据表和数据列是否存在,解析名字和别名)。
查询优化器
现在由优化器把合格的语法树转化成执行计划。一个SQL有很多执行方式,优化器的作用是找到最好的执行计划。MySQL使用基于成本的优化器,可以用查询当前会话的Last_query_const来查看当前查询的成本。
1 |
|
这是根据每个表或者索引的页面个数、索引的基数(索引中不同值的数量)、索引和数据行的长度、索引分布情况来进行评估的。评估结果是认为大概需要做**(上面SQL生成的结果)个数据页的随机查找才能完成查询。
查询优化器选择错误的执行计划的原因:
- 统计信息补准确:优化依赖于存储引擎提供的统计信息来评估成本,有的存储引擎提供的信息是准确的,有的偏差非常大。InnoDB因为MVCC的架构,并不能维护一个数据表行数的准确信息。
- 执行计划中的成本估算不等于实际执行的成本。MySQL并不知道哪些页面在内存中,哪些在磁盘上。所以并不知道进行了多少磁盘IO
- 优化器的最优和你想的最优不一样。它是基于成本模型选择最优的执行计划,有时这并不是最快的执行计划。
- MySQL从不考虑其他并发执行的查询,这可能回影响当前查询的速度。
- 有时候会基于固定的规则,如果存在全文搜索的
MATCH()
子句,则在存在全文索引时就使用全文索引,哪怕使用别的索引会比这个更快。 - MySQL不会考虑不受控制的操作的成本,例如存储过程或者用户自定义函数的成本。
- 优化器无法去估算所有可能的执行计划。
优化策略
优化策略可以简单地分为两种:
- 静态优化:直接对解析树分析,进行优化。通过简单的代数变换将WHERE条件转换成另一种等价形式。可以认为是编译时优化。
- 动态优化:与查询的上下文有关,也可能和其他因素有关。对WHERE条件中的取值、索引中条目对应数据行数进行重新评估。可以认为是运行时优化。
- 执行语句和存储过程时,只执行一次静态优化,对查询的动态优化则在每次执行时都要重新评估。有时甚至在查询的执行过程中也会重新优化。
优化类型
下面时一些MySQL能处理的优化类型:
- 重新定义关联表的顺序:数据表的关联并不总是按照查询所指定的顺序来的。
- 将外连接转换为内连接:有时,优化器会WHERE条件和库表结构,让外连接等价于一个内连接。
- 使用等价变换规则:使用等价变换来简化并规范表达式。例如,(5=5 AND a>5)会被改写为a>5,(a<b AND b=c) AND a=5则会改写为b>5
- 优化
COUNT() MIN() MAX()
:索引可以帮忙。例如要找某一列的最小值,只需要查询对应B-Tree索引最左端的记录,直接获取索引的第一行。如果使用了这种优化,在EXPLAIN中可以看到(Select tables optimized away)。类似的不带任何条件的COUNT(*)查询通常可以使用优化(MyISAM维护了常量来存放数据表的行数) - 预估并转化为常量表达式:甚至一个查询也能转换为一个常数,在索引列上执行
MIN()
函数或是主键或唯一键查找语句也可以转换为常熟表达。因为当使用id索引的时,肯定只会返回一条属于那个id的数据。这里有点难理解,如果无法理解就算了。 - 覆盖索引查询:这里再之前索引的时候讨论过了,如果返回的列在索引中就不用再次去查数据了。
- 子查询优化:在某些情况可以将子查询转换一种效率更高的形式,从而减少多个查询对多个数据进行访问。
- 提前终止查询:在发现已经满足查询需求的时候,MySQL总是能够立刻终止查询。当使用LIMIT子句的时候就是这样
- 等值传播:如果两个列的值通过等式关联,那么MySQL能够把其中一个列的WHERE条件传递到另一列上。
- 列表
IN()
的比较:MySQL将IN列表中的数据先进行排序,然后通过二分查找来确定列表中的值是否满足条件,对于IN中有大量取值的时候,MySQL的速度会更快。
上面的举例只是优化器能办到的一些事情,所以,就不要自以为自己比优化器更聪明,很有可能会让查询变得更加复杂而且难以维护。
MySQL如何执行关联查询
MySQL中关联一次所包含的意义比一般意义上理解的更广泛。总的来说,MySQL认为任何一个查询都是一次关联,并不仅仅时一个查询需要到两个表匹配才叫关联。
对于UNION查询,MySQL先将一系列的单个查询结果放到一个临时表中,然后再重新读出临时表数据来完成UNION查询。
MySQL对任何关联都执行嵌套循环关联操作,即MySQL先在一个表中循环读出单条数据,然后再嵌套循环到下一个表中寻找匹配的行,返回查询中需要的各个列。并尝试再最后一个关联表中找到所有匹配的行,如果最后一个联表无法找到更多,会返回上一层次关联表,看是否能找到更多匹配记录,依次推迭代执行。
执行计划
MySQL生成查询的一棵指令树,然后通过存储引擎执行完成这棵指令树并返回结果。最终执行计划包含了重构查询的全部信息。对一个查询执行EXPLAIN EXTENDED
H后,再执行SHOW WARNINGS
,就可以看到重构出的查询。
关联查询优化器
MySQL优化器最重要的一部分就是关联查询优化,它决定了多个表关联时的顺序。假如你觉得优化器没有给出正确的顺序,你可以使用STRAIGHT_JOIN
关键字重写查询,让优化器按照你的关联顺序执行。
优化器会遍历每一个表然后计算每一棵计划树的成本,选择成本最低的执行。不过糟糕的是,如果超过n个表的关联,那么需要检查n的阶乘种关联顺序。这些称为所有可能的执行计划的搜索空间。列入,10个表的关联,共有3628800种不同的关联顺序!搜索空间超过参数optimizer_search_depth
的限制时, 不可能逐一去评估每一种的成本。那么就会使用贪婪搜索方式(书中没有介绍什么是贪婪搜索方式,不过我在网上搜到一篇关于贪婪算法的:贪婪算法
排序优化
无论如何排序都是一个成本很高的操作,从性能角度触发,应该尽可能避免排序或者尽量避免对大量数据进行排序。
当不能使用索引生成排序结果的时候,MySQL需要自己进行排序,数据量小的话在内存种排序,数据量大则需要使用磁盘,MySQL将这个过程统一称为文件排序。
如果内存不够排序,那么会将数据分块,对每个独立块使用快速排序,并将各个块的排序结果存放在磁盘上,然后合并以后返回排序结果。
MySQL共有两种排序算法:
- 两次传输排序(旧版本使用):读取行指针和需要排序的字段,对其进行排序,然后再根据排序结果读取所需要的数据行。因为需要读取两次,这会产生大量的随机I/O。
- 单次传输排序(新版本使用):MySQL4.1后续版本更行的,先读取查询所需要的所有列,然后根据定列进行排序,最后直接返回排序结果。只读取一次,效率高了很多。
很难说哪个算法效率更高,当查询需要所有列的总长度不超过参数max_length_for_sort_data
时,MySQL使用单次传输排序。
MySQL在进行文件排序的时候需要使用的临时空间可能会比想象的要大很多。原因在于:对每一个排序记录都会分配一个足够长的定长空间来存放。假如如果使用VARCHAR列则需要分配其完整长度,如果使用UTF-8将会为每个字符预留三个字节。
在关联查询时,如果排序字段都是关联的第一个表,那么在关联处理第一个表的时候就进行文件排序。这样的话,EXPLAIN结果中可以看到Extra字段会有”Using filesort”。除此之外都是会先将关联的记过存放到一个临时表中,然后所有关联结束以后再进行文件排序。这样的话,Extra字段可以看到”Using temporary;Using filesort”。如果查询中有LIMIT的话,LIMIT也会在排序之后应用,所以即使需要返回较少的数据,排序的数据量仍然会非常大。
MySQL5.6这里做了很多重要的改进,当只需要返回部分排序结果的时候,MySQL不会对所有结果进行排序
查询执行引擎
在根据执行计划逐步执行的过程中,需要通过调用存储引擎实现的接口来实现,这些接口叫”handler API”。MySQL在优化阶段就为每个表创建了一个handler实例,优化器根据这些实例的接口可以获得表的相关信息(表的所有列明、索引统计信息等)
存储引擎接口有非常丰富的功能,但是底层接口只有几十个。这种简单的接口模式,让MySQL的存储引擎插件式架构成为可能,不过这也给优化器带来了一些限制。
返回结果给客户端
查询最后一个阶段是将结果返回给客户端。即使查询不需要返回结果集,MySQL仍然会返回值这个查询的一些信息。如果查询可以被缓存,那么也会在这个阶段放入缓存。
MySQL将结果返回客户端是一个增量、逐步返回的过程,一旦服务器处理完最后一个关联表,开始生成第一条结果时,就可以开始逐步返回结果了。这样做服务器端无须存储太多的结果。也能让客户端第一时间收到结果。
MySQL查询优化器的局限性
MySQL的嵌套循环并不是对每种查询都是最优的。不过MySQL5.6以后会消除很多MySQL原本的限制,让更多的查询能够以尽可能高的效率完成。
关联子查询
MySQL的子查询实现得非常糟糕。最糟糕得一类查询时WHERE条件中包含IN()的子查询语句。
1 |
|
你可能会希望先查询括号中的东西,然后根据id进行外层的循环。但事实上会被改写成下面的形式:
1 |
|
看执行计划可以知道先对film进行了全表查询,然后根据返回的film_id逐个执行子查询。如果外层的表非常大的话,这个查询的性能会非常糟糕。一种方法是通过重写SQL来实现。
1 |
|
另一个优化的办法是使用函数GROUP_CONCAT()
在IN()
中构造一个由逗号分隔的列表。有时会比上面关联得更快。不过通常建议使用EXISTS()
等效的改写查询来获得更好的效率。
不过并不是所有关联子查询的性能都会很差,我们应该用测试来获得更好的结果。如果有问题,优化便是了。
UNION的限制
有时,MySQL无法将限制条件从外层下推到内层,这使得原本能够限制部分返回结果的条件无法应用到内层查询的优化上。
如果希望UNION的各个子句能根据LIMIT只取部分结果,那么就需要分别在子句中使用LIMIT。如果是希望全部只取某几条,那么就整体使用LIMIT。两个子句产生的临时表顺序并不是是一定的,所以如果想获得正确的顺序,还需要加上一个全局的ORDER BY和LIMIT操作。
索引合并优化
在5.0之后的版本,MySQL能够从多个单个索引自动组合为一个组合索引。
等值传递
有一个非常大的IN()
列表,优化器发现存在WHERE、ON、USING的子句,将这个列表的值另一个表的某个列相关联。那么优化器会将IN()
列表都会复制应用到关联的各个表中。如果列表非常大,会导致优化和执行都会变慢。
并行执行
MySQL无法利用多核特性来并行执行查询。
哈希关联
MySQL的所有关联都是嵌套循环关联,至少在5.6版本及其之前。
松散索引扫描
通常,MySQL的索引扫描需要先定义一个起点和终点,即使需要的数据只是这段索引中很少几个,仍然需要扫描这段索引中每一个条目。
在MySQL5.6之后的版本,关于松散索引扫描的一些限制将会通过索引条件下推的方式解决。
最大值和最小值优化
对于MIN()
和MAX()
查询,MySQL的优化做得并不好。
1 |
|
如果first_name字段上没有索引,那么MySQL将会进行一次全表扫描。如果MySQL能进行主键扫描,那么理论上,读到的第一个满足条件的记录的时候,就是我们需要找到的最小值,因为主键是按照actor_id字段的大小顺序排列的。一个曲线救国的方法是移除MIN()
,然后使用LIMIT来重写查询
1 |
|
通过SQL并不能一眼就看出其实是想获得一个最小值,有时候为了更高的性能,不得不放弃一些原则。
在同一个表上查询和更新
MySQL不允许对同一张表进行查询和更新
优化特定类型的查询
优化COUNT()
查询
COUNT()
是一个特殊的函数,有两种非常不同的作用:
统计某个列值的数量:如果在
COUNT()
的括号中指定了列或者列的表达式,则统计的就是这个表达式有值的结果数。例如COUNT(name is null)
会统计是null的name的结果集。在某次做题中,我使用了下面的写法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31SELECT
uid,
COUNT(submit_time IS NULL) AS incomplete_cnt,
COUNT(submit_time IS NOT NULL) AS complete_cnt,
GROUP_CONCAT(DISTINCT CONCAT_WS(":",DATE(start_time),tag)SEPARATOR ';')
FROM
exam_record AS er INNER JOIN examination_info USING(exam_id)
WHERE YEAR(start_time) = 2021
GROUP BY
uid
HAVING
complete_cnt >= 1
AND incomplete_cnt IN (2,3,4)
ORDER BY incomplete_cnt DESC
# 下面是正确的,上面这个SQL并不会报错,但是会统计出奇怪的值,所以在使用的时候还是写清楚比较好。最好涉及到使用COUNT()条件的只有一个表达式就行,当有两个COUNT需要使用不同的条件时还是使用if比较把稳。
SELECT
uid,
COUNT(if(submit_time IS NULL,1,null)) AS incomplete_cnt,
COUNT(if(submit_time IS NOT NULL,1,null)) AS complete_cnt,
GROUP_CONCAT(DISTINCT CONCAT_WS(":",DATE(start_time),tag)SEPARATOR ';')
FROM
exam_record AS er INNER JOIN examination_info USING(exam_id)
WHERE YEAR(start_time) = 2021
GROUP BY
uid
HAVING
complete_cnt >= 1
AND incomplete_cnt IN (2,3,4)
ORDER BY incomplete_cnt DESC
我推测原因可能是MySQL中带count条件查询。使用一个COUNT会自动加筛选条件,但是加两个相反的统计的话会让服务器混乱
- 统计结果集的行数:最简单的就是
COUNT(*)
这并不会扩展成所有列,实际上,它会忽略所有的列而直接统计所有的行数。我们最常见的错误就是,在括号内指定一个列却希望统计结果集的行数。如果希望知道的行数,那么直接使用COUNT(*)
。这样写意义清晰,性能也更好
简单优化的两个方面:
- 利用MyISAM在
COUNT(*)
全表非常的体性来加速一些特定条件的COUNT()
的查询。
1 |
|
- 使用近似值:有些时候某些业务场景不要求精确的COUNT值,EXPLAIN出来的优化器估算的行数就是一个不错的近似值。执行EXPLAIN并不需要真正地去执行查询,所以成本很低。
优化关联查询
这里需要注意的点是:
- 确保ON或者USING子句中的列上有索引,一般来说,除非有其他理由,否则只需要在关联顺序中的第二个表的相应列上创建索引。
- 确保任何的GROUP BY和ORDER BY中表达式只涉及到一个表中的列,这样才有可能使用索引来优化这个过程。
优化子查询
如果使用MySQL5.6或更新的版本,就可以直接忽略关于子查询的这些建议了
优化GROUP BY和DISTINCT
很多场景下,MySQL都用同样的办法优化这两种查询。当无法使用索引时会使用临时表或者文件排序来做分组。
如果需要对关联查询做分组,那么通常采用查找表的标识列分组的效率会比其他列更高。标识列就是你用来把两张表联合在一起的列。
如果没有通过ORDER BY子句显式地指定排序列,当查询使用GROUP BY子句的子句时,结果集会自动按照分组的字段及逆行排序。如果不关心结果集的顺序,而这种默认排序又导致了需要文件排序,则可以使用ORDER BY NULL,让MySQL不再进行文件排序。
优化LIMIT分页
例如常见的问题有LIMIT 10000,20这样的查询。
最简单的办法就是尽可能使用索引覆盖扫描,而不是查询所有的列。然后根据需要做一次关联查询。或是将LIMIT查询转换为已知位置的查询,
优化UNION查询
很多优化策略在UNION查询中都没法很好地使用,经常需要手工地将WHERE等子句下推到各个子句中,以便优化器可以充分利用这些条件进行优化。
除非确实需要服务器消除重复的行,否则就一定要使用UNION ALL。如果没有ALL,会导致对整个临时表的数据做唯一性检查。这样的代价非常高。
总结
通常,我们要做的不是查询优化,不是库表结构优化,不是索引优化也不是应用设计优化,而是面对所有都柔和到一起的情况。理解擦汗寻时如何被执行的以及时间都消耗在哪些地方,优化通常都需要三管齐下:不做、少做、快速地做。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!