从底层解析B+索引提高查询速度的原因

前言

我的上篇文章《解析B+树比B树更加适合做数据库索引的原因》介绍了为什么B+树更适合做索引,并以InnoDB和MyISAM存储引擎介绍了聚集索引和非聚集索引的区别。Mysql默认的存储引擎是InnoDB,因此此篇文章主要以InnoDB的数据页结构以及数据页结构之间的连接方式和查找过程来说明B+索引提高查询速度的原因。

数据页结构

Mysql进行数据存储的基本结构是数据页(也可称作块),一页的大小一般是16K。Mysql表中的记录都是存储在数据页中。InnoDB数据页由以下7个部分组成,每个部分都有着不同的功能,并且每个部分又分为若干个小部分,小部分这里就不详细介绍,主要介绍下7个部分的功能,为下文的讲解做下铺垫。
在这里插入图片描述
(1)File Header: 表示文件头,占固定的38字节。记录了包括页的槽数量、页号以及页在树中的位置等众多信息,且里面含有上一个页号,和下一个页号,以此在各个数据页之间构成了双向链表。
在这里插入图片描述
(2)Page Header:表示页里的一些状态信息,占固定的56个字节。

(3)Infimum + Supremum:两个虚拟的伪记录,分别表示页中的最小和最大记录,占固定的26个字节。

(4)User Records: 存储我们插入的记录的部分,大小不固定。然后每一个记录除了真实的数据,还有记录头,用以记载记录的类型以及下一条记录等信息。
在这里插入图片描述
(4.1)记录头中的next_record属性值为从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的next_record值为13,意味着从第一条记录的真实数据的地址处向后找13个字节便是下一条记录的真实数据,这里的下一条记录是以主键值顺序大小排序的下一条记录,记录与其下一条记录在物理上并不一定是紧挨着的,因此页内的各个记录之间形成的其实是个单链表。
(4.2)记录头中的record_type表示当前记录的类型,一共有4种类型的记录,0表示普通记录,1表示B+树非叶结点记录(目录项记录),2表示最小记录,3表示最大记录。
(4.3)n_owned 指的是该槽所指向的组内的记录的个数。最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1-8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。

(5)Free Space: 页中尚未使用的部分,大小不确定。每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的部分中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。

(6)Page Directory: 页目录,由槽组成,槽也就是每一组最后一个记录的地址偏移量,我们可通过该地址偏移量找到该组最后一个记录。页目录大小不固定,插入的记录越多,这个部分占用的空间越多。
(6.1)页目录的作用: 页内的记录以单链表的结构进行连接,那我们在页内查找记录岂不是只能从最小记录来挨个进行遍历?当页内记录过多时,那效率岂不是很低?当然不得行!InnoDB可通过页目录以主键值进行快速查找。InnoDB将若干个记录分为一组,目录页的槽中记录每组最后一个记录的位移偏移量和主键值。当我们进行记录查找时,先在槽中对槽编号进行二分查找,然后比较要查找的记录的主键值和该槽中的主键值,若不相等,则继续进行二分查找,直到找到我们需要查找的记录所在的槽或者槽范围,然后通过位移偏移量找到该组中的最后一个记录,通过该记录的next_record进行组内遍历即可找到该记录。

以下图进行具体查找示例说明: 比如我要查找主键值为11的记录
图中有5个槽,槽编号为0~4,第一步对槽编号进行二分查找,初始时,low=0,high=4,因此mid=(0+4)/2=2,所以先进入槽2进行主键值比较,8<11,所以low=mid=2,high=4,此时mid=3,进入槽3进行主键值比较,12>11,因此low=2,high=3,high=low-1,因此确定记录在槽3中,拿着槽2中的位移偏移量找到该组中的最后一个记录,通过该记录的next_record开始对槽3对应的组内记录进行遍历,即可找到该记录。

(7)File Trailer:用于检验页是否完整的部分,占用固定的8个字节。为保证从内存中同步到磁盘的页的完整性,在页的首部和尾部都会存储页中数据的校验和和LSN值,如果首部和尾部的校验和和LSN值校验不成功的话,就说明同步过程出现了问题。

小结

1、没有使用索引情况下,主键查找比其它字段查找快的原因,是因为其可通过页目录进行二分查找查找到该记录所在的分组,然后再到该分组中进行遍历即可找到记录,其它字段只能在数据页中对记录进行依次遍历。
2、数据库中的记录是以数据页为基本单位进行存储的,数据页之间是以主键的大小为顺序通过双链表进行连接,A数据页的所有主键大小必然小于其下一页的所有主键大小,数据页内的记录也是以主键大小顺序通过单链表进行连接,A记录的主键值小于其下一记录的主键值。

没有使用索引时,记录的查找过程

1、记录的查找,包括两个阶段,分别是页的定位、以及在页中记录的定位。
2、数据页之间是通过双向链表进行连接。我们定位记录所在的页时,需要从第一数据页开始遍历,依次遍历双向链表中的每一页中是否存在该记录,直至找到该页。
3、在页中具体的查找记录的过程分为两种:
通过主键进行查找: 对页目录进行二分查找,找到该分组,然后在组内进行遍历,速度较快。
其它非主键字段查找: 在页内的记录单链表从头开始遍历,直至找到该关键字。

因此当数据量很大、页表很多时,那I/O等开销就非常的大。

使用B+索引提高查询速度的原因

InnoDB中B+索引能提高查询速度的原因其实就是通过引进B+树的结构将页的定位过程进行了优化。

B+索引的结构

B+索引中数据页分为两种,一种是目录数据页,另外一种就是普通的用户记录数据页。目录数据页中每条记录都是一个目录项记录,目录项记录与数据记录没什么太大区别,除了该记录的record_type为1,目录项列信息只包括所指向的数据页中最小的主键值和所指向的数据页的页号。InnoDB中每个非叶子结点都是一个目录数据页,而每个叶子结点都是一个用户记录数据页,只有叶子结点才存储实际的用户记录,该记录的record_type为0,记录的列信息中包括实际字段值。非叶子结点(目录项数据页)的目录项记录起着指针的作用。
在这里插入图片描述
以上图中,页33是根目录数据页,页中的目录项30,目录项32分别指向目录数据页30和目录数据页32,然后目录数据页中的目录项再指向用户记录数据表。
(说明: 数据页的编号可能并不是连续的,这也是以上兄弟结点编号不相邻的原因。)

使用B+索引,查找过程

以上图为例,比如我要查找主键值为220的记录。

(1)先在根目录数据页(头结点)中,进行目录项确定。通过页内的目录表进行二分查找,找到该分组,在组内进行遍历,找到该主键,关键字1<220<320,第二个目录项所指向的数据页中最小的关键字都是320,因此我们要找的目录项肯定是第一块。
(2)我们通过根目录数据页的第一块目录项,拿到页号30,然后到数据页表30中进行目录项确定。在页30的目录表进行二分查找,找到该分组,在组内进行遍历,220>209,因此我们取得数据页表30的最后一个目录项记录。
(3)我们拿着该记录中的页号20,去数据页表20中进行记录查找。数据页表20是用户记录页表,也就是叶子结点,存储着用户记录。通过页内的目录表进行二分查找,找到记录所在的分组,在组内进行遍历,最终找到了关键字220的那一个记录。

小结

通过比较使用B+索引和不使用B+索引的查找过程,我们可以发现即使是主键查找,建立索引和不建立索引的性能也是有着很大的差别。例如一个拥有着1000000000条记录的表,每个数据页可以存放1000条记录。1000000000=1000✖1000✖1000,构成的B+树只有三层。我使用B+索引只需要3次页的查找就能找到所查询的记录所在的页。而不使用B+索引,则可能要查找1000000个页才能找到该记录所在的页。这样就是说我们可能得做1000000的I/O操作。而非主键查找,不建立索引,我们只能一页页的遍历,页内也是一个记录一个记录的依次查找。因此B+索引很大程度上提高了查询速度,当然天下没有免费的午餐,快查询带来的代价就是我们在增删改数据时,可能会改变B+树的结构,因此需要动态的维护着B+树的结构,这就降低了增删改的速度。因此B+索引通常建立在常用来做查询条件或者排序的字段之上。

拓展(必看)

申明:觉得这部分聚集索引和非聚集索引没看懂的话可以看我另外一篇带有图的、更具体的聚集索引和非聚集索引区别讲解《解析B+树比B树更加适合做数据库索引的原因

本篇文章主要是讨论B+索引提高查询速度的原因,但不知道读者有没有发现,我以上讨论的其实都是聚集索引,InnoDB使用聚集索引,默认是建立在主键之上。InnoDB在插入数据时其实就是以聚集索引的值的大小顺序进行存储,若不满足大小顺序则需要对数据进行移动。例如数据表中已经插入主键值为1、3、5、7、9的数据(存储顺序以主键值大小顺序进行存储),此时我要插入主键值为4的数据记录,此时磁盘上数据的存储顺序就变成了1、3、4、5、7、9,这也是聚集索引修改慢的原因,插入数据可能造成数据的移动,需要对数据页进行重排序,开销很大,所以一般采取主键自增。聚集索引的叶子结点(用户记录数据表)中存储了完整的用户记录(包括所有的列值),因此就不需要进行回表。InnoDB的数据文件本身就是索引,数据和索引一起存在一个XX.IDB文件中。

但我们不可能所有的查询条件和排序条件都是主键,因此我们需要使用到非聚集索引,一个表中可以有多个非聚集索引。

非聚集索引

非聚集索引中索引值的逻辑顺序与磁盘上的物理存储顺序不同,一个表中可以拥有多个非聚集索引。数据是以聚集索引值大小顺序进行存储,但聚集索引只在搜索条件是主键的时候才起作用,因此我们常常使用到非聚集索引,那么如何使用呢?我们需要以非聚集索引值建立一颗新的B+树,比如主键为A1,我们经常以字段A2为条件进行查找,那可以在A2上建立一个非聚集索引,以A2值为关键字建立一棵B+树。该B+树有以下特点:页内的记录是按照A2列的大小顺序排成一个单向链表;各个存放用户记录的页也是根据页中记录的A2列大小顺序排成一个双向链表;各个存放目录项的页也是根据页中记录的A2列大小顺序排成一个双向链表。当以A2为条件进行查找时,就以A2值 在该B+树上进查找,查找到后,若需要select的字段都存在叶结点中,则返回该值,若不在,则需要进行回表操作。

InnoDB回表操作-非聚集索引的叶子结点(用户数据页表)除了存储了该记录的非聚集索引值,还存储了主键值,当该索引不是覆盖索引时,需要通过主键值在聚集索引中查找到该具体记录。

联合索引

联合索引指的是以多个列的大小作为排序规则,同时在多个列上建立索引。例如为A1、A2两个字段建立联合索引,在建立B+树的过程中,我们以A1的大小顺序进行树的构建,若A1相同时,则以A2的大小为顺序进行树的构建。且目录数据页表中每条目录项记录都由A1、A2、页号这三个部分组成,各条记录先按照A1列的值进行排序,如果记录的A1列相同,则按照A2列的值进行排序。叶子结点(用户记录数据表)记录了A1、A2、主键值。(当查找的字段不止A1、A2、主键值时,需要通过主键值去查聚集索引的B+树来获得完整用户记录)
在这里插入图片描述
最左匹配原则: 在使用联合索引时,我们要注意最左匹配原则。当查询条件和ORDER BY子句不满足最左匹配原则时,索引未命中,进行index等级的索引扫描。最左匹配原则也叫最左前缀原则,如果查询的时候查询条件精确匹配索引的左边连续一列或几列,则索引命中。但索引进行从左往右进行匹配时,只能匹配值而不能匹配范围,当遇到范围查询(>、<、between、like左匹配)等就不能进一步匹配了,后续退化为线性查找。因此我们建立索引时,应将经常做范围查询的字段放在索引的后面。

例如,建立联合索引(name,age,high,weight),表中字段不止这四个

select * from user where name=xx and age=xx ; //可以命中索引,需要回表
select * from user where name=xx ;           / / 可以命中索引,需要回表
select * from user where age=xx and name=xx ; //可以命中索引,需要回表
select * from user where age=xx ;           / / 无法命中索引,index等级索引扫描  
select * from user where name=xx and age=xx and weight>10 and high=173 ;   / /命中索引,需要回表
select * from user where  age=xx and name=xx and weight=10 and high<173   / /索引命中age name,high,后续进行线性查找
select * from user where  age=xx and name=xx and weight>10 and high<173    / /索引命中age name,high,后续进行线性查找      

注意: 上例中第三行查询虽然看起来不满足最左匹配原则,但它具有索引的最左边的两个字段,只是顺序不同而已,查询引擎会对其优化,调整顺序,使得索引命中。
注意: 仔细观察最后三行查询的区别,倒数第三行之所以能命中索引,是因为查询引擎会自动优化查询的条件顺序,以匹配更多的索引字段。第一行被优化为name=xx and age=xx and high=173 and weight>10 的查询顺序,因此查询匹配。倒数第一行第二行都是因为索引的第三个字段是范围查询,即使是优化之后,索引从左向右进行字段匹配,到了第三个字段high就停止匹配,因此只命中了age、name、high三个字段,后续字段进行线性查询。

覆盖索引

如果一个索引包含了所有需要查询的字段的值,我们就称之为“覆盖索引”。这种索引避免了进行回表操作。比如我们在字段username和high上创建联合索引。

select username,high from user where username = 'Java' and high=173

当执行以上sql语句时,这个联合索引其实就是个覆盖索引,不需要进行回表操作

冗余索引

冗余索引指的是多个索引的前缀列相同,或者在联合索引中包含了主键的索引。例如一张表有索引(name)和索引(name,age)以及索引(name,age,high)三个索引,这三个索引就是冗余索引。能命中(name,age,high)索引的查询肯定能命中索引(name)和索引(name,age)。我们需要避免冗余索引,当我们需要通过构建覆盖索引时,最好是在已有的索引上面扩展,而不是新建索引。例如上例子中要用到索引(name,age)来避免回表操作,我们就通过在索引(name)上面进行扩展,加入age字段作为索引的一部分。

重复索引

重复索引指的是在同一个列或者顺序相同的几个列(age,school),建立了多个索引,重复索引没有任何帮助只会增大索引文件,影响更新速度。

MyISAM中的索引方案

MyISAM存储引擎虽然默认使用的也是 B+索引,但它使用的是非聚集索引,数据和索引是分开的,索引放在XX.MYI文件中,数据放在XX.MYD文件中。MyISAM的表中数据是按照插入先后顺序进行存储的,不是以主键值的大小顺序进行存储的,因此我们无法进行二分查找。但每一个记录都有一个行号,我们可以通过行号在数据文件中找到完整用户记录。MyISAM会为表的主键创建B+索引,数据页的每个记录中不仅有主键值还有行号,通过主键值获得行号,然后进行回表操作,用行号去数据文件中进行完整记录的查找。
在这里插入图片描述

参考:
《Java工程师修炼之道》
《MySQL高性能书籍_第3版》
MySQL的索引

发布了20 篇原创文章 · 获赞 27 · 访问量 2748

猜你喜欢

转载自blog.csdn.net/qq_41008202/article/details/105285127