📊 InnoDB记录结构
💬 简介
在MySQL中,数据存储的单位为行(或记录),行(或记录)在磁盘上的存储格式也被称为行格式
(或记录格式
)。
MySQL服务器支持多种不同的存储引擎,如InnoDB
、MyISAM
、Memory
等。不同存储引擎的数据存储与处理机制的实现细节差异显著,而又由于InnoDB是目前使用最广泛的MySQL存储引擎,因此本文只讨论InnoDB。
目前,InnoDB共支持 Compact
、Redundant
、Dynamic
和Compressed
。尽管它们在实现细节上有所差异,但它们的实现原理大体上是相似的。
🎯 指定行格式
行格式是针对表而言的,这意味着当选定表的行格式后,表中所有行的存储结构都会受到影响。在创建和修改表的语句中,都可以使用如下语法指定表的行格式。
create table {表名} (列的信息) row_format = {行格式名称}
alter table {表名} row_format = {行格式名称}
例如,在fatgod
数据库中,我们创建一个测试表record_format_test
,并指定其行格式为Compact
,字符集为ascii
。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
字符集只包括空格、标点符号、数字、大小写英文字母和一些不可见字符,每个字符占用
为了以下更好地讲解行格式,我们往record_format_test
中插入几条测试数据,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值列表
和记录头信息
三部分组成;记录的真实数据由用户自定义数据
和隐藏列数据
两部分组成。
记录的额外信息
变长字段长度列表
MySQL支持多种变长的数据类型,比如VARCHAR(M)
、VARBINARY(M)
、各种TEXT
类型、各种BLOB
类型等。我们把拥有这些数据类型的列称为变长字段
,变长字段中存储的数据字节大小是不固定的。那MySQL服务器每次在获取变长字段数据的字节长度时,是否都需要遍历整体数据吗?答案是否定的,这种做法的效率十分差。为此,InnoDB采取了一种聪明的做法:将这些变长字段的长度信息逆序存储在记录格式的第一段,即变长字段长度列表
。
我们拿测试表record_format_test
中的第一条记录举例。因为record_format_test
表的c1
、c2
、c4
三个列的类型都为VARCHAR(10)
,也就是变长数据类型,所以这三个列的值的长度都需要保存在变长字段长度列表中,长度信息统计如下所示。
又因为这些表示长度的信息需要按照逆序存储,所以最终的变长字段长度列表的字节串用十六进制表示为01 03 04
(各个字节之间实际上没有空格,用空格隔开只是为了可视化)。
思考一下,
- 假设某个字符集中表示一个字符最多需要使用的字节数为
。例如 utf8mb4
字符集中的为 , gbk
字符集中的为 , ascii
字符集中的为 。我们可以使用语句 show character set
进行查看,结果中的MaxLen
字段值就是各字符集的值。 - 假设变长字段最多能存储
个字符。例如 VARCHAR(10)
类型字段中的为 。 - 假设变长字段实际存储的内容字节数为
。 - 如果
,那么使用 个字节表示长度。 - 如果
,继续分类讨论:若 ,则使用 个字节来表示长度。否则,使用 个字节表示长度。
某个变长字段允许存储的最大字节数也不一定是通过公式 VARBINARY(128)
类型中,该值为
再思考一下,既然
当InnoDB读到某个字节时,其会查看表结构。如果对应的变长字段的最大字节数小于等于
tips:
- 变长字段长度列表中不会存储值为NULL的列的长度。
- 如果表中所有列都不是变长数据类型,那么记录格式中不会包含变长字段长度列表这部分信息。
NULL值列表
在MySQL中,列存在两种与NULL值相关的约束,分别为NULL
和NOT NULL
,NULL表示一个未知或缺失的值。那InnoDB在存储值为NULL的列时,会在其记录的真实数据处用一个特殊符号表示吗?答案是否定的,这种做法存在二进制不安全的问题,意味着我们不能使用该特殊符号作为用户数据,同时还会导致存储空间的浪费。为此,InnoDB采取了一种聪明的做法:用一个位图来表示各列NULL值的出现情况,这个位图也通常被称为NULL值列表
,具体处理过程如下。
- 首先,统计表中约束为
NULL
的列; - 其次,将每个列对应一个比特位,并按列的顺序逆序排列,其中,
表示该列的值为NULL, 表示该列的值不为NULL; - 最后,NULL值列表高位补
以达到整数个字节大小,并存储在记录格式的第二段。
我们拿测试表record_format_test
中的第一条记录举例。因为record_format_test
表的c1
、c3
、c4
三个列的约束是NULL
,所以这三个列的NULL值出现情况需要保存在NULL值列表中,NULL值信息统计如下所示。
将这 000
,但这不足
综上所述,在第一条记录中,最终的NULL值列表的字节串用十六进制表示为00
。
tip: 如果表中所有列都存在
NOT NULL
约束,那么记录格式中不会包含NULL值列表这部分信息。
记录头信息
除了变长字段长度列表和NULL值列表外,记录的额外信息中还包含了一个重要的记录头信息
。该部分主要描述记录的一些相关属性,它由固定的
可以看到,这
我们拿测试表record_format_test
中的第一条记录举例。0
;delete_mask
为0
,因为当前记录未被删除;min_rec_mask
为0
,因为当前记录包含用户数据,在B+树中处于叶子节点;n_owned
为0000
,因为当前记录拥有的记录数为 heap_no
为0000000000010
,因为当前记录在记录堆 record_type
为000
,因为当前记录包含用户数据,是一条普通记录;next_record
为0000000000101101
,因为当前记录与下一条记录之间的偏移量为0x2D
。于是,我们能够完整地得到记录头信息的各比特位,如下。
综上所述,在第一条记录中,最终的记录头信息的字节串用十六进制表示为00 00 10 00 2D
(各个字节之间实际上没有空格,用空格隔开只是为了可视化)。
记录的真实数据
记录的真实数据指的是用户定义列中存储的实际数据,例如record_format_test
表中的第一条记录的真实数据为aaaa bbb cc d
(用空格隔开只是为了可视化)。此外,InnoDB还会为每条记录默认地添加一些隐藏列
数据,如下。
tip:
- 在MySQL源码中,这几个列的真正名称其实分别为:
DB_ROW_ID
、DB_TRX_ID
和DB_ROLL_PTR
。本文中将它们书写成row_id
、transaction_id
和roll_pointer
了,目的只是为了美观,方便展示。- 只有聚簇索引中的记录才会包含这三个隐藏列。
transaction_id
和roll_pointer
在每个记录中都会存在,它们都与InnoDB的MVCC机制有关,这两个字段会在章节《MVCC》中进行详细介绍。
row_id
与InnoDB中表的主键生成策略密切相关。在InnoDB中,每个表都必须拥有一个主键,它会优先选择用户自定义的主键,其次会选择一个拥有唯一约束UNIQUE
的列作为主键,最后如果既没有PRIMARY KEY
约束的列,也没有UNIQUE
约束的列,则会为表默认添加一个名为row_id
的隐藏列并作为主键。
我们拿测试表record_format_test
中的第一条记录举例。在ascii
字符集中,用十六进制表示法,字符串aaaa
为0x61616161
,字符串bbb
为0x626262
,字符串cc
为0x6363
,字符串d
为0x64
。列c3
的数据类型是CHAR(10)
,固定长度为 cc
只占了 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_id
、transaction_id
和roll_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)
数据类型的列来说,其存储的数据的字节长度范围为
再思考一下,为什么InnoDB不直接为列数据存储分配 节省存储空间
与防止产生碎片
两方面之间作了一个平衡的取舍。
测试
以上介绍了Compact
行格式的所有内容,那么测试表record_format_test
中的第二条记录的二进制数据是什么呢?请用十六进制来表示每个字节,其中row_id
为00 00 00 00 07 6C
,transaction_id
为00 00 00 00 69 AB
,roll_pointer
为FA 00 00 01 70 01 1F
;记录头信息中next_record
为FF C2
。
答案如下,具体分析过程省略。
🔲 Redundant行格式
一条完整的Redundant
格式的记录也可以被分为记录的额外信息
和记录的真实数据
两大部分。记录的额外信息由字段长度偏移列表
和记录头信息
两部分组成;记录的真实数据由用户自定义数据
和隐藏列数据
两部分组成。
从上图中可以看出,在Redundant
行格式中,记录的真实数据的组成与Compact
行格式是一致的,所以本文聚焦于介绍Redundant
行格式中记录的额外信息这一部分的内容。
Redundant
是MySQL5.0版本之前使用的一种行格式,从目前来看,该行格式的设计已经过时和落后,因而其在绝大数场景下不再被使用。
为了演示,我们需要把表record_format_test
的行格式改为Redundant
,SQL语句如下。
alter table record_format_test row_format = redundant;
记录的额外信息
字段长度偏移列表
字段长度偏移列表
会以逆序的方式存储当前记录所有列(包括隐藏列)的长度信息。需要注意的是,它并不直接存储每个列的实际长度值,而是存储每个列数据在记录的真实数据
中的偏移量,这里的偏移量指的是每一列数据在记录真实数据中的结束位置。
我们拿测试表record_format_test
中的第一条记录举例。该记录的字段长度偏移列表的字节串用十六进制表示为25 24 1A 17 13 0C 06
(各个字节之间实际上没有空格,用空格隔开只是为了可视化),具体推导过程如下。
- 第一列
row_id
的长度为0x06
个字节,所以第一列的偏移量为0x06
; - 第二列
transaction_id
的长度为0x06
个字节,所以第二列的偏移量为0x06 + 0x06 = 0x0C
; - 第三列
roll_pointer
的长度为0x07
个字节,所以第三列的偏移量为0x0C + 0x07 = 0x13
; - 第四列
c1
的长度为0x04
个字节,所以第四列的偏移量为0x13 + 0x04 = 0x17
; - 第五列
c2
的长度为0x03
个字节,所以第五列的偏移量为0x17 + 0x03 = 0x1A
; - 第六列
c3
的长度为0x0A
个字节,所以第六列的偏移量为0x1A + 0x0A = 0x24
; - 第七列
c4
的长度为0x01
个字节,所以第七列的偏移量为0x24 + 0x01 = 0x25
。 - 将所有列的偏移量按逆序排列,得到字节串为(用十六进制表示):
25 24 1A 17 13 0C 06
。
思考一下,InnoDB如何快速获取某个列数据的字节长度呢?其实,只需要计算当前列的偏移量与上一个列的偏移量之间的差值,即可得到该列数据的字节长度。
记录头信息
在Redundant
行格式中,记录头信息由
我们拿测试表record_format_test
中的第一条记录举例。0
;delete_mask
为0
,因为当前记录未被删除;min_rec_mask
为0
,因为当前记录包含用户数据,在B+树中处于叶子节点;n_owned
为0000
,因为当前记录拥有的记录数为 heap_no
为0000000000010
,因为当前记录在记录堆 n_field
为0000000111
,因为当前记录包含 1byte_offs_flag
为1
,这是因为当前记录使用 next_record
为0000000010111100
,因为当前记录与下一条记录之间的偏移量为0xBC
。于是,我们能够完整地得到记录头信息的各比特位,如下。
综上所述,在第一条记录中,最终的记录头信息的字节串用十六进制表示为00 00 10 0F 00 BC
(各个字节之间实际上没有空格,用空格隔开只是为了可视化)。
与Compact
行格式的记录头信息对比来看,Redundant
有两处不同:
- 多了
n_field
和1byte_offs_flag
这两个属性。 - 没有
record_type
属性。
1byte_offs_flag
比特位被用来控制列在字段长度偏移列表中的偏移量单位是 Redundant
行格式记录的实际真实数据占用的总大小,具体规则如下。
- 当记录的真实数据占用的字节数小于等于
(十六进制为 0x7F
,二进制为01111111
)时,每个列对应的偏移量占用个字节。 - 当记录的真实数据占用的字节数大于
,但不大于 (十六进制为 0x7FFF
,二进制为0111111111111111
)时,每个列对应的偏移量占用个字节。 - 当记录的真实数据占用的字节数大于
时,本页中仅会保留该数据的前 个字节,并额外使用 个字节来存储 溢出页
的地址和其他相关信息,溢出页
用于存储剩余的真实数据。由于字段长度偏移列表只需要记录每个列在本页中的偏移,因此在这种特殊情况下使用 个字节作为偏移量的单位就已经足够了。
可以看出,这种设计方式是比较浪费存储空间的,因为即使绝大数的列的偏移量能够用 0x7F
,那么所有的列的偏移量都要使用
对NULL值的处理
思考一下,当Redundant
行格式选择用 Redundant
行格式中没有NULL
值列表,其使用了字段长度偏移列表
中各偏移量的第一个比特位作为标志位来标记对应列的值是否为NULL:标志位为
在Redundant
行格式中,是否将字段定义为定长类型会决定其NULL值的存储格式,分类讨论如下。
- 如果存储NULL值的字段是定长类型的,比如说
CHAR(M)
数据类型,则NULL值会在记录的真实数据处占用存储空间,并把该字段对应的数据使用0x00
字节填充,这也就意味着该数据的字节长度会被算在偏移量中。 - 如果存储NULL值的字段是变长类型的,比如说
VARCHAR(M)
数据类型,则NULL值不会在记录的真实数据处占用任何存储空间,这也就意味着该数据的字节长度为,偏移量与上一列相同。
CHAR(M)列的存储格式
在Redundant
行格式中,CHAR(M)
列的存储格式与字符集无关,该列的值占用的空间永远为字符集的最大字节数MaxLength
与最大字符数M
的乘积。这种设计方式虽然在一定程度上浪费存储空间
,但能够有效防止碎片产生
。
比方说使用utf8
字符集的CHAR(10)
类型的列占用的真实数据空间始终为 gbk
字符集的CHAR(10)
类型的列占用的真实数据空间始终为
测试
以上介绍了Redundant
行格式的所有内容,那么测试表record_format_test
中的第二条记录的二进制数据是什么呢?请用十六进制来表示每个字节,其中row_id
为00 00 00 00 07 6C
,transaction_id
为00 00 00 00 69 AB
,roll_pointer
为FA 00 00 01 70 01 1F
;记录头信息中next_record
为00 74
。
答案如下,具体分析过程省略。
行溢出
Dynamic行格式
阿萨德
Compressed行格式
大刀