索引的数据结构
这一节先从索引的数据结构开始讲起,结合日常开发中常见的数据结构进行分析、对比。
Hash表
Hash表是一种以(key,value)
形式的存储的数据结构,实现简单且查询效率也非常高,像Redis这样的内存数据库就有大量用到Hash表这样的数据结构作为索引。
但为什么在MySQL中没有大量应用呢?(MySQL目前只有memory
存储引擎支持指定使用Hash索引)
主要有如下几个原因:
- Hash索引无法支持范围查询,就是等值查询很快,但大于或小于这样的范围查询无法利用Hash索引来加速。
- 无法支持排序,像
order by
这样的需求无法利用Hash索引来加速。 - 只能支持完整字段快速查询,不能加速类似右模糊这样的查询。
可以看到上述3个条件都是平常业务中非常常见的场景,因此如果不能满足,则就应该考虑使用其他数据结构了。
InnoDB引擎有一个特殊的能力,叫做自适应哈希索引,当某些索引值使用的非常频繁时,InnoDB会在内存中基于这个值再构建一个哈希索引,这样就能利用哈希索引的优势加快查询,不过这个行为对于客户端来说是不可调整的,客户端能做的就是决定开启还是关闭这个能力。
有序数组
有序数组能够很好的支持范围查询以及排序这样的需求,并且因为是有序,所以可以利用二分查找让时间复杂度做到O(logN)
,如果只是查询,有序数组就是最合适的数据结构了,但在实际的应用中还会存在大量的新增、修改、删除这样的操作,而有序数组为了保证有序,就需要频繁的挪动数组,这样的话代价就太大了。
树
要说有没有一种数据结构对于查询和修改都很快呢?那自然就是树了,二叉搜索树能做到查询和修改的时间复杂度都是O(logN)
,如果说数据都存在内存中那直接用二叉搜索树问题倒也不大,不过,实际上由于MySQL要保证数据持久化,所以数据要保存在磁盘上,因此不能使用二叉搜索树,否则,就会因为树的深度问题造成磁盘数据块访问成为性能瓶颈。
详细聊聊B+Tree
上节中,我们了解到无论是哈希、有序数组还是树,虽然都是拥有高效的数据检索方式,但在应对数据需存储在磁盘、需支持范围查询等场景时,则无法保持高效。
目前绝大多数应用场景中,使用的都是InnoDB存储引擎,而InnoDB存储引擎构建索引的数据结构是B+Tree
,因此本小节,我们主要了解一下B+Tree
的数据结构,以及在实际应用场景中的使用,看看B+Tree
是如何解决上面其他几种数据结构无法解决的问题的。
B+Tree的特点
首先,我们结合下图,了解一下B+Tree大概的结构以及特征。
树的度(宽度)可以很大
前面提到二叉树时讲到,二叉树有一个关键问题就是树的度太小,而B+Tree
则没有这个问题了,它的宽度可以很大,自然就解决了深度问题。在B+Tree
中,每个节点(实际上对应的参数为:innodb_page_size
)默认的大小是16KB
。假设我们索引的字段是int类型,也就是4
个字节,那么也就意味着我们一次性可以读取4096
条索引值到内存中(16KB/4byte=4096条
,实际情况还要考虑指针记录所占用的空间)。
既然这样,是不是可以直接调整innodb_page_size
的大小,比如innodb_page_size
支持你调整到32KB
或者64KB
,其实之所以默认是16KB
,主要是因为当前主流的磁盘设置,扇区大小就是16KB
,保持页的大小与扇区大小一致,可以最大限度的减少将未更改的数据重写到磁盘,此外如果是经常用于频繁写入和读取的场景,较小的page_size
也可以减少单个节点被频繁重写到磁盘的情况。
叶子节点存储数据
对于主键索引与普通索引来说,叶子节点的存储的数据还有点不一样,如果是主键索引,叶子节点存储的就是整行数据,而如果是普通索引,叶子节点存储的则是索引列数据。
普通索引
像这样的查询select id from t where phone = ?;
因为普通索引的叶子节点除了会记录索引内容本身之外,还会记录主键索引的数据,因此如果只是查询id,则直接通过扫描普通索引即可完成。
主键索引
但如果是这样查询select id, name from t where phone = ?;
就需要先通过phone
这个普通索引定位到主键id
,然后再用主键id
去主键索引中查找一次,最终才能将id和name
一起返回。
数据只保存在最底层的叶子节点上,可以最大限度的让非叶子节点多存储一些数据,同时也就不用担心树的深度问题。
叶子节点双向指针记录
有了双向指针的记录,则可以更好的支持范围查询,叶子节点之间可以直接顺序遍历到,而不需要再从根节点走下来。
聚簇索引
叶子节点存储数据这个设计,在MySQL中还有个专门的叫法:聚簇索引。
聚簇索引并不是一种单独的索引类型,而是一种数据存储的方式,聚簇指的就是数据行和索引值存储在一起。
聚簇索引的优点
- 因为数据行和索引键值存放在一起,因此在根据索引找到对应数据页之后,即可直接取出相关数据行并返回,如果没有使用聚簇索引,则至少还需要再进行一次磁盘I/O。
- 查询时能使用覆盖索引也是建立在聚簇索引的前提之上才能实现的。
聚簇索引的缺点
- 因为需要存储数据行,因此在数据插入和更新时,聚簇索引的设计要明显慢于非聚簇索引,尤其是随机插入或者更新时更为明显。
- 由于每个普通索引都会记录主键索引的键值,因此不但会占用更多的存储空间,也会在数据行删除和插入时更加费时。
- 由于数据行保存在主键索引的叶子节点上,因此每次根据普通索引查询时,普通索引如果又不是完全覆盖,则还需要再回到主键索引上查询一次,这个过程我们也称为回表查询。
覆盖索引
覆盖索引也不是一种单独的索引类型,准确的说它只是一种利用数据存储在叶子节点上的优化行为,而且只是相对于非聚簇索引的。
举个例子
当执行这个select id from t where phone = ?;
查询时,时可以利用普通索引本身就记录了主键索引id
的值,而避免再去主键索引中查询行数据的过程,提升了查询效率。
也就是说,如果查询所需要的列信息,本身在普通索引的叶子节点中就已经全部包含了,那么像这样的查询我们就称为覆盖索引查询。
如何利用覆盖索引
要想利用覆盖索引的特性,一般我们会通过两种方式来实现,一种就是在建立索引时考虑,一种就是在执行查询时考虑,下面我们可以分别对这两种情况进行分析。
建立索引时考虑
就像前面select id, name from t where phone = ?;
这样的案例,也许在你的业务场景中并不存在直接通过phone和name
过滤查询数据行信息的情况,但却有大量的根据phone查询name
的需求,那么在这样的情况下,也是可以考虑建立phone和name
的联合索引的。
查询时考虑
常见的案例比如:分页查询。
直接查询只能全表扫描:select * from page_test LIMIT 100000,5;
利用覆盖索引过滤部分结果集:select * from page_test a, (select id from page_test LIMIT 100000,5) b where a.id = b.id;
普通索引与唯一索引的选择
前面两小节主要介绍了一些索引的入门内容,是深入理解索引的前提,接下来,我们开始结合一些具体的问题来进一步的了解索引。
本节,我们就先来看看关于普通索引和唯一索引的选择问题。
查询
对于唯一索引来说,很明显,一旦查询到数据后,即可直接返回。而如果是普通索引,则需要继续查找当前数据的下一条记录,当下一条记录不能匹配时则可返回。
看起来普通索引因为不能确认当前查询到的结果是否是唯一的,所以需要额外的查询一次下一条记录,但实际上我们应该考虑到由于数据是按页加载到内存中的,因此绝大多数情况下,所谓的获取下一条记录并进行判断只是一次内存中的寻址和简单的计算而已,这样的性能差异几乎是可以忽略不计的。
当然,也有比较特殊的情况,就是匹配到的数据刚好是当前数据页中的最后一条记录,那么再查询下一条记录时则有可能因为下一条记录所在的数据页不在内存中,而需要进行一次从磁盘加载到内存中的操作了,不过,对于一个数据页通常能记录上千条索引值来说,这样的概率也是非常小的。
基于上面的分析,我们可以发现对于查询操作来说,普通索引与唯一索引并没有明显的性能差异。
更新
我们再来对比一下两种索引的更新操作,不过,在这之前需要先简单介绍一下change buffer
。
change buffer
为了加快数据更新的速度,在innodb中,每次数据更新实际上都会先查询当前要更新的数据页是否已经加载到内存中了,如果是,则直接更新内存中的数据页即可,如果不是,则会先将本次更新记录到change buffer
中,这样则能免去一次从磁盘加载数据页的过程。
change buffer的merge
更新记录当然不会一直保留在change buffer
中,最终还是需要写到数据页的,change buffer
写数据页的过程也被称为change buffer的merge
过程,通常有如下三种情况会触发change buffer的merge
操作:
- 当前数据页被访问了。
- 后台定时merge。
- 数据库关闭。
change buffer会丢失吗?
当然,也许你也注意到了,如果在change buffer merge
之前数据库宕机了,那change buffer
中记录的信息岂不是全部都丢失了?这里需要说明一下,change buffer
的改动实际上也会记录在redo log
里,并随之一起写磁盘,所以redo log
保证了数据不会丢失。
change buffer也有不适用的场景
change buffer
更适合写多读少的情况,或者说适合写完以后不会立刻读的情况,反过来,如果写完以后,立刻就要读,那就会触发merge
,这样反而多了一次维护change buffer
的过程。
再看更新
说了这么多,现在我们可以回到更新这个问题上来了,我们分两种情况进行分析:
第一种,如果数据页已经在内存中了,那么对于唯一索引来说,需要先判断前一条和后一条记录是否重复,如果不重复则直接更新即可,对于普通索引来说,无需判断直接更新即可。就和查询时的校验一样,这样的操作性能差异并不大。
第二种,如果数据页不在内存中,那么对于唯一索引来说,必须先将数据页加载到内存中,并进行判断,而对于普通索引来说,则不需要,只要直接写change buffer
即可,也就真正利用了change buffer
的能力。
结合查询和更新两大场景来看,在大多数情况下普通索引实际上比唯一索引更合适,不过,我们经常也为了解决幂等性问题,通过建立唯一索引来作为最后一道天然的屏障,如果业务上有这样的需要,那就建立唯一索引,不必在意change buffer
的问题。
建立索引的原则
建立索引的原则,主要指的是什么情况下应该建索引,在哪些列上建索引,建什么样的索引,建多少索引等方面,这些问题应该是日常开发中最常见的,但凡建一张表就需要考虑上述几个问题。
在哪些列上建索引?
首先,索引列最好要有比较高的区分度,因为只有足够高的区分度才能更好的体现出索引的优势。通过执行show index from table;
可以得到你所建立的表索引的大致信息,其中有一个关键列Cardinality
,其大致表示了该索引列不重复值的个数,因此该值当然越接近表的总记录数越好。
当然,这个统计并不是100%准确的,如果你遇到过MySQL选错索引的情况,很有可能就是因为这个统计有误导致的,后面会单独对这个问题进行分析,现在你只要知道有这样一个参考值即可。
其次,一般就是我们常说的涉及到where、group by、order by
等查询情况时需要考虑建立索引,但前面在介绍覆盖索引时也提到,要注意利用覆盖索引的能力来加速查询,也是不错的选择。
最后,有一点比较重要,要尽量避免在频繁更新的列上建立索引,因为每一次更新都需要对索引的存储进行维护,某些情况可能还会造成数据页的分裂与合并,这将会造成更多的性能消耗。
建什么样的索引?
索引类型
按索引的选择可以分为:主键索引、唯一索引、普通索引、联合索引、全文索引。
主键索引
自增主键与业务主键
数据表的主键到底是应该用自增还是直接用业务主键,比如数据表中记录的订单号,本身就必须是唯一的,因此是否可以直接作为数据表的主键?
在大多数情况下,还是建议使用自增主键,这样做的原因主要是因为自增主键通常空间占用更少(每个普通索引都会额外记录主键索引的值),以及自增的有序性保证也可以避免数据页的分裂问题。当然直接使用业务主键作为索引可以在查询该业务字段时减少一次回表的过程,比如:像这样的查询select order_id from t where create_time > ?
如果order_id
是主键,则直接建立create_time
索引即可,否则通常需要建立create_time和order_id
的联合索引。
唯一索引、普通索引
业务上如果有需要用到唯一索引来防幂等性问题时可以选择唯一索引,其他情况下建议直接使用普通索引即可,理由在前面关于普通索引与唯一索引的选择时已经说明。
联合索引
如果明确有多个索引列就是会一块查询时,那肯定是改为联合索引比较高效,就这好比原本你需要到多个索引文件中挨个检索的情况,现在只需要在一个索引文件中就能完成。
通常如果你在执行计划中看到比如:Using union 、Using intersect
此时你就应该考虑建立联合索引了。
联合索引要注意什么?
-
遵守最左匹配原则。
-
区分度高的尽量放在最左边。
-
联合索引列数不宜过多,很明显,列数过多就像字段长度太长一样,从对磁盘和内存的使用来说,都不是好的选择。
全文索引
全文索引是一种特殊的索引,它主要是用来在一段文本中查找关键字,而不是直接比较索引中的值,它和前面几类索引完全不一样,全文索引更类似于搜索引擎做的事,使用时你得关心停用词、词干等,而不是简单的where
查询。
不过全文索引和前面这些索引并不冲突,也就是说你可以在同一个列上同时创建普通索引和全文索引。
使用建议
索引长度
通过前面的介绍,你应该明白索引也是需要存储的,索引长度首先影响的就是存储空间(不局限于磁盘上的存储,也关系到内存中的数据页能一次性加载多少索引键值)。
前缀索引
一般如果要在比较长的列上加索引时,可以考虑使用前缀索引,这样不但有时候反而可以提升索引性能,以及节省索引空间。
前缀索引最关键的就是找到一个合适的索引长度,兼顾索引的选择性以保证查询效率,索引的选择性指的是不重复的索引值和数据表的总记录的比值,选择性越高查询效率也就越高,比如唯一索引的选择性就是1。
关于选择性的计算
select count(distinct left(索引列,索引长度))/count(*) from table;
SELECT count(DISTINCT LEFT(order_no, 20)) / count(*) AS '20', count(DISTINCT LEFT(order_no, 22)) / count(*) AS '22', count(DISTINCT LEFT(order_no, 24)) / count(*) AS '24', count(DISTINCT LEFT(order_no, 26)) / count(*) AS '26', count(DISTINCT LEFT(order_no, 28)) / count(*) AS '28', count(DISTINCT LEFT(order_no, 30)) / count(*) AS '30', count(DISTINCT LEFT(order_no, 32)) / count(*) AS '32' FROM test;
order_no
字段长度是32
,可以看出从获取长度为26
开始,区分度已经接近1
,再增加长度性价比已经不高了。
使用前缀索引值得注意的地方
- 由于数据是不断变化的,因此之前计算得到的选择性可能因为数据的变化而变化,最终导致性能越来越差。
- 选择性的计算也许并不够准确,比如从上面的结果的来看,长度26已经比较合适了,但实际上这只是一个平均值,你要确保数据分布的是否足够均匀,否则你可能会掉入陷阱。
- 一旦使用了前缀索引你就失去了
order by
、group by
、覆盖索引等性能提升的能力。
索引有序性
为什么我们都说要尽量避免使用像UUID
这样的数值作为聚簇索引,除了他本身比较占用空间之外,还有一个更重要的原因就是UUID
是无序的,无序性会带来更严重的问题。
建议自己先试验一下使用自增主键,与使用UUID
作为主键两种方式在数据插入上的性能差异,尤其是当索引大小超过服务器内存时。
如果索引值是顺序的,那么当一个数据页满了以后(实际上不会完全满,InnoDB
默认的最大填充因子是页大小的15/16
),只需要将数据插入下一个新页即可。
如果索引值是随机的,那么就无法简单的把数据插入最后,InnoDB
得先为数据找到合适的位置,这会带来以下几个问题:
- 可能目标数据页已经刷到磁盘上了,那么
InnoDB
就必须先将数据页从磁盘加载到内存中,这将会产生大量随机I/O。 - 因为数据的顺序是随机的,所以可能会产生大量的页分裂操作,页分裂会导致需要移动大量的数据。
- 页分裂过多的结果又会造成数据页变得稀疏,最终可能又会触发数据页合并,数据页合并又会导致需要移动大量的数据。
使用
optimize table
命令可以重新优化数据页的使用情况。
建立多少索引?
显然,索引并不是越多越好,否则也不多想,直接给所有列都加上索引就好了,之所以要限制索引的数量,主要是在新增、修改、删除等操作时,会因为需要同时维护索引而产生较高的消耗,我们知道每一个索引都会有一份独立的存储,索引越多也就意味着每添加一行数据,除了要记录本身的行数据之外,要写的索引文件也就越多,因此一定会对写入的性能造成一定的影响,尤其是那种写多读少的场景。
此外,索引越多也就意味着要存储的内容也就越多,就需要更多的磁盘空间来存储。
所以,我认为建多少索引主要还是需要结合实际的业务场景来定夺,你只要清楚的了解其中的利弊就可以了。
不过有一类场景可以提醒一下,就是类似归档表这样的数据表,有时这样的表可能并不需要像原表一样为了满足业务查询建立许多索引。减少一些不必要的索引,不但能够加快归档的速度,同时还能减少存储的空间。
为什么会选错索引?
MySQL的优化器使用的是基于成本的模型,而衡量成本的主要指标就是一个查询需要扫描多少行。如果这个信息统计的不准确,优化器就很有可能做出错误的决定。
前面我们也提到过可以通过show index from table;
得到一个索引的大致信息,其中有一个关键列Cardinality
,其大致表示了该索引列不重复值的个数,因此该值当然越接近表的总记录数越好。
现在我们有必要了解一下这个Cardinality
是怎么得来的,实际上,InnoDB
引擎是通过抽样的方式来计算这个基数信息的,首先读取少量的数据页(具体数据量由innodb_stats_sample_pages
来设置),统计这些数据页上的数值,得到一个平均值,然后乘以索引总共的数据页,就得到了这样一个基数。
当然,数据表是会变化的,所以索引的统计信息也必须跟着变,触发重新统计的条件大概有下面几种:
- 超过一定数量的数据行产生了变化。
- 执行
analyze table、show table status、show index
等这样的命令。
索引的优点
最后我们总结一下索引的优点,索引除了可以加快数据定位的时间,实际上还有可以加快数据排序的时间,数据分组的时间,总的来说索引主要是通过以下几种方式来提升性能的:
- 减少扫描数据行的数量。
- 查询时能够解决排序、分组相关的需要。
- 将随机I/O变为顺序I/O。
其他1,2两条也是提升数据检索效率的通用思想,大多数场景的设计都是围绕这两条来的。