摘要
MySQL 的页有多种类型,本文主要讲述数据页的结构和记录管理,数据页也就是表中一行一行的数据组成的页。
前言
MySQL 的数据存储在磁盘中,每次执行 SQL 命令时需要把对应的记录从磁盘读取到内存中,这个过程叫做磁盘 IO。无论磁盘使用传统的机械硬盘,还是使用固态硬盘,磁盘 IO 相对于内存 IO 来说,速度都是较慢的。以机械硬盘为例,每进行一次随机 IO,都需要磁头移动到对应的磁道大约需要 10 ms,这个过程称为寻道,然后磁盘旋转到读取数据的位置,这个过程耗时与磁盘的转速有关,一般为 3 ms,但是内存的一次随机读取大约耗时在几百纳秒,比磁盘随机 IO 快了好几个数量级。
为了减少磁盘 IO 的次数,MySQL 将若干记录组成一个数据页,一般一个数据页的大小为 16 KB,每次磁盘 IO 最少读取一个数据页,很多时候还会把相邻的数据页一起读到内存,这种 IO 称为连续 IO,相对于随机 IO,省了寻道的时间,这个时间占磁盘 IO 的大头。
MySQL 有多种存储引擎,不同的存储引擎对页和行的结构设计有所不同,本文只讲述 InnoDB 使用的页和行。在 InnoDB 存储引擎中,页有多种用途,不同用途的页结构也会有所不同,下文主要讲述数据页的结构,其它类型的页结构比较相似,就不再展开了,感兴趣的可以自行了解。
1. 数据页的结构
数据页占用的16KB空间可以划分为多个部分,具体如下图所示。
- File Header:文件头部,主要存储页的通用信息。
- Page Header:页面头部,数据页独有的信息。
- Infimum+Supremum:页面中最小记录和最大记录。
- User Record:用户记录,即表中的数据行。
- Free Space:页的空闲空间。
- Page Directory:页目录,页中某些记录的相对位置。
- File Trailer:文件尾部,校验页是否完整。
2. 记录在页中的存储
我们知道记录存储在页的User Record
部分,具体存储形式如下图所示,
2.1 记录的存储结构
记录的格式一般称为行格式,如果做过主从复制,应该对这个概念比较清楚。行格式有 4 种,分别为COMPACT
、REDUNDANT
、DYNAMIC
和COMPRESSED
。行格式不同,记录在磁盘中的存储结构就不同,下文主要讲述COMPACT
格式,其它行格式类似,不再展开。
COMPACT
格式组成如下图所示,
2.1.1 变长字段列表
表中有些字段是可变长度的,例如varchar(20)
类型,其中的 20 表示这个字段能够存储的最大字符数,变长字段列表就是记录变长字段实际使用的字节数。如果要将字符数转换为字节数需要确定表使用的字符集,例如 ascii 字符集,一个字符占用一个字节,如果是 utf8mb4 字符集,一个字符占用 4 个字节。
2.1.2 NULL值列表
表中有些字段是允许为空的,每个允许为空的列在 NULL 值列表中都会对应一个比特位,比特位为 1 时,说明对应的列存储的是 NULL;比特位为 0 时,说明对应的列存储的值非空。这样区分的好处是,值为 NULL 的列不需要分配存储空间,能够节省内存。
2.1.3 记录头
如上图所示,记录头包含如下结构:
名称 | 大小(比特) | 描述 |
保留位 | 2 | 暂未使用 |
deleted_flag | 1 | 删除标记,为 1 表示该记录已被删除 |
min_rec_flag | 1 | 讲索引时再介绍 |
n_owned | 4 | 一个页中的记录会分为多个组,组长的这个字段显示该分组包含的记录数 |
heap_no | 13 | 表示当前记录是 |
record_type | 3 | 记录类型,0:普通记录,1:B+树非叶子节点记录,2:Infimum 记录,3:Supremum 记录 |
next_record | 16 | 下一条记录在页面的偏移量 |
2.1.4隐藏列
每条记录都有三个隐藏列,
- row_id:占用 6 个字节。记录的唯一标识。该列只是确保每条记录有一个唯一标识,如果表中设置了主键或非空的唯一索引,那么该列不会生成。
- trx_id:占用 6 个字节。表示事物 ID。讲 MVCC 时再做详细介绍。
- roll_pointer:占用 7 个字节。表示回滚指针。讲 MVCC 时再做详细介绍。
这三个隐藏列都是 InnoDB 自动生成的,用户不需要去维护。
2.2 记录的管理方式
通过行格式中的heap_no
和next_record
两个字段可以知道记录在页中以链表的方式存在,具体如下图所示(仅展示必须的字段),记录的链表结构
如图所示,有两条虚拟的记录 Infimum 和 Supremum 分别在链表的头部和尾部,表示该页中存储的最小记录和最大记录,这两条虚拟记录的heap_no
固定为 0 和 1。链表中的其它记录也是按照由小到大的顺序排列的。
记录包含多个字段,如何比较记录的大小?
假设表中只有一个主键没有其它索引,那么就是通过比较主键的大小来比较记录的大小。如果没有主键还能比较隐藏列row_id
的大小。
3. Page Directory(页目录)
如果存在如下SQL,其中id
为主键,
select * from t where id = 7;
按照常规思路,我们需要遍历页中的所有记录,才能找到满足id = 7
的记录,这样查询的效率太低了。这时可能有人会说,链表中的记录是有序的,为什么不能使用二分查找呢?虽然我们知道链表的最小值和最大值,但是不知道中间值,这是由链表的特性决定的,不能快速定位到某条记录。然而,如果能把链表转成数组,就能愉快的使用二分查找了。
InnoDB 将链表的记录进行分组,每组最大的记录的n_owned
记录了组内的记录数量。每个组内最大记录的地址偏移量保存到页目录中,这些偏移量称为槽,每个槽占用两个字节。具体组织形式如下图所示,
槽 0 只有一条记录,因为 InnoDB 规定了 Infimum 所在的分组只能有一条记录,即 Infimum 记录独自成组。Supremum 记录所在的分组可以有 1-8 条记录,其余分组可以有 4-8 条记录。多个槽指向的记录构成了一个有序数组,当插入一条新记录时,只需要使用二分搜索找到应该插入的槽,然后遍历槽中的记录,即可找到插入的位置。相对于遍历整页的记录,这种查询方式的效率要高很多。
4. Page Header(页面头部)
页面头部主要记录了整个页的统计信息。主要包含如下内容,
- PAGE_N_DIR_SLOTS:占用 2 字节,表示页目录中槽的数量。
- PAGE_HEAP_TOP:占用 2 字节,表示页中
Free Space
区域的首地址。 - PAGE_N_HEAP:占用 2 字节,表示第 1 位本页的记录是否为紧凑型记录,剩余 15 位表示本页的记录条数。
- PAGE_FREE:占用 2 字节,每个被标记删除的记录会组成一个链表,PAGE_FREE 记录该链表的头节点的偏移量。
- PAGE_GARBAGE:占用 2 字节,已删除记录占用的字节数。
- PAGE_LAST_INSERT:占用 2 字节,最后插入记录的位置。
- PAGE_DIRECTION:占用 2 字节,记录插入的方向。新纪录插入到页中,会有两个方向,如果比上次插入的记录大,则算是右边插入;反之,则是左边插入。
- PAGE_N_DIRECTION:占用 2 字节,一个方向连续插入的记录数量。反应了最近一系列插入记录的单调性。
- PAGE_N_RECS:占用 2 字节,本页用户创建的记录数量。
- PAGE_MAX_TRX_ID:与事务相关,后面再讲。
- PAGE_LEVEL:与索引相关,后面再讲。
- PAGE_INDEX_ID:与索引相关,后面再讲。
- PAGE_BTR_SEG_LEAF:与索引相关,后面再讲。
- PAGE_BTR_SEG_TOP:与索引相关,后面再讲。
5. File Header(文件头部)和 File Ttrailer(文件尾部)
不同类型的页,其结构也不相同,但都有文件头部和尾部。头部主要记录各种页的信息,以及页与页之间的关系。主要有如下内容,省略不用的字段,
- FIL_PAGE_OFFSET:占用 4 字节,表示页号。
- FIL_PAGE_PREV:占用 4 字节,表示上一页的页号。
- FIL_PAGE_NEXT:占用 4 字节,表示下一页的页号。
- FIL_PAGE_LSN:MVCC 相关,后面再讲。
- FIL_PAGE_TYPE:占用 2 字节,表示该页的类型。
- FIL_PAGE_FILE_FLUSH_LSN:MVCC 相关,后面再讲。
- FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID:页属于的表空间。
通过FIL_PAGE_PREV
和FIL_PAGE_NEXT
通过这两个字段,可以看出页与页之间也组成链表结构,已知页内的记录是有序的,可以猜测若干页组成的链表也是有序的,这是实现索引的基础。
文件尾部主要用于页的完整性校验。
思考题
一个页的大小为 16 KB,里面包含了固定头信息和记录信息。如果记录中某个字段占用的字节数超过了 16 KB,那么 InnoDB 会如何处理这条记录呢?