Skip to content

📊 InnoDB记录结构

💬 简介

​ 在MySQL中,数据存储的单位为(或记录),行(或记录)在磁盘上的存储格式也被称为行格式(或记录格式)。

​ MySQL服务器支持多种不同的存储引擎,如InnoDBMyISAMMemory等。不同存储引擎的数据存储与处理机制的实现细节差异显著,而又由于InnoDB是目前使用最广泛的MySQL存储引擎,因此本文只讨论InnoDB。

​ 目前,InnoDB共支持 4 种不同类型的行格式,分别为CompactRedundantDynamicCompressed。尽管它们在实现细节上有所差异,但它们的实现原理大体上是相似的。

🎯 指定行格式

​ 行格式是针对表而言的,这意味着当选定表的行格式后,表中所有行的存储结构都会受到影响。在创建和修改表的语句中,都可以使用如下语法指定表的行格式。

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

​ 例如,在fatgod数据库中,我们创建一个测试表record_format_test,并指定其行格式为Compact,字符集为ascii。SQL语句如下。

sql
drop database if exists fatgod;
create database if not exists fatgod;
use fatgod;

create table if not exists record_format_test(
    `c1` varchar(10),
    `c2` varchar(10) not null ,
    `c3` char(10),
    `c4` varchar(10)
) charset = ascii row_format = compact;

​ 需要注意的是,ascii字符集只包括空格、标点符号、数字、大小写英文字母和一些不可见字符,每个字符占用 1​ 字节存储空间。因此,该表的任意字段都不能存储中文字符。

​ 为了以下更好地讲解行格式,我们往record_format_test中插入几条测试数据,SQL语句如下。

sql
truncate table record_format_test;
insert record_format_test(c1, c2, c3, c4)
values ('aaaa', 'bbb', 'cc', 'd'),
       ('eeee', 'fff', null, null);

🗂️ Compact行格式

​ 一条完整的Compact格式的记录可以被分为记录的额外信息记录的真实数据两大部分。记录的额外信息由变长字段长度列表NULL值列表记录头信息三部分组成;记录的真实数据由用户自定义数据隐藏列数据两部分组成。

Compact行格式

记录的额外信息

变长字段长度列表

​ MySQL支持多种变长的数据类型,比如VARCHAR(M)VARBINARY(M)、各种TEXT类型、各种BLOB类型等。我们把拥有这些数据类型的列称为变长字段,变长字段中存储的数据字节大小是不固定的。那MySQL服务器每次在获取变长字段数据的字节长度时,是否都需要遍历整体数据吗?答案是否定的,这种做法的效率十分差。为此,InnoDB采取了一种聪明的做法:将这些变长字段的长度信息逆序存储在记录格式的第一段,即变长字段长度列表

​ 我们拿测试表record_format_test中的第一条记录举例。因为record_format_test表的c1c2c4三个列的类型都为VARCHAR(10),也就是变长数据类型,所以这三个列的值的长度都需要保存在变长字段长度列表中,长度信息统计如下所示。

统计变长字段长度

​ 又因为这些表示长度的信息需要按照逆序存储,所以最终的变长字段长度列表的字节串用十六进制表示为01 03 04(各个字节之间实际上没有空格,用空格隔开只是为了可视化)。

变长字段列表示例

​ 思考一下,1 个字节能够表示的长度区间为 [0,255],那如果列的内容超过了 255 个字节,此时长度应该如何表示呢?答案是使用 2 个字节,2 个字节能够表示的长度区间为 [0,65535]。那如果列的内容小于等于 255 个字节,InnoDB也是使用 2 个字节来表示长度,并将高 8 位默认补 0 的吗?答案是否定的,这种做法十分浪费存储空间(其中一个字节是无意义的), 其实,InnoDB有一套完善的规则来选择表示变长字段长度的字节数,步骤如下。

  1. 假设某个字符集中表示一个字符最多需要使用的字节数为 W。例如utf8mb4字符集中的 W4gbk字符集中的 W2ascii字符集中的 W1。我们可以使用语句show character set进行查看,结果中的MaxLen字段值就是各字符集的 W 值。
  2. 假设变长字段最多能存储 M 个字符。例如VARCHAR(10)类型字段中的 M10
  3. 假设变长字段实际存储的内容字节数为 L
  4. 如果 WM255,那么使用 1 个字节表示长度。
  5. 如果 WM>255,继续分类讨论:若 L127,则使用 1 个字节来表示长度。否则,使用 2 个字节表示长度。

​ 某个变长字段允许存储的最大字节数也不一定是通过公式 WM 计算得出,这只适用于字符类型。例如在VARBINARY(128)类型中,该值为 128

​ 再思考一下,既然 1 个 或 2 个字节都能表示变长字段长度,那么InnoDB是如何区分和识别某个字节是用来表示哪个变长字段的长度呢?

InnoDB识别表示长度的字节

​ 当InnoDB读到某个字节时,其会查看表结构。如果对应的变长字段的最大字节数小于等于 255,那么该字节是单独的字段长度;如果最大字节数大于 255,则InnoDB会根据该字节的第一个比特位(标志位)来判断:若标志位为 0,则该字节就是一个单独的字段长度,若标志位为 1​,则该字节就是半个字段长度。因此,当使用两个字节来表示变长字段长度时,实际上只有 15 位有效位被用于存储长度信息,范围为 [0,2151]​​。

tips:

  1. 变长字段长度列表中不会存储值为NULL的列的长度。
  2. 如果表中所有列都不是变长数据类型,那么记录格式中不会包含变长字段长度列表这部分信息。

NULL值列表

​ 在MySQL中,列存在两种与NULL值相关的约束,分别为NULLNOT NULL,NULL表示一个未知或缺失的值。那InnoDB在存储值为NULL的列时,会在其记录的真实数据处用一个特殊符号表示吗?答案是否定的,这种做法存在二进制不安全的问题,意味着我们不能使用该特殊符号作为用户数据,同时还会导致存储空间的浪费。为此,InnoDB采取了一种聪明的做法:用一个位图来表示各列NULL值的出现情况,这个位图也通常被称为NULL值列表,具体处理过程如下。

  1. 首先,统计表中约束为NULL的列;
  2. 其次,将每个列对应一个比特位,并按列的顺序逆序排列,其中,1 表示该列的值为NULL,0 表示该列的值不为NULL;
  3. 最后,NULL值列表高位补 0 以达到整数个字节大小,并存储在记录格式的第二段。

​ 我们拿测试表record_format_test中的第一条记录举例。因为record_format_test表的c1c3c4三个列的约束是NULL,所以这三个列的NULL值出现情况需要保存在NULL值列表中,NULL值信息统计如下所示。

统计NULL值出现情况

​ 将这 3 个比特位逆序排列后为000,但这不足 1 个字节,InnoDB会将其高 5 位补 0

NULL值列表高位补0

​ 综上所述,在第一条记录中,最终的NULL值列表的字节串用十六进制表示为00

NULL值列表示例

tip: 如果表中所有列都存在NOT NULL约束,那么记录格式中不会包含NULL值列表这部分信息。

记录头信息

​ 除了变长字段长度列表和NULL值列表外,记录的额外信息中还包含了一个重要的记录头信息。该部分主要描述记录的一些相关属性,它由固定的 5 个字节构成。这 5 个字节共 40 个比特位,每个比特位都具有特定的含义,如下图。

记录头信息40个比特位组成

​ 可以看到,这 40 个比特位被划分成了 8 个部分,每个部分的含义如下表所示。

记录头信息每部分含义

​ 我们拿测试表record_format_test中的第一条记录举例。2 个预留位默认是0delete_mask0,因为当前记录未被删除;min_rec_mask0,因为当前记录包含用户数据,在B+树中处于叶子节点;n_owned0000,因为当前记录拥有的记录数为 0heap_no0000000000010,因为当前记录在记录堆 2​ 处;record_type000,因为当前记录包含用户数据,是一条普通记录;next_record0000000000101101,因为当前记录与下一条记录之间的偏移量为0x2D。于是,我们能够完整地得到记录头信息的各比特位,如下。

记录头信息40位比特示例

​ 综上所述,在第一条记录中,最终的记录头信息的字节串用十六进制表示为00 00 10 00 2D(各个字节之间实际上没有空格,用空格隔开只是为了可视化)。

记录头信息示例

记录的真实数据

​ 记录的真实数据指的是用户定义列中存储的实际数据,例如record_format_test表中的第一条记录的真实数据为aaaa bbb cc d(用空格隔开只是为了可视化)。此外,InnoDB还会为每条记录默认地添加一些隐藏列数据,如下。

记录的隐藏列

tip:

  1. 在MySQL源码中,这几个列的真正名称其实分别为:DB_ROW_IDDB_TRX_IDDB_ROLL_PTR。本文中将它们书写成row_idtransaction_idroll_pointer了,目的只是为了美观,方便展示。
  2. 只有聚簇索引中的记录才会包含这三个隐藏列。

transaction_idroll_pointer在每个记录中都会存在,它们都与InnoDB的MVCC机制有关,这两个字段会在章节《MVCC》中进行详细介绍。

row_id与InnoDB中表的主键生成策略密切相关。在InnoDB中,每个表都必须拥有一个主键,它会优先选择用户自定义的主键,其次会选择一个拥有唯一约束UNIQUE的列作为主键,最后如果既没有PRIMARY KEY约束的列,也没有UNIQUE约束的列,则会为表默认添加一个名为row_id的隐藏列并作为主键。

​ 我们拿测试表record_format_test中的第一条记录举例。在ascii字符集中,用十六进制表示法,字符串aaaa0x61616161,字符串bbb0x626262,字符串cc0x6363,字符串d0x64。列c3的数据类型是CHAR(10),固定长度为 10 个字符,也就是 10 个字节,但数据字符串cc只占了 2 个字节,所以整个列存储的内容还需用 8​ 个空格字符填充,空格字符在ascii字符集中的表示为0x20。于是,我们就可以得到用户自定义的字节数据:61 61 61 61 62 62 62 63 63 20 20 20 20 20 20 20 20 64。由于record_format_test表没有指明主键,也没有唯一约束的列,因此InnoDB会在第一条记录中添加row_idtransaction_idroll_pointer三个隐藏列的数据,隐藏列的值不需要我们关心,InnoDB会按照特定的规则自动生成。

记录的真实数据示例

tip: 如果列的值为NULL,那么它们只会被存储在NULL值列表中,不会在真实数据中冗余存储。

CHAR(M)列的存储格式

​ 前文提到,在Compact行格式下,变长类型的列的长度会被逆序存储到变长字段长度列表中。思考一下,拥有CHAR(M)数据类型的列一定是定长的吗?答案是否定的,因为该数据类型只限制列的最大字符数为M,而非最大字节数,如果表采用了非定长的字符集,那么该列就不能保证为定长了。

​ 前文中使用的ascii就是一个定长的字符集,每个字符固定使用1个字节表示。非定长的字符集有很多,比如gbk表示一个字符需要1 ~ 2个字节,utf8表示一个字符需要1 ~ 3个字节。

​ 综上所述,如果字符集是定长的,则InnoDB不会将CHAR(M)数据类型的列的长度存储在变长字段长度列表中;相反,如果字符集是非定长的,则InnoDB会将CHAR(M)数据类型的列的长度存储在变长字段长度列表中。


​ 此外,还需要注意一点,即变长字符集的CHAR(M)类型的列要求至少占用M个字节,而VARCHAR(M)类型却没有这个要求。例如,对于utf8字符集中的CHAR(10)数据类型的列来说,其存储的数据的字节长度范围为[10,30]。这意味着,即使向该列插入一个空字符串数据,也会导致 10 个字节大小的空间被占用。InnoDB采取这种设计是为了在更新列数据时,如果数据长度超出了原有数据的字节长度但未达到预定的字节长度(在此处为 10​ 字节),可以避免重新分配记录空间,从而预防碎片的产生。

​ 再思考一下,为什么InnoDB不直接为列数据存储分配 30 个字节的空间呢?这可能是在节省存储空间防止产生碎片两方面之间作了一个平衡的取舍。

测试

​ 以上介绍了Compact行格式的所有内容,那么测试表record_format_test中的第二条记录的二进制数据是什么呢?请用十六进制来表示每个字节,其中row_id00 00 00 00 07 6Ctransaction_id00 00 00 00 69 ABroll_pointerFA 00 00 01 70 01 1F;记录头信息中next_recordFF C2

​ 答案如下,具体分析过程省略。

Compact行格式测试答案

🔲 Redundant行格式

​ 一条完整的Redundant格式的记录也可以被分为记录的额外信息记录的真实数据两大部分。记录的额外信息由字段长度偏移列表记录头信息两部分组成;记录的真实数据由用户自定义数据隐藏列数据两部分组成。

Redundant行格式

​ 从上图中可以看出,在Redundant行格式中,记录的真实数据的组成与Compact行格式是一致的,所以本文聚焦于介绍Redundant行格式中记录的额外信息这一部分的内容。

Redundant是MySQL5.0版本之前使用的一种行格式,从目前来看,该行格式的设计已经过时和落后,因而其在绝大数场景下不再被使用。

​ 为了演示,我们需要把表record_format_test的行格式改为Redundant,SQL语句如下。

sql
alter table record_format_test row_format = redundant;

记录的额外信息

字段长度偏移列表

字段长度偏移列表会以逆序的方式存储当前记录所有列(包括隐藏列)的长度信息。需要注意的是,它并不直接存储每个列的实际长度值,而是存储每个列数据在记录的真实数据中的偏移量,这里的偏移量指的是每一列数据在记录真实数据中的结束位置。

偏移量示例

​ 我们拿测试表record_format_test中的第一条记录举例。该记录的字段长度偏移列表的字节串用十六进制表示为25 24 1A 17 13 0C 06(各个字节之间实际上没有空格,用空格隔开只是为了可视化),具体推导过程如下。

  1. 第一列row_id的长度为0x06个字节,所以第一列的偏移量为0x06
  2. 第二列transaction_id的长度为0x06个字节,所以第二列的偏移量为0x06 + 0x06 = 0x0C
  3. 第三列roll_pointer的长度为0x07个字节,所以第三列的偏移量为0x0C + 0x07 = 0x13
  4. 第四列c1的长度为0x04个字节,所以第四列的偏移量为0x13 + 0x04 = 0x17
  5. 第五列c2的长度为0x03个字节,所以第五列的偏移量为0x17 + 0x03 = 0x1A
  6. 第六列c3的长度为0x0A个字节,所以第六列的偏移量为0x1A + 0x0A = 0x24
  7. 第七列c4的长度为0x01个字节,所以第七列的偏移量为0x24 + 0x01 = 0x25
  8. 将所有列的偏移量按逆序排列,得到字节串为(用十六进制表示):25 24 1A 17 13 0C 06

Redundant行格式的字段长度偏移列表示例

​ 思考一下,InnoDB如何快速获取某个列数据的字节长度呢?其实,只需要计算当前列的偏移量与上一个列的偏移量之间的差值,即可得到该列数据的字节长度。

记录头信息

​ 在Redundant行格式中,记录头信息由 6 个字节组成,即 48 个比特位。这 48 个比特位共被划分成了 9 个部分,每个部分的含义如下表所示。

Redundant行格式的记录头信息各部分含义

​ 我们拿测试表record_format_test中的第一条记录举例。2 个预留位默认是0delete_mask0,因为当前记录未被删除;min_rec_mask0,因为当前记录包含用户数据,在B+树中处于叶子节点;n_owned0000,因为当前记录拥有的记录数为 0heap_no0000000000010,因为当前记录在记录堆 2 处;n_field0000000111,因为当前记录包含 7 个列(包含隐藏列);1byte_offs_flag1,这是因为当前记录使用 1 个字节表示字段长度偏移列表中的偏移量;next_record0000000010111100,因为当前记录与下一条记录之间的偏移量为0xBC。于是,我们能够完整地得到记录头信息的各比特位,如下。

Redundant行格式的记录头信息48位比特示例

​ 综上所述,在第一条记录中,最终的记录头信息的字节串用十六进制表示为00 00 10 0F 00 BC(各个字节之间实际上没有空格,用空格隔开只是为了可视化)。

Redundant行格式的记录头信息示例

​ 与Compact行格式的记录头信息对比来看,Redundant有两处不同:

  1. 多了n_field1byte_offs_flag这两个属性。
  2. 没有record_type属性。

1 个字节能够表示的长度范围为 [0,255],当列数据很大时,这显然是不够的。为了解决这个问题,记录头信息中的1byte_offs_flag比特位被用来控制列在字段长度偏移列表中的偏移量单位是 1 个字节还是 2 个字节。判断依据为当前Redundant行格式记录的实际真实数据占用的总大小,具体规则如下。

  1. 当记录的真实数据占用的字节数小于等于 127 (十六进制为0x7F,二进制为01111111)时,每个列对应的偏移量占用 1 个字节。
  2. 当记录的真实数据占用的字节数大于 127,但不大于 32767 (十六进制为0x7FFF,二进制为0111111111111111)时,每个列对应的偏移量占用 2 个字节。
  3. 当记录的真实数据占用的字节数大于 32767 时,本页中仅会保留该数据的前 768 个字节,并额外使用 20 个字节来存储溢出页的地址和其他相关信息,溢出页用于存储剩余的真实数据。由于字段长度偏移列表只需要记录每个列在本页中的偏移,因此在这种特殊情况下使用 2​ 个字节作为偏移量的单位就已经足够了。

​ 可以看出,这种设计方式是比较浪费存储空间的,因为即使绝大数的列的偏移量能够用 1 个字节来表示,但只要存在一个列的偏移量超出了0x7F,那么所有的列的偏移量都要使用 2 个字节来存储。

对NULL值的处理

​ 思考一下,当Redundant行格式选择用 1 个字节来表示偏移量时,真实数据占用的字节数最大为 127271),而选择用 2 个字节来表示偏移量时,真实数据占用的字节数最大为 327672151),为什么这两种情况下它们所能表示的偏移量范围都少了一个最高的比特位呢?这是因为Redundant行格式中没有NULL值列表,其使用了字段长度偏移列表中各偏移量的第一个比特位作为标志位来标记对应列的值是否为NULL:标志位为 1,则表示该列的值为NULL,否则就不是NULL。

​ 在Redundant行格式中,是否将字段定义为定长类型会决定其NULL值的存储格式,分类讨论如下。

  1. 如果存储NULL值的字段是定长类型的,比如说CHAR(M)数据类型,则NULL值会在记录的真实数据处占用存储空间,并把该字段对应的数据使用0x00字节填充,这也就意味着该数据的字节长度会被算在偏移量中。
  2. 如果存储NULL值的字段是变长类型的,比如说VARCHAR(M)数据类型,则NULL值不会在记录的真实数据处占用任何存储空间,这也就意味着该数据的字节长度为 0,偏移量与上一列相同。

CHAR(M)列的存储格式

​ 在Redundant行格式中,CHAR(M)列的存储格式与字符集无关,该列的值占用的空间永远为字符集的最大字节数MaxLength最大字符数M的乘积。这种设计方式虽然在一定程度上浪费存储空间,但能够有效防止碎片产生

​ 比方说使用utf8字符集的CHAR(10)类型的列占用的真实数据空间始终为 30 个字节,使用gbk字符集的CHAR(10)类型的列占用的真实数据空间始终为 20​ 个字节。

测试

​ 以上介绍了Redundant行格式的所有内容,那么测试表record_format_test中的第二条记录的二进制数据是什么呢?请用十六进制来表示每个字节,其中row_id00 00 00 00 07 6Ctransaction_id00 00 00 00 69 ABroll_pointerFA 00 00 01 70 01 1F;记录头信息中next_record00 74

​ 答案如下,具体分析过程省略。

image-20240718145608087

行溢出

Dynamic行格式

阿萨德

Compressed行格式

大刀

上次更新于: