文章目录
- 前言
- 一、索引的常见模型
- 二、InnoDB的索引模型
- 三、索引的维护
- 四、索引的优化
- 覆盖索引
- 联合索引
- 最左前缀原则
- 索引下推
前言
我们在看书的时候,打算回看某一个桥段的内容时。这是你肯定会是先翻看书的目录,从目录确定这段内容的位置,然后再到确切的页中去寻找。你肯定不会,从书的第一页一页一页的翻找。同样,数据库也是,查找数据也不会从一条记录一条记录的寻找。也肯定是先从像书的目录一样的中确定了,才寻找。而这个像书的目录一样的,在数据库中我们称为索引。我们这篇文章就是对MySQL中的索引进行的总结学习
一、索引的常见模型
索引就像我前言中介绍的和书的目录一样,主要是为了提高查询效率的。通过我们对数据结构的了解,可以提高读写效率的数据结构很多。
这以我们主要介绍三种常见的数据结构,分别是哈希表、有序数组和搜索树。
接下来我们从使用性能上,对这三种数据结构进行一个分析。
哈希表:
众所周知,哈希表是一种以键-值(key-value)存储数据的结构。这里对哈希表的基本概念不再赘述,不了解的可以百度搜索以下。这里主要举个用哈希表的例子:
假设,现在维护着一个身份证信息和姓名的表,需要根据身份证号查找对应的名字,这时对应的哈希索引的示意图如下所示:
从图中可以看出,User2和User4根据身份证哈希算出来的值都是N,后面紧跟着一个链表进行存储。这时如果要查ID_card_n2对应的名字是什么,处理步骤就是:首先,将ID_card_n2通过哈希函数算出N,然后按顺序遍历,找到User2。注意,链表中存储的ID_card_n的值并不是递增的,这样虽然增加的时候比较方便,直接追加即可。但是查询的时候效率较低,需要从头遍历。
像上述,一个一个查询数据的时候用哈希作为索引结构问题不大。但如果是范围查询,比如查找身份证在[ID_card_X,ID_card_Y]区间的所有用户,那么就必须全部扫描一遍,这样显的效率就比较低了。
所以,哈希表这种结构适用于只有等值查询的场景
有序数组:
有序数组在等值查询和范围查询场景中的性能就都非常优秀。还是以上面的身份证和名字的表为例,用有序数组存储的话,就是如下图所示:
这是如果要查ID_card_n2对应的名字,那么使用二分法就可以快速得到,这个时间复杂度是O(log(N))。
如果要进行范围查询,如查身份证在[ID_card_X,ID_card_Y]区间的User,可以先用二分法找到ID_card_X(如果不存在ID_card_X,就找到大于ID_card_X的第一个User),然后向右遍历,直到查到第一个大于ID_card_Y的身份证,退出循环。这么来看,用有序数组当索引,效率挺高啊。
但是,更新数据,往记录中插入数据的时候,就必须挪到后面的数据,成本太高。
所以,有序数组索引只适用于静态存储引擎。只适合存储那些不会再修改的数据。
二叉搜索树:
我们依旧使用上面身份证和名字的例子,如果用二叉搜索树来存储,如下图所示:
二叉搜索树的时间复杂度是O(log(N))。为了维持O(log(N))的查询复杂度,就需要保持这棵树是平衡二叉树,这是理论情况下。但是实际存储中,索引不仅存在内存中,还要写到磁盘上。而此时如果使用的是平衡二叉树,那么数据量大的时候,那么层高会很高,那么访问磁盘操作就会很耗时。
因此,为了查询尽量少读磁盘,就必须让查询过程访问尽量少的数据块,此时不应该使用二叉树,而应该使用N叉树来降低树高。
到此,我们已经了解了三个基本数据存储模型。
接下来,我们来深入了解下,MySQL中的InnoDB的索引模型
二、InnoDB的索引模型
在InnoDB中,表都是根据主键顺序以索引的形式存放的,并且InnoDB使用的是B+树索引模型,数据都是存储在B+树中的。每一个索引在InnoDB里面都对应一棵B+树。
接下来我们以一个例子进行以下讲解:
假设,我们创建了一个主键为ID的表,表中有字段k,并且在k上有索引。建表SQL语句如下:
create table T(
id int primary key,
k int not null,
name varchar(16),
index(k) )engine=InnoDB;
表中R1~R5的(ID,k)值分别为(100,1)、(200,2)、(300,3)、(500,5)和(600,6)。这两棵树的示例图如下所示:
从图中可以看出,根据叶子节点的内容,索引类型可以分为主键索引和非主键索引。
其中,主键索引的叶子节点存的是整行数据,在InnoDB里,主键索引也被称为聚簇索引。
非主键索引的叶子节点内容是主键的值,在InnoDB里,非主键索引也被称为二级索引。
由此,我们可以看出,主键索引和普通索引存储的内容是不同的。那么进行查询的时候,肯定会有区别的。
接下来,我们接着以上面创建的那个T表。执行如下两个SQL查询语句看看区别:
- select * from T where ID=500;主键查询方式,此时进行搜索的时候只需要搜索ID这棵B+树就好。
- select * from T where k=5;普通索引查询方式,此时进行搜索的时候需要先搜索k索引树,得到ID的值为500,再到ID索引树搜索一次。这个过程称为回表。
不知你是否还记得,我们在将有序数组这个基础结构进行记录存储的时候,说到对于更新数据添加记录,都会涉及到记录的整体后移,整体效率比较低。
那么对于这B+树呢?毕竟它也是有序的。那么对于数据记录的更新,B+树我们是怎么维护的呢?
接下来我们来学习一下
三、索引的维护
我们接着以上面创建的那个T表为例。
如果此时,我们插入新的行ID值为700,则只需在R5的记录后面插入一个新的记录。
但是如果我们新插入的ID值为400,那么就需要逻辑上挪动后面的数据,空出位置。
并且如果此时,也比较倒霉,R5所在的数据页已经满了。这是还需要根据B+树的算法,申请一个新的数据页,然后挪动部分数据过去,这个过程称为页分裂。这种操作必定对性能有影响。除了性能之外,页分裂还会影响数据页的利用率。毕竟原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约50%。
知道了添加数据会存在页分裂。那么删除数据的时候,就有可能会引起页合并。当两个相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。
从上面我们也知道了,使用B+树做索引结果,可能会引起页分裂,影响性能。那么有没有方法避免这种情况呢。接下来我们分析下看;
通过页分裂的讲述,我们知道导致产生页分裂,是因为添加数据的时候,不是按照索引递增添加的,所以会涉及到数据的挪动。为了解决这个问题,在建表规范的时候,有这么一句叙述,要求建表语句里一定要有自增主键。当然事无绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该:
还是接着上面的创建的T表。如果我们把表里的主键ID改为自增主键,那么插入新记录的时候可以不指定ID的值,那么系统会获取当前ID最大值加1作为下一条记录的ID值。符合递增插入的场景,每次插入新记录都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。
创建的这个T表只是我们能的一个demo。对于在实际开发中有业务逻辑的。如果业务逻辑字段做主键,那么插入数据的时候,不一定是按序插入的,就可能会涉及到分页。并且针对存储空间来看,如果表中只有一个唯一字段,如字符串类型的身份证号,那么应该用身份证号做主键,还是用自增字段做主键呢?
因为每个个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的
叶子节点占用约20个字节,而如果用整型做主键,则只要4个字节,如果是长整型(bigint)则是8个字节。由此可见,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。 所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。
我们清楚了用自增主键的业务场景,那么有没有什么场景适合用业务字段直接做主键呢?
那当然有,比如,有些业务的场景需求是这样的:
- 只有一个索引
- 该索引必须是唯一索引
这就是典型的KV场景。由于没有其他索引,也就不用考虑其他索引的叶子节点大小的问题。
了解了这些问题之后,接下来我们对索引进行深入的学习。即实际使用中有关索引的一些优化
四、索引的优化
首先在讲解之前,我们也先创建个表T,通过实际查询语句来进行分析。
创建T表的语句如下:
create table T(
ID int primary key,
k int NOT NULL DEFAULT 0,
s varchar(16) NOT NULL DEFAULT '',
index k(k) )engine=InnoDB;
往表里插入数据的语句如下:
insert into T values(100,1,'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');
ID和k索引列创建的B+树,如下:
此时,我们如果执行如下查询语句:
select * from T where k between 3 and 5;
这条SQL查询语句执行流程如下:
- 在k索引树上找到k=3的记录,取得ID=300;
- 再到ID索引树查到ID=300对应的R3;
- 在k索引树取下一个值k=5,取得ID=500;
- 再回到ID索引树查到ID=500对应的R4;
- 在k索引树取下一个值k=6,不满足条件,循环结束。
通过上述执行过程,我们可以看到,这个查询过程读了k索引树的3条记录(步骤1、3和5),回表了两次(步骤2和4)。
众所众知,回表操作是非常耗时的一件事,降低索引的性能。那么有没有可能经过索引优化,避免回表过程呢?
覆盖索引
如果要执行的查询语句是:
select ID from T where k between 3 and 5;
这时只需要查ID的值,而ID的值已经在k索引树上了,因此可以直接提供查询结果,不需要回表。在这个查询里面,索引k已经“覆盖了”我们的查询需求,我们称为覆盖索引。
因为覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。
明白了用覆盖索引的优化。紧接着我们再引出一个优化手段
联合索引
假设我们有一张市民的表,创建语句如下:
create table tuser(
id int(11) NOT NULL,
id_card varchar(32) DEFAULT NULL,
name varchar(32) DEFAULT NULL,
age int(11) DEFAULT NULL,
ismale tinyint(1) DEFAULT NULL,
PRIMARY KEY(id),
KEY id_card(id_card),
KEY name_age(name,age)
)ENGINE=InnoDB;
这里我们来讨论个问题,有没有必要将身份证号和名字建立联合索引?
此时我们会想,如果有根据身份证号查询市民信息的需求,我们只要在身份证号字段上建立索引就够了。而再建立一个(身份证号、姓名)的联合索引,是不是浪费空间?
但是,如果现在有一个高频请求,要根据市民的身份证号查询他的姓名,这个联合索引就有意义了。它可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。
但是,需要另外建立索引。那么对于这 建立的冗余索引,浪费的存储空间,以及索引字段维护的这些代价都需要我们进行权衡考虑的。
说到了联合索引,那就无法避免的聊到匹配的原则了。
最左前缀原则
上面讲的对于高频请求,根据市民身份证查询姓名,可以建立个联合索引。那么如果也有根据身份证查询地址是不是也需要单独建立个联合索引呢?
这里,便引出了我们这个部分的学习。其实,在B 树这种索引结构,可以利用索引的 树 “最左前缀”,来定位记录。
为了清楚的说明,我们还是接着用一个实例进行解释。我们用(name,age)这个联合索引来分析。索引示意图如下图所示:
从图中,我们可以看到,索引项是按照索引定义里面出现的字段顺序排序的。
从这个数据表中,如果要查找所有名字是“张三”的人时,可以快速定位到ID4,然后向后遍历得到所有需要的结果。
而,如果这是我们要查找第一个字是“张”的人,即SQL语句的条件如下:
where name like '张%’;
这时,也能够用上这个索引,查找到第一个符合条件的记录是ID3,然后向后遍历,直到不满足条件为止。
由此可见,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左
前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。
知道了索引的最左匹配原则,那么我们在建立联合索引的时候,对索引内的字段顺序排列就十分重要了。
基于索引的复用能力。因为可以支持最左前缀,所以当已经有了(a,b)这个联合索引后,一般就不需要单独在a上建立索引了。因此,第一原则是,如果通过调整顺序,可第以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。
这是回到开头,用身份证查询地址的需求。因为根据前面,我们知道也需要建立(身份证,姓名)这个联合索引,并且身份证在最前头,那么我们再在这联合索引后面加上地址这个列,就也可以满足我们的需求了啊,并且也不需要另外创建索引了。
但是,需要注意的是联合索引也不是万能的。如果对于表的查询语句,既有联合查询,也有对于单个列的查询。比如还是对(a,b)这个联合索引来说,如果查询条件里面只有b的语句,是无法使
用(a,b)这个联合索引的,这时候你不得不维护另外一个索引,也就是说你需要同时维护(a,b)、(b) 这两个索引。这是我们就需要考虑空间了。
学完这之后,我们在接着对所有的另一个优化。
索引下推
我们依旧是用例子进行讲解。
我们还是使用上面的市民表的联合索引(name,age)为例。如果有一个需求,检索出表中“名字第一个字是张,而且年龄是10岁的所有男孩”。SQL语句如下:
select * from tuser where name like '张%' and age=10;
根据我们讲述的索引匹配原则。肯定会先到联合索引的搜索树上,找到第一个满足名字的第一个字是“张”的ID3记录。那然后呢?
对于在MySQL5.6之前,此时只能从ID3开始一个个回表,到主键索引找出数据行,再对比字段值。流程如下图所示 :
而在而MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。流程如下图所示:
可以看到,引入了索引下推的,这样对查询条件中的联合索引的其他字段也先检索一下。这样大大减少了回表操作。