暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

如何正确构建MYSQL索引

豆豆羊 2021-04-01
743

数据库添加索引确实是解决数据库访问性能的一个利器,但是索引并不是越多越好,如果索引过多,应用程序的性能可能会受到影响,如果索引过少,查询性能又可能产生影响。所以找寻这个平衡点,就是我们需要特别注意的点。

很多程序员总是在事后才考虑添加索引。可问题是,事后把这个职责丢给DBA,DBA对业务数据流的理解肯定不会比开发更了解,他们需要耗费大量的时间和精力,应用各种监控,从大量的SQL语句中进行查找,来定位问题。这本省就是一个ROI比较低的策略。光靠慢查日志监控,不能解决根本问题,研发在事后永远不会对业务有更好的感知。并且也不一定能解决所有问题,会有遗漏的情况出现。因此,还是要要求研发的同学在设计表的时候就应该对数据的量级,关键的查询字段和查询模式有自己对业务的认知。

以前在一本书中看到这么一个场景,一台mysql的服务器iostat显示磁盘使用率一直处于100%,经过分析发现是因为开发人员添加了太多的索引,在删除一些不必要的索引后,磁盘使用率立马降到了20%。可见索引的添加也不是无限制的。

一、索引成本

既然要分析索引的构建如何才能算是合理,那么首先要做的就是先要知道构建索引会产生哪些成本。成本不清楚是无法知道边界的。那么构建索引的成本主要来自于三个方面。

1、 维护成本

2、 空间成本

3、 回表成本

先来看看维护成本。只要创建索引,就需要创建对应的B+树,新增数据时要同时修改聚簇索引,如果有二级索引,还要修改二级索引。这些都是维护代价。我们可以做一个实验,来看创建索引的代价。

/*创建了一个只含主键索引的表*/

CREATE TABLE `person` (

  `id` bigint(20) NOT NULL AUTO_INCREMENT,

  `name` varchar(255) NOT NULL,

  `score` int(11) NOT NULL,

  `create_time` timestamp NOT NULL,

  PRIMARY KEY (`id`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

通过以下存储过程插入10W条测试数据

耗费了大约6分45秒。

/*删掉表,从新构建含两个索引的表,一个联合索引,一个单列索引*/

drop table person

CREATE TABLE `person` (

  `id` bigint(20) NOT NULL AUTO_INCREMENT,

  `name` varchar(255) NOT NULL,

  `score` int(11) NOT NULL,

  `create_time` timestamp NOT NULL,

  PRIMARY KEY (`id`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE INDEX `name_score` ON person(`name`,`score`)USING BTREE;

CREATE INDEX `create_time` ON person(`create_time`)USING BTREE;

重新调用存储过程,插入10W条测试数据

耗费时间提升到了大约6分55秒。在上一个文章《INNODB存储引擎和索引探秘》中我们知道,数据是以页的方式存储的,一页就是16K。现在新增记录就要往页中插入数据,现有的页满了就需要创建一个新的页,把现有页的部分数据转移过去,这就是页分裂。如果是删除了很多数据使得页比较空闲,那就还要进行页合并。页分裂和页合并都是有IO代价的,并且可能在操作中产生死锁。

我们可以看一下它插入10W条记录后,页分裂了多少出来。

初始化构建的时候

因为一页大小16K,16*1024=16384 B。所以初始化的时候总共是98304/6384=6(页)。

插入了10W条记录后,页大小发生了变化

22020096/16384=1344(页),页数扩大了224倍。

再来看空间成本,虽然二级索引不用保存原始数据,但是要保存索引列的数据,所以会占用更多的空间。

数据本身只占了4.7M,但是索引却占用了8.4M 。

最后来看一下回表成本,关于回表,在上一篇文章《INNODB存储引擎和索引探秘》中已经介绍了,我就不赘述了。主要是二级索引没有保存具体数据,只有找到主键后,再从聚簇索引中查找具体内容,如何免去回表是我们在设计时重点考虑的点。我们看一个例子。

从执行计划中可以看到这个sql用到了name_score索引,type表示访问方式,这里是 ref 说明是二级索引等值匹配。

如果我们把*改成name和score,再看一下执行计划

其他都跟*一样,但是extra不是null,而是using index,证明这次查询免去了回表,直接走的二级索引。为什么呢,其实我们可以再回顾一下《INNODB存储引擎和索引探秘》中介绍的二级索引的数据结构。

这个组合索引,按照构建语句的定义

CREATE INDEX `name_score` ON person(`name`,`score`)USING BTREE;

每个结点的第一个数据方块先按照name列的内容进行顺序记录,在name相同的时候,第二个数据方块顺序记录了score列的内容。很明显,name和score 在二级索引中都做了记录,因此 ,不需要再拿第三个数据方块记录的主键进行回表查询了。

总结一下索引开销:

1、 无需一开始就构建索引,等到数据量超过1W,查询变慢了,再针对需求,查询、排序或分组的字段来构建索引。

2、 尽量在轻量级的字段上构建索引,例如,能在int上构建索引,不要在varchar上构建。

3、 尽量不要在sql中使用select * ,而是select必要的字段。如果能针对查询字段做联合索引就更好了,能避免回表,降低IO开销。

 

二、索引生效问题

问题:是不是见了索引就一定能生效,到底是建联合索引还是独立索引好?

1、 索引只能匹配列前缀

看一下这个like,模糊匹配在前面,则用不到索引,模糊匹配在后面,前缀是确定的,则能用到索引。这是为什么呢?

原因也很简单,索引B+树中数据按照索引值排序,只能根据前缀进行比较。

2、 条件涉及函数操作无法走索引

原因其实也和1是一样的,索引保存的是索引列的原始值,不是经过函数计算后的值,当然没法进行比较。

3、 联合索引只能匹配左边的列。

虽然对name和score建了联合索引,但是仅按照score列搜索无法走索引。原因也很简单,按照前面展现的联合索引的B+树的数据结构,它是先排序第一个列,再排序第二个列。我们不去查name,直接跳过它去找score,是不可能的。

如果我把搜索条件加入name列,让他强制先检索name,那么就能走name_score索引了。

这里需要注意的是,name检索放在score检索前面后面其实无所谓,因为通过查询优化器,都会转化为先按name进行检索。

总结一下:

1、 并不是建立了索引就一定能用的上,只有查询能符合索引的数据结构时,才能用上索引。

2、 如果搜索条件经常会针对多个字段进行检索,那么构建联合索引肯定是更好用的。毕竟能减少查询的次数,一个节点就能拿到需要检索的所有字段值。当然,也要正确的编写sql,做到字段顺序依赖才能保证用起来,如果出现字段跳跃式应用,或者缺失前序字段检索,那就算是构建了联合索引,还是用不起来的。例如:索引X构建顺序是A,B,C。结果where条件是A,C(C,A)【跳跃】,或者B,C(C,B)【前置缺失】,都是没办法用到这个索引X的。那么,如果搜索条件仅仅是针对某一个字段进行检索,那使用单列索引是比较合适的,因为毕竟联合索引还是会多处一部分不必要字段的存储开销。

 

三、数据库基于成本决定是否走索引

我们发现,其实有一些时候,即使数据库可以走索引,但是它偏偏没有走。这是为啥呢。

其实,MYSQL在查询数据之前,会先对可能的方案做执行计划,然后依据成本决定走那个执行计划。这里的成本,包括IO成本和CPU成本。

1、IO成本,是从磁盘把数据加载到内存的成本。默认情况下,读取数据页的IO成本常数是1

2、CPU成本,是检测数据是否满足条件和排序等CPU操作的成本。默认情况下,检测记录的成本是0.2

全表扫描,就是把聚簇索引中的记录依次和给定的搜索条件做比较,把符合搜索条件的记录加入结果集的过程。那么,要计算全表扫描的代价需要两个信息:

1、 聚簇索引占用的页面数,用来计算读取数据的IO成本

2、 表中的记录数,用来计算搜索的CPU成本

我们可以看到mysql其实已经维护了这些统计信息,并不是实时计算出来的。这里的Rows:100587是总行数。有同学可能会疑问,为什么不是10W整,这里涉及到mysql内部的一些算法,我们可以近似认为是OK的,不用太过纠结。

由此,我们可以得到CPU成本:100587*0.2=20117

Index_length: 4734976是聚簇索引所占用的空间,聚簇索引占用的空间/每个页面的大小=聚簇索引页面数量,4734976/16384=289(页)。

由此,我们可以得到IO成本:289*1=289

因此,全表扫描的总成本就等于20117+289=20406

OK,知道了成本如何计算,我们来看一下下面这个例子:

同样的查询列,name列在我的联合索引 name_score申明内,且是第一个列,create_time在我的二级索引create_time申明中。为啥仅仅是查询时间的不一样,而导致一个用到了索引,一个没有用到索引。

我们查看一下这个create_time到底是怎么存的。

从这里可以看到是从id为1开始,创建时间取2021-03-24 18:25:18 。ID每+1,创建时间减少1秒。

这说明什么问题?如果我用第一个查询条件,create_time>'2021-03-23 18:25:16',那我可能要筛选80%的结果,这个成本跟全表扫没啥区别。如果用第二查询条件,create_time>'2021-03-24 18:25:16',那其实只需要扫3行出来就OK了,明显索引的成本更低。

真实情况是不是这样的呢?我们开启一下mysql的optimizer_trace来跟踪一下优化器的执行过程。

1、先开启追踪

root@localhost:mysql 06:22:04>set optimizer_trace="enabled=on";

2、再执行要监测的SQL

oot@localhost:mysql 06:22:05>SELECT * FROM person WHERE NAME >'name84059' AND create_time>'2021-03-23 18:25:16'

3、再查一下追踪器看结果

root@localhost:mysql 06:15:39>select * from information_schema.optimizer_trace\G;

因为返回结果太长,我截取最重要的点来分析。

这个是走name_score索引的成本分析,扫描行数25362,成本是30435。这个cost就是将IO成本和CPU成本加在一起得出来的。

这里是走create_time索引的开销,扫描行数5W,但是成本是60353。我们刚才计算出来走全表扫描用到的成本是20406,因此无论是走name_score索引的30435,还是走明显走create_time索引的60353,全表扫描成本会更低。那最终MYSQL是不是选择走全表扫描的呢。继续往下看

可以看到最终还是选择了走全表扫描做为执行计划,扫描100587行,开销是20406,跟我们刚才用公式计算出来的结果一致。

同理,如果我们把create_time的条件改成了create_time>'2021-03-24 18:25:16'

再进行一次追踪,我们发现。

看到了吧,create_time索引的开销变成了3.41,扫描行是2 。非常nice。

再看最终的执行计划是怎么选择的。

OK了。非常正确的选择。仅使用了二级索引 create_time。

总结:

1、 构建索引的时候需要考虑到索引的维护代价,空间代价和回表代价。不能认为索引越多越好。

2、 不能认为建了索引就一定有用。要根据索引数据结构,表查询是否发生数据偏移,以及通过各种成本分析来评判是走全表划算还是走索引划算。



文章转载自豆豆羊,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论