MySQL-深入理解InnoDB数据结构

InnoDB行格式

行格式/Row Format简介

我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为 行格式 或者 记录格式 。

InnooDB目前有四种行格式:

  • compact(简洁的)
  • redundant(冗余的)
  • dynamic(动态的)
  • compress(压缩的)

创建表时可以指定它的 行格式 :

1
2
3
4
5
6
CREATE TABLE record_format_demo (
c1 VARCHAR(10),
c2 VARCHAR(10) NOT NULL,
c3 CHAR(10),
c4 VARCHAR(10)
) CHARSET = ascii ROW_FORMAT = COMPACT;

COMPACT行格式

image-20221026171111339

Compact行格式中,一条完整的记录可以被分为 记录的额外信息记录的真实数据 两大部分。

变长字段长度列表
MySQL 支持一些变长的数据类型,比如 VARCHAR(M) 、 VARBINARY(M) 、各种 TEXT 类型,各种 BLOB 类型,变长字段中存储多少字节的数据是不固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来,这样才不至于把 MySQL 服务器搞懵。

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

image-20221026171932292

如上图,其 变长字段长度列表 的效果为:

image-20221026172026887

另外需要注意的一点是,变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 。

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

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

MySQL 规定 NULL值列表 必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补 0 。

image-20221026175247019 image-20221026175335419

所以这两条记录在填充了 NULL值列表 后的示意图就是这样:

image-20221026175406601

记录头信息

用于描述记录的记录头信息,它是由固定的5个字节组成。也就是40个二进制位,不同的位代表不同的意思。

image-20221026175507943

这些二进制位代表的详细信息如下表:(只需要看一遍混个脸熟,等之后用到这些属性的时候我们再回过头来看)

image-20221101222608679

记录的真实数据

对于 record_format_demo 表来说, 记录的真实数据 除了 c1 、 c2 、 c3 、 c4 这几个我们自己定义的列的数据以外, MySQL 会为每个记录默认的添加一些列(也称为 隐藏列 ),具体的列如下:

image-20221026175711411

InnoDB 表对主键的生成策略
优先使用用户自定义的主键作为主键,如果用户没有定义主键,则选取一个 Unique 键作为主键,如果表中连 Unique 键都没有定义的话,则 InnoDB 会为表默认添加一个名为row_id 的隐藏列作为主键。

因为表 record_format_demo 并没有定义主键,所以 MySQL 服务器会为每条记录增加上述的3个列。现在看一下加上 记录的真实数据 的两个记录长什么样吧:

image-20221026175843625

CHAR(M)列的存储格式
record_format_demo 表的 c1 、 c2 、 c4 列的类型是 VARCHAR(10) ,而 c3 列的类型是 CHAR(10) ,我们说在Compact 行格式下只会把变长类型的列的长度逆序存到 变长字段长度列表 中。但是这只是因为我们的 record_format_demo 表采用的是 ascii 字符集,这个字符集是一个定长字符集,也就是说表示一个字符采用固定的一个字节,如果采用变长的字符集(也就是表示一个字符需要的字节数不确定,比如gbk 表示一个字符要12个字节、 utf8 表示一个字符要13个字节等)的话, c3 列的长度也会被存储到 变长字段长度列表 中。

image-20221026181815967 image-20221026181821951

这就意味着:对于 CHAR(M) 类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表中,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表中。

另外有一点还需要注意,变长字符集的 CHAR(M) 类型的列要求至少占用 M 个字节,而VARCHAR(M) 却没有这个要求。比方说对于使用 utf8 字符集的 CHAR(10) 的列来说,该列存储的数据字节长度的范围是10~30个字节,即使我们向该列中存储一个空字符串也会占用 10 个字节。这是因为怕将来更新该列的值的字节长度大于原有值的字节长度而小于10个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片。

Redundant行格式

其实知道了 Compact 行格式之后,其他的行格式就是依葫芦画瓢了。Redundant 行格式是MySQL5.0 之前用的一种行格式,也就是说它已经非常老了,此处就不做介绍了。

Dynamic和Compressed行格式

下边要介绍另外两个行格式, Dynamic 和 Compressed 行格式,我现在使用的 MySQL 版本是 5.7,它的默认行格式就是 Dynamic ,这俩行格式和 Compact 行格式挺像,只不过在处理 行溢出 数据时有点儿分歧,它们不会在记录的真实数据处存储字段真实数据的前 768 个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址,就像这样:

image-20221026182813125

Compressed 行格式和 Dynamic 不同的一点是, Compressed 行格式会采用压缩算法对页面进行压缩,以节省空间。

总结

  1. 页是 MySQL 中磁盘和内存交互的基本单位,也是 MySQL 是管理存储空间的基本单位。

  2. InnoDB 目前定义了4种行格式

    1. COMPACT行格式

      image-20221026183106564
    2. Redundant行格式

      image-20221026183253376
    3. Dynamic和Compressed行格式
      这两种行格式类似于 COMPACT行格式 ,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字符串的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。另外, Compressed 行格式会采用压缩算法对页面进行压缩。

  3. 一个页一般是 16KB ,当记录中的数据太多,当前页放不下的时候,会把多余的数据存储到其他页中,这种现象称为 行溢出 。

InnoDB数据页

InnoDB 是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启我们的数据还是存在的。而真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中,如果是处理写入或修改请求的话,还需要把内存中的内容刷新到磁盘上。而我们知道读写磁盘的速度非常慢,和内存读写在速度上差了几个数量级,所以当我们想从表中获取某些记录时, InnoDB 存储引擎需要一条一条的把记录从磁盘上读出来么?

不,那样会慢死,InnoDB 采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。

数据页代表的这块 16KB 大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:

image-20221101220324116

从图中可以看出,一个 InnoDB 数据页的存储空间大致被划分成了 7 个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。下边我们用表格的方式来大致描述一下这7个部分都存储一些啥内容:

image-20221101220439530

记录在页中的存储

  • 存储的记录会按照我们指定的行格式存储到User Records部分。
  • 一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分。
  • 当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。
image-20221101220811539

记录头信息的秘密

我们先创建一个表:

1
2
3
4
5
6
CREATE TABLE page_demo(
c1 INT,
c2 INT,
c3 VARCHAR(10000),
PRIMARY KEY (c1)
) CHARSET = ascii ROW_FORMAT = Compact;

这个新创建的 page_demo 表有3个列,其中 c1 和 c2 列是用来存储整数的, c3 列是用来存储字符串的。需要注意的是,我们把 c1 列指定为主键,所以在具体的行格式中InnoDB就没必要为我们去创建那个所谓的 row_id 隐藏列了。而且我们为这个表指定了 ascii 字符集以及 Compact 的行格式。所以这个表中记录的行格式示意图就是这样的:

image-20221101222444127

从图中可以看到,我们特意把 记录头信息 的5个字节的数据给标出来了,说明它很重要,我们再次先把这些 记录头信息 中各个属性的大体意思浏览一下(我们目前使用 Compact 行格式进行演示):

image-20221101222608679

下边我们试着向 page_demo 表中插入几条记录:

1
INSERT INTO page_demo VALUES(1, 100, 'aaaa'), (2, 200, 'bbbb'), (3, 300, 'cccc'), (4, 400, 'dddd'); 

为了方便分析这些记录在 页 的 User Records 部分中是怎么表示的,把记录中头信息和实际的列数据都用十进制表示出来了(其实是一堆二进制位),所以这些记录的示意图就是:

image-20221101222910182

看这个图的时候需要注意一下,各条记录在 User Records 中存储的时候并没有空隙,这里只是为了大家观看方便才把每条记录单独画在一行中。我们对照着这个图来看看记录头信息中的各个属性是啥意思:

  • delete_mask:

    • 这个属性标记着当前记录是否被删除,占用1个二进制位,值为 0 代表记录并没有被删除,为 1 代表记录被删除掉了。

    • 被删除的记录还在 页 中么?

      是的,你以为它删除了,可它还在真实的磁盘上。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗,所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的 垃圾链表 ,在这个链表中的记录占用的空间称之为所谓的 可重用空间 ,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。

  • min_rec_mask:

  • B+树的每层非叶子节点中的最小记录都会添加该标记。

  • n_owned :这个暂时保密,稍后它就是主角~

  • heap_no:

    • 这个属性表示当前记录在本页中的位置,从图中可以看出来,我们插入的4条记录在本 页 中的位置分别是: 2 、 3 、 4 、 5 。是不是少了点啥?是的,怎么不见 heap_no 值为 0 和 1 的记录呢?
      这其实是设计 InnoDB 者的一个小把戏,他们自动给每个页里边儿加了两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为 伪记录 或者 虚拟记录这两个伪记录一个代表 最小记录 ,一个代表 最大记录 。

      image-20221101223918314
    • 记录可以比大小么?

      是的,记录也可以比大小,对于一条完整的记录来说,比较记录的大小就是比较 主键 的大小。比方说我们插入的4行记录的主键值分别是: 1 、 2 、 3 、 4 ,这也就意味着这4条记录的大小从小到大依次递增。
      由于这两条记录不是我们自己定义的记录,所以它们并不存放在 页 的 User Records 部分,他们被单独放在一个称为 Infimum + Supremum 的部分,如图所示:

      image-20221101224028919
    • 从图中我们可以看出来,最小记录和最大记录的 heap_no 值分别是 0 和 1 ,也就是说它们的位置最靠前。

  • record_type:

    这个属性表示当前记录的类型,一共有4种类型的记录:

    • 0 表示普通记录
    • 1 表示B+树非叶节点记录
    • 2 表示最小记录
    • 3 表示最大记录

    从图中我们也可以看出来,我们自己插入的记录就是普通记录,它们的record_type 值都是 0 ,而最小记录和最大记录的 record_type 值分别为 2 和 3 。

  • next_record:

    它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。
    比方说第一条记录的 next_record 值为 32 ,意味着从第一条记录的真实数据的地址处向后找 32 个字节便是下一条记录的真实数据。如果你熟悉数据结构的话,就立即明白了,这其实是个链表 ,可以通过一条记录找到它的下一条记录。下一条记录 是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录)。

image-20221101224819448

​ 如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:

image-20221101225158276

从图中可以看出来,删除第2条记录前后主要发生了这些变化: 第2条记录并没有从存储空间中移除,而是把该条记录的delete_mask值设置为1。 第2条记录的next_record值变为了0,意味着该记录没有下一条记录了。 第1条记录的next_record指向了第3条记录。 还有一点你可能忽略了,就是最大记录的n_owned值从5变成了4,关于这一点的变化我们稍后会详细说明的。

如果我们再次把这条记录插入到表中的话:

image-20221101225324511

从图中可以看到,InnoDB并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间(可重用空间)。当数据页中存在多条被删除掉的记录时,这些记录的next_record属性将会把这些被删除掉的记录组成一个垃圾链表,以备之后重用这部分存储空间。

Page Directory(页目录)

现在我们了解了记录在页中按照主键值由小到大顺序串联成一个单链表,那如果我们想根据主键值查找页中的某条记录该咋办呢?

比如说这样的查询语句:

1
SELECT * FROM page_demo WHERE c1 = 3;

Innodb查找方案:
我们平常想从一本书中查找某个内容的时候,一般会先看目录,找到需要查找的内容对应的书的页码,然后到对应的页码查看内容。 InnoDB 设计者们为我们的记录也制作了一个类似的目录,他们的制作过程是这样的:

  1. 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
  2. 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
  3. 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近 页 的尾部的地方,这个地方就是所谓的 Page Directory ,也就是 页目录 。页面目录中的这些地址偏移量被称为 槽 (英文名: Slot ),所以这个页面目录就是由 槽 组成的。

比方说现在的 page_demo 表中正常的记录共有6条, InnoDB 会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录,看下边的示意图:

image-20221101225810821

从这个图中我们需要注意这么几点:

  • 注意最小和最大记录的头信息中的n_owned属性 最小记录的n_owned值为1,这就代表着以最小记录结尾的这个分组中只有1条记录,也就是最小记录本身。 最大记录的n_owned值为5,这就代表着以最大记录结尾的这个分组中只有5条记录,包括最大记录本身还有我们自己插入的4条记录。

  • 所以 最小记录的 n_owned 是 1, 当前最大记录的 n_owned 是 5

    image-20221101230038607
  • 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。

  • 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。

  • 再次添加12条数据

    image-20221101230109464
  • 比方说我们想找主键值为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中的记录。
    4. 我们可以拿到槽1对应的记录(主键值为4),该条记录的下一条记录就是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽2中的各条记录,直到找到主键值为6的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。
  • 所以在一个数据页中查找指定主键值的记录的过程分为两步:

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

Page Header(页面头部)

为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第
一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫 Page Header 的部分,它是页 结构的第二部分,这个部分占用固定的 56 个字节,专门存储各种状态信息,具体各个字节都是干嘛的看下表:

image-20221101230621617

File Header(文件头部)

Page Header 是专门针对 数据页 记录的各种状态信息,比方说页里头有多少个记录了呀,有多少个
槽了呀。我们现在描述的 File Header 针对各种类型的页都通用,也就是说不同类型的页都会以 File Header 作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁。 这个部分占用固定的 38 个字节,是由下边这些内容组成的:

image-20221101230727633

对照着这个表格,我们看几个目前比较重要的部分:

  • FIL_PAGE_SPACE_OR_CHKSUM

    这个代表当前页面的校验和(checksum)。啥是个校验和?就是对于一个很长很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为 校验和 。这样在比较两个很长的字节串之前先比较这两个长字节串的校验和,如果校验和都不一样两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。

  • FIL_PAGE_OFFSET

    每一个页都有一个单独的页号,就跟你的身份证号码一样, InnoDB 通过页号来可以唯一定位一个页。

  • FIL_PAGE_TYPE

    这个代表当前 页 的类型,我们前边说过, InnoDB 为了不同的目的而把页分为不同的类型,我们上边介绍的其实都是存储记录的 数据页 ,其实还有很多别的类型的页。
    我们存放记录的数据页的类型其实是 FIL_PAGE_INDEX ,也就是所谓的 索引页 。

  • FIL_PAGE_PREV 和 FIL_PAGE_NEXT

    FIL_PAGE_PREV 和 FIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,不过数据页 是有这两个属性的,所以所有的数据页其实是一个双链表,就像这样:

    image-20221101231039843

File Trailer

我们知道 InnoDB 存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以 页 为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办,这不是莫名尴尬么?为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),设计 InnoDB 的大叔们在每个页的尾部都加了一个 File Trailer 部分,这个部分由 8 个字节组成,可以分成2个小部分:

  • 前4个字节代表页的校验和

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

  • 后4个字节代表页面被最后修改时对应的日志序列位置(LSN)

总结

  1. InnoDB为了不同的目的而设计了不同类型的页,我们把用于存放记录的页叫做 数据页 。一个数据页可以被大致划分为7个部分,分别是:
    • File Header ,表示页的一些通用信息,占固定的38字节。
    • Page Header ,表示数据页专有的一些信息,占固定的56个字节。
    • Infimum + Supremum ,两个虚拟的伪记录,分别表示页中的最小和最大记录,占固定的 26 个字节。
    • User Records :真实存储我们插入的记录的部分,大小不固定。
    • Free Space :页中尚未使用的部分,大小不确定。
    • Page Directory :页中的某些记录相对位置,也就是各个槽在页面中的地址偏移量,大小不固定,插入的记录越多,这个部分占用的空间越多。
    • File Trailer :用于检验页是否完整的部分,占用固定的8个字节。
  2. 每个记录的头信息中都有一个 next_record 属性,从而使页中的所有记录串联成一个 单链表 。
  3. InnoDB 会为把页中的记录划分为若干个组,每个组的最后一个记录的地址偏移量作为一个 槽 ,存放在Page Directory 中,所以在一个页中根据主键查找记录是非常快的,分为两步:
    1. 通过二分法确定该记录所在的槽。
    2. 通过记录的next_record属性遍历该槽所在的组中的各个记录。
  4. 每个数据页的 File Header 部分都有上一个和下一个页的编号,所以所有的数据页会组成一个 双链表。为保证从内存中同步到磁盘的页的完整性,在页的首部和尾部都会存储页中数据的校验和和页面最后修改时对应的 LSN 值,如果首部和尾部的校验和和 LSN 值校验不成功的话,就说明同步过程出现了问题。

参考文档:

【MySQL进阶】深入理解InnoDB记录结构

【MySQL进阶】深入理解InnoDB数据页结构