从根儿上理解 MySQL - 页总结

页结构

由于 MySQL 的真实数据是存储在磁盘, 因此在读写数据是会涉及磁盘 IO, 为了更高效率的读取, MySQL 设计页结构, 每次交互以页为单位读取到内存. 页的大小一般为 16KB

一个数据页可以被大致划分为7个部分

File Header,表示页的一些通用信息,占固定的38字节。
Page Header,表示数据页专有的一些信息,占固定的56个字节。
Infimum + Supremum,两个虚拟的伪记录,分别表示页中的最小和最大记录,占固定的26个字节。
User Records:真实存储我们插入的记录的部分,大小不固定。
Free Space:页中尚未使用的部分,大小不确定。
Page Directory:页中的某些记录相对位置,也就是各个槽在页面中的地址偏移量,大小不固定,插入的记录越多,这个部分占用的空间越多。
File Trailer:用于检验页是否完整的部分,占用固定的8个字节。

image-20200129175558772

image-20200129175624645

上述的行格式即为 User Records的一部分

新生成的页并没有 User Records, 当插入数据时, 会从 Free Space 申请, 如果 Free Space 的空间全部被替换掉, 则说明该页满了, 需要申请新的页

File Header (文件头部)

文件头部表主要关注点在于页号, 上一页的页号和下一页的页号. 构成了双向链表

image-20200130141415406

Page Directory (页目录)

image-20200129180219930

从 select * from table wher id = 1 讲起
比较笨的方式会从最小记录开始遍历往下找, 知道找到 next_record = 0

MySQL 的优化点在于新增了一个目录, 利用二分查找优化

  1. 通过二分法确定该记录所在的槽(实际是找到确定的槽, 遍历该槽的上一个槽或者当前槽)
  2. 通过记录的next_record属性遍历该槽所在的组中的各个记录

image-20200129180437808

1
2
3
4
5
6
7
8
9
10
11
12
13
因为把16条记录的全部信息都画在一张图里太占地方,让人眼花缭乱的,所以只保留了用户记录头信息中的n_owned和next_record属性,也省略了各个记录之间的箭头,我没画不等于没有啊!现在看怎么从这个页目录中查找记录。因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用所谓的二分法来进行快速查找。5个槽的编号分别是:0、1、2、3、4,所以初始情况下最低的槽就是low=0,最高的槽就是high=4。比方说我们想找主键值为6的记录,过程是这样的:

计算中间槽的位置:(0+4)/2=2,所以查看槽2对应记录的主键值为8,又因为8 > 6,所以设置high=2,low保持不变。

重新计算中间槽的位置:(0+2)/2=1,所以查看槽1对应的主键值为4,又因为4 < 6,所以设置low=1,high保持不变。

因为high - low的值为1,所以确定主键值为6的记录在槽2对应的组中。此刻我们需要找到槽2中主键值最小的那条记录,然后沿着单向链表遍历槽2中的记录。但是我们前边又说过,每个槽对应的记录都是该组中主键值最大的记录,这里槽2对应的记录是主键值为8的记录,怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到槽1对应的记录(主键值为4),该条记录的下一条记录就是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽2中的各条记录,直到找到主键值为6的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。

所以在一个数据页中查找指定主键值的记录的过程分为两步:

通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。

通过记录的next_record属性遍历该槽所在的组中的各个记录。

行格式

行格式主要分为四种类型CompactRedundantDynamicCompressed. 主要理解 Compact

Compact 行格式

image-20200129145838600

行格式主要分为记录的额外信息, 记录的真实数据

变长字段长度列表

变长字段长度列表存储的是变长类型的真实数据的占用字节数(逆序).

如果该表没有变长类型, 则无变长字段长度列表

变长字段长度列表只保存 NOT NULL列, 如果该列允许为 NULL, 则不会保存

1
2
3
举例: 字段 0 int 0, 字段1 vachar(10) NOT NULL 'A', 字段2 vachar(10) default NULL 'B', 字段3 vachar(10) NOT NULL 'AA'
变长字段长度列表: 02 01
可通过 变长字段反推字段的长度

NULL 值列表

NULL 值列表只统计哪些字段允许为 NULL的值状态(逆序)

如果该表没有允许 NULL 列, 则无 NULL 值列表

MySQL规定NULL值列表必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0

二进制位的值为1时,代表该列的值为NULL
二进制位的值为0时,代表该列的值不为NULL

1
2
3
4
举例: 字段 0 vachar(10) NOT NULL 'A', 字段1 vachar(10) default NULL 'B', 字段2 vachar(10) default NULL NULL, 字段3 vachar(10) default NULL NULL
逆序后: 字段3 - 1, 字段2 - 1, 字段1 - 0
NULL 值列表: 00000110
可通过 NOT NULL 字段反推哪些字段值为 NULL, 哪些字段值不为 NULL

记录头

这一部分牵扯的内容较多

大致可以看一下删除标识(删除是并非真正删除, 只是修改表示), 下一条记录位置(B+树特性)

image-20200129152146891

image-20200129152534179

除了这些详细信息, 还会有 MySQL 自动添加的隐藏列

实际上这几个列的真正名称其实是:DB_ROW_ID, DB_TRX_ID, DB_ROLL_PTR, 为了美观才写成了row_id、transaction_id和roll_pointer

row_id: 有主键使用主键, 没有主键有唯一键使用唯一键, 没有则自动生成

transaction_id: 事务 ID

roll_pointer: 回滚指针

image-20200129152851500

行溢出

因为一个页占用 16KB, 16 * 1024 = 16384 字节, 而 varchar 最多可以占用65535, 不包括隐藏列和记录头信息

但是在我们使用 varchar 时, 会设置变长字段长度列表和 NULL 值列表

对于 NOT NULL, 只能使用65533字节 (两个字节用来表示长度)
对于非 NOT NULL, 只用使用 65532 字节 (两个字节用来表示长度, 一个字节用来表示 NULL 标识)

image-20200129154058216

1
对于行溢出的情况, 真实的数据只用存储前 768 字节, 后面放的是溢出页地址

行溢出的节点

综上所述, 我们知道在数据的占用字节数超过某个阈值就会发生行溢出, 那么行溢出的计算方式分析如下

ps: 截图来源于MySQL 是怎样运行的:从根儿上理解 MySQL(小孩子 4919)

image-20200129154804902

其他

delete_mask

这个属性标识该条记录是否已删除

如果 MySQL 设计删除一条则执行磁盘删除, 会导致磁盘IO增加, MySQL 为了优化, 使用一个标识表示该记录已被删除, 如果有新的记录来, 会覆盖该条记录
原理: 删除数据时, 标识该记录已删除, 并且放入垃圾链表, 新的记录插入时, 先从垃圾链表中替换

如果想要优化空间, 则执行 optimize table ‘name’ 即可

链表

页与页之间通过 File Header 双向链表连接上一条记录和下一条记录

页内数据记录 通过 next_record 单向链表连接下一条记录

参考文献

MySQL 是怎样运行的:从根儿上理解 MySQL(记录结构)

MySQL 是怎样运行的:从根儿上理解 MySQL(数据页结构)


从根儿上理解 MySQL - 页总结
https://gallrax.github.io/2020/01/28/从根儿上理解MySQL-页总结/
作者
Gallrax
发布于
2020年1月28日
许可协议