URL
date
AI summary
slug
status
tags
summary
type

前言

本篇文章我们会通过理论结合实验的形式来学习InnoDB数据页的结构

InnoDB数据页结构

Innodb数据页总共有7个部分组成,其中File HeaderFile Trailer是所有类型的页都有的结构,而中间的5块区域是数据页独有的。
notion image

File Header

File Header用来记录页的一些头信息,共由8个部分组成,共占用38个字节。不同类型的页的第一个部分都是文件头。File Header里定义了页的具体类型,本篇文章我们重点关注的是数据页。
名称
大小
描述
FIL_PAGE_SPACE_OR_CHKSUM
4
MySQL 4.0.14 之前为 0,之后的版本中该值代表页的 checksum 值(总和检验码)
FIL_PAGE_OFFSET
4
页号,表空间中页的偏移值,起始值为 0。如果某独立表空间 a.ibd 的大小为 1 GB,如果页的大小为 16 KB,那么总共有 65536 个页。偏移值代表该页在所有页中的位置。同时通过该部分的长度 4 个字节可以算出一个表空间最大支持 2^32 * 16 KB = 64 TB
FIL_PAGE_PREV
4
上一页的页号,B+Tree 的特性决定了叶子节点必须是双向列表
FIL_PAGE_NEXT
4
下一页的页号,B+Tree 的特性决定了叶子节点必须是双向列表
FIL_PAGE_LSN
8
最后被修改的日志序列的 LSN(Log Sequence Number)
FIL_PAGE_TYPE
2
页的类型,数据页对应的类型为 FIL_PAGE_INDEX,值为 0x45BF
FIL_PAGE_FILE_FLUSH_LSN
8
仅在系统表空间的一个页中定义,代表文件成功刷新到磁盘的 LSN。独立表空间中该值都是 0
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID
4
从 MySQL 4.1 开始,该值代表页属于哪个表空间(存放的是表空间 ID)

Page Header

Page Header是数据页独有的,用来记录数据页的状态信息,由14个部分组成,共占用56个字节。
名称
大小
描述
PAGE_N_DIR_SLOTS
2
在 Page Directory 中 Slot 的数量
PAGE_HEAP_TOP
2
堆中第一个记录的指针(记录在页中是以堆的形式存放的),该地址之后就是 Free Space
PAGE_N_HEAP
2
堆中的记录数(包括最小记录和最大记录以及标记为删除的记录),但是第 15 位表示行记录格式
PAGE_FREE
2
指向可重用空间的首指针,即第一个标记为删除的记录的地址(已删除的记录通过 next_record 组成一个链表),如果这个页上有记录要插入,可以先从这里分配空间,如果空间不够,再从 Free Space 分配。
PAGE_GARBAGE
2
已删除的字节数,即行记录中 delete_mask 为 1 的记录大小的总和
PAGE_LAST_INSERT
2
最后插入记录的位置(指向最近一个被插入的记录,主要用来方便后续的插入操作)
PAGE_DIRECTION
2
最后一个记录插入的方向。假如新插入的一条记录的主键值比上一条记录的主键值大,那么这条记录的插入方向是从右插入,反之则是从左插入。
PAGE_N_DIRECTION
2
一个方向连续插入的记录个数,如果最后一条记录的插入方向发生了改变,那么这个该值会被清零重新统计
PAGE_N_RECS
2
该页中记录的数量(不包括最小记录和最大记录以及标记为删除的记录)
PAGE_MAX_TRX_ID
8
修改当前页的最大事务 ID,该值仅在二级索引中定义
PAGE_LEVEL
2
当前页在索引树中的位置,0x00 代表叶子节点,即叶子节点总是在第 0 层
PAGE_INDEX_ID
8
索引 ID,表示当前页属于哪个索引
PAGE_BTR_SEG_LEAF
10
B+树数据页非叶子节点所在段的 segment header。该值仅在 B+树的 Root 页中定义
PAGE_BTR_SEG_TOP
10
B+树数据页所在段的 segment header。该值仅在 B+树的 Root 页中定义
这里我们重点关注PAGE_FREEPAGE_GARBAGE。InnoDB就是通过这两个字段实现了删除记录空间的复用
  • PAGE_FREE指向可重用空间链表的头元素,被删除的记录都会被加入到可重用空间链表的头部,并指向上一个头元素,这就形成了可重用空间链表。
  • PAGE_GARBAGE则是本页所有可重用空间的总和(包含可重用记录空间以及碎片空间)。
如果这个页上有新记录要插入,会优先从这里分配空间,如果空间不够,再从Free Space分配。不过注意,这里只会判断可重用空间的头元素,并不会往下遍历
💡
1. 可重用记录空间指的是某条记录删除后留下的空间 2. 碎片空间指的是某条记录的可变字段缩短更新后产生的碎片,这部分空间要重新整理本页后才有可能被复用。

Users Records + Free Space + Infimum + Supremum

我们先跳过InfimumSupremum,来看Users RecordsUsers Records的大小和页内存储的记录有关,它的空间从下面的Free Space分配,Free Space的起始位置记录在Page Header里的PAGE_HEAP_TOP 字段
notion image

行记录结构

我们再来看看单条行记录的结构:
notion image
可以看到单条行记录的结构分为记录的额外信息记录的真实数据。这里对记录头信息里的几个字段重点说明一下:
  • record_type:记录类型
    • 0 - 普通记录
    • 1 - 非叶子结点记录
    • 2 - infimum
    • 3 - supremum。
  • delete_mark:删除标记
    • DELETE语句只是打个标记,并不会直接删除,因为MVCC机制的存在,这条记录可能还需要被访问,所以InnoDB采用单独的purge线程来处理什么时候把它真正的“物理删除”。
    • 还有一个原因是因为移除被删除的记录就意味着需要把其他的记录在磁盘上重新排列,会消耗性能。
    • 被删除掉(指的是purge线程的删除)的记录会被加入到可重用空间链表,之后如果本页要插入新数据,会优先复用这些空间。
  • next_record:下一条记录的相对偏移量
    • 页内的记录通过一个单向链表连接,通过这个字段来指向下一条记录。next_record是一个相对偏移量,并且可能是向前偏移(比如指向Supremum或者是前面释放出来的空间)也可能是向后偏移。
    • 可重用空间链表也是通过这个字段连接起来的。
  • n-owned:槽内的记录数量
    • 这个字段是配合后面要聊的Page Directory使用的,也就是通过它来做到整个记录链表的逻辑分组
下面是页内记录的单向链表按照其物理存储的简化图示:
notion image
而这个单向链表的头和尾就是我们跳过的InfimumSupremum,它俩是每个数据页都固定有的虚拟最小记录和虚拟最大记录,一头一尾串联起来所有真实的记录,并且这个单向链表是从小到大递增的,比如插入了一条主键较小的记录,那么它需要保存到单向链表的中间位置(逻辑位置,并非物理位置)。
💡
上面这段描述看起来是不是感觉主键无序插入好像也不会产生性能问题? 其实在同一个页上确实没什么影响,但是当页面空间不足需要拆分的时候,无序数据就会涉及较多的数据移动。并且如果插入记录的主键值要落到一个之前已满了的页上的话,那就面临页分裂了,这是产生性能问题的关键。
这两条虚拟记录的结构和我们前面看的实际的记录一样,也是【记录的额外信息】 + 【记录的真实数据】。它俩的真实数据就是它们自身这个字符串,因为infimum不足8个字节,所以在最后给它补了一个0。
💡
上图的额外信息里没画的部分不代表没有,只是此处为了版面清晰做了省略

Page Directory

Page Directory是为了提升页内查询速度而诞生的,不然只能通过遍历上面的单向链表来获取对应主键的记录,时间复杂度为O(n)。当页内存储的记录数量较多时,会对性能产生影响。而Page Directory采用了类似跳表的原理,把4-8条记录分成一组,并记录下这一组的最大值的偏移量到Page Directory里,称为slot
根据主键查找记录时,根据不同的索引树层高,可能会经过根节点、内节点和叶结点。在每种类型的节点上查找对应记录的流程基本都一样:首先通过Page Directory二分查找到对应的槽,然后在槽上顺序查找。由于槽内保存的是每一组的最大值,所以要从Page Directory找出主键值比本记录的主键值大且差值最小的槽(high槽),再通过上一个槽(low槽)往下遍历,最多遍历n-owned 条记录。
notion image
💡
注意,上面这张图为了美观,和物理存储是有出入的,比如最大记录应该是紧挨着最小记录,Page Directory应该在User Records之后,且是倒序存储的。
下面这篇文章对于如何定位一条记录有详细的描述,并以一个层高为2的B+树的查找过程演示了整个定位过程,推荐大家详细阅读——InnoDB B-TREE 索引怎么定位一条记录?

槽位的分配规则

对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。所以分组是按照下边的步骤进行的:
  • 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
  • 之后每插入一条记录,都会从Page Directory中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。
  • 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在Page Directory中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。

File Trailer

为了保证页能够完整地写入磁盘(如可能发生的写入过程中磁盘损坏、机器宕机等原因),InnoDB存储引擎的页中设置了File Trailer部分。File Trailer只有一个FIL_PAGE_END_LSN部分,占用8个字节。前4个字节代表该页的checksum值,最后4个字节和File Header中的FIL_PAGE_LSN相同。通过这两个值来和File Header中FIL_PAGE_SPACE_OR_CHKSUMFIL_PAGE_LSN值进行比较,看是否一致(checksum的比较需要通过InnoDB的checksum函数来进行比较,不是简单的等值比较),以此来保证页的完整性(not corrupted)。

实验

下面进入实验阶段,我们会尽可能的多做一些实验来证明前面的一些结论

建表及数据准备

CREATE TABLE `test`.`update_test` ( `id` int NOT NULL AUTO_INCREMENT, `name1` longtext NOT NULL, `name2` longtext NOT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic; drop table update_test; insert into update_test(name1, name2) values(repeat('a',5), repeat('b',5)); insert into update_test(name1, name2) values(repeat('c',5), repeat('d',5)); insert into update_test(name1, name2) values(repeat('e',5), repeat('f',5));

如何找到root page

root page是我们查找的起点,所以找到root page很重要
mysql> select * from information_schema.innodb_indexes where table_id in (select table_id from information_schema.INNODB_TABLES where name = 'test/update_test') and name='primary'; +----------+---------+----------+------+----------+---------+-------+-----------------+ | INDEX_ID | NAME | TABLE_ID | TYPE | N_FIELDS | PAGE_NO | SPACE | MERGE_THRESHOLD | +----------+---------+----------+------+----------+---------+-------+-----------------+ | 342 | PRIMARY | 1213 | 3 | 5 | 4 | 118 | 50 | +----------+---------+----------+------+----------+---------+-------+-----------------+ 1 row in set (0.02 sec)
可以看到page_no为4,我们的MySQL版本是8.0.25。而MySQL 5.7的page_no应该是3。参考:MySQL 引擎特性 · InnoDB 手动分析 B+树

验证变长字段未溢出存储

可以看到变长字段都在Root页存储,和行数据在一起
❯ hexdump -C -s 65536 -n16384 update_test.ibd 00010000 c4 70 3b 6e 00 00 00 04 ff ff ff ff ff ff ff ff |.p;n............| 00010010 00 00 00 00 07 57 e4 ac 45 bf 00 00 00 00 00 00 |.....W..E.......| 00010020 00 00 00 00 00 72 00 02 00 de 80 05 00 00 00 00 |.....r..........| 00010030 00 c3 00 02 00 02 00 03 00 00 00 00 00 00 00 00 |................| 00010040 00 00 00 00 00 00 00 00 01 52 00 00 00 72 00 00 |.........R...r..| 00010050 00 02 02 72 00 00 00 72 00 00 00 02 01 b2 01 00 |...r...r........| 00010060 02 00 1c 69 6e 66 69 6d 75 6d 00 04 00 0b 00 00 |...infimum......| 00010070 73 75 70 72 65 6d 75 6d 05 05 00 00 10 00 22 80 |supremum......".| 00010080 00 00 01 00 00 00 00 46 36 81 00 00 00 d8 01 10 |.......F6.......| 00010090 61 61 61 61 61 62 62 62 62 62 05 05 00 00 18 00 |aaaaabbbbb......| 000100a0 22 80 00 00 02 00 00 00 00 46 3d 82 00 00 00 dc |"........F=.....| 000100b0 01 10 63 63 63 63 63 64 64 64 64 64 05 05 00 00 |..cccccddddd....| 000100c0 20 ff ad 80 00 00 03 00 00 00 00 46 44 82 00 00 | ..........FD...| 000100d0 00 e0 01 10 65 65 65 65 65 66 66 66 66 66 00 00 |....eeeeefffff..| 000100e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00013ff0 00 00 00 00 00 70 00 63 c4 70 3b 6e 07 57 e4 ac |.....p.c.p;n.W..| 00014000

next_record如何向前偏移

notion image
上图中标红的是每条行记录的next_record字段(2字节),它表示相对偏移量,指向下一条记录。上图的链表表示:
infimum -> 1 -> 2 -> 3 -> supremum # 数字表示对应记录的主键值
需要注意的是,因为这里的supremum的物理位置在前,而实际记录在后,所以它的前一条记录(主键为3的记录)是要往前偏移的。最简单的做法就是通过正负号来表示,比如此处的ff ad,其实是它的反码-83,刚好就是supremum的位置。这里除了指向supremum会向前偏移,还有删除的空间被复用或者是插入了一条主键更小的数据也会向前偏移。

删除测试

删除主键为1的数据

我们尝试把主键值为1的数据删掉,来看看页上的数据变化
notion image
  • 第一行的改动是File Header的校验和,页面内容变了,这个值大概率会变
  • 第二行是页面被最后修改时对应的日志序列位置,LSN,每次修改也肯定会变
  • 第三行
    • 改动1是Page Header里的PAGE_FREE指向了被删除的记录(这个偏移量是从页最开始的位置算,是绝对偏移量)
    • 改动2是PAGE_GARBAGE增加了34(也就是id=1这行记录的占用空间)
  • 第四行
    • 改动1是PAGE_LAST_INSERT(最后插入记录的位置)重置为0
    • 改动2是PAGE_N_RECS(该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录))减1
  • 第七行
    • 改动1是修改infimumnext_record,因为第一条记录删除了,所以要指向id=2的记录
    • 改动2是supremumn_owned数量要减1
  • 第八行
    • 改动1是标记第一条记录的delete_mask为1
    • 改动2是标记第一条记录的next_record为00,不指向第二条记录
  • 第九行
    • 改动1是ROW_TRX_ID变化
    • 改动2是ROW_ROLL_POINTER变化
  • 最后一行的改动是File Trailer的校验和和LSN的改动

删除主键为2的数据

我们再把主键值为2的数据删除
notion image
和第一次删除没太大的区别,可以看到
  1. PAGE_FREE指向了最新的被删除的记录,PAGE_GARBAGE又增加了34
  1. PAGE_N_RECS减了1
  1. 主键值为2的这条记录的delete_mark被置为了1,并且它的next_record也被置为了ff de,这个位置是前面被删除的主键为1的记录所占的空间,形成了可重用空间链表:2 -> 1
  1. 记录链变成了infimum -> 3 -> supremum

删除主键为3的数据

我们继续删除主键值为3的数据。
这次发现整个页面似乎干净了,之前标记为删除的记录以及此次删除的记录全部都清空了。
如果需要删除的记录是这个数据页的最后一个记录,那么直接把这个数据页重新初始化成空页(page_create_empty)即可。参考:MySQL · 引擎特性 · InnoDB 数据页解析
❯ hexdump -C -s 65536 -n16384 update_test.ibd 00010000 a3 c0 25 83 00 00 00 04 ff ff ff ff ff ff ff ff |..%.............| 00010010 00 00 00 00 07 58 00 ed 45 bf 00 00 00 00 00 00 |.....X..E.......| 00010020 00 00 00 00 00 72 00 02 00 78 80 02 00 00 00 00 |.....r...x......| 00010030 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 00 |................| 00010040 00 00 00 00 00 00 00 00 01 52 00 00 00 72 00 00 |.........R...r..| 00010050 00 02 02 72 00 00 00 72 00 00 00 02 01 b2 01 00 |...r...r........| 00010060 02 00 0d 69 6e 66 69 6d 75 6d 00 01 00 0b 00 00 |...infimum......| 00010070 73 75 70 72 65 6d 75 6d 00 00 00 00 00 00 00 00 |supremum........| 00010080 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00013ff0 00 00 00 00 00 70 00 63 a3 c0 25 83 07 58 00 ed |.....p.c..%..X..| 00014000 (base)

重用被删除的记录空间

如果页面上的记录并没有被全部都删除的情况下,我们是否能复用被删除的空间?答案是可以的。

新插入记录占用空间大于删除记录

大于的情况是无法复用空间的,并且只会检查Garbage队列的第一个空间,这个实验由于两个Garbage的空间是一样的,所以无法证明只检查了第一个
insert into update_test(name1, name2) values(repeat('x',6), repeat('y',5));
❯ hexdump -C -s 65536 -n16384 update_test.ibd 00010000 f7 34 ad 61 00 00 00 04 ff ff ff ff ff ff ff ff |.4.a............| 00010010 00 00 00 00 08 10 ac af 45 bf 00 00 00 00 00 00 |........E.......| 00010020 00 00 00 00 00 96 00 02 01 01 80 06 00 a1 00 44 |...............D| 00010030 00 e5 00 05 00 00 00 02 00 00 00 00 00 00 00 00 |................| 00010040 00 00 00 00 00 00 00 00 01 76 00 00 00 96 00 00 |.........v......| 00010050 00 02 02 72 00 00 00 96 00 00 00 02 01 b2 01 00 |...r............| 00010060 02 00 60 69 6e 66 69 6d 75 6d 00 03 00 0b 00 00 |..`infimum......| 00010070 73 75 70 72 65 6d 75 6d 05 05 20 00 10 00 00 80 |supremum.. .....| 00010080 00 00 01 00 00 00 00 d7 9b 01 00 00 01 14 1d f2 |................| 00010090 61 61 61 61 61 62 62 62 62 62 05 05 20 00 18 ff |aaaaabbbbb.. ...| 000100a0 de 80 00 00 02 00 00 00 00 d7 a2 01 00 00 01 2d |...............-| 000100b0 12 25 63 63 63 63 63 64 64 64 64 64 05 05 00 00 |.%cccccddddd....| 000100c0 20 00 22 80 00 00 03 00 00 00 00 d7 98 81 00 00 | .".............| 000100d0 00 d4 01 10 65 65 65 65 65 66 66 66 66 66 05 06 |....eeeeefffff..| 000100e0 00 00 28 ff 8b 80 00 00 04 00 00 00 00 d7 a5 81 |..(.............| 000100f0 00 00 00 d8 01 10 78 78 78 78 78 78 79 79 79 79 |......xxxxxxyyyy| 00010100 79 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |y...............| 00010110 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00013ff0 00 00 00 00 00 70 00 63 f7 34 ad 61 08 10 ac af |.....p.c.4.a....|

新插入记录占用空间等于删除记录

我们在上面删除主键值等于3的记录之前,再插入一条数据看看,并且让新插入的记录刚好等于删除记录的大小
insert into update_test(name1, name2) values(repeat('j',5), repeat('i',5));
❯ hexdump -C -s 65536 -n16384 update_test.ibd 00010000 68 47 9b 55 00 00 00 04 ff ff ff ff ff ff ff ff |hG.U............| 00010010 00 00 00 00 07 5a a5 19 45 bf 00 00 00 00 00 00 |.....Z..E.......| 00010020 00 00 00 00 00 77 00 02 00 de 80 05 00 7f 00 22 |.....w........."| 00010030 00 a1 00 05 00 00 00 02 00 00 00 00 00 00 00 00 |................| 00010040 00 00 00 00 00 00 00 00 01 57 00 00 00 77 00 00 |.........W...w..| 00010050 00 02 02 72 00 00 00 77 00 00 00 02 01 b2 01 00 |...r...w........| 00010060 02 00 60 69 6e 66 69 6d 75 6d 00 03 00 0b 00 00 |..`infimum......| 00010070 73 75 70 72 65 6d 75 6d 05 05 20 00 10 00 00 80 |supremum.. .....| 00010080 00 00 01 00 00 00 00 47 b4 02 00 00 00 e9 17 86 |.......G........| 00010090 61 61 61 61 61 62 62 62 62 62 05 05 00 00 18 ff |aaaaabbbbb......| 000100a0 cf 80 00 00 04 00 00 00 00 47 be 82 00 00 00 ed |.........G......| 000100b0 01 10 6a 6a 6a 6a 6a 69 69 69 69 69 05 05 00 00 |..jjjjjiiiii....| 000100c0 20 ff de 80 00 00 03 00 00 00 00 47 b1 82 00 00 | ..........G....| 000100d0 00 e6 01 10 65 65 65 65 65 66 66 66 66 66 00 00 |....eeeeefffff..| 000100e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00013ff0 00 00 00 00 00 70 00 63 68 47 9b 55 07 5a a5 19 |.....p.chG.U.Z..| 00014000
可以看到此时的PAGE_FREE指向了主键值为1的记录且PAGE_GARBAGE也减少了34。并且新插入的记录复用了最后一次删除的(原来主键值为2的那条)记录的空间,刚好填满。

新插入记录占用空间小于删除记录

我们再试试小于的情况。
insert into update_test(name1, name2) values(repeat('l',3), repeat('m',3));
❯ hexdump -C -s 65536 -n16384 update_test.ibd 00010000 b6 ef a7 6d 00 00 00 04 ff ff ff ff ff ff ff ff |...m............| 00010010 00 00 00 00 07 5b 2a 7c 45 bf 00 00 00 00 00 00 |.....[*|E.......| 00010020 00 00 00 00 00 78 00 02 00 de 80 05 00 00 00 04 |.....x..........| 00010030 00 7f 00 02 00 01 00 03 00 00 00 00 00 00 00 00 |................| 00010040 00 00 00 00 00 00 00 00 01 58 00 00 00 78 00 00 |.........X...x..| 00010050 00 02 02 72 00 00 00 78 00 00 00 02 01 b2 01 00 |...r...x........| 00010060 02 00 60 69 6e 66 69 6d 75 6d 00 04 00 0b 00 00 |..`infimum......| 00010070 73 75 70 72 65 6d 75 6d 03 03 00 00 10 ff f1 80 |supremum........| 00010080 00 00 05 00 00 00 00 47 f4 81 00 00 01 84 01 10 |.......G........| 00010090 6c 6c 6c 6d 6d 6d 62 62 62 62 05 05 00 00 18 ff |lllmmmbbbb......| 000100a0 de 80 00 00 04 00 00 00 00 47 f1 81 00 00 01 83 |.........G......| 000100b0 01 10 6a 6a 6a 6a 6a 69 69 69 69 69 05 05 00 00 |..jjjjjiiiii....| 000100c0 20 ff de 80 00 00 03 00 00 00 00 47 e4 81 00 00 | ..........G....| 000100d0 01 b7 01 10 65 65 65 65 65 66 66 66 66 66 00 00 |....eeeeefffff..| 000100e0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................| * 00013ff0 00 00 00 00 00 70 00 63 b6 ef a7 6d 07 5b 2a 7c |.....p.c...m.[*|| 00014000
可以看到小于的情况也是可以复用的,但此时利用率不是100%,缩减的空间并不会进入Garbage队列,而是被标记为碎片空间,这种无法被重用(除非全表重建)。这部分在PAGE_GARBAGE上可以得到体现。

缩短更新的表示问题

之前一直想不明白缩短更新是怎么表示的,因为在行记录里对于一个变长字段只记录了一个长度,这个时候如果字段实际缩减了,那长度字段必然要缩减到真实长度,这不是会导致解析错位吗?后来通过分析ibd文件知道了,Innodb会把这条记录的实际数据往前挪,相当于是只对这条记录做了整理,这样的话这条记录的尾部剩下的就都是碎片空间。因为记录之间是通过next_record来串联的,所以也读不到后面碎片空间的数据了。这个碎片空间的大小也会被记录到PAGE_GARBAGE里。

变长字段更新

更新的记录为该页最后一条记录

这种情况可以直接在原记录上增长或者缩短,不会产生垃圾或者碎片
notion image
notion image

更新的记录不是该页最后一条记录

如果是增长更新,这种情况无法直接在原空间上增长,只能重新分配新的空间(等价于删除老记录,再插入一条主键值相同的新记录,老记录会被放到Garbage队列中),下图是主键值为1的记录的name1字段从aaaaa更新成aaaaac的前后对比。
notion image
如果是缩短更新,则会产生碎片,下图是主键值为2的记录的name1字段从ccccc修改成a的前后对比,最终记录了4个字节的碎片,但是Garbage队列为空
notion image

先缩短再增长,且增长后的空间小于最初

缩短更新和上面的情况一样,虽然有碎片空间,但是增长更新时也无法重用,只能重新分配新的空间,下图是主键值为2的记录的name1字段从ccccc修改成a再修改成aa的前后对比
notion image

Innodb page预留的1/16空闲空间

有个选项innodb_fill_factor用于定义InnoDB page的填充率,默认值是100。但是即便 innodb_fill_factor=100,也会预留1/16的空闲空间,用于现存记录长度扩展用。
  • 在最佳的顺序写入数据模式下,page填充率有可能可以达到15/16。
  • 在随机写入新数据模式下,page填充率约为 1/2 ~ 15/16。
  • 预留1/16这个规则,只针对聚集索引的叶子节点有效。对于聚集索引的非叶子节点以及辅助索引(叶子及非叶子)节点都没有这个规则。
  • innodb_fill_factor选项对叶子节点及非叶子节点都有效,但对存储text/blob溢出列的page无效
InnoDB表聚集索引层高什么时候发生变化 这篇文章指出:一个page最大约能存储15/16容量,扣掉用于存储page header、trailer信息,以及index header、File Segment Header、Infimum&Supremum(两条虚拟记录)等必要的固定消耗之后,实际大约只有15212字节可用于存储用户数据。
但是作者没有给出15212的计算过程,我尝试自己算了一下,应该是
16384 * 15/16 - 38(File Header) - 56(Page Header) - 26(Infimum & Supremum) - 8(File Trailer) = 15232
于是我想着用实验来证实一下,先创建表结构和初始化数据,尽量把页面撑满
mysql> CREATE TABLE `high_test` ( `i` int(10) unsigned NOT NULL AUTO_INCREMENT, k varchar(20) not null, PRIMARY KEY (`i`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC; Query OK, 0 rows affected (0.08 sec) mysql> DELIMITER $$ CREATE PROCEDURE InsertRecords() BEGIN DECLARE v_max INT DEFAULT 620; DECLARE v_counter INT DEFAULT 1; WHILE v_counter <= v_max DO insert into high_test select 0, 'a'; SET v_counter = v_counter + 1; END WHILE; END$$ DELIMITER ; Query OK, 0 rows affected (0.03 sec) mysql> CALL InsertRecords(); Query OK, 1 row affected (2.16 sec)
上面这620条记录每条记录的占用大小都是一样的:
1(可变字符长度) + 5(记录头) + 4(主键) + 1(可变字符) + 6(事务id) + 7(回滚指针) = 24
那么记录部分总共占用的空间大小就是24 * 620 = 14880
我们再算上Page Directory的占用空间,620 / 4 = 155,不过由于infimum需要单独占用一个slot,所以需要156个slot,所以Page Directory总共占用是156 * 2 = 312
那么这两部分总共占用空间为14880 + 312 = 15192,按照我们计算的15232那还剩下40个字节。我们尝试插入一个占用40个字节的记录:只需要把列k字符串的长度调整到17个字节即可。
mysql> insert into high_test select 0, REPEAT('a',17); Query OK, 1 row affected (0.06 sec) Records: 1 Duplicates: 0 Warnings: 0
我们看一下插入之后的页面情况,发现层高已经变化了
mysql> analyze table high_test; +----------------+---------+----------+----------+ | Table | Op | Msg_type | Msg_text | +----------------+---------+----------+----------+ | test.high_test | analyze | status | OK | +----------------+---------+----------+----------+ 1 row in set (0.03 sec) mysql> select table_name, format_bytes(data_length) DATA_SIZE, data_free from information_schema.TABLES where table_name = 'high_test'; +------------+-----------+-----------+ | TABLE_NAME | DATA_SIZE | DATA_FREE | +------------+-----------+-----------+ | high_test | 48.00 KiB | 0 | +------------+-----------+-----------+ 1 row in set (0.00 sec)
反复尝试,发现插入的字符串长度为14时,层高还是为1,长度为15时层高就变为2。插入长度为14时的总字节数为15192 + 37 = 15229和我们计算出的15232差了3个字节,不知道是怎么个逻辑,希望懂的朋友不吝赐教,感谢感谢。
既然15/16的策略是针对插入数据的,那我们试试更新看看。注意,由于我们是增长更新,所以是delete + insert的方式。整个页面的剩余空间还有16384 - 15229 - 38 - 56 -26 - 8 = 1027。我们把每条记录的大小增加20个字节,总共44个字节,那么应该可以更新23条数据,23 * 44 = 1012
update high_test set k = REPEAT('a',21) where i <= 23;
notion image
此时我们再更新一条记录试试,发现Garbage列表为空,也就是可重用空间全部被整理了。当空闲空间全部用完后,若此时Garbage队列不为空,则会对其进行重整后,变成可用空间再次被分配
update high_test set k = REPEAT('a',21) where i = 24;
notion image
此时总占用空间为15229 + 20 * 24 + 38 + 56 + 26 + 8 = 15837,剩余空间为16384 - 16149 = 547,我们继续更新
update high_test set k = REPEAT('a',21) where i > 24 and i <= 50 ;
一次性更新多条数据会直接把整理的动作做掉。这下还剩27个字节的空间,我们看看能不能给它填满
update high_test set k = REPEAT('a',4) where i = 51;
这一次直接是整理加更新了,没有Garbage产生,于是最后可以更新到
update high_test set k = REPEAT('a',26) where i = 51;
最后剩下的3个字节貌似没法使用,一用层高就增加了。记得前面我们计算innodb_fill_factor的值也是差了3个字节。另外,前面直接整理加更新我怀疑也和这3个字节有关系。我们再试试看如果说更新到剩余3个字节,看看会不会整理
update high_test set k = REPEAT('a',21) where i > 24 and i <= 49 ; update high_test set k = REPEAT('a',20) where i = 50 ; update high_test set k = 'aa' where i = 51;
notion image
notion image
果然就是这3个字节,如果多加一个字节更新成aaa我们看看,果然层高增长了
update high_test set k = REPEAT('a',21) where i > 24 and i <= 49 ; update high_test set k = REPEAT('a',21) where i = 50 ; update high_test set k = 'aaa' where i = 51;
notion image
notion image

层高从1->2后的结构分析

notion image
层高从1→2之后,就变成了根节点→叶节点这种结构了。用红色下划线和蓝色下划线表示的分别是两个叶节点页的记录信息(图中有误,写成了内节点),我们来分析一下这两条记录
字段
记录1
记录2
预留位1
0
0
预留位2
0
0
delete_mask
0
0
min_record_mask
1
0
n_owned
0000
0000
heap_no
0000000000010
0000000000011
record_type
001
001
next_record
0000000000001110
1111111111100110
可得
  • 两条记录的record_type都等于1,表示非叶子节点记录
  • 第一条记录的min_record_mask为1,代表最小的非叶子节点记录
而对于两条记录的内容,记录了2个信息
  1. 对应页面上最小的主键值
  1. 对应的页号(page_no)
notion image
而上面两条记录对应的两个页面page_no5和page_no6上最小的记录分别是1和311

参考

  1. InnoDB表聚集索引层高什么时候发生变化
  1. MySQL · 引擎特性 · 手动分析InnoDB B+Tree结构
  1. 堂妹问我:innodb是如何插入数据的?
  1. InnoDB——Data Layout
  1. MySQL 引擎特性 · InnoDB 手动分析 B+树
  1. MySQL · 引擎特性 · InnoDB 数据页解析
  1. 盛放记录的大盒子 —— InnoDB 数据页结构
  1. InnoDB B-TREE 索引怎么定位一条记录?
  1. InnoDB表聚集索引层高什么时候发生变化
  1. InnoDB数据页结构分析
  1. https://smartkeyerror.oss-cn-shenzhen.aliyuncs.com/Psyduck/MySQL/InnoDB-Page.pdf
  1. InnoDB Page结构详解
  1. innblock | InnoDB page观察利器
 
使用多值索引(Multi-Value Index)导致Canal同步异常HTTP缓存机制解析