MySQL查询性能优化

十度 mysql 2017年04月17日 收藏

MySQL查询性能的优化涉及多个方面,其中包括库表结构、建立合理的索引、设计合理的查询。库表结构包括如何设计表之间的关联、表字段的数据类型等。这需要依据具体的场景进行设计。如下我们从数据库的索引和查询语句的设计两个角度介绍如何提高MySQL查询性能。

数据库索引

索引是存储引擎中用于快速找到记录的一种数据结构。索引有多种分类方式,按照存储方式可以分为:聚簇索引和非聚簇索引;按照数据的唯一性可以分为:唯一索引和非唯一索引;按照列个数可以分为:单列索引和多列索引等。索引也有多种类型:B-Tree索引、Hash索引、空间数据索引(R-Tree)、全文索引等。

B-Tree索引

在利用B-Tree索引进行查询的过程中,有几点注意事项,我们以表A进行说明。其中表A的定义如下:

create table A(id int auto_increment primary key, name varchar(10), age tinyint, 
sex enum('男','女'), birth datatime, key(name,age,sex));
 id为主键,并在name,age,sex列上建立了索引。
  • 全值匹配:指和索引中的所有列进行匹配,例如查找name='Jone' and age=13 and sex='男'的人;
  • 匹配最左前缀:指用索引的第一列name,如where name='Jone',该查询只使用了索引的第一列
  • 匹配列前缀:匹配索引列值的开头,如where name like 'J%',查找名字以J开头的人;
  • 匹配范围值:例如查找年龄在10-30之间的Jone,where name='Jone' and age between 10 and 30;
  • 只访问索引的查询:如果在select中选择的字段都是索引中的字段,那么就不需要访问数据行,从而提高查询速度。
  • 如果不是按照索引的最左列进行查找,则无法使用索引,如当仅查找表A中年龄为15岁的人时则无法使用索引;
  • 不能跳过索引中的列,如查找表A中名字为Jone且为男性的人,则索引只能使用name列,无法使用sex列;
  • 查询中索引的某列是范围查询,则该列后的查询条件将不能使用索引。
Hash索引与B-Tree的区别:
  1. Hash索引指包含哈希值(根据key中的列计算)和行指针,而B-Tree存储的是列值。所以Hash不能使用索引来避免读取数据行;
  2. Hash索引数据不是按照索引值顺序存储的,所以无法用于排序;
  3. Hash索引不支持部分索引列匹配查找,因为Hash值是根据索引中的全部列计算出来的;
  4. Hash索引只支持等值比较查询,包括=、in()、<=>。不支持范围查询。

索引的优点

索引不仅仅可以让服务器快速定位到表的指定位置,而且还有以下优点:
  1. B-Tree索引按照列的顺序存储数据,所以可以用来做Order by和group by操作,避免排序和临时表
  2. B-Tree索引中存储索引列的值,所以当select的值在索引中时,可以避免访问数据行
  3. 索引可以有效减少服务器扫描的数据量。

高性能的索引策略

正确地创建和使用索引是实现高性能查询的基础。前面已经介绍了各种类型的索引以及对应的优缺点。高效地选择和使用索引有很多种方式,其中有些是针对特殊案例的优化,有些则是针对特定行为的优化。

  1. 独立的列:指索引不能是表达式的一部分,也不能是函数的参数。如:select * from A where id+1=5; 则无法使用主键索引。
  2. 前缀索引和索引选择性:有时需要索引很长的字符串,索引会占用很大的空间,通常可以索引开始的部分字符来节约索引空间,提高索引效率,但也会降低索引的选择性。索引的选择性=不重复索引值/数据表的记录总数。索引的选择性越高查询效率越高。
  3. 多列索引:首先需要说明在多列上创建索引不等同于给这些列的每一列单独建立索引。当执行查询的时候,MySQL只能使用一个索引。如果你有三个单列的索引,MySQL会试图选择一个限制最严格的索引。即使是限制最严格的单列索引,它的限制能力也肯定远远低于这三个列上的多列索引。比如我们想查询表A中id为3或者名字首字母为A的人,sql语句的两种写法对比,其中第二种写法比第一种减少对表的扫描次数:
  4. 多列索引中索引列的顺序也十分重要,在设计索引的顺序时也需要考虑如何更好地满足排序和分组的需要(B-Tree)。在一个多列的B-Tree索引中,索引列的顺序意味着索引首先按照最左列进行排序,其次是第二列等等。确定索引列的顺序有一个经验法则:将选择性最高的列放到索引最前列。当然如果需要考虑对表的排序的情况就需要另当考虑了。
  5. 聚簇索引:不是一种单独的索引类型,而是一种数据存储方式,具体的细节依赖于其实现方式,InnoDB的聚簇索引实际上在同一个结构中保存了B-Tree索引和数据行,一个表只能有一个聚簇索引。聚簇索引的优(1-3)缺(4-7)点如下:
    1. 可以把相关数据保存在一起。例如实现电子邮箱时,可以根据用户ID来聚集数据,这样只需要从磁盘读取少数的数据页就能够获取某个用户的全部邮件。如果没有聚簇索引,则每封邮件都可能导致一次磁盘I/O;
    2. 数据访问更快。聚簇索引将索引和数据保存在同一个B-Tree中,因此聚簇索引中获取数据通常比在非聚簇索引中查找要快;
    3. 使用覆盖索引扫描的查询可以直接使用页节点中的主键值;
    4. B-Tree索引插入速度严重依赖于插入顺序。按照聚簇索引列中值的顺序插入是加载数据到InnoDB表中速度最快的;
    5. 更新聚簇索引列的代价很高,因为会强制InnoDB将每个被更新的行移动到新的位置;
    6. 被插入的新行在移动时,可能面临“页分裂”的问题。页分裂问题是聚簇索引要求必须将这一行插入到某个已满的页中时,存储引擎会将该页分裂成两个页面来容纳该行,也就是一次页分裂操作,导致表占用更多的磁盘空间;
    7. 聚簇索引可能导致全表扫描变慢,尤其是行比较稀疏,或者由于页分裂导致数据存储不连续的时候。
    8. 如上是盗取的一个向InnoDB表中插入数据的时间和索引大小的图,其中userinfo表和userinfo_uuid表唯一的区别是userinfo表以id为主键,而userinfo_uuid表以uuid为主键,而插入100万和300万数据的顺序是按照id列的顺序插入的,由上图可知,当插入300万数据行时,userinfo_uuid表由于不是按照主键(uuid)的顺序插入的数据,会导致大量的页分裂,从而插入需要更多的时间、索引占用更大的空间。

  6. 覆盖索引:大家都会根据where的条件建立合适的索引,这只是索引优化的一个方面。优秀的索引还应该考虑整个查询。MySQL可以使用索引直接获取列的数据,这样就不需要读取数据行了。如果索引包含(覆盖)所有需要查询的字段值,我们就称之为覆盖索引。当查询是一个索引覆盖查询时,Extra列可以看到Using index的信息。

    当然覆盖查询还是有很多陷阱可能导致无法实现优化的。MySQL查询优化器会在执行查询前判断是否有一个索引能够进行覆盖,覆盖where条件中的字段和select的字段。如果不能覆盖,则还是需要扫描数据行。

    因为InnoDB表中非聚簇索引中存储主键值,所以我们先根据条件获取主键值,然后再根据主键值进行查询,这种方式叫做延迟关联。

  7. 使用索引扫描来做排序。如果EXPLAIN出来的type列值为index,说明MySQL使用了索引扫描来做排序。扫描索引本身是很快的,但是如果索引不能覆盖查询所需的全部列,那就不得不每扫描一条索引记录就回表查询一次对应的行。这基本都是随机I/O,因此按索引顺序读取的速度通常要比顺序地全表扫描慢,尤其是I/O密集型的工作负载时。因此MySQL设计索引时应尽可能的满足排序和查找。只有索引列顺序和order by子句的顺序完全一致,并且所有列的排序方向都一致时,MySQL才能使用索引来对结果做排序。如果查询关联多张表,则只有order by子句引用的字段全部为第一个表时,才能使用索引排序。

    如上是分别使用主键id排序和name排序的查询,可以看出使用id排序的查询使用了索引排序,而name排序的查询使用的是filesort。

总结

总的来说编写查询语句时,应尽可能选择合适的索引以避免单行查找,尽可能的使用原生顺序从而避免额外的排序操作,并尽可能使用索引覆盖查询。我们通过响应时间来对查询进行分析,找出消耗时间最长的查询或者给服务器带来压力最大的查询,然后检查查询的schema、SQL和索引结构,判断是否有查询扫描了太多的行,是否做了很多额外的排序或者使用了临时表,是否使用了随机I/O访问数据,或者太多回表查询哪些不在索引中的列的操作。

查询设计

在发现查询效率不高时,首先就需要考虑查询语句的设计是否合理。如下将会介绍一些查询优化技巧,然后在介绍一些MySQL优化器内部的机制,并展示MySQL是如何执行查询的。最后探索查询优化的模式,以帮助MySQL更有效地执行查询。

优化数据访问

查询性能低下的最基本原因是访问的数据太多了。因此大部分的性能低下查询都可以通过减少访问的数据量进行优化。减少数据访问量通常意味着访问了太多的行,但有时也可能是访问了太多的列。在查询时如果仅需要查询结果集中的前某些行,则最简单的方式是在查询语句的最后加上limit。在进行多表关联查询时应尽量避免使用select *,因为它返回表的所有列,但是这些列可能并不都是必须的。除了请求了不需要的数据,还需要查看MySQL是否在扫描额外的记录,其中可以通过扫描行数和返回行数进行衡量。如果发现查询中需要扫描大量的数据但是只返回少数的行,通常可以:

  1. 使用索引覆盖扫描,把所有需要的列都放入索引,这样存储引擎无须回表获取对应行就可以返回结果;
  2. 改变库表结构;
  3. 重写这个复杂的查询,让MySQL优化器能够以更优的方式执行这个查询。

重构查询方式

设计查询的时候一个需要考虑的重要问题是,是否需要将一个复杂的查询分成多个简单的查询。在传统的实现中总是强调数据库层完成尽可能多的工作,这样的逻辑在于以前总是认为网络通信、查询解析和优化是一件代价很高的事情。但是这样的想法对于MySQL并不适用,MySQL从设计上连接和断开连接都很轻量级,在返回一个小的查询结果方面很高效。

分解关联查询:很多高性能的应用都会对关联查询进行分解,简单地说就是对每个表进行一次单表查询,然后将结果在应用程序中进行关联。如下图所示:

查询计算机1班学生的所有成绩,我们可以将上过程分解为三个子步骤,如下:

那么这么分解的好处又在哪里呢?首先是让缓存的效率更高。许多应用程序可以方便的缓存单表查询对应的结果对象。如已经缓存了计算机1班对应的id为1,tb_student表中1班的学生有1号和5号,从而可以从成绩表中查询1号和5号学生的成绩;其次查询分解后,执行单个查询可以减少锁竞争;再次查询本身效率也会有所提升。如上使用in()代替关联查询,可以让MySQL按照ID顺序进行查询,这可能比随机的关联更加高效;最后分解关联查询可以减少冗余记录的查询,在应用层做关联查询时,意味着对于某条记录应用只需要查询一次,而在数据库中做关联查询,则可能需要重复地访问一部分数据。

查询执行的基础

当希望MySQL能够以较高的性能运行查询时,最好的办法就是弄清楚MySQL是如何优化和执行查询的。如下图展示了向MySQL发送一个请求时MySQL具体的操作过程:

  1. 首先服务器接收到一条客户端请求,先检查查询缓存,如果命中缓存,则立刻返回缓存中的数据,否则进入下一阶段;
  2. 服务器进行SQL解析、预处理,再由优化器生成对应的执行计划;
  3. MySQL根据优化器生成的执行计划,调用存储引擎的API执行查询;
  4. 将结果返回给客户端。

第一步是MySQL客户端/服务器通信,二者之间通信协议是“半双工”的,也就是说在某一时刻只能有一方在发送数据。在任何一个时刻MySQL连接都有一个状态,该状态表示MySQL当前的工作,通过SHOW FULL PROCESSLIST命令查询状态。其中状态有Sleep、Query、Locked、Analyzing and statistics、Coping to tmp table、Sorting result、Sending data.

第二步是查寻缓存。在解析一个查询语句之前,如果查询缓存是打开的,那么MySQL会优先检查这个查询是否命中查询缓存中的数据。通常是通过一个对大小写敏感的Hash查找实现。如果命中,那么在返回结果前MySQL会检查一次用户权限,该过程无须解析查询SQL语句。如果未命中,则解析SQL语句。

第三步是查询优化处理。包括解析SQL、预处理、优化SQL执行计划,其中出现任何错误都会终止查询。首先,MySQL通过关键字将SQL语句进行解析,并生成一棵对应的“解析树”。查询优化器负责将解析树转化成执行计划,优化器的作用就是找到查询的较优执行计划。MySQL使用基于成本的优化器,它将尝试预测一个查询使用某种执行计划时的成本(SHOW STATUS LIKE 'Last_query_cost'),并选择成本最小的一个。查询优化器是一个非常复杂的部件,它使用了很多优化策略来生成一个最优的执行计划。优化策略分为:静态优化和动态优化。静态优化可以直接对解析树进行分析,并完成优化。例如,优化器可以通过简单的代数变换将where条件转换成另一种等价形式,静态优化不依赖于特别的数值,如where中带入的常数。静态优化在第一次完成后就一直有效,即使使用不同的参数重复执行也不会发生变化,可以认为是一种“编译时优化”。动态优化是上下文相关的,如where条件中取值、索引条目对应的数据行数等,是一种“运行时优化”。如下是MySQL能够处理的优化类型:

  1. 重新定义关联表的顺序:数据表的关联并不总是按照查询中指定的顺序进行。
  2. 将外连接转化为内连接:并不是OUTER JOIN语句都必须以外连接的方式执行。如where条件、库表结构都可能会让外连接等价于一个内连接;
  3. 使用等价变换:MySQL使用等价变换来规范表达式。如(a<b and b=c) and a=10则会改写为a=10 and b>10 and b=c
  4. 优化count()、min()、max()
  5. 覆盖索引扫描:当索引中的列包含所需要的列时,MySQL使用索引返回需要的数据,不需要查询对应的行数据;
  6. 子查询优化:将子查询转化一种效率更高的形式,从而减少多个查询多次对数据的访问;
  7. 提前终止查询:使用limit时,发现已经满足查询需求时,MySQL能够立刻终止查询;
  8. 列表in()比较:MySQL中in()不等同于多个or条件的子句,因为MySQL首先对in()中的数据进行排序,然后通过二分查找的方式来确定列表中的值是否满足条件,该时间复杂度为o(logn),而多个or查询的时间复杂度为o(n)。

当MySQL需要对选择的数据进行排序时,如果无法使用索引进行排序,那么MySQL在数据量小则在内存中进行排序,如果数据量大则需要磁盘进行排序,不过MySQL将这一过程统一称为文件排序(filesort)。如果需要排序的数据量小于“排序缓冲区”,MySQL使用内存进行“快速排序”操作,如果内存不够排序,MySQL先对数据进行分块,然后对每个独立的块使用“快速排序”,并将各块排序结果放入磁盘,然后将各个排好序的块进行合并(merge)。在关联查询的时候如果需要排序,MySQL会分两种情况来处理这样的文件排序,如果order by子句中的所有列都来自关联的第一个表,那么MySQL在关联处理第一个表的时候就进行文件排序,则MySQL的EXPLAIN结果的extra字段就会有“using filesort”。除此之外的其他情况,MySQL都会先将关联结果放到一个临时表中,,然后在所有关联都结束后再进行文件排序,此时的MySQL的EXPLAIN结果的extra字段值为“Using temporary;Using filesort”。如果查询中有limit的话,limit也会在排序之后应用,所以即使返回较少的数据,临时表和需要排序的数量仍会非常大(MySQL5.6的limit子句在此处已经做了改进)。

第四步是查询执行引擎。MySQL根据执行计划给出的指令逐步执行,在该过程中,有大量的操作需要通过调用存储引擎实现的接口来完成,也就是“Handler API”。MySQL在优化阶段就为每个表创建一个handler实例,优化器根据这些实例的接口获取表的相关信息。

最后一步就是将查询的结果返回给客户端。MySQL将结果集返回客户端是一个增量、逐步返回的过程。一旦服务器处理完最后一个关联表,开始生成第一条结果时,MySQL就可以开始想客户端逐步返回结果。这样有两个好处:一是服务器端无须存储太多的结果;二是结果集中的每一行都会以一个满足MySQL客户端/服务器通信协议的封包发送,再通过TCP协议进行传输,从而是客户端可以在第一时间获得返回的结果。

优化特定类型的查询

  1. 优化count()查询。如果指定了列,则查询该列不为null的行数,如果为count(*)则查询总行数。
  2. 优化关联查询,确保on或者using子句中的列上有索引。确保group by和order by的表达式只涉及一个表中的列,这样MySQL才有可能使用索引来优化整个过程。
  3. 优化group by和distinct。MySQL使用同样的方法优化这两类查询,通常是利用索引的顺序性进行优化。但是如果无法使用索引,group by使用两种策略来完成:使用临时表或者文件排序来做分组。
  4. 优化limit分页,使用延迟关联的方式来优化limit分页;
  5. 优化UNION查询。MySQL通过创建并填充临时表的方式来执行UNION查询,因此需要手工的将where、limit、order by等子句“下推”到UNION的各个子查询中,除非确实需要服务器消除重复的行,否则一定要使用UNION ALL,如果没有ALL关键字,MySQL会给临时表加上distinct,从而对临时表的数据做唯一性检查,这样代价非常高。

总结

综上所有的内容可知,创建高性能应用程序要考虑schema、索引、查询语句以及查询优化等问题。理解查询是如何被执行的以及时间都消耗在哪些地方,从而针对耗时大的查询语句进行改进。