一、背景
Innodb 1.0 版本开始引入了新的行格式 dynamic,新的行格式在存放 blob 中的数据采用了完全行溢出的方式,在数据页中只存放 20 字节的指针,实际数据都存放在 Off page 中,而 compact 会存放 768 个前缀字节。
二、dynamic 行记录格式
数据区在数据⾏中的位置如下图所⽰:
从图中可以观察到,dynamic 行记录格式的首部是一个非 null 变长字段长度列表,并且是按照列的逆序放置的,其长度为:
若列的长度小于255字节,用 1 字节表示
若大于255个字节,则用 2 字节表示
边长字段的长度最大不能超过两个字节,这是因为 mysql 数据库中 varchar 类型的最大长度限制为 65535。
变长字段之后的第二个部分是 null 标识位,该标识指示了该行数据是否有 null 值,有则用 1 表示,该部分所占字节应该为 1 字节。如果创建表结构数据列都指定为非空,则 null 标识位省略。
接下来的部分是记录头信息,固定占用 5 字节,每位含义如下:
名称 | 大小(bit) | 描述 |
---|---|---|
() | 1 | 预留字节 |
() | 1 | 预留字节 |
deleted_flag | 1 | 该行是否已被删除 |
min_rec_flag | 1 | 存储目录项记录中主键值最小的目录项记录置为 1,其它情况都置0. |
n_owned | 4 | 页目录中每个组的最后一条记录会存储该组的记录数,作为 n_owned 字段。值的关注的是,在mysql中最小记录是一组,普通记录与其它记录是一组,因此最小记录中n_owned 属性是1,最大记录的 n_owned 值是5. |
heap_no | 13 | 当前页中该记录的排序位置 |
record_type | 3 | 记录类型 0 表示普通类型,1 表示B+树的非叶子节点 2 表示 最小记录,3 表示 最大记录。 |
next_record | 16 | 页中 下一条记录的相对位置 |
total | 40 | 合计 |
从分隔线向右第⼀个字段存储真实数据的主键值,对于主键值有以下⼏种情况:
如果表中定义了主键,则直接存储主键的值;
如果是复合主键会根据列定义的顺序依次排列在这⾥;
如果没有主键,会优先使⽤第⼀个不允许为NULL的 UNIQUE 唯⼀列作为主键;
如果既没有主键也没有唯⼀键,那么InnoDB会构建⼀个6字节的字段 DB_ROW_ID 作为⾏的唯⼀标识,存储在真实数据的头部
紧接着是在事务运⾏中两个⾮常重要的固定字段,
6 字节的事务ID字段 DB_TX_ID ,记录创建或最后⼀次修改该记录的事务ID
7 字节的回滚指针字段 DB_ROLL_PTR ,如果在事务中这条记录被修改,指向这条记录的上⼀个版本
接下来就是除了主键和值为NULL的列之外,其他列的真实数据,按照顺序从左到右依次排列
⾄于为什么不存储 NULL 值,原因很简单,就是为了节少空间,所有允许为 NULL 的列都会在⾏额外信息区的 NULL 值列表中进⾏标识。
三、实践
# 创建表结构
mysql> create table mytest (-> t1 varchar(10),-> t2 varchar(10),-> t3 char(10),-> t4 VARCHAR(10)-> ) engine=innodb charset=latin1 row_format = dynamic;
Query OK, 0 rows affected (0.03 sec)# 插入数据
mysql> INSERT INTO mytest VALUES ('a', 'bb', 'bb', 'ccc');
Query OK, 1 row affected (0.01 sec)
mysql> INSERT INTO mytest VALUES ('d', 'ee', 'ee', 'fff');
Query OK, 1 row affected (0.00 sec)
mysql> insert into mytest values ('d', null, null, 'fff');
Query OK, 1 row affected (0.00 sec)# 用 notepad++ 打开 mytest.ibd 文件,需要下载 hex-editor 插件, 定位到C078位置
03 02 01 # 变长字段列表 逆序
00 # null 标识位
00 00 10 00 2c # record header
00 00 00 00 02 01 # rowid
00 00 00 00 05 56 # transactionid
d1 00 00 01 50 01 10 # roll pointer
61 # 第一列数据
62 62 # 第二列数据
62 62 20 20 20 20 20 20 20 20 # 第三列数据
63 63 63 # 第四列数据# 第一行 变长字段列表为逆序状态,转换回来为01 02 03,
# 对应第一列、第二列、第4列长度分别为、1字节、2字节、3字节# 第二行 null 标识位 目前没有null的列 所以为 00# 第三行为 record header 占用5字节
# 第1个字节转换为二进制 0 0 0 0 0 0 0 0
# 预留字段 0
# 预留字段 0
# deleted_flag 0 该行 未删除
# min_rec_flag 0 该行不是索引列
# n_owned 0000 该组拥有的记录数 不记录在当前节点
# 第 2 个和第 3 个字节转为二进制 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0
# heap_no 0 0 0 0 0 0 0 0 0 0 0 1 0 排在第一行数据
# record_type 0 0 0 表示普通记录
# 第3个和第5个字节 next_record 002C 下一行的记录为当前位置 + 002C
# C080 + 002C = C0AC 下一行数据的next_record位置# 第二行数据
03 02 01 # 边长字段列表 逆序
00 # null 标识位
00 00 18 00 2b # record header
00 00 00 00 02 02 # rowid
00 00 00 00 05 57 # transactionid
d2 00 00 01 51 01 10 # roll pointer
64 # 第一列数据
65 65 # 第二列数据
65 65 20 20 20 20 20 20 20 20 # 第三列数据
66 66 66 # 第四列数据
# 第一行 变长字段列表为逆序状态,转换回来为01 02 03,
# 对应第一列、第二列、第4列长度分别为、1字节、2字节、3字节# 第二行 null 标识位 目前没有null的列 所以为 00# 第三行为 record header 占用5字节
# 第1个字节转换为二进制 0 0 0 0 0 0 0 0
# 未知 0
# 未知 0
# deleted_flag 0 改行未删除
# min_rec_flag 0 该行不是最小记录
# n_owned 0000 该组拥有的记录数 不记录在当前节点
# 第2个和第3个字节转为二进制 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0
# heap_no 0 0 0 0 0 0 0 0 0 0 0 1 1 排在第二行数据
# record_type 0 0 0 表示普通记录
# 第3个和第5个字节 next_record 002B 下一行的记录为当前位置 + 002B
# C0AC + 002B = C0D7 下一行数据的next_record位置# 第三行数据
03 01 # 边长字段列表 逆序
06 # null 标识位
00 00 20 00 1F # record header
00 00 00 00 02 03 # rowid
00 00 00 00 05 58 # transactionid
d3 00 00 01 52 01 10 # roll pointer
64 # 第一列数据
66 66 66 # 第四列数据
# 第一行 变长字段列表为逆序状态,转换回来为01 03,
# 对应第1列、第4列长度分别为1字节、3字节# 第二行 null 标识位 06 转换为二进制 0110
# 第二列、第三列 为 null# 第三行为 record header 占用5字节
# 第1个字节转换为二进制 0 0 0 0 0 0 0 0
# 未知 0
# 未知 0
# deleted_flag 0 改行未删除
# min_rec_flag 0 该行不是最小记录
# n_owned 0000 该组拥有的记录数 不记录在当前节点
# 第2个和第3个字节转为二进制 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0
# heap_no 0 0 0 0 0 0 0 0 0 0 1 0 0 排在第 3 行数据
# record_type 0 0 0 表示普通记录
# 第3个和第5个字节 next_record 001F 下一行的记录为当前位置 + 001F
# C0D7 + 001F = C0F6 下一行数据的next_record位置# 当我们在插入 5 条数据
mysql> insert into mytest values ('d', null, null, 'fff');
Query OK, 1 row affected (0.00 sec)mysql> insert into mytest values ('e', null, null, 'ggg');
Query OK, 1 row affected (0.00 sec)mysql> insert into mytest values ('h', null, null, 'lll');
Query OK, 1 row affected (0.00 sec)mysql> insert into mytest values ('m', null, null, 'nnn');
Query OK, 1 row affected (0.01 sec)mysql> insert into mytest values ('o', null, null, 'ppp');
Query OK, 1 row affected (0.00 sec)# 解析一下第 4 条数据
03 01 # 边长字段列表 逆序
06 # null 标识位
04 00 28 00 1F # record header
00 00 00 00 02 04 # rowid
00 00 00 00 05 59 # transactionid
d4 00 00 01 53 01 10 # roll pointer
64 # 第一列数据
66 66 66 # 第四列数据
# 重点看一下 record header 第一个字节 04 其中 n_owned 占用4位等于4
# 表明当前数据为当前组的最后一条数据,其中这个组包含4条数据
四、验证 null 标识位
# 创建表结构
create table mytest1 (t1 varchar(10) not null
) engine=innodb charset=latin1 row_format = dynamic;# 插入数据
mysql> insert into mytest1 select 'aaa';
Query OK, 1 row affected (0.01 sec)# 用 notepad++ 打开 mytest1.ibd 文件,需要下载 hex-editor 插件, 定位到C078位置
03 # 变长字段列表 逆序
00 00 10 ff f2 # record header
00 00 00 00 04 0a # rowid
00 00 00 00 0b 15 # transactionid
b2 00 00 01 26 01 10 # roll pointer
61 61 61 # 第一列数据
# 整理完成后,我们发现, 当一行数据都是非空字段时,null 标识位是省略的
五、验证行溢出
# 创建表结构
mysql> create table mytest1 (-> t1 varchar(53353) not null-> ) engine=innodb charset=latin1 row_format = dynamic;
Query OK, 0 rows affected (0.02 sec)# 插入数据
mysql> insert into mytest1 select repeat('a', 53353);
Query OK, 1 row affected (0.01 sec)# 用 notepad++ 打开 mytest1.ibd 文件,需要下载 hex-editor 插件, 定位到C078位置
14 c0 # 变长字段标识
00 00 10 ff f1 # header
00 00 00 00 04 0b
00 00 00 00 0b 1b
b6 00 00 01 2a 01 10# 使⽤ 20 个字节来标记这个溢出⻚的位置信息
00 00 00 31
00 00 00 04
00 00 00 26
00 00 00 00
00 00 d0 69 # 指针# 使用 py_innodb_page_info.py 查看 mytest1.ibd 文件
D:\ProgramData\MySQL\py_innodb_page_type>py_innodb_page_info.py -v "D:/ProgramData/MySQL/MySQL Server 5.7/MySQL_data/innodb_test/mytest1.ibd"
page offset 00000000, page type <File Space Header>
page offset 00000001, page type <Insert Buffer Bitmap>
page offset 00000002, page type <File Segment inode>
page offset 00000003, page type <B-tree Node>, page level <0000>
page offset 00000004, page type <Uncompressed BLOB Page>
page offset 00000005, page type <Uncompressed BLOB Page>
page offset 00000006, page type <Uncompressed BLOB Page>
page offset 00000007, page type <Uncompressed BLOB Page># 我们发现,存在一个数据页,存在 4 个溢出页,同时通过上面的验证,
# 我们知道数据行中存储的是 20 位的指针数据
六、实践 min_rec_flag 标识位
# 创建表结构
create table mytest1 (t1 varchar(8090)
) engine=innodb charset=latin1 row_format = dynamic;# 插入数据
mysql> insert into mytest1 select repeat('a', 8090);
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0mysql> insert into mytest1 select repeat('b', 8090);
Query OK, 1 row affected (0.00 sec)
Records: 1 Duplicates: 0 Warnings: 0mysql> insert into mytest1 select repeat('c', 8090);
Query OK, 1 row affected (0.01 sec)
Records: 1 Duplicates: 0 Warnings: 0# 查看表空间情况
D:\ProgramData\MySQL\py_innodb_page_type>py_innodb_page_info.py -v "D:/ProgramData/MySQL/MySQL Server 5.7/MySQL_data/innodb_test/mytest1.ibd"
page offset 00000000, page type <File Space Header>
page offset 00000001, page type <Insert Buffer Bitmap>
page offset 00000002, page type <File Segment inode>
page offset 00000003, page type <B-tree Node>, page level <0001>
page offset 00000004, page type <B-tree Node>, page level <0000>
page offset 00000005, page type <B-tree Node>, page level <0000>
Total number of page: 6:
Insert Buffer Bitmap: 1
File Space Header: 1
B-tree Node: 3
File Segment inode: 1# 我们发现存在非叶子节点, 解析第一个非页子节点
00 # 变长字段列表
10 00 11 00 10 # record header
00 00 00 00 04 0c # 子目录最小值 主键
00 00 00 04 # 对应子目录位置# 对于第一行 00 表示为变长字段列表 对于非子节点 默认为 00# 第二行为 record header 占用 5 字节
# 第1个字节转换为二进制 0 0 0 1 0 0 0 0
# 预留字段 0
# 预留字段 0
# deleted_flag 0 该行 未删除
# min_rec_flag 1 当前记录 为索引节点,同时为 最小记录,所以值为 1
# n_owned 0000 该组拥有的记录数 不记录在当前节点
# 第 2 个和第 3 个字节转为二进制 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1
# heap_no 0 0 0 0 0 0 0 0 0 0 0 1 0 排在第一行数据
# record_type 0 0 1 表示索引数据
# 第3个和第5个字节 next_record 0010 下一行的记录为当前位置 + 0010
# C07d + 0010 = C08d 下一行数据的 next_record 位置# 第三行 为:数据页最小主键,主键值为 00 00 00 00 04 0c
# 第四行 为:数据页编号,当前记录的叶子节点变化为:00 00 00 04
# 对应 page offset 00000004, page type <B-tree Node>, page level <0000>