10 | MySQL为何有时候会选错索引?

前面咱们介绍过索引,你已经知道了在MySQL中一张表实际上是能够支持多个索引的。可是,你写SQL语句的时候,并无主动指定使用哪一个索引。也就是说,使用哪一个索引是由MySQL来肯定的。mysql

不知道你有没有碰到过这种状况,一条原本能够执行得很快的语句,却因为MySQL选错了索引,而致使执行速度变得很慢?程序员

咱们一块儿来看一个例子吧。sql

咱们先建一个简单的表,表里有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;

而后,咱们往表t中插入10万行记录,取值按整数递增,即:(1,1,1),(2,2,2),(3,3,3) 直到(100000,100000,100000)。session

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

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();

接下来,咱们分析一条SQL语句:优化

mysql> select * from t where a between 10000 and 20000;

你必定会说,这个语句还用分析吗,很简单呀,a上有索引,确定是要使用索引a的。spa

你说得没错,图1显示的就是使用explain命令看到的这条语句的执行状况。线程

图1 使用explain命令查看语句执行状况

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

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

图2 session A和session B的执行流程

这里,session A的操做你已经很熟悉了,它就是开启了一个事务。随后,session B把数据都删除后,又调用了 idata这个存储过程,插入了10万行数据。

这时候,session B的查询语句select * from t where a between 10000 and 20000就不会再选择索引a了。咱们能够经过慢查询日志(slow log)来查看一下具体的执行状况。

为了说明优化器选择的结果是否正确,我增长了一个对照,即:使用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语句执行完成后的慢查询日志。

图3 slow log结果

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

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

优化器的逻辑

在第一篇文章中,咱们就提到过,选择索引是优化器的工做。

而优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的CPU资源越少。

固然,扫描行数并非惟一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。

咱们这个简单的查询语句并无涉及到临时表和排序,因此MySQL选错索引确定是在判断扫描行数的时候出问题了。

那么,问题就是:扫描行数是怎么判断的?

MySQL在真正开始执行语句以前,并不能精确地知道知足这个条件的记录有多少条,而只能根据统计信息来估算记录数。

这个统计信息就是索引的“区分度”。显然,一个索引上不一样的值越多,这个索引的区分度就越好。而一个索引上不一样的值的个数,咱们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。

咱们可使用show index方法,看到一个索引的基数。如图4所示,就是表t的show index 的结果 。虽然这个表的每一行的三个字段值都是同样的,可是在统计信息中,这三个索引的基数值并不一样,并且其实都不许确。

图4 表t的show index 结果

那么,MySQL是怎样获得索引的基数的呢?这里,我给你简单介绍一下MySQL采样统计的方法。

为何要采样统计呢?由于把整张表取出来一行行统计,虽然能够获得精确的结果,可是代价过高了,因此只能选择“采样统计”。

采样统计的时候,InnoDB默认会选择N个数据页,统计这些页面上的不一样值,获得一个平均值,而后乘以这个索引的页面数,就获得了这个索引的基数。

而数据表是会持续更新的,索引统计信息也不会固定不变。因此,当变动的数据行数超过1/M的时候,会自动触发从新作一次索引统计。

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

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

因为是采样统计,因此无论N是20仍是8,这个基数都是很容易不许的。

但,这还不是所有。

你能够从图4中看到,此次的索引统计值(cardinality列)虽然不够精确,但大致上仍是差很少的,选错索引必定还有别的缘由。

其实索引统计只是一个输入,对于一个具体的语句来讲,优化器还要判断,执行这个语句自己要扫描多少行。

接下来,咱们再一块儿看看优化器预估的,这两个语句的扫描行数是多少。

图5 意外的explain结果

rows这个字段表示的是预计扫描行数。

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

到这里,可能你的第一个疑问不是为何不许,而是优化器为何放着扫描37000行的执行计划不用,却选择了扫描行数是100000的执行计划呢?

这是由于,若是使用索引a,每次从索引a上拿到一个值,都要回到主键索引上查出整行数据,这个代价优化器也要算进去的。

而若是选择扫描10万行,是直接在主键索引上扫描的,没有额外的代价。

优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。固然,从执行时间看来,这个选择并非最优的。

使用普通索引须要把回表的代价算进去,在图1执行explain的时候,也考虑了这个策略的代价 ,但图1的选择是对的。也就是说,这个策略并无问题。

因此冤有头债有主,MySQL选错索引,这件事儿还得归咎到没能准确地判断出扫描行数。至于为何会获得错误的扫描行数,这个缘由就做为课后问题,留给你去分析了。

既然是统计信息不对,那就修正。analyze table t 命令,能够用来从新统计索引信息。咱们来看一下执行效果。

图6 执行analyze table t 命令恢复的explain结果

这回对了。

因此在实践中,若是你发现explain的结果预估的rows值跟实际状况差距比较大,能够采用这个方法来处理。

其实,若是只是索引统计不许确,经过analyze命令能够解决不少问题,可是前面咱们说了,优化器可不止是看扫描行数。

依然是基于这个表t,咱们看看另一个语句:

mysql> select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;

从条件上看,这个查询没有符合条件的记录,所以会返回空集合。

在开始执行这条语句以前,你能够先设想一下,若是你来选择索引,会选择哪个呢?

为了便于分析,咱们先来看一下a、b这两个索引的结构图。

图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;

图8 使用explain方法查看执行计划 2

能够看到,返回结果中key字段显示,此次优化器选择了索引b,而rows字段显示须要扫描的行数是50198。

从这个结果中,你能够获得两个结论:

  1. 扫描行数的估计值依然不许确;

  2. 这个例子里MySQL又选错了索引。

索引选择异常和处理

其实大多数时候优化器都能找到正确的索引,但偶尔你仍是会碰到咱们上面举例的这两种状况:本来能够执行得很快的SQL语句,执行速度却比你预期的慢不少,你应该怎么办呢?

一种方法是,像咱们第一个例子同样,采用force index强行选择一个索引。MySQL会根据词法解析的结果分析出可能可使用的索引做为候选项,而后在候选列表中依次判断每一个索引须要扫描多少行。若是force index指定的索引在候选索引列表中,就直接选择这个索引,再也不评估其余索引的执行代价。

咱们来看看第二个例子。刚开始分析时,咱们认为选择索引a会更好。如今,咱们就来看看执行效果:

图9 使用不一样索引的语句执行耗时

能够看到,本来语句须要执行2.23秒,而当你使用force index(a)的时候,只用了0.05秒,比优化器的选择快了40多倍。

也就是说,优化器没有选择正确的索引,force index起到了“矫正”的做用。

不过不少程序员不喜欢使用force index,一来这么写不优美,二来若是索引改了名字,这个语句也得改,显得很麻烦。并且若是之后迁移到别的数据库的话,这个语法还可能会不兼容。

但其实使用force index最主要的问题仍是变动的及时性。由于选错索引的状况仍是比较少出现的,因此开发的时候一般不会先写上force index。而是等到线上出现问题的时候,你才会再去修改SQL语句、加上force index。可是修改以后还要测试和发布,对于生产系统来讲,这个过程不够敏捷。

因此,数据库的问题最好仍是在数据库内部来解决。那么,在数据库里面该怎样解决呢?

既然优化器放弃了使用索引a,说明a还不够合适,因此第二种方法就是,咱们能够考虑修改语句,引导MySQL使用咱们指望的索引。好比,在这个例子里,显然把“order by b limit 1” 改为 “order by b,a limit 1” ,语义的逻辑是相同的。

咱们来看看改以后的效果:

图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是执行效果。

mysql> 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;

图11 改写SQL的explain

在这个例子里,咱们用limit 100让优化器意识到,使用b索引代价是很高的。实际上是咱们根据数据特征诱导了一下优化器,也不具有通用性。

第三种方法是,在有些场景下,咱们能够新建一个更合适的索引,来提供给优化器作选择,或删掉误用的索引。

不过,在这个例子中,我没有找到经过新增索引来改变优化器行为的方法。这种状况其实比较少,尤为是通过DBA索引优化过的库,再碰到这个bug,找到一个更合适的索引通常比较难。

若是我说还有一个方法是删掉索引b,你可能会以为可笑。但实际上我碰到过两次这样的例子,最终是DBA跟业务开发沟通后,发现这个优化器错误选择的索引其实根本没有必要存在,因而就删掉了这个索引,优化器也就从新选择到了正确的索引。

小结

今天咱们一块儿聊了聊索引统计的更新机制,并提到了优化器存在选错索引的可能性。

对于因为索引统计信息不许确致使的问题,你能够用analyze table来解决。

而对于其余优化器误判的状况,你能够在应用端用force index来强行指定索引,也能够经过修改语句来引导优化器,还能够经过增长或者删除索引来绕过这个问题。

你可能会说,今天这篇文章后面的几个例子,怎么都没有展开说明其原理。我要告诉你的是,今天的话题,咱们面对的是MySQL的bug,每个展开都必须深刻到一行行代码去量化,实在不是咱们在这里应该作的事情。

因此,我把我用过的解决方法跟你分享,但愿你在碰到相似状况的时候,可以有一些思路。

你平时在处理MySQL优化器bug的时候有什么别的方法,也发到评论区分享一下吧。

最后,我给你留下一个思考题。前面咱们在构造第一个例子的过程当中,经过session A的配合,让session B删除数据后又从新插入了一遍数据,而后就发现explain结果中,rows字段从10001变成37000多。

而若是没有session A的配合,只是单独执行delete from t 、call idata()、explain这三句话,会看到rows字段其实仍是10000左右。你能够本身验证一下这个结果。

这是什么缘由呢?也请你分析一下吧。

你能够把你的分析结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一块儿阅读。

上期问题时间

我在上一篇文章最后留给你的问题是,若是某次写入使用了change buffer机制,以后主机异常重启,是否会丢失change buffer和数据。

这个问题的答案是不会丢失,留言区的不少同窗都回答对了。虽然是只更新内存,可是在事务提交的时候,咱们把change buffer的操做也记录到redo log里了,因此崩溃恢复的时候,change buffer也能找回来。

在评论区有同窗问到,merge的过程是否会把数据直接写回磁盘,这是个好问题。这里,我再为你分析一下。

merge的执行流程是这样的:

  1. 从磁盘读入数据页到内存(老版本的数据页);

  2. 从change buffer里找出这个数据页的change buffer 记录(可能有多个),依次应用,获得新版数据页;

  3. 写redo log。这个redo log包含了数据的变动和change buffer的变动。

到这里merge过程就结束了。这时候,数据页和内存中change buffer对应的磁盘位置都尚未修改,属于脏页,以后各自刷回本身的物理数据,就是另一个过程了。

相关文章
相关标签/搜索