MySQL查询优化探秘

Updated on with 0 views and 0 comments

1 为啥MySQL对【B+树】情有独钟?

MySQL的数据是持久化的,意味着数据(索引+记录)是保存到磁盘上的,因为这样即使设备断电了,数据也不会丢失。

这里铺垫一些计算机基础知识:

  • 磁盘读写的最小单位是扇区,扇区的大小只有512B大小
  • 操作系统一次会读写多个扇区,所以操作系统的最小读写单位是块(Block)
  • Linux 中的块大小为4KB,也就是一次磁盘 I/O 操作会直接读写8个扇区

由于数据库的索引是保存到磁盘上的,因此当我们通过索引查找某行数据的时候,就需要先从磁盘读取索引到内存,再通过索引从磁盘中找到某行数据,然后读入到内存,也就是说查询过程中会发生多次磁盘 I/O,而磁盘 I/O 次数越多,所消耗的时间也就越大。

另外,MySQL是支持范围查找的,所以索引的数据结构不仅要能高效地查询某一个记录,而且也要能高效地执行范围查找。

所以,要设计一个适合MySQL索引的数据结构,至少满足以下要求:

  • 能在尽可能少的磁盘的 I/O 操作中完成查询工作

  • 要能高效地查询某一个记录,也要能高效地执行范围查找

1.1 查询数据结构对比

分析完要求后,我们针对每一个数据结构简要分析一下:

  • 二分查找树虽然是一个天然的二分结构,能很好的利用二分查找快速定位数据,但是它存在一种极端的情况,每当插入的元素都是树内最大的元素,就会导致二分查找树退化成一个链表,此时查询复杂度就会从O(logn)降低为O(n)。

  • 为了解决二分查找树退化成链表的问题,就出现了自平衡二叉树,保证了查询操作的时间复杂度就会一直维持在O(logn) 。但是它本质上还是一个二叉树,每个节点只能有2个子节点,随着元素的增多,树的高度会越来越高。而树的高度决定于磁盘 I/O 操作的次数,因为树是存储在磁盘中的,访问每个节点,都对应一次磁盘 I/O 操作,也就是说树的高度就等于每次查询数据时磁盘 IO 操作的次数,所以树的高度越高,就会影响查询性能。

  • B树和B+树都是通过多叉树的方式,会将树的高度变矮,所以这两个数据结构非常适合检索存于磁盘中的数据。但是MySQL默认的存储引擎InnoDB采用的是B+作为索引的数据结构,原因有:

    • B+树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比既存索引又存记录的B树节点,一个数据页可以存放更多的索引,因此B+树可以比B树更矮胖,查询底层节点的磁盘 I/O次数会更少

      说明:数据库的 I/O 操作的最小单位是页,InnoDB数据页的默认大小是16KB,意味着数据库每次读写都是以16KB为单位的,一次最少从磁盘中读取16K的内容到内存中,一次最少把内存中的16K内容刷新到磁盘中。

    • B+树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让B+树在插入、删除的效率都更高,比如删除根节点的时候,不会像B树那样会发生复杂的树的变化
    • B+树叶子节点之间用链表连接了起来,有利于范围查询,而B树要实现范围查询只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如B+树。

1.2 InnoDB和MyISAM用的B+树索引是一样的吗?

MySQL默认的存储引擎是InnoDB,它采用B+树作为索引的数据结构。在创建表时,InnoDB存储引擎默认会创建一个主键索引,也就是聚簇索引,其它索引都属于二级索引

MySQL的MyISAM存储引擎支持多种索引数据结构,比如:B+树索引、R树索引、Full-Text索引。MyISAM存储引擎在创建表时,创建的主键索引默认使用的是B+树索引。

虽然,InnoDB和MyISAM都支持B+树索引,但是它们数据的存储结构实现方式不同:

  • InnoDB存储引擎:B+树索引的叶子节点保存数据本身
    77e9e881456646e08f7d91e49e725a2a.png

  • MyISAM存储引擎:B+树索引的叶子节点保存数据的物理地址
    b2a019bccc70429789a4d40b0cb9633a.png

另外我们通过两个存储引擎的磁盘文件也可以发现有区别:

c0c69a2f56774ddaaed77e0eec6d2b9f.png

知识点扩展 >> 不同的存储引擎支持的索引类型如下图所示:

2f629c5960b0404785f028e245a526b4.png

1.3 查询过程是怎么选择用哪种索引?

InnoDB存储引擎根据索引类型不同,分为聚簇索引和二级索引。它们区别在于:聚簇索引的叶子节点存放的是实际数据,所有完整的数据都存放在聚簇索引的叶子节点;而二级索引的叶子节点存放的是主键值,而不是实际数据,如下图所示:

f9b2dd5cc6fb46148376f423fb531eb0.png

在我们使用主键索引字段作为查询条件的时候,如果要查询的数据都在聚簇索引的叶子节点里,那么就会在聚簇索引中的B+树检索到对应的叶子节点,然后直接读取要查询的数据,如下面这条语句:

49a862a6fb044576a6c2b85dbfe051a4.png

在我们使用二级索引字段作为查询条件的时候,如果要查询的数据在聚簇索引的叶子节点里,那么需要检索两颗B+树

  • 先在二级索引的B+树找到对应的叶子节点,获取主键值
  • 然后用上一步获取的主键值,在聚簇索引中的 B+ 树检索到对应的叶子节点,然后获取要查询的数据

上面这个过程叫做回表,如下面这条语句:

6307254a033a4a939cb3b47b76e2d685.png

在我们使用二级索引字段作为条件查询的时候,如果要查询的数据在二级索引的叶子节点,那么只需要在二级索引的B+树找到对应的叶子节点,然后读取要查询的数据,这个过程叫做覆盖索引。如下面这条语句:

c80725aa0f234b51bd4697497e653837.png

注意,查询条件用上了索引列,并不意味着查询过程就一定都用上索引,接下来我们再一起看看哪些情况会导致【索引失效】,从而造成【全表扫描】。

2 为啥【隐式转换】会导致【索引失效】?

这个问题只是个引子,其实是想盘一盘常见的6种会发生索引失效的场景:

2.1 对索引使用左或者左右模糊匹配

当我们使用左或者左右模糊匹配的时候,也就是 like %xx 或者 like %xx% 这两种方式都会造成索引失效。为什么呢?

因为B+树索引是按照索引值有序排列存储的,只能根据前缀进行比较。

如果使用 name like '%林' 方式来查询,因为查询的结果可能是陈林、张林、周林等之类的,所以不知道从哪个索引值开始比较,于是就只能通过全表扫描的方式来查询。

2.2 对索引字段使用函数

有时候我们会用一些MySQL自带的函数来得到我们想要的结果,这时候需要注意了,如果查询条件中对索引字段使用函数,就会导致索引失效。为什么呢?

因为索引保存的是索引字段的原始值,而不是经过函数计算后的值,自然就没办法走索引了。

不过,从 MySQL 8.0 开始,索引特性增加了函数索引,即可以针对函数计算后的值建立一个索引,也就是说该索引的值是函数计算后的值,所以就可以通过扫描索引来查询数据。

举个栗子,通过下面这条语句,对 length(name) 的计算结果建立一个名为idx_name_length的索引:

alter table t_user add key idx_name_length ((length(name)));

然后再用下面这条查询语句,这时候就会走索引了,如下图所示:

bdb218f6032f4285929dae591b33c647.png

2.3 对索引字段进行表达式计算

当我们在查询条件中对索引列进行表达式计算,也是无法走索引的。看下面这个栗子:

24b321db5c2042ca8f408b968358d331.png

原因跟对索引使用函数差不多。因为索引保存的是索引字段的原始值,而不是 id + 1 表达式计算后的值,所以无法走索引,只能通过把索引字段的取值都取出来,然后依次进行表达式的计算来进行条件判断,因此采用的就是全表扫描的方式。

有的同学可能会问,这种对索引进行简单的表达式计算,在代码特殊处理下,应该是可以做到索引扫描的,比方将 id + 1 = 10 变成 id = 10 - 1

是的,是能够实现,但是MySQL还是偷了这个懒,没有实现。如果非要较真的话,可以这么来理解:因为表达式计算的情况多种多样,每种都要考虑的话,代码可能会很臃肿,所以干脆将这种索引失效的场景告诉程序员,让程序员自己保证在查询条件中不要对索引进行表达式计算。

再多说一嘴,万一线上出现这种索引失效的情况,可以使用MySQL 5.7提供的 query_rewrite 功能,增加一个语句改写规则,如下所示:

insert into query_rewrite.rewrite_rules(
  pattern
  , replacement
  , pattern_database
) values (
  "select * from t where id + 1 = ?"
  , "select * from t where id = ? - 1"
  , "db1");

call query_rewrite.flush_rewrite_rules();

这里,call query_rewrite.flush_rewrite_rules()这个存储过程,是让插入的新规则生效,也就是我们说的查询重写,效果如下图所示:

01b69aa8e1d7413d9bec1af2a533ebc2.png

学废了吧,就问你屌不屌吧!

2.4 联合索引非最左匹配

对主键字段建立的索引叫做聚簇索引,对普通字段建立的索引叫做二级索引

那么多个普通字段组合在一起创建的索引就叫做联合索引,也叫组合索引

联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引的匹配,否则就会导致索引失效。

假设创建了一个 (a, b, c) 联合索引,如果查询条件是以下这几种,就可以匹配上联合索引:

bc68e5bc54df41649a64607629796123.png

需要注意的是,因为有查询优化器,所以上面的字段a和字段b在where从句的顺序并不重要

但是,如果查询条件是以下这几种,因为不符合最左匹配原则,所以就无法匹配上联合索引,联合索引就会失效:

b11b7063ccbb47aca5b0a20143eaf447.png

为什么呢?原因是,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。如果我们仅仅按照第二列搜索,肯定无法走索引。

有一个比较特殊的查询条件:where a = 1 and c = 3 ,符合最左匹配吗?

这种其实严格意义上来说是属于索引截断,不同版本处理方式也不一样:

  • MySQL 5.5 的话,前面字段a会走索引,在联合索引找到主键值后,开始回表,到主键索引读取数据行,然后再比对字段c的值。

  • 从 MySQL 5.6 之后,有一个索引下推功能(参考 Index Condition Pushdown 优化),可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。
    20f7f2e1d420474c96f96f48a2eebb4b.png

关于索引截断,再啰嗦一句,除了刚才那种情况,范围查询也会造成索引截断,这一点我在 你可能不够了解的NULL和IN 这篇博客中提到了,当然也可以参考MySQL官方文档 Range Optimization,如下图所示:

b374ede9dc5642ea9ed53dc4e7ee6013.png

2.5 WHERE从句中的OR

在WHERE从句中,如果在OR前的条件列是索引列,而在OR后的条件列不是索引列,那么索引会失效。

举个栗子,比如下面的查询语句,id 是主键,age 是普通列,从执行计划的结果看,是走了全表扫描:

50436f7dbf0148938e3ece81e283bdab.png

这是因为OR的含义就是两个只要满足一个即可,因此只有一个条件列是索引列是没有意义的,只要有条件列不是索引列,就会进行全表扫描。

要解决这个问题也很简单,将age字段设置为索引即可,如下图所示:

8317e4e2a1464af38331529c32b2aa06.png

可以看到 type=index_mergeindex_merge 的意思就是对id和age分别进行了扫描,然后将这两个结果集进行了合并,这样做的好处就是避免了全表扫描。

2.6 对索引隐式类型转换

如果索引字段是字符串类型,但是在条件查询中,输入的参数是整型的话,会在执行计划中发现这条语句走的是全表扫描。看下面这个栗子,字段phone是varchar类型:

a2e8c64ae990420e969ba991dbad5fda.png

为什么会这样?这么简单的类型转换,如果MySQL不直接处理了,就不必做全表扫描了。。。甚至有同学可能会认为MySQL不做隐式类型转换比刚才索引表达式的问题还要脑残,就问你们是不是这么想的吧。

要讲明白这个原因,我们首先需要搞清楚的是MySQL做数据类型转换规则是什么,说白了,就是要搞清楚MySQL是会将字符串转成数字处理,还是将数字转换成字符串处理

我们先来看一个简单的栗子:

5d81b710ba95446b8b64f13f3c8d993e.png

上面的返回结果为1,说明MySQL在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较

再举一个栗子来证明这个结论,还是刚才那个SQL,这次我们把where条件改成int类型的主键id:

e5bff8316ed045cf835ee6f1d733c0de.png

虽然id是整型,虽然查询条件是字符串 '1',但是这条语句还是走了索引扫描。

我相信很多同学都看过或者听人说过如果有隐式转换的话,MySQL不走索引,而我想告诉大家的是,不要随波逐流,不要人云亦云,这样真的太LOW了,要学会自己去判断!!!

我们再来看看那个因为隐式转换走全表扫描的SQL:

1a3ee3b3b6c846ed906916dd7b508705.png

因为phone字段为字符串,所以MySQL会自动把字符串转为数字,所以这条语句等价于:

8bcdb3f6403743f9a7cc3ba17c6269ac.png

现在清楚了吧,CAST函数是作用在了phone字段上,而phone字段是索引字段,也就是对索引使用了函数!前面我们已经说过了,对索引使用函数是会导致索引失效的。

最后分析下那条有隐式转换但依然走了索引的SQL:

652d2a71f954452b979b18dea24b2a45.png

可以看到这时字符串部分是输入参数,需要将字符串转为数字,所以这条语句等价于:

702b3fc498464fb08772c4aa7face855.png

这一次索引字段并没有用任何函数,CAST函数是用在了输入参数上,因此是可以走索引扫描的。

其实还有一种更加难以察觉的隐式转换陷阱,如下图所示:

a98f4efa235749568eb4c86b2599b06d.png

上图中3张表的name字段都是varchar类型,但是由于编码不同也会使得索引失效。因为字符集utf8mb4是utf8的超集,MySQL内部的实现为了避免数据在转换过程中造成数据截断,会按数据长度增加的方向进行转换,即把utf8字符串转成utf8mb4字符串,所以依然是在索引字段上使用函数导致的索引失效。

就问你有没有豁然开朗的感觉!?不要再幼稚地以为MySQL是不是脑残了,居然对隐式转换见死不救。我只想告诉你,你可能真的不够了解MySQL,所以接着往下看吧,后面还有干货哦!

3 【Sending data】究竟是啥意思?

如果你对【Sending data】很陌生的话,请先看看下图:

7d4ebcc01b994161934a39b1dc8ade92.png

相信很多同学对【Sending data】的理解是【正在发送数据】,其实是被误导了。我们先来看看大多数同学理解的【正在发送数据】究竟对应哪个状态。

假设MySQL服务器内存是64G,在test库有一张表t,其占用的磁盘空间是100G,如果执行下面的操作:

mysql -h$host -P$port -u$user -p$pwd \
  -e "select * from db1.t" > $target_file

显然,上面会执行【全表扫描】,请问服务器的内存会被用光吗?答案是否定的。为什么呢?

前面已经提到,InnoDB数据保存在主键索引上,所以全表扫描实际上是直接扫描表t的主键索引。这条查询语句由于没有其他判断条件,所以查到的每一行都可以直接放到结果集,然后返回给客户端。

那么,这个【结果集】存在哪里呢?

其实服务端无需在内存中保存完整结果集,取数据和发数据的流程如下:

c6db234ba67d4a0e800ba9c5b298256a.png

  • 获取一行,写到 net_buffer。这块内存的大小是由参数 net_buffer_length 定义,默认16k

  • 重复获取行,直到 net_buffer 写满,调用网络接口发出去

  • 若发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer

  • 若发送函数返回 EAGAINWSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送

从上述流程中,可以看到:

  • 一个查询在发送过程中,占用的MySQL内部的内存最大就是 net_buffer_length 这么大,不会达到100G

  • socket send buffer 也不可能达到100G(默认定义在/proc/sys/net/core/wmem_default),若socket send buffer被写满,就会暂停读数据的流程

  • MySQL其实是边读边发,这意味着,若客户端接收得慢,会导致MySQL服务端由于结果发不出去,造成这个事务的执行时间变长

那么当客户端不读socket receive buffer内容时,服务端是什么状态呢?请看下图:

89fd73363d5c4329b96131a64ca19df7.png

讲到这里,大家有没有发现【Sending to client】才是我们理解的【正在发送数据】。

说明:在MySQL 5.7.8之前 Sending to client 被称为 Writing to net

补充一个小知识点,MySQL客户端有一个 --quick 参数,如下图所示:

27e27bccb2c64963a68b8d1fbe4c430b.png

加上这个参数后,会使用 mysql_use_result 方法,即读一行处理一行。假设某个业务逻辑很复杂,每读一行数据后要处理很长时间,就会导致客户端要过很久才取下一行数据,可能就会出现上面【Sending to client】的情况。

因此,对于正常的线上业务来说,若一个查询的返回结果不多,推荐使用 mysql_store_result 接口,即直接把查询结果保存到本地内存

如果MySQL里看到很多个线程都处于【Sending to client】,这时可能就需要让业务开发同学优化查询结果,并评估这么多的返回结果是否合理。若要快速减少处于这个状态的线程数,可以将 net_buffer_length 设置得更大。

相比【Sending to client】这个状态,日常我们更多看到的是【Sending data】,但查看网络也没什么问题,为什么Sending data要这么久?先来看看MySQL官网对【Sending data】的解释:

1b311733de794e289236f012a078e8d8.png

其实,一个查询语句的状态变化是这样的:

  • MySQL查询语句进入执行阶段后,先把状态设置成【Sending data】
  • 然后,发送执行结果的列相关的信息(meta data) 给客户端
  • 再继续执行语句的流程
  • 执行完成后,把状态设置成空字符串

也就是说,【Sending data】并不一定是指【正在发送数据】,而可能是处于执行器过程中的任意阶段,所以我们经常看到的是【Sending data】而不是【Sending to client】。

4 想学习【MySQL查询优化三板斧】吗?

但凡提到MySQL查询优化,大多数同学应该都能说出 EXPLAIN。不过,我也相信大部分同学仅仅知道有EXPLAIN,至于是否还有其他的就只能呵呵了。。。

在讲三板斧之前,我们先做个简单的总结。

在MySQL中,会引发性能问题的慢查询,大体有以下三种可能:

  • 索引没有设计好
  • SQL语句没写好
  • MySQL选错了索引

一般地,碰到慢SQL,经过分析后,如果判断需要人为干预,那么常见的优化手段有:

  • 没有合适的索引,就创建索引
  • 存在多个可选索引时,force index 强制使用某个更有利于查询的索引
  • 关闭当前会话的MySQL优化器的某些选项
  • 改写SQL,让优化器更易懂(Join是最容易被SQL优化器理解的)

清楚了上述原因和手段后,就可以对症下药,这就是所谓的【理论指导实践】,不至于乱打一通。

4.1 EXPLAIN

通常,我们在执行一条SQL时,想要知道这个SQL先后查询了哪些表,是否使用了索引,这些数据从哪里获取到,获取到数据遍历了多少行数据等等,我们可以通过EXPLAIN命令来查看这些执行信息,如下图所示:

5980839a92c94cbfa29131263d9f7ac8.png

注意:如果执行完EXPLAIN后有 warning,一定记得 show warnings,可能会发现有用的信息,比如上面截图里执行 show warnings 后会发现原SQL被改写了。

下面对图示中的每一个字段进行一个说明,从中你也能收获到很多零散的知识点。

  • id:id列的编号是SELECT的序列号,有几个SELECT就有几个id,并且id的顺序是按照SELECT出现(执行)顺序增长的。
    • id列的值越大,执行优先级越高
    • id相同则从上往下执行
    • id值如果为NULL则最后执行
  • select_type:表示SELECT查询类型,可分为简单查询(SIMPLE)和复杂查询(PRIMARY),其中复杂查询又可以细分为简单子查询派生表(FROM语句中的子查询)、UNION查询。常见的取值如下:
    • SIMPLE(普通查询,即没有联合查询、子查询)
    • PRIMARY(主查询,即最外层的SELECT)
    • DERIVED(派生表,包含在FROM子句中的子查询)
    • UNION(在UNION中的第二个和随后的SELECT)
    • UNION RESULT(从UNION临时表检索结果的SELECT)
    • SUBQUERY(包含在SELECT中的子查询,不在FROM子句中)
    • DEPENDENT SUBQUERY(相关子查询,参考 相关子查询
    • MATERIALIZED(物化子查询,参考 Materialization优化
  • table:当前执行计划查询的表。
    • 如果给表起别名了,则显示别名信息,请注意有例外情况,比如派生表。
    • 当FROM子句中有子查询时,table列是 <derivedN> 格式,表示当前查询依赖 id=N 的查询,于是先执行 id=N 的查询。
    • 当有UNION时,UNION RESULT 的table列的值为 <unionM,N>,M和N表示参与UNION的SELECT行id。
    • 当该行引用id值为N的物化子查询的结果,table列值为 <subqueryN>,更多信息可参考 Materialization优化
  • partitions:访问的分区表信息。
  • type:表示从表中查询到行所执行的方式,查询方式是 SQL 优化中一个很重要的指标,结果值从好到差依次是:system > const > eq_ref > ref > ref_or_null > index_merge > range > index > ALL。一般来说,得保证查询达到range级别,最好达到ref。
    • system/const:表中只有一行数据匹配,此时根据索引查询一次就能找到对应的数据。如果是 B+ 树索引,我们知道此时索引构造成了多个层级的树,当查询的索引在树的底层时,查询效率就越低。const表示此时索引在第一层,只需访问一层便能得到数据。
    • eq_ref:使用唯一索引扫描,常见于多表连接中使用主键和唯一索引作为关联条件。
    • ref:非唯一索引扫描,还可见于唯一索引最左原则匹配扫描。
    • ref_or_null:这个查询类型和ref很像,但是 MySQL 会做一个额外的查询,来看哪些行包含了NULL。可参考 索引对NULL值的处理
    • index_merge:说明查询同时使用了两个或以上的索引,最后取了交集或者并集。
    • range:索引范围扫描,比如 <,>,between 等操作。
    • index:索引全表扫描,此时遍历整个索引树。
    • ALL:表示全表扫描,需要遍历全表来找到对应的行。
    • NULL:表示MySQL能够在优化阶段分解查询语句,在执行阶段用不着再访问表或索引。例如:在索引列中选取最小值,可以单独查找索引来完成,不需要在执行时访问表。
  • possible_keys:这一列显示查询可能使用哪些索引来查找。
    • EXPLAIN时可能出现possible_keys有列,而key显示NULL的情况,这种情况是因为表中数据不多,MySQL认为索引对此查询帮助不大,选择了全表查询。
    • 如果该列为NULL,则没有相关的索引。在这种情况下,可以通过检查WHERE从句看是否可以创造一个适当的索引来提高查询性能,然后用EXPLAIN查看效果。
  • key:这一列显示MySQL实际采用哪个索引来优化对该表的访问。 如果没有使用索引,则该列为NULL。如果想强制MySQL使用或忽视possible_keys列中的索引,在查询中使用 force indexignore index
  • key_len:当前使用的索引的长度。key_len计算规则如下:
    • 字符串:
      • char(n):n字节长度
      • varchar(n):2字节存储字符串长度,如果是utf8,则长度 3n + 2
    • 数值类型:
      • tinyint:1字节
      • smallint:2字节
      • int:4字节
      • bigint:8字节
    • 时间类型:
      • date:3字节
      • timestamp:4字节
      • datetime:8字节
    • BLOB和TEXT类型:
      • 在创建索引时必须指定索引前缀的长度
      • 例如:create index idx_blob on t(content(150));
    • 如果字段允许为NULL,需要1字节记录是否为NULL
    • 如果 innodb_large_prefix 被启用,对于使用 DYNAMICCOMPRESSED 行格式的InnoDB表,索引键前缀的限制是3072字节
    • 如果 innodb_large_prefix 被禁用,对于任何行格式的表,索引键的前缀限制是767字节
    • 当字符串过长时,MySQL会做一个类似左前缀索引的处理,将前半部分的字符提取出来做索引
  • ref:这一列显示了在key列记录的索引中,表查找值所用到的列或常量,常见的取值有:
    • const(常量)
    • 字段名
    • func
      • 表示使用的值是某个函数的结果。
      • 要查看哪个函数,请使用EXPLAIN后面的 SHOW WARNINGS 查看扩展的EXPLAIN输出
      • 函数实际上可能是一个运算符,比如算术运算符。
  • rows:查找到记录所扫描的行数,是预估值,MySQL如何做采样统计可参考 Cardinality 统计
  • filtered:SQL语句执行后返回结果的行数占读取行数的百分比,值越大越好。MySQL会使用 Table Filter (在分析WHERE条件时涉及到 Index Key、Table Filter 和 Index Filter,参考 复杂 SQL 加锁分析)来对读取出来的行数据进行过滤,理论上,读取出来的行等于返回结果的行数时效率最高,过滤的比率越多,效率越低。
  • Extra:额外的信息。常见的重要值如下:
    • Using index:SELECT的列被索引覆盖,并且WHERE筛选条件是索引的前导列,是性能高的表现。一般是使用了覆盖索引(索引包含了所有查询的字段)。对于InnoDB来说,如果是二级索引,那么性能会有不少提高。
    • Using where:SELECT的列未被索引覆盖,WHERE筛选条件不是索引的前导列。
    • Using where; Using index:SELECT的列被索引覆盖,并且WHERE筛选条件是索引列之一,但不是索引的前导列,意味着无法直接通过索引查找来查询到符合条件的数据。
    • NULL:SELECT的列未被索引覆盖,并且WHERE筛选条件是索引的前导列,意味着用到了索引,但是部分字段未被索引覆盖,必须通过回表来实现,不是纯粹地用到了索引,也不是完全没用到索引。
    • Using index condition:与 Using where 类似,查询的列不完全被索引覆盖,WHERE条件中是一个前导列的范围,参考 Index Condition Pushdown 优化
    • Using temporary:MySQL需要创建一张临时表来处理查询。出现这种情况一般是要进行优化的,可以先尝试用索引来优化。
    • Using filesort:MySQL会对结果使用一个外部索引排序,而不是按索引次序从表里读取行。常出现在使用 GROUP BY 或者 ORDER BY 的时候,没有合适的索引,所以这种情况一般也是要考虑使用索引来优化的。
    • Using join buffer (Block Nested Loop):参考 Block Nested-Loop Join Algorithm
    • Using join buffer (Batched Key Access):参考 Batched Key Access Joins

更多EXPLAIN细节详见MySQL官方文档 EXPLAIN Output Format

4.2 PROFILING

上述通过EXPLAIN分析执行计划,仅仅是停留在分析SQL的外部的执行情况,如果我们想要深入到MySQL内核中,从执行线程的状态和时间来分析的话,这个时候我们就可以选择PROFILING。

PROFILING除了可以分析执行线程的状态和时间,还支持进一步选择 ALL、CPU、MEMORY、BLOCK IO、CONTEXT SWITCHES 等类型来查看SQL语句在不同系统资源上所消耗的时间。以下是相关命令的注释:

3a8457379e0d464f99a6d4b9a437386d.png

在使用PROFILING功能前,我们先查看PROFILING信息:

ac6a4ded092844e284cf4e11bfba3e6b.png

PROFILING一般操作如下图所示:

7105f7e4033f4354b39e3345393437e2.png

说明:

  • SET profiling=1 开启SESSION级别的PROFILING,只记录当前SESSION的PROFILING。

  • profiling_history_size 默认值为15,最大值为100,如果将该值设置为0的实际效果是禁用PROFILING。

  • 提交SQL后,无论是否有语法错误、无论是否中途被kill,都会被记录到PROFILING的历史记录中。

  • show profile 之类的语句不会被PROFILING,即自身不会产生PROFILING。

  • 大部分情况下,在PROFILING的结果中我们主要关注两列:StatusDuration。Status表示的是PROFILING里的状态,它和PROCESSLIST的 State 基本是一致的;Duration表示该状态的耗时。

  • 我们主要关注处于哪个状态耗时最久(比如前面提到的 Sending data),这些状态中,哪些可以进一步优化。

  • 我们还可以通过观察某些阶段是否存在,以及如何避免这些阶段的出现进行优化,比如避免内存排序的出现。

  • PROFILING功能即将废弃(从MySQL 5.6.7开始标记为 deprecated),改用 Performance Schema 代替,参考 Query Profiling Using Performance Schema,如下图所示:

999150f76e8d4465a4830747a780392e.png

4.3 OPTIMIZER TRACE

利用EXPLAIN能展示SQL语句的执行计划,却无法展示为什么一些其他的执行计划未被选择,比如说明明有索引,但是为什么查询时未使用索引等。为此,MySQL 5.6 及之后的版本中提供了 Optimizer Trace 功能,让我们能更加详细的了解SQL语句执行的所有替代计划、估计成本、优化和选择过程。

在讲 Optimizer Trace 的之前,我们先来说说查询成本这个概念。

MySQL优化器首先会计算出全表扫描的成本,然后选出该SQL可能涉及到的所有索引并且计算索引的成本,然后选出所有成本最小的那个来执行,关于MySQL成本优化器可以参考 Cost-based Optimizer。这里说的成本是指基于数据采样计算的预估成本,可能无法正确反映整体的情况,这也是MySQL优化器有时做出错误优化的重要原因之一。

OPTIMIZER TRACE 相关参数如下:

28ec9a2e0b4e4e5bad7d28a7fb192128.png

OPTIMIZER TRACE 使用步骤如下:

-- 打开 optimizer_trace
SET optimizer_trace="enabled=on";
-- 设置 optimizer_trace 最大内存占用
SET optimizer_trace_max_mem_size=1000000;
-- 执行我们想要分析的SQL
SELECT * FROM order_info where uid = 5837661 order by id asc limit 1;
-- 查看执行计划表
SELECT * FROM information_schema.OPTIMIZER_TRACE;
-- 关闭 optimizer_trace
SET optimizer_trace="enabled=off";

查询计划表 OPTIMIZER_TRACE 有4个列,如下所示:

  • QUERY:表示我们的查询语句。
  • TRACE:表示优化过程的JSON格式文本。
  • MISSING_BYTES_BEYOND_MAX_MEM_SIZE:由于优化过程可能会输出很多,如果超过某个限制时,多余的文本将不会被显示,这个字段展示了被忽略的文本字节数。
  • INSUFFICIENT_PRIVILEGES:表示是否没有权限查看优化过程,默认值是0,只有某些特殊情况下才会是1,我们暂时不关心这个字段的值。

其中,最为重要的就是第二列TRACE,其内容是一个超级大的JSON,主要结构如下:

23df99706eff4c678f9884e2c66552eb.png

可以看到,它有三大块内容,也代表着SQL语句处理的三个阶段,分别为准备阶段,优化阶段和执行阶段:

  • 我们所说的基于成本的优化主要集中在优化阶段
  • 对于单表查询来说,我们主要关注优化阶段的 rows_estimation 这个过程,这个过程深入分析了对单表查询的各种执行方案的成本
  • 对于多表连接查询来说,我们更多需要关注 considered_execution_plans 这个过程,这个过程里会写明各种不同的连接方式所对应的成本。优化器会选择成本最低的那种方案来作为最终的执行计划,也就是我们使用EXPLAIN语句所展现出的那种方案。

接下来我们以一个栗子来说明 OPTIMIZER TRACE 的使用:

c254ad2410e6476dbe6744b943b3d6eb.png

可以看到该查询可能使用到的索引有3个,那么为什么优化器最终选择了idx_key2而不选择其他的索引或者直接全表扫描呢?这时候就可以通过otpimzer trace功能来查看优化器的具体工作过程。

我们直接看一下通过查询OPTIMIZER_TRACE表得到的输出(#后跟随的注释解释了优化过程中的一些比较重要的点,大家重点关注一下):

*************************** 1. row ***************************
# 分析的查询语句是什么
QUERY: SELECT * FROM s1 WHERE
    key1 > 'z' AND
    key2 < 1000000 AND
    key3 IN ('a', 'b', 'c') AND
    common_field = 'abc'

# 优化的具体过程
TRACE: {
  "steps": [
    {
      "join_preparation": {     # prepare阶段
        "select#": 1,
        "steps": [
          {
            "IN_uses_bisection": true
          },
          {
            "expanded_query": "/* select#1 */ select `s1`.`id` AS `id`,`s1`.`key1` AS `key1`,`s1`.`key2` AS `key2`,`s1`.`key3` AS `key3`,`s1`.`key_part1` AS `key_part1`,`s1`.`key_part2` AS `key_part2`,`s1`.`key_part3` AS `key_part3`,`s1`.`common_field` AS `common_field` from `s1` where ((`s1`.`key1` > 'z') and (`s1`.`key2` < 1000000) and (`s1`.`key3` in ('a','b','c')) and (`s1`.`common_field` = 'abc'))"
          }
        ] /* steps */
      } /* join_preparation */
    },
    {
      "join_optimization": {    # optimize阶段
        "select#": 1,
        "steps": [
          {
            "condition_processing": {   # 处理搜索条件
              "condition": "WHERE",
              # 原始搜索条件
              "original_condition": "((`s1`.`key1` > 'z') and (`s1`.`key2` < 1000000) and (`s1`.`key3` in ('a','b','c')) and (`s1`.`common_field` = 'abc'))",
              "steps": [
                {
                  # 等值传递转换
                  "transformation": "equality_propagation",
                  "resulting_condition": "((`s1`.`key1` > 'z') and (`s1`.`key2` < 1000000) and (`s1`.`key3` in ('a','b','c')) and (`s1`.`common_field` = 'abc'))"
                },
                {
                  # 常量传递转换    
                  "transformation": "constant_propagation",
                  "resulting_condition": "((`s1`.`key1` > 'z') and (`s1`.`key2` < 1000000) and (`s1`.`key3` in ('a','b','c')) and (`s1`.`common_field` = 'abc'))"
                },
                {
                  # 去除没用的条件
                  "transformation": "trivial_condition_removal",
                  "resulting_condition": "((`s1`.`key1` > 'z') and (`s1`.`key2` < 1000000) and (`s1`.`key3` in ('a','b','c')) and (`s1`.`common_field` = 'abc'))"
                }
              ] /* steps */
            } /* condition_processing */
          },
          {
            # 替换虚拟生成列
            "substitute_generated_columns": {
            } /* substitute_generated_columns */
          },
          {
            # 表的依赖信息
            "table_dependencies": [
              {
                "table": "`s1`",
                "row_may_be_null": false,
                "map_bit": 0,
                "depends_on_map_bits": [
                ] /* depends_on_map_bits */
              }
            ] /* table_dependencies */
          },
          {
            "ref_optimizer_key_uses": [
            ] /* ref_optimizer_key_uses */
          },
          {
          
            # 预估不同单表访问方法的访问成本
            "rows_estimation": [
              {
                "table": "`s1`",
                "range_analysis": {
                  "table_scan": {   # 全表扫描的行数以及成本
                    "rows": 9688,
                    "cost": 2036.7
                  } /* table_scan */,
                  
                  # 分析可能使用的索引
                  "potential_range_indexes": [
                    {
                      "index": "PRIMARY",   # 主键不可用
                      "usable": false,
                      "cause": "not_applicable"
                    },
                    {
                      "index": "idx_key2",  # idx_key2可能被使用
                      "usable": true,
                      "key_parts": [
                        "key2"
                      ] /* key_parts */
                    },
                    {
                      "index": "idx_key1",  # idx_key1可能被使用
                      "usable": true,
                      "key_parts": [
                        "key1",
                        "id"
                      ] /* key_parts */
                    },
                    {
                      "index": "idx_key3",  # idx_key3可能被使用
                      "usable": true,
                      "key_parts": [
                        "key3",
                        "id"
                      ] /* key_parts */
                    },
                    {
                      "index": "idx_key_part",  # idx_keypart不可用
                      "usable": false,
                      "cause": "not_applicable"
                    }
                  ] /* potential_range_indexes */,
                  "setup_range_conditions": [
                  ] /* setup_range_conditions */,
                  "group_index_range": {
                    "chosen": false,
                    "cause": "not_group_by_or_distinct"
                  } /* group_index_range */,
                  
                  # 分析各种可能使用的索引的成本
                  "analyzing_range_alternatives": {
                    "range_scan_alternatives": [
                      {
                        # 使用idx_key2的成本分析
                        "index": "idx_key2",
                        # 使用idx_key2的范围区间
                        "ranges": [
                          "NULL < key2 < 1000000"
                        ] /* ranges */,
                        "index_dives_for_eq_ranges": true,   # 是否使用index dive
                        "rowid_ordered": false,     # 使用该索引获取的记录是否按照主键排序
                        "using_mrr": false,     # 是否使用mrr
                        "index_only": false,    # 是否是索引覆盖访问
                        "rows": 12,     # 使用该索引获取的记录条数
                        "cost": 15.41,  # 使用该索引的成本
                        "chosen": true  # 是否选择该索引
                      },
                      {
                        # 使用idx_key1的成本分析
                        "index": "idx_key1",
                        # 使用idx_key1的范围区间
                        "ranges": [
                          "z < key1"
                        ] /* ranges */,
                        "index_dives_for_eq_ranges": true,   # 同上
                        "rowid_ordered": false,   # 同上
                        "using_mrr": false,   # 同上
                        "index_only": false,   # 同上
                        "rows": 266,   # 同上
                        "cost": 320.21,   # 同上
                        "chosen": false,   # 同上
                        "cause": "cost"   # 因为成本太大所以不选择该索引
                      },
                      {
                        # 使用idx_key3的成本分析
                        "index": "idx_key3",
                        # 使用idx_key3的范围区间
                        "ranges": [
                          "a <= key3 <= a",
                          "b <= key3 <= b",
                          "c <= key3 <= c"
                        ] /* ranges */,
                        "index_dives_for_eq_ranges": true,   # 同上
                        "rowid_ordered": false,   # 同上
                        "using_mrr": false,   # 同上
                        "index_only": false,   # 同上
                        "rows": 21,   # 同上
                        "cost": 28.21,   # 同上
                        "chosen": false,   # 同上
                        "cause": "cost"   # 同上
                      }
                    ] /* range_scan_alternatives */,
                    
                    # 分析使用索引合并的成本
                    "analyzing_roworder_intersect": {
                      "usable": false,
                      "cause": "too_few_roworder_scans"
                    } /* analyzing_roworder_intersect */
                  } /* analyzing_range_alternatives */,
                  
                  # 对于上述单表查询s1最优的访问方法
                  "chosen_range_access_summary": {
                    "range_access_plan": {
                      "type": "range_scan",
                      "index": "idx_key2",
                      "rows": 12,
                      "ranges": [
                        "NULL < key2 < 1000000"
                      ] /* ranges */
                    } /* range_access_plan */,
                    "rows_for_plan": 12,
                    "cost_for_plan": 15.41,
                    "chosen": true
                  } /* chosen_range_access_summary */
                } /* range_analysis */
              }
            ] /* rows_estimation */
          },
          {
            
            # 分析各种可能的执行计划
            #(对多表查询这可能有很多种不同的方案,单表查询的方案上边已经分析过了,直接选取idx_key2就好)
            "considered_execution_plans": [
              {
                "plan_prefix": [
                ] /* plan_prefix */,
                "table": "`s1`",
                "best_access_path": {
                  "considered_access_paths": [
                    {
                      "rows_to_scan": 12,
                      "access_type": "range",
                      "range_details": {
                        "used_index": "idx_key2"
                      } /* range_details */,
                      "resulting_rows": 12,
                      "cost": 17.81,
                      "chosen": true
                    }
                  ] /* considered_access_paths */
                } /* best_access_path */,
                "condition_filtering_pct": 100,
                "rows_for_plan": 12,
                "cost_for_plan": 17.81,
                "chosen": true
              }
            ] /* considered_execution_plans */
          },
          {
            # 尝试给查询添加一些其他的查询条件
            "attaching_conditions_to_tables": {
              "original_condition": "((`s1`.`key1` > 'z') and (`s1`.`key2` < 1000000) and (`s1`.`key3` in ('a','b','c')) and (`s1`.`common_field` = 'abc'))",
              "attached_conditions_computation": [
              ] /* attached_conditions_computation */,
              "attached_conditions_summary": [
                {
                  "table": "`s1`",
                  "attached": "((`s1`.`key1` > 'z') and (`s1`.`key2` < 1000000) and (`s1`.`key3` in ('a','b','c')) and (`s1`.`common_field` = 'abc'))"
                }
              ] /* attached_conditions_summary */
            } /* attaching_conditions_to_tables */
          },
          {
            # 再稍稍的改进一下执行计划
            "refine_plan": [
              {
                "table": "`s1`",
                "pushed_index_condition": "(`s1`.`key2` < 1000000)",
                "table_condition_attached": "((`s1`.`key1` > 'z') and (`s1`.`key3` in ('a','b','c')) and (`s1`.`common_field` = 'abc'))"
              }
            ] /* refine_plan */
          }
        ] /* steps */
      } /* join_optimization */
    },
    {
      "join_execution": {    # execute阶段
        "select#": 1,
        "steps": [
        ] /* steps */
      } /* join_execution */
    }
  ] /* steps */
}

# 因优化过程文本太多而丢弃的文本字节大小,值为0时表示并没有丢弃
MISSING_BYTES_BEYOND_MAX_MEM_SIZE: 0

# 权限字段
INSUFFICIENT_PRIVILEGES: 0

1 row in set (0.00 sec)

标题:MySQL查询优化探秘
作者:yanghao
地址:http://solo.fancydigital.com.cn/articles/2022/01/28/1643300712121.html