深度剖析InnoDB存储结构

大家都知道 MySQL 的数据都是存储在物理磁盘上的,那具体是保存在哪个文件呢?我们首先要知道MySQL 存储的行为是由存储引擎实现的,不同的存储引擎保存的文件自然也不同。由于InnoDB 是我们常用的存储引擎,也是 MySQL 默认的存储引擎,所以本文主要以 InnoDB 存储引擎展开讨论。

InnoDB 存储模型概览

在研究Buffer Pool的时候,我们知道真正处理数据的过程是发生在内存中的。为了提升读写性能,每次都是将数据所在的数据页加载到 Buffer Pool 中。这里提到的就是InnoDB存储模型中的最小单位,接下来我们来了解下InnoDB的存储结构到底是怎样的。

InnoDB的存储结构主要包括以下几个部分:

  • 表空间:所有InnoDB数据的存储都在表空间中。默认的表空间被称为系统表空间,所有的数据和索引默认都会存储在系统表空间。然而,从MySQL 5.6.6开始,InnoDB也支持多表空间模式,每个表可以有自己的表空间。
  • 段(Segment):一个表空间可以包含多个段,比如数据段,索引段,回滚段等。
  • 区(Extent):每个段又可以包含多个区,一个区通常包含1MB的连续空间。
  • 页(Page):区是由多个页组成的,页是InnoDB存储的最小单位,有效值为64KB,32KB,16KB(默认值 ),8kB和4kB。不同类型的页负责存储不同类型的数据,例如数据页用于存储表数据,索引页用于存储索引数据,撤销页用于存储撤销日志等。

我们可以认为在一般情况下:InnoDB最少从磁盘中读取16KB的内容到内存中,最少把内存中的16KB内容刷新到磁盘中。

存储结构大致如下图:

image-20230610182510217

接下来我们首先粗略了解下表空间、段以及区的相关概念,然后来深入研究页的结构。

表空间

InnoDB存储引擎中,表空间是磁盘上的物理空间,用于保存InnoDB引擎的所有数据和索引。表空间内的数据组织形式采用了一种称为聚集的方式,即根据主键值将数据聚集到一起存储。

表空间的主要作用是管理数据库的物理存储,包括数据文件的创建、扩展和删除等。表空间可以看作是存储结构的最高层,它把磁盘空间划分为了数据段,索引段,撤销段等,每个段又被划分为区,页等。这种结构使得InnoDB能够有效地管理磁盘空间,提高数据访问的效率。

表空间又分为系统表空间(共享表空间)文件表空间(独立表空间)

  • 系统表空间:在早期的InnoDB版本中,所有的InnoDB数据和索引都存储在一个称为系统表空间的文件中。默认情况下,这个文件的名称是ibdata1。系统表空间还存储了一些其他信息,如撤销日志,数据字典,系统变量等。系统表空间可以包含多个文件,但这些文件在逻辑上被视为一个单一的连续空间。
  • 文件表空间:从MySQL 5.6.6开始,InnoDB支持每个表有自己的表空间,这称为文件表空间。在文件表空间中,每个表的数据和索引都存储在自己的.ibd文件中。这样可以使得每个表的数据独立于其他表,有利于表的备份和移动。通过innodb_file_per_table参数控制,默认开启。

InnoDB的表空间结构中,段是一个逻辑概念,代表了一类特定的存储结构。一个段由多个连续或非连续的区组成。它是InnoDB为了满足不同的数据存储需求而设立的结构,比如表数据的存储,索引的存储,回滚信息的存储等。

根据存储的数据类型和用途,InnoDB中有几种不同类型的段:

  • 数据段:数据段是用于存储表数据的段。每一个InnoDB表都会有一个对应的数据段。在数据段中,数据按照主键的顺序进行存储。

  • 索引段:索引段用于存储表的索引数据。每一个InnoDB表的每一个索引都会有一个对应的索引段。包括主键索引和其他非主键索引。

  • 回滚段:回滚段用于存储事务的回滚信息。在事务执行过程中,如果发生错误或者事务需要回滚,那么就需要用到回滚段中的数据。

  • Undo段:Undo段是一种特殊的回滚段,用于存储长时间运行的事务的回滚信息。与一般的回滚段不同,Undo段的信息即使在事务提交后也会被保留一段时间,以供其他事务进行MVCC(多版本并发控制)

如何管理和使用段:

当创建一个新的表或者索引时,InnoDB会分配一个新的段用于存储表或者索引的数据。如果一个段的空间用完了,InnoDB会从表空间中分配一个新的区给这个段。当一个段不再需要时,比如表或者索引被删除,那么这个段会被销毁,其占用的区可以被其他段使用。

段的管理主要是由InnoDB的内部算法进行的,对于用户来说是透明的。用户可以通过SQL语句来操作表和索引,但无法直接操作段。在大多数情况下,用户无需关心段的具体实现和管理,只需要知道每个表和索引都有自己的存储空间,这些空间会根据需要自动扩展和收缩即可。

InnoDB存储结构中,区是一个更大的连续存储单位,由一组连续的页组成。默认情况下,一个区包含64个连续的页,也就是1MB的空间。

区在段中起到了承上启下的作用。在InnoDB的存储结构中,每个段都是由一个或多个区组成的。当一个段的空间用完时,InnoDB会为这个段分配一个新的区。同理,当一个段的空间过多时,InnoDB也会将未使用的区回收。

此外,区还起到了提高存储效率和查询效率的作用。因为区内的页是连续的,所以可以通过减少磁盘寻道时间来提高I/O性能。同时,由于InnoDB使用了聚集索引,相邻的数据在物理上也是相邻的,所以在做范围查询时,可以通过一次性读取一个区的数据,提高查询效率。(例如,假设你有一个订单表,主键是订单ID,现在你想查询所有订单ID在1000到2000之间的订单。由于这些订单在磁盘上是连续存储的,InnoDB可以通过一次磁盘I/O操作,将所有这些订单的数据一次性读入内存,大大提高了查询的效率。)

行格式(重点来了嗷)

在讲之前,我们先要来了解下行格式。

我们平时是以记录(一行记录)为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为行格式或者记录格式。它是对数据库表的一行数据的抽象,包含了若干字段(Field),每个字段对应于表中的一个列。一行记录可以以不同的格式存在InnoDB中,行格式分别是compactredundantdynamiccompressed行格式。

可以在创建或修改的语句中指定行格式:

-- 创建数据表时,显示指定行格式
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称;
-- 创建数据表时,修改行格式
ALTER TABLE 表名 ROW_FORMAT=行格式名称;
-- 查看数据表的行格式
show table status like '<数据表名>';

mysql5.0之前默认的行格式是redundantmysql5.0之后的默认行格式为compact , 5.7之后的默认行格式为dynamic。由于redundant格式过于古老,这里我们不过多表述,主要了解下compact格式。

Compact格式

image-20230612222224288

从上图可以看出,在compact格式下一条完整的记录包含记录的额外信息和记录的真实数据两大部分。

额外信息

记录的额外信息主要包含3类:变长字段长度列表、NULL值列表和记录头信息。需要注意的是这部分信息是为了描述这条记录而不是额外添加的一些信息。

变长字段长度列表

MySQL支持一些变长的数据类型,如Varchar,Text等,它们存储多少字节的数据是不固定的,所以为了准确描述这种数据,这种变长字段占用的存储空间要同时包含:

  • 真实的数据内容
  • 占用的字节数

Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放

举例说明:创建了如下表格并插入两条数据;

image-20230612230848408

image-20230612230740510

我们先以第一条数据为例:因为a、b、d三列都是变长字段,所以我们要将这3列值的长度按照列的顺序逆序存放在变长字段长度列表中。

image-20230612231947412

其次,变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的。这里以第二条数据为例:因为 d 列的值为 Null,所以第二条记录的变长字段长度列表只需要存储 a 和 b 列的长度即可。

image-20230612232543734

最后还需要注意的是 :变长字段长度列表不是一定存在的,如表中没有变长类型的字段,或者该记录中所有的变长字段值均为NULL

NULL值列表

记录中的某些列可能存储NULL值,如果把这些NULL值都放到记录的真实数据中存储则比较浪费空间,所以Compact行格式把这些值为NULL的列统一管理起来,存储到NULL值列表中。具体处理流程如下:

  • 如果表中有字段允许为NULLInnoDB就会开辟一块空间来标识每个字段实际存储的数据是不是NULL,如果表中的字段都不允许为NULL,则NULL值列表也就不存在了。

  • 每个允许存储NULL的列对应一个二进制位,二进制位按照列的逆序排列,二进制位表示的意义如下

    • 二进制位的值为1时,代表该列的值为NULL

    • 二进制位的值为0时,代表该列的值不为NULL

  • NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则需要在字节的高位补0。(如果一个表中有9个允许为NULL的列,那这个记录的NULL值列表部分就需要2个字节来表示。)

流程可能看上去比较难懂,我们还以上述的表和数据为例:表中的a、c、d是允许为Null的;

首先看第一条数据:

三个字段存储的实际数据都不为Null,按照列的逆序排列,所以用二进制来表示如下所示:

image-20230613235205064

不足8位的要在高位补0,最终用二进制来表示如下:

image-20230613235420939

接下来我们看第二条数据:c、d两列为Null

image-20230613235930853

不足8位的要在高位补0,最终用二进制来表示如下:

image-20230613235951622

记录头信息

记录头信息由固定的5个字节组成,即40个二进制位,不同的位代表不同的意思;

image-20230612235518424

名称大小(单位:bit)描述
预留位11未使用
预留位21未使用
delete_mask1标记该记录是否被删除
min_rec_mask1B+树的每层非叶子节点中的最小记录都会添加该标记
n_owned4当前记录拥有的记录数
heap_no13当前记录在记录堆的位置信息
record_type3记录类型 0:普通记录 ;1:B+树非叶子节点记录 ;2:最小记录 ;3:最大记录
next_record16下一条记录的相对位置

真实数据

记录的真实数据除了自定义的列的数据以外,MySQL还会为每条记录默认的添加一些列(也称为隐藏列),具体的列如下:

列名是否必须占用空间描述
DB_ROW_ID6字节行ID,唯一标识一条记录
DB_TRX_ID6字节事务ID
DB_ROLL_PTR7字节回滚指针

当用户未指定数据表的主键时,MySQL会选择非NULLUnique列作为主键,而如果非NULLUnique列也没有,这个时候MySQL就会向数据表添加DB_ROW_ID字段用来作为主键。

注意:记录的数据内容不包括字段值为NULL的数据内容。

image-20230614001950870

Dynamic格式

在现在 mysql 5.7 的版本中,使用的格式就是 Dynamic

DynamicCompact 基本是相同的,只有在溢出页的处理上面有所不同。

溢出页

MySQL对一条记录占用的最大存储空间是有限制的,除了BLOB或者TEXT类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节( InnoDB 存储引擎)。可以不严谨的认为,MySQL一行记录占用的存储空间不能超过65535个字节

上面我们说过MySQL中磁盘与内存交互的最小单位是页,一般为16KB:16384个字节,而一行记录最大可以占用65535个字节,这就会造成一页存不下一行数据的情况

为了解决这种问题,在Compact行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的前768个字节的数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址,从而可以找到剩余数据所在的页。

image-20230614110959570

这种在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中的情况就叫做行溢出,存储超出768字节的那些页面也被称为溢出页。

Dynamic格式处理的方式则是直接在真实数据区记录 20字节的溢出页地址,不会在记录的真实数据出存放前768个字节。

Compressed格式

compressed 格式和Dynamic类似, 主要在其基础上面进行额外的压缩处理。但这种压缩处理其实是以时间换空间,性能并不友好,所以使用的时候需要根据实际情况判断。

InnoDB存储结构中,页是最小的存储单位。所有的数据,包括行数据,索引数据,系统数据,都是存储在页中的。页的大小是固定的,通常是16KB。我们这里需要关注的是在这16KB大小的存储空间到底有哪些部分?

数据页在结构上可以划分为7个部分,不同的部分有不同的功能,具体如下图所示:

image-20230615114427220

名称中文名大小描述
File Header文件头部38字节页通用信息
Page Header页面头部56字节页专有信息
infimun + supermun最小记录和最大记录26字节虚拟的行记录
User Rcords用户记录不确定实际存储的行记录内容
Free Space空闲空间不确定页中未使用的空间
Page Directory页面目录不确定页中一些记录的相对位置
File Tariler文件尾部8字节校验页的完整性

用户自己的存储的数据会按照对应的行格式存在User Records中。新生成的页面是没有User Records的,只有当我们插入一条记录,才会从Free Space部分、也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:

image-20230615122709871

为了能够将User Records讲清楚,我们这里举一个栗子:

 -- 先创建一个表:
 CREATE TABLE test(
         a INT,
         b INT,
         c VARCHAR(100),
         PRIMARY KEY (a)
     ) CHARSET=ascii ROW_FORMAT=Compact;
 
-- 插入几条记录:
INSERT INTO test VALUES(1, 10, 'aaa'); 
INSERT INTO test VALUES(2, 20, 'bbb'); 
INSERT INTO test VALUES(3, 30, 'ccc'); 
INSERT INTO test VALUES(4, 40, 'ddd');

这4条记录在InnoDB中的行格式如下(只展示记录头和真实数据),列中数据均用十进制表示:

image-20230614134846678

我们对照着上图来看下记录头中几个属性的详细信息:

  • delete_mask:标记着当前记录是否被删除,0表示未删除,1表示删除。未删除的记录不会立即从磁盘上移除,而是先打上删除标记,所有被删除的记录会组成一个垃圾链表。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列会有一定的性能消耗。之后新插入的记录可能会重用垃圾链表占用的空间,因此垃圾链表占用的存储空间也被成为可重用空间。

  • min_rec_mask:B+树的每层非叶子节点中的最小记录都会添加该标记,并设置为1,否则为0。

  • n_owned:表示当前记录拥有的记录数,页中的数据其实还会分为多个组,每个组会有一个最大的记录,最大记录的 n_owned 就记录了这个组中的记录数。在后面介绍 Page Directory 时会看到这个属性的用途。

  • heap_no:表示当前记录在本页中的位置,比如上边4条记录在本页中的位置分别是2、3、4、5。heap_no 值为0和1的记录,称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录。这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的固定部分(其实内容就是infimum或者supremum)组成的。这两条记录被单独放在Infimum + Supremum的部分。

  • record_type:该属性表示当前记录的类型,一共有4种类型的记录,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录。最底层的叶子节点应该就是普通记录,record_type 为 0。

  • next_record:表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定Infimum记录(最小记录) 的下一条记录就是本页中主键值最小的记录,而本页中主键值最大的记录的下一条记录就是 Supremum记录(最大记录)。

    image-20230614140910277

    从图中也能看出来,我们的记录实际上按照主键大小正序排序行成一个单向链表。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:

    image-20230614141443197

    第2条记录并没有从存储空间中移除,而是把该条记录的delete_mask值设置为1,同时next_record值变为了0,意味着该记录没有下一条记录了,并且第1条记录的next_record指向了第3条记录。

    还有一点需要注意的是 next_record 指向的是记录头与数据之间的位置偏移量。这个位置向左读取就是记录头信息,向右读取就是真实数据,之前说过变长字段长度列表NULL值列表中都是按列逆序存放的,所以这时往左读取的标识和往右读取的列就对应上了,提高了读取的效率。

Page Directory(页目录)

上面我们了解了记录在页中按照主键值由小到大顺序串联成一个单链表,单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。因此在页结构中专门设计了页目录这个模块,专门给记录做一个目录,通过二分查找法的方式进行检索,提升效率。

大致原理如下:

  • 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
  • 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned属性表示该组内共有几条记录。
  • 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页尾部的地方,这个地方就是所谓的Page Directory

那记录如何分组呢?

首先MySQL对于分组中的记录数是有规定的:Infimum记录(最小记录)所在的分组只能有 1 条记录,Supremum记录(最大记录)所在的分组中的记录条数只能在 1~8 条之间,中间的其它分组中记录数只能在是 4~8 条之间。

Page Directory 的生成过程如下:

  • 初始情况下一个数据页里只有InfimumSupremum两条记录,它们分属于两个组。Page Directory 中就有两个槽,分别指向这两条记录,且这两条记录的 n_owned 都等于 1。
  • 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的 n_owned 值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8条。
  • 在一个组中的记录数等于8条后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的相对位置。
  • 当记录被删除时,对应槽的最大记录的 n_owned 会减 1,当 n_owned 小于 4 时,各分组就会平衡一下,总之要满足上面的规定。

其实正常情况下,按照主键自增长新增记录,可能每次都是添加到 Supremum 所在的组,直到它的 n_owned 等于8时,再新增记录时就会分成两个组,一个组4条记录,一个组5条记录。还会新增一个槽,指向4条记录分组中的最大记录,并且这个最大记录的n_owned会改为4,Supremum 的n_owned就会改为5。

这里我们还以上面的四条数据为例,按照如上规则,首先将最小记录和最大记录分为两组,然后依次添加四条记录:

image-20230614162954019

我们再添加8条记录看看效果:

INSERT INTO test VALUES(5, 50, 'eee'); 
INSERT INTO test VALUES(6, 60, 'fff'); 
INSERT INTO test VALUES(7, 70, 'ggg'); 
INSERT INTO test VALUES(8, 80, 'hhh'); 
INSERT INTO test VALUES(9, 90, 'iii');
INSERT INTO test VALUES(10, 100, 'jjj');
INSERT INTO test VALUES(11, 110, 'kkk');
INSERT INTO test VALUES(12, 120, 'lll');

image-20230614164410024

为了方便理解,图中只保留了用户记录头信息中的n_ownednext_record属性。

可以看到,上述数据经过分组后在 Page Directory 中就形成了一个目录槽,每个槽就指向了分组中的最大记录,最大记录的记录头中的 n_owned 就记录了这个组中的记录数。

有了目录槽之后,InnoDB就可以使用二分法来进行快速查找,整个过程分为两步:

  • 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
  • 通过记录的next_record属性遍历该槽所在的组中的各个记录。

关于数据页结构上的其他几部分我们这里简单了解即可:

  • File Header 用来记录页的一些头信息,由8个部分组成,固定占用38字节。它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁等。知道上页和下页后,能建立一个双向链表把许许多多的页就都串联起来。

  • Page Header是专门用来存储数据页相关的各种状态信息,由14个部分组成,固定占用56个字节:比如本页中已经存储了多少条记录、第一条记录的地址是什么、页目录中存储了多少个槽等等。

  • File Trailer主要为了校验页是否完整写入磁盘,只有一个FIL_PAGE_END_LSN,占用8字节。

索引

了解完InnoDB数据页的主要组成部分后,我们会有如下认识:

首先各个数据页可以通过File Header中记录的上页、下页组成一个双向链表;其次在每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表,每个数据页都会为存储的记录生成一个页目录;然后我们再通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,最终遍历得到我们需要的记录。

我们能通过以上方式直接查询数据吗?显然是不能的。我们从两个方面分析:

首先我们假设只在一个页面中进行查询:

  • 如果以主键为搜索条件,那么这个就很简单了,我们直接在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。

  • 但是如果以其他列作为搜索条件那就要出问题了,因为在数据页中并没有对非主键列建立页目录,所以我们无法通过二分法快速定位相应的槽。这种情况下只能从最小记录开始依次遍历单链表中的每条记录,显然效率很慢。

但是一般情况我们肯定有很多数据页,这个时候我们该如何查询呢?

  • 首先需要定位到记录所在的页,怎么定位呢?只能从第一个页沿着双向链表一直往下找。
  • 定位到页后,再从所在的页内中查找相应的记录。

如果这样查询的话是不是效率太低了?这个时候就需要引出索引来解决了。

首先我们要知道索引上的记录是顺序排列的,而且要求下一个数据页中记录的主键值必须大于上一个页中记录的主键值

我们以上面建的表为例(a为主键),清空插入的数据,为了便于讲述,我们可以简单的把表的行格式理解如下:

image-20230614185956233

假设我们的每个数据页最多能存放3条记录,这时候我们向表中插入三条记录,那么数据页就如图所示:

INSERT INTO test VALUES(1, 10, 'aa'); 
INSERT INTO test VALUES(2, 20, 'bb'); 
INSERT INTO test VALUES(4, 40, 'dd');

image-20230614191022087

此时我们再来插入一条记录:

INSERT INTO test VALUES(3, 30, 'cc');

因为上面说明了一个页最多只能放3条记录,所以我们不得不再分配一个新页:

image-20230614191122169

但这里是有点问题的,可以发现页1中记录最大的主键值为4,而页2中有一条记录的主键值为3,这不符合下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值的要求。所以在插入主键值为3的记录的时候需要伴随着一次记录移动,也就是把主键值为4的记录移动到页2中,然后再把主键值为3的记录插入到页1中。最后形成如图所示:

image-20230614191657002

这个过程表明了在对页中的记录进行增删改操作的过程中,会通过一些移动记录的操作来保证下一个数据页中记录的主键值始终大于上一个页中记录的主键值,称为页分裂。

B+树

存储用户记录的页在物理存储上可能并不相邻,所以如果想从这么多页中根据主键值快速定位某些记录所在的页,就需要给它们做个目录,每个页对应一个目录项,每个目录项由页中记录的最小主键值和页号组成。

InnoDB中复用了之前存储用户记录的数据页来存储目录项,通过record_type来区分普通的用户记录还是目录项记录,record_type=1就是目录项记录。

这时,目录页中就会有两条目录项记录,第一条记录的页号为 1、主键值为1;第二条记录的页号为 2、主键值为4。

image-20230614232925385

随着不断插入记录,数据页越来越多,会导致目录页中的记录满了,这时要再插入一个目录项记录就放不下了。

image-20230614233424792

解决该问题也很简单,我们只需再执行一遍上述操作,多生成一层目录页即可。

image-20230614234711668

上面这幅图现在看起来就像一个倒过来的树,这其实就是 B+树,B+ 树就是一种用来组织数据的数据结构。

从图中可以看到无论是存放用户记录的数据页,还是存放目录项记录的数据页,都把它们存放到 B+ 树这个数据结构中了。用户记录页都存放在B+树的最底层的节点上,这些节点也被称为叶子节点叶节点,其余用来存放目录项的节点称为非叶子节点或者内节点,其中B+树最上边节点就称为根节点

这个时候假设我们要查找 id=6 的记录,就可以按如下步骤来查找:

  • 首先读取索引的根节点页(页7)到内存中,然后在内存中遍历根节点页中的记录项,这些记录可以根据主键划分几个区间:(Infimum, 1),[1, 10),[10,Supremum)。id=6的记录落在 [1, 10) 这个区间,所以定位到 id=1 这条记录,对应的页号是 3。

  • 接着读取页 3到内存中,同样的遍历页中的记录,这时 id=6 落在 [4, 7) 这个区间,因此定位到 id=4 这条记录,对应的页号是 2。

  • 接着读取页 2 到内存中,再遍历页中的记录,就可以定位到 id=6 这条记录了。

需要注意的是,不管是目录页还是记录页,页中都会有一个 Page Directory,可以通过二分法快速定位到页中的一条记录,而不是从左往右一条条遍历。

聚簇索引

我们可以发现上边介绍的B+树有两个特点:

  1. 使用记录主键值的大小进行记录和页的排序
  2. B+树的叶子节点存储的是完整的用户记录

我们把具有这两种特性的B+树称为聚簇索引,这种聚簇索引并不需要我们在MySQL语句中显式的使用INDEX语句去创建,InnoDB存储引擎会自动的为表添加一个 row_id 的隐藏列作为主键并创建聚簇索引;当然这是在我们没有为某个表显式的定义主键,并且表中也没有定义唯一索引的情况下。

非聚簇索引

InnoDB 在创建表时,默认会创建一个主键的聚簇索引,而除此之外的其它索引都属于非聚簇索引,也被称为二级索引辅助索引

聚簇索引只能在搜索条件是主键值时才能发挥作用,因为目录页中存储的都是主键,B+树中的数据都是按照主键进行排序的。如果我们要根据其它的非主键列来查询,比如前面表中的 b 列,这时就可以再建一个 b 列的辅助索引。

image-20230615112520118

这个时候我们再根据b列查找数据时就会用上这个索引了,查找过程和聚簇索引是类似的。我们根据该图来看看非聚簇索引与聚簇索引到底有哪里不同:

  • 使用记录b列的大小进行记录和页的排序

  • B+树的叶子节点存储的并不是完整的用户记录,而只是 b列 + 主键这两个列的值。

  • 目录项记录中不再是主键+页号的搭配,而变成了b列+页号的搭配。

这里最主要的区别在于利用辅助索引查找到的数据不是完整的用户记录,所以找到叶子节点上的记录后,还会根据对应的主键值回到主键索引上再根据主键值找到对应的完整记录,这个过程叫做回表。

但是在查询的过程中也并非一定需要回表,如果我们查找的数据在辅助索引上已经存在,那么就不会发生回表的操作。比如select a, b from test where b = 10 ,这个SQL就不会回表,因为这个辅助索引上已经包含了要查找的所有列,所以只有索引上不包含要查找的列时,才会发生回表。

以上便是InnoDB存储结构的全部内容,掌握这些内容能帮助我们更清楚的了解到在Mysql中是怎样对数据进行存储的,同时能帮助我们在工作中更加合理的使用索引。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

、楽.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值