众所周知,MySQL 的 InnoDB 存储引擎中内存与硬盘交互的基本单位是页。具体地,有数据页(又称为索引页)、Undo页、系统页、溢出页等类型。而所谓数据页,即是用来存放数据记录

来自:MySQL之InnoDB存储引擎:数据页

Page 是 Innodb 存储的最基本结构,也是 Innodb 磁盘管理的最小单位,与数据库相关的所有内容都存储在 Page结构里。Page 分为几种类型:数据页(B-Tree Node)Undo页(Undo Log Page)系统页(System Page)事务数据页(Transaction System Page)等;每个数据页的大小为 **_16KB_**,每个 Page 使用一个 32 位(一位表示的就是 0 或 1)的 int 值来表示,正好对应 Innodb 最大 64TB 的存储容量( 16KB * 2^32=64tib)

计算如下:2^32 x 16KB / 1024 ^ 3 = 64TB

概述

数据页包含以下七个部分。如下图所示,未标明所占空间大小的部分表示其所占空间不固定。其中 Infimum、Supremum 部分所占空间与该数据页所使用的raw format行格式有关(例如在 compact 行格式下,其占用 2x(5+8)=26 个字节)。其中对于File Header、File Trailer部分而言,是各类型页通用的部分

image.png

MySQL之InnoDB存储引擎:数据页 - 图2

「File Header」

描述当前页通用的状态信息

「Page Header」

描述数据页特有的状态信息

「Infimum、Supremum」

InnoDB 插入的两条虚拟记录——即 Infimum 最小记录、Supremum 最大记录

「User Records」

存储用户插入的记录数据,即用户记录

「Free Space」

剩余空间

「Page Directory」

Page Directory 页目录中包含若干个槽,每个槽中会存储某个数据记录在该页的地址偏移量

「File Trailer」

用于检验当前页的完整性

File Header 文件头

File Header 文件头部,该部分固定使用 38 个字节。如前所述,该部分在各类型页中是通用的。故其只是用于描述当前页的一些基本状态信息,而不涉及数据页这一类型下的相关信息。下面对 File Header 中的各属性依次做相关解释、说明。

名称 占用空间大小 描述
FIL_PAGE_SPACE_OR_CHKSUM 4字节 页的校验和(checksum值)
FIL_PAGE_OFFSET 4字节 页号
FIL_PAGE_PREV 4字节 上一个页的页号
FIL_PAGE_NEXT 4字节 下一个页的页号
FIL_PAGE_LSN 8字节 页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number)
FIL_PAGE_TYPE 2字节 该页的类型
FIL_PAGE_FILE_FLUSH_LSN 8字节 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的 LSN值
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 4字节 页属于哪个表空间
  • FIL_PAGE_OFFSET
    当前页的页号,其占用 4 个字节。InnoDB存储引擎通过页号即可找到该页面。具体地,页号从 0 开始编号,将页号乘上数据页的大小(对于非压缩的页,MySQL中默认大小为 16KB)即可得到该页的地址偏移量
  • FIL_PAGE_TYPE
    当前页的类型,其占用2个字节。常见的有
    1. 「0x0002」: FIL_PAGE_UNDO_LOG(Undo日志页)
    2. 「0x0003」: FIL_PAGE_INODE(段信息节点)
    3. 「0x0004」: FIL_PAGE_IBUF_FREE_LIST(Insert Buffer空闲列表)
    4. 「0x0005」: FIL_PAGE_IBUF_BITMAP(Insert Buffer位图)
    5. 「0x0006」: FIL_PAGE_TYPE_SYS(系统页)
    6. 「0x0007」: FIL_PAGE_TYPE_TRX_SYS(事务系统页)
    7. 「0x0008」: FIL_PAGE_TYPE_FSP_HDR(表空间头部信息)
    8. 「0x0009」: FIL_PAGE_TYPE_XDES(拓展描述页)
    9. 「0x000A」: FIL_PAGE_TYPE_BLOB(溢出页)
    10. 「0x45BF」: FIL_PAGE_INDEX(索引页,即数据页)

Page Header 页面头

名称 占用空间大小 描述
PAGE_N_DIR_SLOTS 2字节 在页目录中的槽数量
PAGE_HEAP_TOP 2字节 还未使用的空间最小地址,也就是说从该地址之后就是 Free Space
PAGE_N_HEAP 2字节 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录)
PAGE_FREE 2字节 第一个已经标记为删除的记录地址(各个已删除的记录通过 next_record 也会组成一个单链表,这个单链表中的记录可以被重新利用)
PAGE_GARBAGE 2字节 已删除记录占用的字节数
PAGE_LAST_INSERT 2字节 最后插入记录的位置
PAGE_DIRECTION 2字节 记录插入的方向
PAGE_N_DIRECTION 2字节 一个方向连续插入的记录数量
PAGE_N_RECS 2字节 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录)
PAGE_MAX_TRX_ID 8字节 修改当前页的最大事务ID,该值仅在二级索引中定义
PAGE_LEVEL 2字节 当前页在B+树中所处的层级
PAGE_INDEX_ID 8字节 索引 ID,表示当前页属于哪个索引
PAGE_BTR_SEG_LEAF 10字节 B+ 树叶子段的头部信息,仅在B+ 树的Root页定义
PAGE_BTR_SEG_TOP 10字节 B+树非叶子段的头部信息,仅在B+树的Root页定义
  • PAGE_N_DIR_SLOTS
    描述Page Directory 页目录中的数量,其占用2个字节。对于一个刚刚新建的空数据页而言,其初值为 2 即有 2 个槽,分别指向 Infimum 最小记录、Supremum 最大记录
  • PAGE_LEVELlevel = 0 说明是叶子节点
    从这里看出只有 PAGE_LEVEL = 0 的页里面的 User records 才会有记录数据。如果不是叶子节点,会是其它数据。看看 聚簇索引(主键为 id )里的 records 是什么 image.png
    通过ibdata文件分析BTree结构

Infimum、Supremum 最小、最大记录

所谓Infimum、Supremum部分,其实很简单。其是 InnoDB 存储引擎自动向数据页插入的两条记录——Infimum 最小记录、Supremum 最大记录。由于这两条记录不是用户插入添加的,故通常其又被称作为「伪记录(虚拟记录)」

对于Infimum最小记录而言,其记录的数据内容部分固定为「0x69 0x6E 0x66 0x69 0x6D 0x75 0x6D 0x00」;类似地,对于 Supremum 最大记录而言,其记录的数据内容部分固定为「0x73 0x75 0x70 0x72 0x65 0x6D 0x75 0x6D」。聪明的朋友可能已经看出来了。实际上,上述两条伪记录的数据内容就是其记录名称( infimum、supremum)的 ascii 码值

而 Infimum最 小记录、Supremum 最大记录的记录头部分则取决于该数据页所使用的 raw format 行格式

User Records 用户记录

该部分不言而喻相信大家都很清楚其作用,即是用来存储用户插入的数据记录的。这里我们以「compact 行格式」的数据记录为例来展开介绍下

记录头

来自:MySQL—记录头信息的秘密

我们先创建一个表,并插入几条数据

  1. mysql> CREATE TABLE page_demo(
  2. -> c1 INT,
  3. -> c2 INT,
  4. -> c3 VARCHAR(10000),
  5. -> PRIMARY KEY (c1)
  6. -> ) CHARSET=ascii ROW_FORMAT=Compact;
  7. Query OK, 0 rows affected (0.03 sec)
  8. mysql> INSERT INTO page_demo VALUES(1, 100, 'aaaa'), (2, 200, 'bbbb'), (3, 300, 'cccc'), (4, 400, 'dddd');
  9. Query OK, 4 rows affected (0.00 sec)
  10. Records: 4 Duplicates: 0 Warnings: 0

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

image.png

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

名称 大小(单位: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 表示下一条记录的相对位置

由于我们现在主要在唠叨记录头信息的作用,所以为了大家理解上的方便,我们只在page_demo表的行格式演示图中画出有关的头信息属性以及c1c2c3列的信息(其他信息没画不代表它们不存在啊,只是为了理解上的方便在图中省略了~),简化后的行格式示意图就是这样:

image.png

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

image.png


  • InnoDB设计师将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。n_owned 表示该组内共有几条记录。

    n_owned


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

    heap_no


  • 这玩意儿非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的 next_record 值为 32,意味着从第一条记录的真实数据的地址处向后找 32 个字节便是下一条记录的真实数据。如果你熟悉数据结构的话,就立即明白了,这其实是个链表,可以通过一条记录找到它的下一条记录。但是需要注意注意再注意的一点是,下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum 记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum 记录(也就是最大记录) ,为了更形象的表示一下这个 next_record 起到的作用,我们用箭头来替代一下 next_record 中的地址偏移量:
    image.png

    next_record

从图中可以看出来,我们的记录按照主键从小到大的顺序形成了一个单链表最大记录next_record的值为0,这也就是说最大记录是没有下一条记录了,它是这个单链表中的最后一个节点。如果从中删除掉一条记录,这个链表也是会跟着变化的,比如我们把第2条记录删掉:

  1. mysql> DELETE FROM page_demo WHERE c1 = 2;
  2. Query OK, 1 row affected (0.02 sec)

删掉第2条记录后的示意图就是:

image.png

从图中可以看出来,删除第 2 条记录前后主要发生了这些变化:

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

所以,不论我们怎么对页中的记录做增删改操作,InnoDB始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序连接起来的。

再来看一个有意思的事情,因为主键值为2的记录被我们删掉了,但是存储空间却没有回收,如果我们再次把这条记录插入到表中,会发生什么事呢?

  1. mysql> INSERT INTO page_demo VALUES(2, 200, 'bbbb');
  2. Query OK, 1 row affected (0.00 sec)

我们看一下记录的存储情况:

image.png

从图中可以看到,InnoDB 并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间。


  • 该字段为记录删除的标志位。当我们删除某记录时,不是直接从硬盘中删除,而是分为两个阶段

    delete_mask字段

    1. 「delete mark阶段」:将记录的该字段置为1
    2. 「purge阶段」:将该记录加入所谓的「垃圾链表」


    对于垃圾链表中记录所占用的空间即为所谓的「可重用空间」。这样下次当有新的记录添加进来时,即可通过覆盖的方式来复用这部分存储空间。当然,所谓的「垃圾链表」也是通过被删除记录的 next_record 字段作为指针来链接形成的

Free Space 剩余空间

该部分即为页面的剩余空间。具体地,User Records 部分从上往下使用剩余空间,而 Page Directory 则从下往上使用剩余空间。示意图如下所示

image.png

Page Directory 页目录

前面我们说到数据页中的各记录实际上相当于一个单向链表,其中,最小记录、最大记录分别为表头、表尾。而链表的查找效率非常低,每次都需从表头开始进行遍历。为了提高查找效率,Page Directory 页目录应运而生。

1.首先将整个链表分为若干个部分(即分组),2.然后将分组内最后一条记录(即组内最大的记录)的地址(即其在数据页中的地址偏移量)存放在一个 Slot 槽中,各 Slot 槽根据其所指向的记录按从小到大的顺序在 Page Directory页目录中排列。示意图如下所示。这里为了简便,各分组内的记录没有全部画出来,而是只是在图中左侧指明该分组中记录的数量。其实关于分组中记录的数量是存储在该分组对应的 Slot 槽所指向的记录(即分组内最后一条记录),当然该记录也是分组内最大的记录)的「n_owned」字段

假设 slot 槽对应的数据为-Inf(对应最小记录 infimum), 5,15,22,+Inf(对应最大记录 supremum)

我们的记录的值是 17,那么用二分查找法的话,我们可以定位到 15 的位置(返回 15 的下标)。然后在 15 的位置开始使用顺序查找

image.png

这样我们在该页下如果需要根据「主键」来查找某条记录时,即可先利用 Page Directory 页目录中的各 Slot 槽,通过二分查找快速确定该记录所在的分组,然后再按链表进行遍历。可以看到通过页目录大大缩小了链表遍历查找记录时的范围,提高了效率。这也是该部分为啥被称之为目录的缘由。

具体地关于如何分组,基本步骤如下:

  1. 数据页初始化后,数据页里只有最小记录、最大记录两条记录,它们分别属于两个分组
  2. 当插入一条新记录到页中后,其所在槽的确定方法是,从Slot 0槽(该槽所指向记录显然是各槽所指向的记录中最小的)开始进行遍历,直至找到第一个 「槽所指向的记录比该新记录大」 的槽。随后将该槽所指向的记录的「n_owned字段」值加1,即该分组中多了一条记录
  3. 为了避免某个分组内记录数量过多(因为这样会导致,在该分组内的查找遍历范围较大),当分组内的记录数达到 8 时,此时如果再向其中插入一条记录,会导致此分组拆分为两个组,一个分组内 4 条记录,另一个分组内 5条记录。当然增加了一个新分组,页目录中的槽数据也需要适时调整、维护,以保证页目录的有序、准确

其实也可以把每一组中最小的记录的地址偏移量作为槽定位的值,这样插入一条数据后,当插入的记录的主键小于槽(记作 slotj)对应的值时,把该记录分配给 slotj-1。而用每一组中最大的记录的地址偏移量作为槽定位的值比较方便一点。

前面我们提到,为了切实保证基于页目录的二分查找能够真正达到缩小链表遍历范围这一目的。我们需要对各分组下的记录数量做限制,而在InnoDB引擎中,具体规定如下

  • 最小记录所在的分组只能有 1 条记录,即只有它自己
  • 最大记录所在的分组的记录数量只能在 1~8 条记录之间
  • 其他分组的记录数量只能在是 4~8 条记录之间

File Trailer 文件尾

该部分与 File Header 文件头一样,为各类型页所通用。其目的用于检验当前页的完整性。具体地其占用8个字节,前 4 个字节为校验和(checksum),后 4 个字节为页面被最后修改时相应的日志序列位置(LSN)

这里就基于校验和的完整性校验原理简单的介绍下。其实也很简单。在页从内存同步回硬盘之前,先计算好校验和(checksum),并赋给页的 File Header 文件头、File Trailer 文件尾的校验和字段。在页从内存同步回硬盘后,如果该页从头到尾都被成功正确写入磁盘的话,则硬盘上该页的 File Header 文件头、File Trailer 文件尾的两个校验和数据应该是一致的;反之,如果发现硬盘中该页的File Header文件头、File Trailer文件尾的两个校验和数据是不同的,则说明该页同步过程中发生了意外(比如断电)造成页只同步一部分到硬盘中了

如果查询不走索引的话会怎样

MySQL之InnoDB存储引擎:数据页 - 图12

我们知道,B+树的排序结构是为了按索引查找数据服务的,因为B+树所存储的键值就是对应的索引值,但如果查询 SQL 语句时不用索引,那么B+树是发挥不到作用的,比如执行 SQL 语句:

  1. select * from user where indexname = 'xxx'

如果没有给 indexname 加索引,那么 MySQL 底层会去如何查询呢?它只能先找到 Level 为 0 的Page 页(还得是第一个 Level 为 0 的 Page 页),然后从左往右一直顺序查询,如果某一页中没有符合 indexname 条件的,就继续跳到下一页去查找(尽管每页 record 的排序是索引有序的,但依然没用,得顺序找)。可想而知,这样的查询时间复杂度为 O(n)(数据量大的时候非常恐怖),而且 IO 次数是不确定的,可能很小也可能很大。而如果走了索引的话,每个 Page 页里面搜寻的时间度仅为 O(logn),而且 IO 次数是固定的,就是 B+ 树的高度。

补充:

在 InnoDB 存储引擎中,所有的数据都被逻辑地存放在表空间中,表空间(tablespace)是存储引擎中最高的存储逻辑单位,在表空间的下面又包括段(segment)、区(extent)、页(page),他们之间的关系如下:

image.png

页是mysql中磁盘和内存交换的基本单位,也是mysql管理存储空间的基本单位。

同一个数据库实例的所有表空间都有相同的页大小;默认情况下,表空间中的页大小都为 16KB,当然也可以通过改变 innodb_page_size 选项对默认大小进行修改,需要注意的是不同的页大小最终也会导致区大小的不同。

一次最少从磁盘读取16KB 内容到内存中,一次最少把内存中 16KB 内容刷新到磁盘中,当然了单页读取代价也是蛮高的,一般都会进行预读。

行格式

来自:MySQL—InnoDB记录结构

InnoDB 行格式

平时我们操作数据库表,都是按照一条一条记录进行CRUD,这些记录在磁盘上的存放方式被称为 行格式 或者记录格式

到目前为止,InnoDB存储引擎有4中行格式,分别是:Compact、Redundant、Dynamic和Compressed

指定行格式的语法

创建表时指定行格式

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

修改表时切换行格式

  1. ALTER TABLE 表名 ROW_FORMAT=行格式名称

InnoDB存储引擎中的 4 种行格式从宏观上来看,都是分为记录的额外信息和真实数据两大部分。如下

image.png

Compact 行格式

image.png

记录的额外信息

额外信息被服务器用来描述这条记录。额外信息被分为3部分:变长字段长度列表、Null值列表 和 记录头信息

  • 变长字段长度列表
    MySQL 中有很多可变长度的字符类型,比如 varchar(M)varbinary(M),各种 text类型(texttinytextlongtext……),各种 blob 类型(blobtinybloblongblob……)。因为上述类型是可变长度,所以在实际存储时不能确定其具体占用的空间大小,所以为了解决这个问题,引入了变长字段长度列表的概念。

如果当前记录中的可变长度类型的字段为 Null 时,则变长字段长度列表为空。那什么时候不为空呢?当我们向表中插入数据时,如果插入数据的可变长度类型的字段不为空,那么此时变长字段长度列表也就随之更新为此次插入数据所占用的字节长度。下面举个例子应该就明白了。

在Compact行格式中,所有可变长度类型字段在存放数据时,其具体所需要占用的字节长度全都存放在变长字段长度列表中。

所有可变长度类型字段所需要占用的字节长度按照字段顺序逆序存放。

假设创建表 test:

  1. CREATE TABLE test (
  2. -> c1 VARCHAR(10),
  3. -> c2 VARCHAR(10) NOT NULL,
  4. -> c3 CHAR(10),
  5. -> c4 VARCHAR(10)
  6. -> ) CHARSET=ascii ROW_FORMAT=COMPACT; #指定行格式

现在 test 表中的记录就是这个样子的:

  1. +------+-----+------+------+
  2. | c1 | c2 | c3 | c4 |
  3. +------+-----+------+------+
  4. | aaaa | bbb | cc | d |
  5. | eeee | fff | NULL | NULL |
  6. +------+-----+------+------+
  7. 2 rows in set (0.00 sec)

请看c1c2c4 列的数据类型为 varchar(10) ,即可变长度类型,所以这 3 个字段的值的 长度(所占空间字节数) 需要存放进变长字段长度列表中。还可以看出,test 表使用的字符集为 ASCII,即记录中每个字符仅需要占用 1 个字节的空间。test 表中第一条记录中 c1c2c4 字段分别占用空间大小如下:

  1. INSERT INTO test(c1, c2, c3, c4) VALUES('aaaa', 'bbb', 'cc', 'd');
列名 存储内容 内容长度(十进制表示) 内容长度(十六进制表示)
c1 'aaaa' 4
字节
0x04
c2 'bbb' 3
字节
0x03
c4 'd' 1
字节
0x01

此时,还有一个必须满足的条件,那就是:所有可变长度类型字段所需要占用的字节长度按照字段顺序逆序存放。

变长字段长度列表的实际存放顺序为(各个字节之间实际上没有空格,用空格隔开只是方便理解)

  1. 01 03 04 #字节长度存放顺序正好与字段的顺序相反

把上面这3个字节长度存入变长字段长度列表中的样子如下图:

image.png

上面的案例是变长字段长度列表占用 1 个字节长度的情况,那什么时候占用 2 个字节呢?其实InnoDB有它自己的一套规则,我们首先声明一下WML的意思

  • W:代表当前字符集的一个字符最大占用字节数
    • ASCII字符集的一个字符最大占用 1 个字节,即 W=1
    • gbk 字符集的一个字符最大占用 2 个字节,即 W=2;
    • utf8 字符集的一个字符最大占用 3个字节,即 W=3;
  • M:代表一个字段存储多少个字符
    • 如果某字段类型为 varchar(10) ,则表示该字段存储 10 个字符,即 M = 10
  • L:代表一个字段实际存储的数据所占用的字节数

通过公式:W*M 可以得出字段最大可占用的字节数。

具体规则如下:

  • W*M <= 255 时,用 1 个字节来表示实际数据所占多少字节(28 = 256)
    • 可以理解为,InnoDB 在读取变长字段长度列表的信息时,首先查看表结构,计算一下变长字段的最大占用字节数,如果计算结果小于等于 255 ,则使用 1 个字节来表示实际数据所占多少字节
  • W*M > 255 时,则分为两种情况:

    • 如果 L <= 127 ,使用 1 个字节来表示实际数据所占多少字节;
    • 如果 L > 127 ,使用 2 个字节来表示实际数据所占多少字节;


    当 W*M >255 时,InnoDB 怎么区分它正在读取的字节代表单独的一个字段长度还是代表半个字段长度?

  • 当该字节二进制位的第一位是 0 时,说明该字节代表的是一个单独字段长度;

  • 当该字节二进制位的第一位是 1 时,说明该字节代表的是半个字段长度