mysql运行原理笔记-InnoDB记录存储结构

Scroll Down

前言

MySQL服务器中负责对表中的数据进行读取和写入工作的部分是存储引擎,而服务器又支持不同类型的存储引擎,比如InnoDB、MyISAM、MEMORY。不同的存储引擎一般是由不同的人为实现不同的特性而开发的,真实数据在不同存储引擎中的存放的格式一般是不同的,甚至有的存储引擎都不用磁盘来存储数据。也就是对于使用内存存储引擎的表来说,关闭服务器后表中的数据就消失了。

InnoDB页简介

InnoDB是一个将表中的数据存储到磁盘上的存储引擎,即使我们关闭并重启服务器,数据还是存在。而真正处理数据的过程发生在内存中,所以需要把磁盘中的数据加载到内存中。如果是处理写入或修改请求,还需要把内存中的内容刷新到磁盘上。而我们知道读写磁盘的速度非常慢,与读写内存差了几个数量级。InnoDB存储引擎采取的存储方式是,将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位。InnoDB中页的大小一般为16KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。

系统变量innodb_page_size表明了InnoDB存储引擎中的页大小,默认值为16384(单位是字节),也就是16kb。该变量只能在第一次初始化MySQL数据目录时指定,之后就再也不能更改了(通过命令mysql --initialize来初始化数据目录。)

InnoDB行格式

我们平时都是以记录为单位向表中插入数据的。这些记录在磁盘上的存放形式也被称为行格式或者记录格式。InnoDB存储引擎到目前为止有4种不同类型的行格式,分别是COMPACT、REDUNDANT、DYNAMIC和COMPRESSED。这些行格式在原理上大体都是相同的。

指定行格式的语法

我们可以在创建或者修改表的语句中指定记录所使用的行格式;

CREATE TABLE 表名 (列的信息) ROW_FORMAT=格式名称;
ALTER TABLE 表名 ROW_FORMAT=行格式名称;

COMPACT行格式

COMPACT.png

从图中可以看出,一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分。下面我们分别看一下这两大部分的组成。

1、记录的额外信息
这部分信息是服务器为了更好地管理记录而不得不额外添加的一些信息。这些额外信息分为3部分,分别是变长字段长度列表、NULL值列表和记录头信息。
(1)变长字段长度列表
我们知道,MySQL支持一些变长的数据类型,比如VARCHAR、VARBINARY、各种TEXT类型、各种BOLB类型。我们也可以把拥有这些数据类型的列称为变长字段。变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。也就是说这些变长字段占用的存储空间分为两部分:

真正的数据内容。
该数据占用的字节数。

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

InnoDB有一套规则,为了更好地表述清除这个规则,我们引入了W、M和L这几个符号,先分别看看这些符号的意思。

  • 假设某个字符集中最多需要W字节来表示一个字符。比如utf8mb4字符集中的W就是4,utf8字符集中的W就是3,gbk字符集中的W就是2,ASCII字符集中的W就是1。
  • 对于变长类型VARCHAR(M)来说,这种类型表示能存储最多M个字符(注意是字符不是字节),所以这种类型能表示的字符串最多占用的字节数就是M * W。
  • 假设该变长字段实际存储的字符串占用的字节数是L。
    确定使用1字节还是2字节来表示一个变长字段的真实数据占用的字节数的规则就是这样:
  • 如果M * W <= 225,那么使用1字节来表示真实数据占用的字节数。

InnoDB在读取记录的变长字段长度列表时先查看表结构。如果某个变长字段允许存储的最大字节数不大于255,可以认为只使用1字节来表示真实数据占用的字节数。

  • 如果M * W > 255,则分为下面两种情况:
  • 如果L <= 127,则用1字节来表示真实数据占用的字节数。
  • 如果L > 127,则用2字节来表示真实数据占用的字节数

InnoDB在读取记录的变长字段长度列表时先查看表结构。如果某个变长字段允许存储的最大字节数大于255,InnoDB使用该字节的第一个二进制位作为标志位;如果该字节的第一位为0,该字节就是一个单独的字段长度(在使用一个字节表示不大于127的数字时,第一个位都为0);如果该字节的第一个位为1,该字节就是半个字段长度。这个规则特别像我们之前说过的GBK字符集的编码规则。
对于一条记录来说,如果某个字段占用的字节数特别多,InnoDB有可能把该字段的值得一部分数据存放到所谓的溢出页中。那么该字段在记录的变长字段长度列表处只存储留在本页中的长度。所以使用2字节就可以表示这个留在本页面中的字节长度。

总结一下就是:如果该变长字段允许存储的最大字节数(M * W)超过255字节,并且真实数据占用的字节数(L)超过127字节,则使用2字节来表示真实数据占用的字节数,否则使用1字节。

另外需要注意一点就是,变长字段长度列表中只存储值为非NULL的列的内容长度,不存储值为NULL的列的内容长度。也就是说对于第二条记录,因为c4列的值为NULL,所以第二条记录的变长字段长度列表只需要存储c1和c2列的内容长度即可。其中c1列存储的值为’eeee’,占用的字节数为3。数字4可以用1字节(0x04)表示,3也可以用1字节(0x03)表示,这样第二条记录的整个变长字段长度列表共需2字节。

mysql-row-data.drawio

并不是所有记录都有这个变长字段长度列表部分,如果表中所有的列都不是变长的数据类型或者所有列的值都是NULL的话,就不需要有变长字段长度列表。

(2)NULL值列表
一条记录中的某列可能储存NULL值,如果把这些NULL值都放到记录的真实数据中存储会很占地方,所以COMPACT行格式把一条记录中值为NULL的列统一管理起来,存储到NULL值列表中。它的处理过程如下所示:

1、首先统计表中允许存储NULL的列都有哪些。
主键以及使用NOT NULL修饰的列都是不可以存储的NULL值,所以在统计时候不会把这些列都算进去。比如record表中的3个列c1、c3、c4都允许存储NULL值,而c2列使用NOT NULL进行了修饰,不允许存储NULL值。

2、如果表中没有允许存储NULL的列,则NULL值列表也就不存在了,否则将每个允许存储NULL的列对应一个二进制位,二进制位按照列的顺序逆序排列。二进制位表示的意义如下:

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

3、MySQL规定NULL值列表必须用整数个字节的位表示,如果使用二进制位个数不是整数个字节,则在字节的高位补0。
以此类推,如果一个表中有9个值允许为NULL的列,则这个记录的NULL值列表部分就需要2字节来表示了。

(3)记录头信息
除了变长字段长度列表、NULL值列表之外,还有一个称之为记录头信息的部分。记录头信息由固定5字节组成,用于描述记录的一些属性。5个字节也就是40个二进制位,不同的位代表不同的意思。

  • deleted_flag:标记该条记录是否被删除。
  • min_rec_flag:B+树的每层非叶子节点中最小的记录都会添加该标记。
  • n_owned:一个页面中的记录会被分为若干个组,每个组中有一条记录会记录该组中所有的记录条数,其余记录的n_owned都是0。
  • heap_no:表示当前记录在页面堆中的相对位置。
  • record_type:表示当前记录的类型,0表示普通记录,1表示B+树非叶子节点的目标项记录,2表示Infimum记录,3表示Supremum记录。
  • next_record:表示下一条记录的相对位置。

另外,记录头信息的前4个位也被称为info bit。

2、记录的真实数据
对于record表来说,记录的真实数据除了c1、c2、c3、c4这几个我们自己定义的数据外,MySQL还会为每条记录默认地添加一些列(也称为隐藏列)

列名 是否必须 占用空间 描述
row_id 6字节 行ID,唯一标识一条记录
trx_id 6字节 事务ID
roll_pointer 7字节 回滚指针

这里需要提一下InnoDB表的主键生成策略:优先使用用户自定义的主键作为主键;如果用户没有定义主键,则选取一个不允许存储NULL值的UNIQUE列键作为主键;如果表中连不允许存储NULL值的UNIQUE键都没有定义,则InnoDB会为表默认添加一个名为row_id的隐藏列作为主键。
所以从表中可以看出,InnoDB存储引擎会为每条记录都添加trx_id和roll_pointer这两个列,但是row_id是可选的(在没有自定义主键以及不允许存储NULL值的UNIQUE键的情况下才会添加该列)。这些隐藏列InnoDB存储引擎会自动生成。
MySQL-InnoDB-row001.drawio
上图要注意以下几点。

  • 表record_format_demo使用的是ASCII字符集,所以0x61616161就表示字符串’aaaa’,0x626262就表示字符串’bbb’;依次类推。
  • 注意第一条记录中c3列是CHAR(10)类型的,它实际存储的字符串是’cc’,使用ASCII字符集来编码这个字符串得到的结果是’0x6363’。虽然表示这个字符串只占用了2字节,但整个c3列仍然占用了10字节的空间,除真实数据以外的8字节统统都用空格字符填充,空格字符在ASCII字符集中的编码就是0x20。
  • 注意第二条记录中c3和c4列的值都为NULL,它们被存储在了前面的NULL值列表处,在记录的真实数据处就不再冗余存储,从而节省了存储空间。

3、CHAR(M)列的存储格式
在COMPACT行格式下,变长字段长度列表只是用来存放一条记录中各个变长字段的值占用的字节长度。record表中c1、c2、c4列的类型都是VARCHAR(10),也就是说c1、c2、c4列都是变长字段;而c3列的类型是CHAR(10),也就是说c3列不属于变长字段。所以会把一条记录的c1、c2、c4这3列占用的字节长度逆序存到变长字段长度列表中。
但是这只是建立了record表采用的是ascii字符集的情况下,采用固定的一个字节来编码一个字符,是一个定长编码字符集。如果采用变长编码的字符集,虽然c3列的类型是CHAR(10),但是MySQL设计者页规定了,此时该列的值的占用的字节数也会被存储到变长字段长度列表中。
这就意味着,对于CHAR(M)类型的列来说,当列采用的是定长编码的字符集时,该列占用的字节数不会被加到变长字段长度列表;而如果采用变长编码的字符集时,该列的占用的字节数就会被加到变长字段长度列表。
另外还有一点需要在注意,MySQL设计者规定COMPACT行格式,采用变长编码字符集的CHAR(M)类型的列要求至少占用M个字节,而VARCHAR(M)却没有这个要求。比如对于使用utf8字符集、类型为CHAR(10)的列来说,该列存储的数据占用的字节长度的范围就是10-30字节。即使我们向该列中存储一个空字符串也会占用10字节,这主要是希望在将来更新该列时,在新值的字节长度大于旧值的字节长度但不大于10个字节时,可以在该记录处直接更新。而不是在存储空间中再重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片。

溢出列

1、溢出列
在COMPACT和REDUNDANT行格式中,对于占用空间非常多的列,在记录的真实数据处只会存储该列的一部分数据,而把剩余的数据分散存储在几个其他的页中,然后在记录的真实数据处用20字节存储指向这些页的地址,从而可以找到剩余数据所在的页。
对于COMPACT和REDUNDANT行格式来说,如果某一列中的数据非常多,则在本记录的真实数据处只会存储该列前768字节的数据以及一个指向其他页的地址,然后把剩下的数据存放到其他页中。这些存储768字节之外的数据的页面也称为溢出页。
最后需要注意的是,不只是VARCHAR(M)类型的列可能成为溢出列,像TEXT、BLOB这些类型的列在存储的数据相当多的时候也会成为溢出列。
2、产生溢出页的临界点
一个列在存储了多少字节之后会变成为溢出列?
MySQL中规定一个页中至少存放两行记录。
每个页除了存放我们的记录以外,也需要储存一些额外的信息。这些额外信息加起来需要132字节的空间,其他的空间都可以被用来存储记录。
每个记录需要的额外信息是27字节。这27字节包括下面这些内容:

  • 2字节用于存储真实数据的长度
  • 1字节用于存储列是否是NULL值
  • 5字节大小的头信息
  • 6字节的row_id列
  • 6字节的trx_id列
  • 7字节的roll_pointer列

假设一个列的真实数据占用的字节数为n,MySQL规定,如果该列不发生溢出现象,就需要满足下面这个不等式:

132 + 2 * (27 + n) < 16384

通过求解这个不等式,得出的解是n < 8099.也就是说,如果一个列中存储的数据小于8099字节,那么该列就不会称为溢出列,否则就会成为溢出列。

总结

页是InnoDB中磁盘和内存交互的基本单位,也是InnoDB管理存储空间的基本单位,默认大小为16KB。
指定和修改行格式的语法如下:

CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称;
ALTER TABLE 表名 ROW_FORMAT = 行格式名称;

InnoDB目前定义了4种行格式

  • 1、COMPACT行格式
  • 2、REDUNDANT行格式
  • 3、DYNAMIC和COMPRESSED行格式。

这两种行格式类似于COMPACT行格式,只不过在处理溢出列数据时有点分歧:它们不会在记录的真实数据处存储列真实数据的前768字节,而是把所有的数据都存储到所谓的溢出页中,只在记录的真实数据处存储指向这些溢出页的地址。另外,COMPRESSED行格式会采用压缩算法对页面进行压缩。

InnoDB数据页的结构

页是InnoDB管理存储空间的基本单位,一个页的大小一般是16KB。InnoDB为了不同的目的而设计了多种不同类型的页,比如存放表空间头部信息的页、存放Change Buffer信息的页、存放INODE信息的页、存放undo日志信息的页。等等。当然,如果我们说的这些名词你一个都没听过,也没有关系。我们今天也不准备说这些类型的页,我们关心的是那些存放在表中记录的那种类型的页,官方称这种存放记录的页为索引(INDEX)页。

数据页结构概览

mysql-data-page.drawio
从上图可以看出,一个InnoDB数据页的存储空间大致划分为7个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。

名称 中文名 占用空间大小 简单描述
File Header 文件头部 38字节 页的一些通用信息
Page Header 页面头部 56字节 数据页专有的一些信息
Infimum + Supremum 页面中最小和最大的记录 26字节 两个虚拟记录
User Records 用户记录 不确定 用户存储的记录内容
Free Space 空闲空间 不确定 页尚未使用的空间
Page Directory 页目录 不确定 页中某些记录的相对位置
File Trailer 文件尾部 8字节 检验页是否完整

记录在页中的存储

在页的7个组成部分中,我们自己存储的记录会按照指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有生成User records部分,每当插入一条记录时,都会从Free Space部分(也就是尚未使用的存储空间)申请一个记录大小的空间,并将这个空间划分到User Records部分。当Free space部分的空间全部被User Records部分替代掉之后,也就意味着这个页用完了,此时如果还有新的记录插入,就需要申请新的页了。
mysql-data-page-save.drawio

mysql-UserRecord-save-struct00.drawio

  • delete_flag:这个属性用来标记当前记录是否被删除,占用1比特。值为0时表示记录没有被删除,值为1表示记录被删除了。这些被删除的记录之所以不从磁盘上移除,是因为在移除它们之后,还需要在磁盘上重新排列其他记录,这回带来性能上的消耗,所以只打了一个删除标记就可以避免这个问题。所有被删除掉的记录会组成一个垃圾链表,记录在这个链表中占用的空间称为可重用空间。之后若有新记录插入到表中,他们就可能覆盖掉被删除的这些记录占用的存储空间。
  • min_rec_flag:B+树每层非叶子节点中最小的目录项记录都会添加该标记。
  • n_owned:代表一个组中有多少条记录。
  • heap_no:我们向表中插入的记录从本质上来说都是放到数据页的User Records部分,这些记录一条一条地排列着。这个空间称为堆。为了方便管理这个堆,把一条记录(这条记录的delete_flag可以为1)在堆中的相对位置称之为heap_no。
    为了方便管理这个堆,它们把一条记录在堆中的相对位置称之为heap_no。在页面前面的记录heap_no相对较小,在页面后边的记录heap_no相对较大,每申请一条记录的存储空间时,该条记录比物理位置在它前边的那条记录的heap_no值大1。MySQL会自动给每个页里面加了两条记录,由于这两条记录不是用户自己插入的,所以有时候也称为伪记录或者虚拟记录。在这两条伪记录中,一条代表页面中的最小记录(也可以称作Infimum记录),另外一条代表页面中的最大记录(也可以称作Supremum记录)。这两条伪记录也算作堆的一部分(很显然这两条伪记录的heap_no值最小,说明它们在页面中的相对位置最靠前)。记录可以比较大小。对于一条完整的记录来说,比较记录的大小就是比较主键的大小。但是无论我们向页中插入多少条记录,MySQL规定,任何用户记录都比Infimum记录大,任何用户记录都比Supreme记录小。Infimum记录和Supreme记录为了与用户自己插入的记录进行区分,我们就不把他们放在UserRecord部分,而是单独放在一个称为Infimum+Supreme部分。Infimum记录和Supreme记录的heap_no值分别为0和1,也就是说他们在堆中的相对位置最靠前。另外还需要注意的一点是堆中记录Heap_no值在分配之后就不会发生改动了,即使之后删除了堆中的某条记录,这条被删除的记录的heap_no值也保持不变。
  • record_type:这个属性表示当前记录的类型。一共有4中类型记录,其中0表示普通记录,1表示B+树非叶节点的目录项记录,2表示Infimum记录,3表示Supreme记录。
  • next_record:它表示从当前记录的真实数据到下一条记录的真实数据的距离。如果该属性为正数,说明当前记录的下一条记录在当前记录的后面;如果该属性为负数,说明当前记录的下一条记录在当前记录的前面。但是需要注意的是,下一条记录指的并不是插入顺序中的下一条记录,而是按照主键值由小到大的顺序排列的下一条记录。而且规定Infimum记录的下一条记录就是本页中主键值最小的用户记录,本页中最大的用户记录的下一条记录就是Supreme记录。如下所示:

mysql-UserRecord-save-struct00.drawio
mysql-UserRecord-save-struct01.drawio
mysql-UserRecord-save-struct02.drawio (1)

从图中可以看出,记录按照主键从小到大的顺序形成了一个单向链表。Supremum记录的next_record值为0,也就是说Supremum记录之后就没有下一条记录了,这也意味着Supremum记录就是这个单向链表中的最后一个节点。如果从表中删除一条记录,这个由记录组成的单向链表也是会跟着变化。
所以,无论怎么对页中的记录进行增删改操作,InnoDB始终会维护记录的一个单向链表,链表中的各个节点是按照主键值由小到大的顺序链接起来的。

大家会不会觉得next_record这个指针有点儿奇怪,它为啥要指向记录头信息和真实数据之间的位置呢?为啥不干脆指向整条记录的开头位置,也就是记录的额外信息开头的位置呢?原因是这个位置刚刚好,向左读取就是记录头信息,向右读取就是真实数据。前文还说过,变长字段长度列表、NULL值列表中的信息都是逆序存放的,这样可以使记录中位置靠前的字段和它们对应的字段长度信息在内存中的距离更近,这样可能会提高高速缓存的命中率。

当数据页中存在多条被删除的记录时,可以使用这些记录的next_record属性将这些被删除的记录组成一个垃圾链表,以备之后重用这部分存储空间。

PageDirectory(页目录)

现在我们知道了记录在页中是按照主键由小到大的顺序串联成一个单向链表,如果想根据主键值查找页中某条记录,比如下面这条语句:

SELECT * FROM page_demo WHERE c1 = 3;

最笨的方法就是从Infimum记录开始,沿着单向链表一直往后找,当页中存储的记录比较少时,这种方法没什么问题。在数据量较大时,MySQL制作了一个目录:

  • 将所有正常的记录(包括Infimum和Supremum记录,但是不包括已经移除到垃圾链表的记录)划分为几个组。
  • 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的n_owned属性表示该组内共有几条记录。
  • 将每个组中最后一条记录在页面中的偏移量单独提取出来,按照顺序存储到页尾部的地方。这个地方就是Page Dictionary(页目录)。页目录中的这些地址偏移量称为槽(slot),每个槽占用2字节。页目录就是由多个槽组成。

一个正常的页面也就是16KB大小,即16384字节,而2字节可以表示的地址偏移量范围是0-65535,所以用2字节表示一个槽足够了。

MySQL规定对于Infimum记录所在的分组只能有1条记录,Supremum记录所在的分组拥有的记录条数只能在1-8条之间,剩下的分组中的记录的条数只能是在4-8之间。所以给记录进行分组是按照下面的步骤:

  • 在初始情况下,一个数据页中只有Infimum记录和Supremum记录这两条,他们分属于两个组。页目录中也只有两个槽,分别代表Infimum记录和Supremum记录在页面中的地址偏移量。
  • 之后每插入一条记录,都会从页目录中找到对应记录的主键值比待插入记录的主键值大,并且差值最小的槽(从本质上来说,槽是一个组内最大的那条记录在页面中的地址偏移量,通过槽可以快速找到对应的记录的主键值),然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8。
  • 当一个组中记录数等于8后,再插入一条记录,会将组中的记录拆分成两个组,其中一个组中4条记录,另一个5条记录。这个拆分过程会在页目录中新增一个槽,记录这个新增分组中最大的那条记录的偏移量。

MySQL-page-data-01.drawio
比如:我们想找主键值为6的记录,过程是这样的。
1、计算中间槽的位置:(0 + 4) / 2 = 2,查看槽2对应的记录的主键值为8;又因为8 > 6,所以设置high = 2,low保持不变。
2、重新计算中间槽的位置:(0 + 2) / 2 = 1,查看槽1对应的记录的主键值为4;又因为4 < 6所以设置low = 1,high保持不变。
3、因为high - low的值为1,所以确定主键值为6的记录在槽2对应的组中。此时需要找到槽2所在分组中主键值最小的那条记录,然后沿着单向链表遍历槽2中的记录。

在一个数据页中查找指定主键值的记录时,过程分为两步:

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

Page Header(页面头部)

InnoDB为了能够得到存储在数据页中的记录的状态信息,比如数据页中已经存储了多少条记录、Free Space在页面中的地址偏移量、页目录中存储了多少个槽等,特意在数据页中定义了一个名为Page Header的部分,它是页结构的第二部分,占用固定的56字节,专门存储各种状态信息。Page Header中各个字节的具体用途如下所示:

状态名称 占用空间大小 描述
PAGE_N_DIR_SLOTS 2字节 在页目录中的槽数量
PAGE_HEAP_TOP 2字节 还未使用的空间最小地址,也就是说从该地址之后就是Free Space
PAGE_N_HEAP 2字节 第1位表示本记录是否为紧凑型的记录,剩余的15位表示本页的堆中记录的数量(包括Infimum和Supremum记录以及标记为已删除的记录)
PAGE_FREE 2字节 各个已删除的记录通过next_record组成一个单向链表,这个单向链表中的记录所占用的存储空间可以被重新利用;PAGE_FREE表示该链表头节点对应记录的页面中的偏移量
PAGE_GARBAGE 2字节 已删除记录占用的字节数
PAGE_LAST_INSERT 2字节 最后插入记录的位置
PAGE_DIRECTION 2字节 记录插入的方向
PAGE_N_DIRECTION 2字节 一个方向连续插入的记录数量
PAGE_N_RECS 2字节 该页中用户记录的数量(不包括Infimum和Supremum记录以及被删除的记录)
PAGE_MAX_TRX_ID 8字节 修改当前页的最大事务id,该值仅在二级索引页中定义
PAGE_LEVEL 2字节 当前页在B+树中所处的层级
PAGE_INDEX_ID 8字节 索引ID,表示当前页属于哪个索引
PAGE_BTR_SEG_LEAF 10字节 B+树叶子节点的头部信息,仅在B+树的根页面中定义
PAGE_BTR_SEG_TOP 10字节 B+树非叶子节点段的头部信息,仅在B+树的根页面中定义

File Header(文件头部)

File Header通用于各种类型的页,也就是说各种类型的页都会以File Header作为第一个组成部分,它描述了一些通用于各种页的信息,比如这个页的编号是多少,它的上一个页和下一个页是谁等等。File Header部分占用固定的38字节。

状态名称 占用空间大小 描述
FIL_PAGE_SPACE_OR_CHKSUM 4字节 当MySQL的版本低于4.0.14时,该属性表示本页面所在的表空间ID;在之后的版本中,该属性表示页的校验和(checksum)
FIL_PAGE_OFFSET 4字节 页号
FIL_PAGE_PREV 4字节 上一个页的页号
FIL_PAGE_NEXT 4字节 下一个页的页号
FIL_PAGE_LSN 8字节 页面被最后修改时对应的LSN值
FIL_PAGE_TYPE 2字节 该页的类型
FIL_PAGE_FILE_FLUSH_LSN 8字节 仅在系统表空间的第一个页中定义,代表文件至少被刷新到了对应的LSN值
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 4字节 页属于哪个表空间
  • FIL_PAGE_SPACE_OR_CHKSUM:这个属性代表当前页面的校验和(checksum)。对于一个很长的字符串来说,我们会通过某种算法计算出一个比较短的值来代表这个很长的字符串,这个字符串的值就是校验和。这样在比较两个很长的字符串之前,先比较这两个字符串的检验和。如果校验和都不一样,则两个长字符串肯定是不同的,这样就省去了直接比较两个长字符串的时间损耗。
  • FIL_PAGE_OFFSET:每一个页都有一个单独的页号。InnoDB通过页号来唯一确定一个页。
  • FIL_PAGE_TYPE:表示当前页的类型。
类型名称 十六进制 描述
FIL_PAGE_TYPE_ALLOCATED 0x0000 最新分配,还未使用
FIL_PAGE_UNDO_LOG 0x0002 undo页
FIL_PAGE_INODE 0x0003 存储段的信息
FIL_PAGE_IBUF_FREE_LIST 0x0004 Change Buffer空闲列表
FIL_PAGE_IBUF_BITMAP 0x0005 Change Buffer的一些属性
FIL_PAGE_TYPE_SYS 0x0006 存储一些系统数据
FIL_PAGE_TYPE_TRX_SYS 0x0007 事务系统数据
FIL_PAGE_TYPE_FSP_HDR 0x0008 表空间头部信息
FIL_PAGE_TYPE_XDES 0x0009 存储区的一些属性
FIL_PAGE_TYPE_BLOB 0x000A 溢出页
FIL_PAGE_TYPE_INDEX 0x45BF 索引页,也就是我们所说的数据页
  • FIL_PAGE_PREV和FIL_PAGE_NEXT:前文强调过,InnoDB是以页为单位存放数据的,有时在存放某种类型的数据时,占用的空间非常大。InnoDB可能无法一次性为这么多数据分配一个非常大的存储空间,而如果分散到多个不连续的页中进行存储,则需要把这些页关联起来,FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本数据页的上一个页和下一个页的页号。这样通过建立一个双向链表就把许许多多的页串联起来了,而无须这些页在物理上真正的连着。需要注意的是,并不是所有类型的页都有上一个页和下一个页的属性。

File Trailer(文件尾部)

我们知道,InnoDB存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页为单位把数据加载到内存中处理。如果该页中的数据在内存中被修改了,那么在修改后的某个时间还需要把数据刷新到磁盘中。但是,如果在刷新还没有结束的时候断电了怎么办?为了检测一个页是否完整,InnoDB在每个页的尾部都加了一个File Trailer部分,这个部分由8字节组成,可以分成2个小部分。

  • 前4字节代表页的校验和。这个部分与FileHeader中的校验和相对应。每当一个页面在内存中发生修改时,在刷新之前就要把页面的校验和算出来。因为File Header页面在前边,所以File Header中的校验和会被首先刷新到磁盘,当完全写完后,校验和也会被写到尾部。如果页面刷新成功,则页首和页尾的校验和应该是一致的。如果刷新了一部分后断电了,那么File Header中的校验和代表了已经修改过的页,而File Trailer中的校验和代表着原先的页,二者不同则意味着刷新期间发生了错误。

总结

InnoDB为了不同的目的而设计了不同类型的页,我们把用于存放记录的页称为数据页。一个数据页可以被大致划分为7个部分,分别如下:

  • File Header:表示页的一些通用信息,占固定的38字节。
  • Page Header:表示数据页专有的一些信息,占固定的56字节。
  • Infimum+Supremum:两个虚拟的伪记录,分别表示页中的最小记录和最大记录,占固定的26字节。
  • User Records:真正存储我们插入的记录,大小不固定。
  • Free Space:页中尚未使用的部分,大小不固定。
  • Page Dictionary:页中某些记录的相对位置,也就是各个槽对应的记录在页面中的地址偏移量;大小不固定,插入记录越多,这个部分占用的空间就越多。
  • File Trailer:用于检验页是否完整,占固定的8字节。

每个记录的头信息中都有next_record属性,从而可以使用页中所有记录串联成一个单向链表。
InnoDB会把页中记录划分为若干组,每个组的最后一个记录的地址偏移量作为一个槽,存放在Page Dictionary中,一个槽占用2字节。在一个页中根据主键查找记录是非常快的,分为两步:

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

每个数据页的File Header部分都有上一个页和下一个页的编号,所以所有的数据页会组成一个双向链表。
在将数据页从内存刷新到磁盘时,为了保证页的完整性,页首和页尾都会存储页中数据的校验和,以及页面最后修改时对应LSN值。如果页首和页尾的校验和以及LSN值校验不成功,就说明刷新期间出现了问题。