例子引入

一条本可以很快执行的语句,由于MySQL选错了索引,导致执行速度变得很慢?

例子

  1. 先建表,表里有a、b两个字段,并分别建上索引:

CREATE TABLE t ( id int(11) NOT NULL, a int(11) DEFAULT NULL, b int(11) DEFAULT NULL, PRIMARY KEY (id), KEY a (a), KEY b (b) ) ENGINE=InnoDB;

  1. 然后,往表t插入10万行记录,取值按整数递增,即:(1,1,1),(2,2,2)…(100000,100000,100000)。

    1. 用存储过程来插入数据的,这里我贴出来方便你复现:

    delimiter ;; create procedure idata() begin declare i int; set i=1; while(i<=100000) do insert into t values(i, i, i); set i=i+1; end while; end;; delimiter ; call idata();

  1. 正常不指定索引的查询

接下来,我们分析一条SQL语句:select * from t where a between 10000 and 20000;
你一定会说,很简单呀,a上有索引,肯定使用索引a。是的,图1用explain查看这条语句的执行情况。

  • 由图1,这条查询语句的执行也确实符合预期,key这个字段值是’a’,表示优化器选择了索引a。

image.png
图1 使用explain命令查看语句执行情况

  1. 删除后再插入的查询结果

不过这个案例不会这么简单。在我们已经准备好的包含了10万行数据的表上,我们再做如下操作:

  1. session A的操作开启了一个事务。
  2. session B把数据都删除后,又调用了 idata这个存储过程,插入了10万行数据。

image.png
图2 session A和session B的执行流程
这时,session B的查询语句不再选择索引a了。可通过慢查询日志(slow log)查看具体的执行情况。

  1. 增加对照:force index(a)

为了说明优化器选择的结果是否正确,增加一个对照:使用force index(a)来让优化器强制使用索引a。

下面的三条SQL语句,就是这个实验过程。
set long_query_time=0;
select * from t where a between 10000 and 20000; /*Q1*/
select * from t force index(a) where a between 10000 and 20000;/*Q2*/

  • 第一句,是将慢查询日志的阈值设置为0,表示这个线程接下来的语句都会被记录入慢查询日志中;
  • 第二句,Q1是session B原来的查询;
  • 第三句,Q2是加了force index(a)来和session B原来的查询语句执行情况对比。

如图3所示是这三条SQL语句执行完成后的慢查询日志。
image.png
图3 slow log结果

  • Q1扫描了10万行,显然是走了全表扫描,执行时间是40毫秒。
  • Q2扫描了10001行,执行了21毫秒。也就是说,我们在没有使用force index的时候,MySQL用错了索引,导致了更长的执行时间。

这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。这时,MySQL竟然会选错索引,是不是有点奇怪呢?今天,我们就从这个奇怪的结果说起吧。

优化器的逻辑

选择索引是优化器的工作。而优化器选择索引的目的:找到最优的执行方案,并用最小的代价去执行语句。

在数据库,扫描行数是影响执行代价的因素之一。(访问磁盘数据的次数 / 消耗的CPU资源。)
扫描行数不是唯一的判断标准,优化器还结合是否使用临时表、是否排序等因素进行综合判断
这个简单的查询语句没涉及到临时表和排序,所以MySQL选错索引就在判断扫描行数的时候出问题。

那么,问题就是:扫描行数是怎么判断的?
MySQL在执行语句前,不能精确地知道满足条件的记录有几条,而只能根据统计信息来估算记录数。

  • 这个统计信息就是索引的“区分度”。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。这个基数越大,索引的区分度越好。
  • 我们可以使用show index方法,看到一个索引的基数。

如图4所示,就是表t的show index 的结果 。

  • 虽然表中每行的三个字段值都一样,但在统计信息中,这三个索引的基数值并不同,而且都不准确。

image.png
图4 表t的show index 结果

那么,MySQL是怎样得到索引的基数的呢?这里,简单介绍MySQL采样统计的方法。

  1. 为什么要采样统计呢?因为把整张表取出来一行行统计,虽然结果精确,但是代价太高。
  2. 怎么采样统计从而得到索引的基数?采样统计时,InnoDB默认选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。
  • 而数据表会持续更新,索引统计信息也会变化。当变更的数据行数超过1/M时,自动触发重新做一次索引统计。

在MySQL中,有两种存储索引统计的方式,可以通过设置参数innodb_stats_persistent的值来选择:

  • 设置为on的时候,表示统计信息会持久化存储。这时,默认的N是20,M是10。
  • 设置为off的时候,表示统计信息只存储在内存中。这时,默认的N是8,M是16。

由于是采样统计,所以不管N是20还是8,这个基数都是很容易不准的。但,这还不是全部。

索引统计值(cardinality列)虽不够精确,但大体上差不多,选错索引一定还有别的原因。

  • 索引统计只是一个输入,对于具体的语句,优化器还要判断:执行这个语句本身要扫描多少行

接下来,再看优化器预估:这两个语句的扫描行数是多少。rows这个字段表示的是预计扫描行数。
image.png
图5 意外的explain结果

  • Q1的结果还是符合预期的,rows的值是104620;
  • Q2的rows值是37116,偏差就大了。而图1中我们用explain命令看到的rows是只有10001行,是这个偏差误导了优化器的判断。

到这,第一个疑问:优化器为什么放着扫描37000行的执行计划不用,却选了100000的呢?

  • 这是因为,如果使用索引a,每次从索引a上拿到一个值,都要回到主键索引上查出整行数据。
  • 而如果选择扫描10万行,是直接在主键索引上扫描的,没有额外的代价。

优化器估算两个选择的代价,从结果:优化器认为直接扫描主键索引更快。但从执行时间:这不是最优的。

使用普通索引要把回表的代价算进去。图1执行explain时,也考虑了这个代价 ,但图1的选择是对的。
所以MySQL选错索引,得归咎到错判扫描行数。至于为什么会得到错误的扫描行数,这留自己分析。

既然是统计信息不对,那就修正。analyze table t 命令:用来重新统计索引信息。看一下执行效果。
image.png
图6 执行analyze table t 命令恢复的explain结果
这回对了。所以实践中,当发现explain的结果预估的rows值跟实际差距较大,可用这个方法来处理。

如果只是索引统计不准确,analyze命令可以解决很多,但优化器不止是看扫描行数。
依然是基于这个表t,我们看看另外一个语句:
select * from t where (a between 1 and 1000) and (b between 50000 and 100000)
order by b limit 1;

在执行这条语句前,你先设想一下,如果你来选择索引,会选哪个?先看a、b这两个索引的结构图。
image.png
图7 a、b索引的结构图

  • 如果使用索引a进行查询,那么就是扫描索引a的前1000个值,然后取到对应的id,再到主键索引上去查出每一行,然后根据字段b来过滤。显然这样需要扫描1000行。
  • 如果使用索引b进行查询,那么就是扫描索引b的最后50001个值,与上面的执行过程相同,也是需要回到主键索引上取值再判断,所以需要扫描50001行。

所以你一定会想,如果使用索引a的话,执行速度明显会快很多。那么,下面来看看是不是这么回事。

图8是执行explain的结果。
mysql> explain select * from t
where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;
image.png
图8 使用explain方法查看执行计划 2
由图,返回结果中key:优化器选择索引b;rows:需要扫描的行数是50198。这个结果可得到结论:

  1. 扫描行数的估计值依然不准确;
  2. 这个例子里MySQL又选错了索引。

索引选择异常和处理

原本可以执行得很快的SQL语句,执行速度却比你预期的慢很多,你应该怎么办呢

  1. 一种方法是,采用force index强行选择一个索引。

我们来看看第二个例子。刚开始分析时,我们认为选择索引a会更好。现在,我们就来看看执行效果:
image.png
图9 使用不同索引的语句执行耗时

  • 原本语句需要执行2.23秒,而当使用force index(a)时,只用0.05秒,比优化器的选择快40多倍。

使用force index的缺点

  1. 这么写不优美
  2. 如果索引改名,这个语句也得改,麻烦。且如果以后迁移到别的数据库,这个语法还可能会不兼容。
  3. 变更的及时性。因为选错索引的情况还是比较少出现的,所以开发的时候通常不会先写上force index。而是等到线上出现问题的时候,你才会再去修改SQL语句、加上force index。但是修改之后还要测试和发布,对于生产系统来说,这个过程不够敏捷。

所以,数据库的问题最好还是在数据库内部解决。那么,在数据库里面该怎样解决呢?

  1. 第二种方法就是,我们可以考虑修改语句,引导MySQL使用我们期望的索引。

比如此例,把“order by b limit 1” 改成 “order by b,a limit 1” ,语义逻辑相同。来看看改后的效果:
image.png
图10 order by b,a limit 1 执行结果

  • 之前优化器选择使用索引b,是因为它认为使用索引b可以避免排序(b本身是索引,已经是有序的了,如果选择索引b的话,不需要再做排序,只需要遍历),所以即使扫描行数多,也判定为代价更小。
  • 现在order by b,a 这种写法,要求按照b,a排序,就意味着使用这两个索引都需要排序。因此,扫描行数成了影响决策的主要条件,于是此时优化器选了只需要扫描1000行的索引a。

当然,这种修改并不是通用的优化手段,只是刚好在这个语句里面有limit 1,因此如果有满足条件的记录, order by b limit 1和order by b,a limit 1 都会返回b是最小的那一行,逻辑上一致,才可以这么做。

如果你觉得修改语义这件事儿不太好,这里还有一种改法,图11是执行效果。
select * from
(select * from t
where (a between 1 and 1000) and (b between 50000 and 100000)
order by b limit 100)
alias limit 1;
image.png
图11 改写SQL的explain

  • 我们用limit 100让优化器意识到,使用b索引代价很高。其实是我们根据数据特征诱导优化器,不具备通用性。
  1. 第三种方法是,在有些场景下,我可以新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。

小结

  1. 聊了聊索引统计的更新机制,
  2. 并提到了优化器存在选错索引的可能性。
  • 对于由于索引统计信息不准确导致的问题,你可以用analyze table来解决。
  • 而对于其他优化器误判的情况,你可以在应用端用force index来强行指定索引,也可以通过修改语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。

思考题

前面我例子中,通过session A的配合,让session B删除数据后又重新插入了一遍数据,然后就发现explain结果中,rows字段从10001变成37000多。
而如果没有session A的配合,只是单独执行delete from t 、call idata()、explain这三句话,会看到rows字段其实还是10000左右。你可以自己验证一下这个结果。这是什么原因呢?分析一下。

delete 语句删掉了所有的数据,再通过call idata()插入了10万行数据,看上去覆盖了原来的10万行。

  • 但是,session A开启了事务并没有提交,所以之前插入的10万行数据是不能删除的。这样,之前的数据每一行数据都有两个版本,旧版本是delete之前的数据,新版本是标记为deleted的数据。
  • 这样,索引a上的数据其实就有两份。

然后你会说,不对啊,主键上的数据也不能删,那没使用force index的语句,使用explain命令看到的扫描行数为啥还是100000左右?(潜台词,如果这个也翻倍,也许优化器还会认为选字段a作为索引更合适)

  • 是的,不过这个是主键,主键是直接按照表的行数来估计的。而表的行数,优化器直接用的是show table status的值。这个值的计算方法,我会在后面有文章为你详细讲解。

image.png