记录行格式和页结构

记录行格式和页结构

InnoDB 页简介

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

行格式

MySQL 是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式被称为 行格式 或者 记录格式。InnoDB 存储引擎目前有 4 种行格式,分别是 CompactRedundantDynamicCompressed 行格式。

指定行格式的语法

create table 表名 (列的信息) row_format=行格式名称

alter table 表名 row_format=行格式名称

例如:

mysql> use test;
Database changed

mysql> crate table record_format_demo (
    ->     c1 VARCHAR(10),
    ->     c2 VARCHAR(10) NOT NULL,
    ->     c3 CHAR(10),
    ->     c4 VARCHAR(10)
    -> ) CHARSET=ascii ROW_FORMAT=COMPACT;
Query OK, 0 rows affected (0.03 sec)

mysql> INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES('aaaa', 'bbb', 'cc', 'd'), ('eeee', 'fff', NULL, NULL);
Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> SELECT * FROM record_format_demo;
+------+-----+------+------+
| c1   | c2  | c3   | c4   |
+------+-----+------+------+
| aaaa | bbb | cc   | d    |
| eeee | fff | NULL | NULL |
+------+-----+------+------+
2 rows in set (0.00 sec)

mysql>

Compact 行格式

compact-row

一条完整的记录其实可以被分为 记录的额外信息记录的真实数据 两部分。

记录的额外信息

为了描述这条记录而不得不额外添加的一些信息,分为 3 类,分别是变长字段长度列表NULL值列表记录头信息

变长字段长度列表

MySQL 支持一些变长的数据类型,比如 VARCHAR(M)VARBINARY(M)TEXTBLOB 等类型,这些数据类型的列被称为变长字段

变长字段中存储多少字节的数据是不固定的,所以存储真实数据的时候需要把数据占用的字节数也存起来,变长字段占用的存储空间分为两部分:

  1. 真正的数据内容
  2. 占用的字节数

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

各变长字段数据占用的字节数按照列的逆序存放,注意是逆序存放

假设一个表的 c1c2c4 列都是 VARCHAR(10) 类型的,所以这三个列的值的长度都需要保存在记录开头处,表中的各个列都使用的是 ascii 字符集,所以每个字符只需要 1 个字节来进行编码,来看一下第一条记录各变长字段内容的长度:

列名存储内容内容长度(十进制表示)内容长度(十六进制表示)
c1‘aaaa’40x04
c2‘bbb’30x03
c4’d'10x01

这些长度值需要按照列的逆序存放。

由于第一行记录中 c1c2c4 列中的字符串都比较短,也就是说内容占用的字节数比较小,用 1 个字节就可以表示,但是如果变长列的内容占用的字节数比较多,可能就需要用 2 个字节来表示。

注意,变长字段长度列表中只存储值为 非 NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的。也就是说对于第二条记录来说,因为 c4 列的值为 NULL,所以第二条记录的变长字段长度列表只需要存储 c1c2 列的长度即可。并不是所有记录都有这个 变长字段长度列表 部分,比方说表中所有的列都不是变长的数据类型的话,就不需要有这部分

compact-demo

NULL 值列表

表中的某些列可能存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中存储会很占地方,所以 Compact 行格式把这些值为 NULL 的列统一管理起来,存储到 NULL 值列表中:

  1. 首先统计表中允许存储 NULL 的列有哪些。

主键列、被 NOT NULL 修饰的列都是不可以存储 NULL 值的,所以在统计的时候不会把这些列算进去。例如表 record_format_demo3 个列 c1c3c4 都是允许存储 NULL 值的,而 c2 列是被 NOT NULL 修饰,不允许存储 NULL 值。

  1. 如果表中都是 NOT NULL 修饰的列,则 NULL 值列表也不存在了,否则将每个允许存储 NULL 的列对应一个二进制位,二进制位按照列的逆序排列,二进制位表示的意义如下:
  • 二进制位的值为 1 时,代表该列的值为 NULL
  • 二进制位的值为 0 时,代表该列的值不为 NULL
  1. MySQL 规定 NULL 值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补 0。表 record_format_demo 只有 3 个值允许为 NULL 的列,对应 3 个二进制位,不足一个字节,所以在字节的高位补 0,效果就是这样:

compact-null

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

compact-null-demo

第二条记录的 c3c4 列的值都为 NULL,所以 NULL 值列表的二进制位为 00000110,也就是 06

记录头信息

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

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

记录的真实数据

对于 record_format_demo 表来说,记录的真实数据除了 c1c2c3c4 这几个自己定义的列的数据以外,MySQL 会为每个记录默认的添加一些列(也称为隐藏列):

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

实际上这几个列的真正名称其实是:DB_ROW_IDDB_TRX_IDDB_ROLL_PTR

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

InnoDB 会为每条记录都添加 trx_idroll_pointer 这两个列

CHAR(M) 列的存储格式

record_format_demo 表的 c3 列的类型是 CHAR(10)Compact 行格式下只会把变长类型的列的长度逆序存到变长字段长度列表中,但是这只是因为 record_format_demo 表采用的是 ascii 字符集,这个字符集是一个定长字符集,也就是说表示一个字符采用固定的一个字节,如果采用变长的字符集(也就是表示一个字符需要的字节数不确定,比如 utf8mb3 表示一个字符要 1~3 个字节等)的话,c3 列的长度也会被存储到变长字段长度列表中。

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

ℹ️

变长字符集的 CHAR(M) 类型的列要求至少占用 M 个字节,而 VARCHAR(M) 却没有这个要求。比方说对于使用 utf8mb3 字符集的 CHAR(10) 的列来说,该列存储的数据字节长度的范围是 10~30 个字节。即使向该列中存储一个空字符串也会占用 10 个字节。

这是怕将来更新该列的值的字节长度大于原有值的字节长度而小于 10 个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片

Compact 行格式的设计既想节省存储空间,又不想更新 CHAR(M) 类型的列产生碎片。

行溢出数据

VARCHAR(M) 最多能存储的数据

VARCHAR(M) 类型的列最多可以占用 65535 个字节。但是当你创建表时使用 VARCHAR(65535)

mysql> CREATE TABLE varchar_size_demo(
    ->     c VARCHAR(65535)
    -> ) CHARSET=ascii ROW_FORMAT=Compact;
ERROR 1118 (42000): Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead, check the manual. You have to change some columns to TEXT or BLOBs
mysql>

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

由于存储一个 VARCHAR(M) 类型的列,其实需要占用 3 部分存储空间:

  • 真实数据
  • 真实数据占用字节的长度
  • NULL 值标识,如果该列有 NOT NULL 属性则可以没有这部分存储空间

如果该 VARCHAR 类型的列没有 NOT NULL 属性,那最多只能存储 65532 个字节的数据,因为真实数据的长度可能占用 2 个字节,NULL 值标识需要占用 1 个字节。

如果 VARCHAR 类型的列有 NOT NULL 属性,那最多只能存储 65533 字节的数据,因为真实数据的长度可能占用 2 个字节,不需要 NULL 值标识。

如果 VARCHAR(M) 类型的列使用的不是 ascii 字符集,那 M 的最大取值取决于该字符集表示一个字符最多需要的字节数。在列的值允许为 NULL 的情况下,utf8mb3 字符集表示一个字符最多需要 3 个字节,那在该字符集下,M 的最大取值就是 21844,就是说最多能存储 21844(也就是:65532/3)个字符。

ℹ️

处理 VARCHAR 类型,并且长度变化较大时,可能会触发页分裂和页合并。

  • VARCHAR 类型的字段数据长度增加,且增加的长度使行总长度超过页面剩余空间时,可能需要数据页分裂
  • VARCHAR 类型的字段长度减少,导致页中大量空间未使用,可能触发页合并

记录中的数据太多产生的溢出

MySQL 中磁盘和内存交互的基本单位是,记录都会被分配到某个页中存储。而一个页的大小一般是 16KB,也就是 16384 字节,而一个 VARCHAR(M) 类型的列就最多可以存储 65532 个字节,这样就可能造成一个页存放不了一条记录的尴尬情况。

MySQL 中规定一个页中至少存放两行记录

Compact 行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据处用 20 个字节存储指向这些页的地址,从而可以找到剩余数据所在的页。这个过程也叫做行溢出。存储超出字节的那些页面也被称为溢出页

只需要知道如果在一个行中存储了很大的数据时,可能发生行溢出的现象

页分裂 (Page Split) 与行溢出 (Row Overflow) 的区别

  1. 页分裂(Page Split)

页分裂是 B+ 树索引结构(如 InnoDB 的主键索引和唯一索引)在插入或更新数据时可能出现的一种情况。

插入数据时:

当向表中插入一条新记录时,InnoDB 会根据主键或唯一索引将记录插入到对应的索引页中。如果目标索引页已经满了(即没有足够的空间容纳新记录),InnoDB 会将该索引页分裂为两个新的索引页,每个新页大约包含原页一半的数据。这样可以为新记录腾出空间。

更新数据时:

如果更新操作导致记录的大小增加(例如,更新某个字段的值使其占用更多空间),并且当前索引页无法容纳更新后的记录,也可能触发页分裂。

  1. 行溢出(Row Overflow)

行溢出是 InnoDB 存储引擎中处理变长字段(如 VARCHARTEXT)时可能出现的一种情况。即使单列不大,多列组合超过限制也会溢出。

Dynamic 和 Compressed 行格式

MySQL 默认的行格式就是 Dynamic

DynamicCompressed 行格式和 Compact 行格式挺像,只不过在处理行溢出数据时不同,它们不会在记录的真实数据处存储字段真实数据的前 768 个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址

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

定长类型的优势

  1. 存储与读取性能更高

固定长度:定长字段(如 CHAR(10))始终占用预分配的空间,无需计算实际长度

直接定位:数据库引擎可以直接通过偏移量访问数据,减少解析时间,适合高频查询。

减少碎片化:变长字段(如 VARCHAR)可能导致存储碎片

  1. 内存对齐优化

定长字段在内存中按固定对齐方式存储,CPU 缓存命中率更高,加速排序、聚合等操作。

  1. 避免行溢出

变长字段(如超长 VARCHAR)可能触发行溢出(行数据超出页大小,存储到额外位置),导致额外 I/O 开销。定长字段无此问题。

  1. 简化索引操作

定长字段读取性能高,那自然定长字段的索引(如 CHAR)也更高效,B+ 树节点分裂和合并更可控

数据页结构

InnoDB 管理存储空间的基本单位是,一个页的大小一般是 16KB。InnoDB 设计了多种不同类型的页,比如存放表空间头部信息的页,存放 Insert Buffer 信息的页等等。存放表中记录的页,叫做 索引(INDEX)页。暂叫做 数据页 吧。

一个 InnoDB 数据页的存储空间大致被划分成了 7 个部分:

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

记录在页中的存储

User Records 部分就是存储实际数据的部分

一开始生成页的时候,是没有 User Records 的,每当插入一条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records 部分

Free Space 空间全部划分到 User Records 之后,就意味着这个页使用完了,插入新的记录,就需要申请新的页。

page-insert-demo

记录头信息

compact 格式为例,先创建一个表:

mysql> CREATE TABLE page_demo(
    ->     c1 INT,
    ->     c2 INT,
    ->     c3 VARCHAR(10000),
    ->     PRIMARY KEY (c1)
    -> ) CHARSET=ascii ROW_FORMAT=Compact;
Query OK, 0 rows affected (0.03 sec)

插入四条记录:

mysql> INSERT INTO page_demo VALUES(1, 100, 'aaaa'), (2, 200, 'bbbb'), (3, 300, 'cccc'), (4, 400, 'dddd');
Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0

这些记录下页中的示意图(记录中头信息和实际的列数据其实是一堆二进制位):

records-in-page

delete_mask

这个属性标记着当前记录是否被删除,占用 1 个二进制位,为 1 的时候代表记录被删除掉了。

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

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

ℹ️

Page Header 部分有一个称之为 PAGE_FREE 的属性,它指向由被删除记录组成的垃圾链表中的头节点

删除一条记录需要经历两个阶段:

  1. Delete Mark 阶段,把记录的 delete_mask 属性设置为 1,其他的不做修改(其实会修改记录的 trx_idroll_pointer 这些隐藏列的值)。但是这个时候还没有被加入到垃圾链表。也就是此时记录处于一个中间状态
  2. Purge 阶段,当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页面中可重用的字节数量PAGE_GARBAGE、还有页目录的一些信息等等。

min_rec_mask

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

heap_no

表示当前记录在本页中的位置。注意上面插入 4 条记录的示意图,4 条记录的位置分别是 2345

0 和 1 去哪了?

因为 InnoDB 给每个页都自动添加了两个记录。这两个记录称为伪记录或者虚拟记录。这两个分别是最小记录最大记录

它们并不存放在页的 User Records 部分,被单独放在一个称为 Infimum + supremum 的部分

infimum-supremum

小记录和最大记录的 heap_no 值分别是 01,也就是说它们的位置最靠前。

record_type

表示当前记录的类型,0 表示普通记录,1 表示 B+ 树非叶子节点记录,2 表示最小记录,3 表示最大记录。

next_record

表示从当前记录的"真实数据"到下一条记录的"真实数据"的地址偏移量。如,第一条记录的 next_record 值为 32,意味着从第一条记录的真实数据的地址处向后找 32 个字节便是下一条记录的真实数据。这其实是个链表,可以通过一条记录找到它的下一条记录。“下一条记录” 指得按照主键值由小到大的顺序的下一条记录。下图箭头来替代一下 next_record 中的地址偏移量:

infimum-supremum

记录按照主键从小到大的顺序形成了一个单链表。最大记录的 next_record 的值为 0。这也就是说最大记录是没有下一条记录了,它是这个单链表中的最后一个节点。如

records-link-demo

如果删掉第 2 条记录:

records-link-delete-demo

删除第 2 条记录前后主要发生了这些变化:

  1. 第 2 条记录的 delete_mask 值设置为 1。
  2. 第 2 条记录的 next_record 值变为了 0,意味着该记录没有下一条记录了。
  3. 第 1 条记录的 next_record 指向了第 3 条记录。
  4. 最大记录的 n_owned 值从 5 变成了 4。

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

next_record 指针为什么要指向记录头信息和真实数据之间的位置

因为这个位置,向左读取就是记录头信息,向右读取就是真实数据

并且变长字段长度列表、NULL 值列表中的信息都是逆序存放,这样可以使记录中位置靠前的字段和它们对应的字段长度信息在内存中的距离更近,可能会提高高速缓存的命中率

ℹ️
CPU 缓存是按“块”读取的,一般一次加载 64 字节。如果长度信息和对应字段数据在同一个缓存块内,CPU 一次缓存加载就能拿到。逆序设计让频繁访问的前几个字段(如主键)和它们的长度信息尽量靠近,减少缓存缺失(Cache Miss)
如果再次把这条记录插入到表中,会发生什么事?
mysql> INSERT INTO page_demo VALUES(2, 200, 'bbbb');
Query OK, 1 row affected (0.00 sec)

InnoDB 没有为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间

Page Directory(页目录)

如何根据主键值查询中的记录?

例如:

SELECT * FROM page_demo WHERE c1 = 3;

因为记录在页中按照主键值由小到大顺序串联成了一个单链表,那么就可以从 Infimum 记录(最小记录)开始遍历链表,当找到主键值大于你想要查找的主键值时,就可以停止了。但是这种方法,如果记录多了,效率及会很差。

InnoDB 的 Page Directory,是一个类似书籍目录的设计:

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

最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间

mysql> INSERT INTO page_demo VALUES(5, 500, 'eeee'), (6, 600, 'ffff'), (7, 700, 'gggg'), (8, 800, 'hhhh'), (9, 900, 'iiii'), (10, 1000, 'jjjj'), (11, 1100, 'kkkk'), (12, 1200, 'llll'), (13, 1300, 'mmmm'), (14, 1400, 'nnnn'), (15, 1500, 'oooo'), (16, 1600, 'pppp');
Query OK, 12 rows affected (0.00 sec)
Records: 12  Duplicates: 0  Warnings: 0

往表中添加 12 条记录,现在页里边就一共有 18 条记录(包括最小和最大记录),这些记录被分成了 5 个组,如图所示:

page-directory-demo

各个槽代表的记录的主键值都是从小到大排序的,所以可以使用二分法来进行快速查找。5 个槽的编号分别是:0、1、2、3、4,所以初始情况下最低的槽就是 low=0,最高的槽就是 high=4。比如找到主键值为 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 对应的记录是主键值为 8 的记录(改组的最大记录),但是槽 1 对应的记录(主键值为 4),该条记录的下一条记录就是槽 2 中主键值最小的记录,该记录的主键值为 5。所以可以从这条主键值为 5 的记录出发,遍历槽 2 中的各条记录。

Page Header(页面头部)

Page Header 占用固定的 56 个字节,专门存储各种状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等。

File Header(文件头部)

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

几个重要的部分:

  • FIL_PAGE_SPACE_OR_CHKSUM,当前页面的校验和。
  • FIL_PAGE_OFFSET,每一个页都有一个唯一的页号。
  • FIL_PAGE_TYPE,页的类型。
  • FIL_PAGE_PREVFIL_PAGE_NEXT,代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。并不是所有类型的页都有上一个和下一个页的属性,但是数据页(也就是类型为 FIL_PAGE_INDEX 的页)是有这两个属性的,所以所有的数据页其实是一个双向链表。

总结

  1. 每个记录的头信息中都有一个 next_record 属性,从而使页中的所有记录串联成一个单链表。
  2. InnoDB 会把页中的记录划分为若干个组,每个组的最后一个记录的地址偏移量作为一个槽,存放在 Page Directory 中,所以在一个页中根据主键查找记录是非常快的,分为两步:
    1. 通过二分法确定该记录所在的槽。
    2. 通过记录的 next_record 属性遍历该槽所在的组中的各个记录。
  3. 每个数据页的 File Header 部分都有上一个和下一个页的编号,所以所有的数据页会组成一个双链表。
最后更新于