MySQL-InnoDB中数据存储结构(页)

1.页的概念

  • 磁盘和内存交互的基本单位是,一次最少从磁盘读取一个页的内容到内存中。

  • 数据库的存储结构也是,InnoDB将数据划分为若干个页,InnoDB中页的默认大小是16KB,因此在数据库中,无论读一行还是多行,都需要将这些行所在的页加载到内存,即数据库I/O操作的最小单位是页。

  • 数据库中多个页可以不在物理结构上相连,只需要通过双向链表相互关联即可,每个数据页中的记录按照主键值从小到大的顺序组成一个单向链表。

  • 多个数据页对应一个页目录,在查找某条记录时只需要在页目录(数组)中使用二分法查找定位对应的页号范围,然后在遍历这个页。

1
2
3
4
5
6
7
8
mysql> show variables like '%innodb_page_size%';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| innodb_page_size | 16384 |
+------------------+-------+
1 row in set (0.01 sec)

2.页的内部结构

页按照类型划分,常见的有数据页(保存B+树节点),系统页,Undo页,事务数据页。

数据页的16KB包括这七个部分:

  1. 文件头(File Header) 38B,描述页的信息

  2. 页头(Page Header) 56B,页的状态信息

  3. 最大、最小记录(Infimum+supremum) 26B,虚拟的行记录

  4. 用户记录(User Records),存储行记录内容

  5. 空闲空间(Free Space),页中还没有被使用的页

  6. 页目录(Page Directory),存储用户记录的相对位置

  7. 文件尾(File Tailer) 8B,检验页是否完整

2.1 文件头和文件尾

文件头的结构:

名称 占用空间大小 描述
FIL_PAGE_SPACE_OR_CHECKSUM 4字节 页的检验和,和文件尾的检验和一起维护页的完整性。当页从磁盘同步到内存或者从内存同步到磁盘后,会检验首尾的检验和是否一致,一致就说明页完整。
FIL_PAGE_OFFSET 4字节 页号,每个页都有唯一的页号,InnoDB通过页号可以唯一确定一个页
FIL_PAGE_PREV 4字节 上一个页的页号
FIL_PAGE_NEXT 4字节 下一个页的页号
FIL_PAGE_LSN 8字节 页面被最后修改时对应的日志序列位置
FIL_PAGE_TYPE 2字节 页的类型,比较常用的页的类型有:1.FIL_PAGE_INDEX索引页/数据页,0x45BF 2.FIL_PAGE_UNDO_LOGUndo日志页,0x0002 3.FIL_PAGE_TYPE_SYS系统页,0x0006
FIL_PAGE_FILE_FLUSH_LSN 8字节 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 4字节 页属于哪个表空间

文件尾的结构:

名称 占用空间大小 描述
FIL_PAGE_SPACE_OR_CHECKSUM 4B 校验和
FIL_PAGE_LSN 4B 代表页面被最后修改时对应的日志序列位置,头部和尾部的LSN值不相等说明页不完整

2.2 最大最小记录、用户记录、空闲空间

2.2.1 空闲空间

一开始生成的页中并没有用户记录这部分空间,每当我们插入一条记录,都会从空闲空间部分(未使用的存储空间)中申请一个记录大小的空间划分到用户记录部分,当空闲空间全部被用户记录替代掉后,再有新的记录插入时就需要去申请新的页了。

2.2.2 用户记录

记录按照指定的行格式一条一条摆放在用户记录空间部分,相互之间形成单链表。

十六进制中两个数连在一起是一个字节。


行格式有COMPACT行格式、DYNAMIC行格式

2.2.2.1 COMPACT行格式

  • 变长字段长度列表 (2B)

    用varchar修饰的字段中存储多少字节的数据不是固定的,在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,就是这个变长字段长度列表。

    列表里面存储的变长长度和字段顺序是反过来的,比如两个varchar字段在表结构中的顺序是a varchar(10),b varchar(15),a=’zhangsan’ (长度为8),b=’lisi’ (长度为4),那么在变长字段长度列表中存储的长度顺序就是04,08 (最后以十六进制存)

  • NULL值列表 (1B)

    当表中某几个字段值为NULL时,如果不管它,到时候可能会在查询数据的时候出现位置错乱,导致读取结果不正确,因此专门在行数据的头部设置一个NULL值列表标志哪个字段当前取值是NULL

    • 二进制位的值为1,表示该列的值为NULL
    • 二进制位的值为0,表示该列的值不为NULL

    将最终的取值倒序存放到NULL值列表中(如果这一列刚开始就被定义成NOT NULL或者是主键(也是非空),就不需要标记这一位了)

  • 记录头信息 (5B)

    • 预留位1、2没有使用

    • delete_mask (1bit)

      标志当前记录是否被删除,值为0代表记录没有被删除,值为1代表记录被删除

    • min_rec_mask

      在存储目录项记录的页中主键值最小的目录项记录的min_rec_mask的值为1,其他记录的min_rec_mask值都是0,B+树的每层非叶子节点中的最小记录都会添加该标记

    • record_type

      表示当前记录的类型

      • record_type = 0:普通记录
      • record_type = 1:B+树的非叶子节点记录
      • record_type = 2:最小记录
      • record_type = 3:最大记录
    • heap_no

      表示当前记录在本页中的位置。

      • heap_no = 0:表示最小记录
      • heap_no = 1:表示最大记录
      • 后面添加用户数据记录时,heap_no的值每次加1
    • n_owned

      如果当前记录时所在组的最后一条记录,那n_owned的值为所在组的记录个数,如果当前记录不是所在组的最后一条记录,那n_owned的值就是0

    • next_record

      表示当前记录的真实数据到下一条记录的真实数据的地址偏移量

  • 记录的真实数据

    • 三个隐藏的列

      • DB_ROW_ID (6B):row_id,不是必须的,表示行ID,唯一标识一条记录
      • DB_TRX_ID (6B):transaction_id,是必须的,表示事务ID
      • DB_ROLL_PTR (7B):roll_pointer,是必须的,表示回滚指针

      如果一个表没有定义主键,会选择一个Unique键作为主键,如果连Unique键都没有,才会为表默认添加一个名为row_id的隐藏列作为主键,否则row_id不会出现。

    • 自己定义的列的数据

2.2.2.1 其他行格式

Dynamic和Compressed 跟Compact行格式差不多,但是有一点不一样。

行溢出:我们知道一个页的最大空间是16KB,即16*1024 = 16384B,而当我们把一个变长字段varchar的最大的长度设置为65535时,一个页连一条行记录都存储不了了,这就是行溢出。

在处理行溢出时采用的策略不同,Compact会在记录中存储部分真实数据,然后将剩余部分的内容分页存储在其他的页中,在原来的存储部分真实数据的后面的空间中存储存储指向这些页的地址。而Dynamic和Compressed则不会在行记录中存储这些数据,而是全部存储在其他页中,在行记录中只存储这些页的地址。

Redundant行格式:没有NULL值列表,并且变长字段偏移量中每个字段的长度都是是从开头开始计算的。并且记录头信息中有些区别。


相关命令:

查看MySQL8的默认行格式

1
2
3
4
5
6
7
mysql> select @@innodb_default_row_format;
+-----------------------------+
| @@innodb_default_row_format |
+-----------------------------+
| dynamic |
+-----------------------------+
1 row in set (0.00 sec)

创建表时可以指定表的行格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
mysql> create table tb_user4(id int,name varchar(20))row_format=compact;
Query OK, 0 rows affected (0.02 sec)

mysql> show table status like 'tb_user4'\G;
*************************** 1. row ***************************
Name: tb_user4
Engine: InnoDB
Version: 10
Row_format: Compact
Rows: 0
Avg_row_length: 0
Data_length: 16384
Max_data_length: 0
Index_length: 0
Data_free: 0
Auto_increment: NULL
Create_time: 2022-03-24 17:30:15
Update_time: NULL
Check_time: NULL
Collation: utf8mb4_0900_ai_ci
Checksum: NULL
Create_options: row_format=COMPACT
Comment:
1 row in set (0.00 sec)

修改行格式

1
2
3
mysql> alter table tb_user2 row_format=compact;
Query OK, 0 rows affected (0.05 sec)
Records: 0 Duplicates: 0 Warnings: 0

2.2.3 最小最大记录

这两条记录不是我们自己定义的记录,所以他们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分

这两条记录都是由5B大小的记录头信息,和8B大小的一个固定部分组成。

最小记录:记录头信息

最小记录的next_record指向第一条记录,即主键值最小的那条记录,并不是我们按照插入顺序拍的那个记录,最后一个用户记录的next_record指向最大记录。

2.3 页目录和页头

2.3.1 页目录

使用页目录的原因是方便快速定位到一个页中的记录

先分组:具体的做法是将所有的记录(包括最大记录和最小记录,但不包括已删除的记录)分成几个组。

  • 第一组只有一条记录,就是最小记录

  • 最后一组会有1-8条记录,是最大记录所在的分组

  • 其余的组中记录的数量在4-8条之间

在每个组的最后一条记录的记录头信息中会存储该组一共有多少条记录,就是n_owned字段

分好组之后,将每组最后一条记录的地址偏移量存储在页目录中,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量被称为槽slot,每个槽相当于指向每个组的最后一条记录。

页目录其实是一个数组,存放的是每组最后一个记录的地址偏移量,因此采用二分法,找到对应的组,然后在组中遍历单向链表,找到目标记录。

2.3.2 页面头部


MySQL-InnoDB中数据存储结构(页)
https://vickkkyz.fun/2022/04/07/计算机/mysql/InnoDB数据存储结构/
作者
Vickkkyz
发布于
2022年4月7日
许可协议