大家都知道 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内容刷新到磁盘中。
存储结构大致如下图:
接下来我们首先粗略了解下表空间、段以及区的相关概念,然后来深入研究页的结构。
表空间
在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
中,行格式分别是compact
、redundant
、dynamic
和compressed
行格式。
可以在创建或修改的语句中指定行格式:
-- 创建数据表时,显示指定行格式
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称;
-- 创建数据表时,修改行格式
ALTER TABLE 表名 ROW_FORMAT=行格式名称;
-- 查看数据表的行格式
show table status like '<数据表名>';
mysql5.0
之前默认的行格式是redundant
,mysql5.0
之后的默认行格式为compact
, 5.7之后的默认行格式为dynamic
。由于redundant
格式过于古老,这里我们不过多表述,主要了解下compact
格式。
Compact格式
从上图可以看出,在compact
格式下一条完整的记录包含记录的额外信息和记录的真实数据两大部分。
额外信息
记录的额外信息主要包含3类:变长字段长度列表、NULL值列表和记录头信息。需要注意的是这部分信息是为了描述这条记录而不是额外添加的一些信息。
变长字段长度列表
MySQL
支持一些变长的数据类型,如Varchar
,Text
等,它们存储多少字节的数据是不固定的,所以为了准确描述这种数据,这种变长字段占用的存储空间要同时包含:
- 真实的数据内容
- 占用的字节数
在Compact
行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放。
举例说明:创建了如下表格并插入两条数据;
我们先以第一条数据为例:因为a、b、d三列都是变长字段,所以我们要将这3列值的长度按照列的顺序逆序存放在变长字段长度列表中。
其次,变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的。这里以第二条数据为例:因为 d 列的值为 Null
,所以第二条记录的变长字段长度列表只需要存储 a 和 b 列的长度即可。
最后还需要注意的是 :变长字段长度列表不是一定存在的,如表中没有变长类型的字段,或者该记录中所有的变长字段值均为NULL
。
NULL值列表
记录中的某些列可能存储NULL
值,如果把这些NULL
值都放到记录的真实数据中存储则比较浪费空间,所以Compact
行格式把这些值为NULL
的列统一管理起来,存储到NULL
值列表中。具体处理流程如下:
-
如果表中有字段允许为
NULL
,InnoDB
就会开辟一块空间来标识每个字段实际存储的数据是不是NULL
,如果表中的字段都不允许为NULL
,则NULL
值列表也就不存在了。 -
每个允许存储
NULL
的列对应一个二进制位,二进制位按照列的逆序
排列,二进制位表示的意义如下-
二进制位的值为
1
时,代表该列的值为NULL
。 -
二进制位的值为
0
时,代表该列的值不为NULL
。
-
-
NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则需要在字节的高位补0。(如果一个表中有9个允许为NULL的列,那这个记录的NULL值列表部分就需要2个字节来表示。)
流程可能看上去比较难懂,我们还以上述的表和数据为例:表中的a、c、d是允许为Null
的;
首先看第一条数据:
三个字段存储的实际数据都不为Null
,按照列的逆序
排列,所以用二进制来表示如下所示:
不足8位的要在高位补0,最终用二进制来表示如下:
接下来我们看第二条数据:c、d两列为Null
;
不足8位的要在高位补0,最终用二进制来表示如下:
记录头信息
记录头信息由固定的5个字节组成,即40个二进制位,不同的位代表不同的意思;
名称 | 大小(单位:bit) | 描述 |
---|---|---|
预留位1 | 1 | 未使用 |
预留位2 | 1 | 未使用 |
delete_mask | 1 | 标记该记录是否被删除 |
min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned | 4 | 当前记录拥有的记录数 |
heap_no | 13 | 当前记录在记录堆的位置信息 |
record_type | 3 | 记录类型 0:普通记录 ;1:B+树非叶子节点记录 ;2:最小记录 ;3:最大记录 |
next_record | 16 | 下一条记录的相对位置 |
真实数据
记录的真实数据除了自定义的列的数据以外,MySQL
还会为每条记录默认的添加一些列(也称为隐藏列),具体的列如下:
列名 | 是否必须 | 占用空间 | 描述 |
---|---|---|---|
DB_ROW_ID | 否 | 6字节 | 行ID,唯一标识一条记录 |
DB_TRX_ID | 是 | 6字节 | 事务ID |
DB_ROLL_PTR | 是 | 7字节 | 回滚指针 |
当用户未指定数据表的主键时,MySQL
会选择非NULL
的Unique
列作为主键,而如果非NULL
的Unique
列也没有,这个时候MySQL
就会向数据表添加DB_ROW_ID
字段用来作为主键。
注意:记录的数据内容不包括字段值为NULL的数据内容。
Dynamic格式
在现在 mysql
5.7 的版本中,使用的格式就是 Dynamic
。
Dynamic
和 Compact
基本是相同的,只有在溢出页的处理上面有所不同。
溢出页
MySQL
对一条记录占用的最大存储空间是有限制的,除了BLOB或者TEXT类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节( InnoDB
存储引擎)。可以不严谨的认为,MySQL
一行记录占用的存储空间不能超过65535个字节。
上面我们说过MySQL
中磁盘与内存交互的最小单位是页,一般为16KB:16384个字节,而一行记录最大可以占用65535个字节,这就会造成一页存不下一行数据的情况。
为了解决这种问题,在Compact
行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的前768个字节的数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用20个字节存储指向这些页的地址,从而可以找到剩余数据所在的页。
这种在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中的情况就叫做行溢出,存储超出768字节的那些页面也被称为溢出页。
Dynamic
格式处理的方式则是直接在真实数据区记录 20字节的溢出页地址,不会在记录的真实数据出存放前768个字节。
Compressed格式
compressed
格式和Dynamic
类似, 主要在其基础上面进行额外的压缩处理。但这种压缩处理其实是以时间换空间,性能并不友好,所以使用的时候需要根据实际情况判断。
页
在InnoDB
存储结构中,页是最小的存储单位。所有的数据,包括行数据,索引数据,系统数据,都是存储在页中的。页的大小是固定的,通常是16KB。我们这里需要关注的是在这16KB大小的存储空间到底有哪些部分?
数据页在结构上可以划分为7个部分,不同的部分有不同的功能,具体如下图所示:
名称 | 中文名 | 大小 | 描述 |
---|---|---|---|
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
部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:
为了能够将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
中的行格式如下(只展示记录头和真实数据),列中数据均用十进制表示:
我们对照着上图来看下记录头中几个属性的详细信息:
-
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
记录(最大记录)。从图中也能看出来,我们的记录实际上按照主键大小正序排序行成一个单向链表。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:
第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
的生成过程如下:
- 初始情况下一个数据页里只有
Infimum
和Supremum
两条记录,它们分属于两个组。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。
这里我们还以上面的四条数据为例,按照如上规则,首先将最小记录和最大记录分为两组,然后依次添加四条记录:
我们再添加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');
为了方便理解,图中只保留了用户记录头信息中的n_owned
和next_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为主键),清空插入的数据,为了便于讲述,我们可以简单的把表的行格式理解如下:
假设我们的每个数据页最多能存放3条记录,这时候我们向表中插入三条记录,那么数据页就如图所示:
INSERT INTO test VALUES(1, 10, 'aa');
INSERT INTO test VALUES(2, 20, 'bb');
INSERT INTO test VALUES(4, 40, 'dd');
此时我们再来插入一条记录:
INSERT INTO test VALUES(3, 30, 'cc');
因为上面说明了一个页最多只能放3条记录,所以我们不得不再分配一个新页:
但这里是有点问题的,可以发现页1中记录最大的主键值为4,而页2中有一条记录的主键值为3,这不符合下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值的要求。所以在插入主键值为3的记录的时候需要伴随着一次记录移动,也就是把主键值为4的记录移动到页2中,然后再把主键值为3的记录插入到页1中。最后形成如图所示:
这个过程表明了在对页中的记录进行增删改操作的过程中,会通过一些移动记录的操作来保证下一个数据页中记录的主键值始终大于上一个页中记录的主键值,称为页分裂。
B+树
存储用户记录的页在物理存储上可能并不相邻,所以如果想从这么多页中根据主键值快速定位某些记录所在的页,就需要给它们做个目录,每个页对应一个目录项,每个目录项由页中记录的最小主键值和页号组成。
在InnoDB
中复用了之前存储用户记录的数据页来存储目录项,通过record_type
来区分普通的用户记录还是目录项记录,record_type=1
就是目录项记录。
这时,目录页中就会有两条目录项记录,第一条记录的页号为 1、主键值为1;第二条记录的页号为 2、主键值为4。
随着不断插入记录,数据页越来越多,会导致目录页中的记录满了,这时要再插入一个目录项记录就放不下了。
解决该问题也很简单,我们只需再执行一遍上述操作,多生成一层目录页即可。
上面这幅图现在看起来就像一个倒过来的树,这其实就是 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+树有两个特点:
- 使用记录主键值的大小进行记录和页的排序
- B+树的叶子节点存储的是完整的用户记录
我们把具有这两种特性的B+树称为聚簇索引,这种聚簇索引并不需要我们在MySQL
语句中显式的使用INDEX语句去创建,InnoDB
存储引擎会自动的为表添加一个 row_id
的隐藏列作为主键并创建聚簇索引;当然这是在我们没有为某个表显式的定义主键,并且表中也没有定义唯一索引的情况下。
非聚簇索引
InnoDB
在创建表时,默认会创建一个主键的聚簇索引,而除此之外的其它索引都属于非聚簇索引
,也被称为二级索引
或辅助索引
。
聚簇索引只能在搜索条件是主键值时才能发挥作用,因为目录页中存储的都是主键,B+树中的数据都是按照主键进行排序的。如果我们要根据其它的非主键列来查询,比如前面表中的 b
列,这时就可以再建一个 b
列的辅助索引。
这个时候我们再根据b
列查找数据时就会用上这个索引了,查找过程和聚簇索引是类似的。我们根据该图来看看非聚簇索引与聚簇索引到底有哪里不同:
-
使用记录
b
列的大小进行记录和页的排序 -
B+树的叶子节点存储的并不是完整的用户记录,而只是
b
列 + 主键这两个列的值。 -
目录项记录中不再是主键+页号的搭配,而变成了
b
列+页号的搭配。
这里最主要的区别在于利用辅助索引查找到的数据不是完整的用户记录,所以找到叶子节点上的记录后,还会根据对应的主键值回到主键索引上再根据主键值找到对应的完整记录,这个过程叫做回表。
但是在查询的过程中也并非一定需要回表,如果我们查找的数据在辅助索引上已经存在,那么就不会发生回表的操作。比如select a, b from test where b = 10
,这个SQL就不会回表,因为这个辅助索引上已经包含了要查找的所有列,所以只有索引上不包含要查找的列时,才会发生回表。
以上便是InnoDB
存储结构的全部内容,掌握这些内容能帮助我们更清楚的了解到在Mysql
中是怎样对数据进行存储的,同时能帮助我们在工作中更加合理的使用索引。