在MySQL-InnoDB行格式浅析中,们简单提了一下 页 的概念,它是 InnoDB 管理存储空间的基本单位,一个页的大小一般是 16KB 。
InnoDB 为了不同的目的而设计了许多种不同类型的 页:
- 存放表空间头部信息的页
- 存放 Insert Buffer信息的页
- 存放 INODE 信息的页
- 存放 undo 日志信息的页
- 存放 索引( INDEX )页,即存放表中记录的页,称作数据页
数据页
数据页代表的这块 16KB 大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:
InnoDB 数据页的存储空间大致被划分成了 7 个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。
File Header (38字节)
File Header是文件头部,保存页的一些通用信息。
不同类型的页都会以 File Header 作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,比方说这个页的编号是多少,它的上一个页、下一个页是谁等。
名称 | 占用空间大小 | 描述 |
---|---|---|
FIL_PAGE_SPACE_OR_CHKSUM | 4 字节 | 页的校验和(checksum值) |
FIL_PAGE_OFFSET | 4 字节 | 页号 |
FIL_PAGE_PREV | 4 字节 | 上一个页的页号 |
FIL_PAGE_NEXT | 4 字节 | 下一个页的页号 |
FIL_PAGE_LSN | 8 字节 | 页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number) |
FIL_PAGE_TYPE | 2 字节 | 该页的类型 |
FIL_PAGE_FILE_FLUSH_LSN | 8 字节 | 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 |
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 4 字节 | 页属于哪个表空间 |
Page Header(56字节)
Page Header是页面头部,数据页专有的一些信息。
为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等,特意在页中定义了一个叫 Page Header 的部分,它是页 结构的第二部分,这个部分占用固定的 56 个字节,专门存储各种状态信息。
名称 | 占用空间大小 | 描述 |
---|---|---|
PAGE_N_DIR_SLOTS | 2 字节 | 在页目录中的槽数量 |
PAGE_HEAP_TOP | 2 字节 | 还未使用的空间最小地址,也就是说从该地址之后就是 Free Space |
PAGE_N_HEAP | 2 字节 | 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) |
PAGE_FREE | 2 字节 | 第一个已经标记为删除的记录地址(各个已删除的记录通过 next_record 也会组成一个单链表,这个单链表中的记录可以被重新利用) |
PAGE_GARBAGE | 2 字节 | 已删除记录占用的字节数 |
PAGE_LAST_INSERT | 2 字节 | 最后插入记录的位置 |
PAGE_DIRECTION | 2 字节 | 记录插入的方向 |
PAGE_N_DIRECTION | 2 字节 | 一个方向连续插入的记录数量 |
PAGE_N_RECS | 2 字节 | 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) |
PAGE_MAX_TRX_ID | 8 字节 | 修改当前页的最大事务ID,该值仅在二级索引中定义 |
PAGE_LEVEL | 2 字节 | 当前页在B+树中所处的层级 |
PAGE_INDEX_ID | 8 字节 | 索引ID,表示当前页属于哪个索引 |
PAGE_BTR_SEG_LEAF | 10 字节 | B+树叶子段的头部信息,仅在B+树的Root页定义 |
PAGE_BTR_SEG_TOP | 10 字节 | B+树非叶子段的头部信息,仅在B+树的Root页定义 |
Infimum+ supremum(26字节)
Infimum + Supremum是最小记录和最大记录,两个虚拟的行记录。
两个虚拟的行记录也是使用行格式(row_format)存储。
两个虚拟的行记录不是我们自己定义的记录,所以它们并不存放在 页 的 User Records 部分,他们被单独放在一个称为 Infimum + Supremum 的部分。
在行格式.记录头信息.heap_no
值为 0 和 1,一个代表 最小记录 ,一个代表 最大记录。
行格式.记录头信息.record_type
取值2或者3(2 表示最小记录, 3 表示最大记录)
User Records (大小不确定)
User Records是用户记录,实际存储的行记录内容。
行格式.记录头信息.record_type
取值0(0 表示普通记录)
存储的记录会按照我们指定的 行格式 存储到 User Records 部分。
开始生成页的时候,其实并没有 User Records 这个部分,每当我们插入一条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records 部分,当 Free Space 部分的空间全部被 User Records 部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页。
Free Space (大小不确定)
Free Space是空闲空间,页中尚未使用的空间。
Page Directory(大小不确定)
Page Directory是页面目录,页中的某些记录的相对位置。
Page Directory类似书的目录,作用是快速查找某些记录。
Page Directory制作过程:
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的 n_owned 属性表示该记录拥有多少条记录,也就是该组内共有几条记录。
- 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到 Page Directory ,也就是 页目录 。页面目录中的这些地址偏移量被称为 槽 (英文名: Slot ),所以这个页面目录就是由 槽 组成的。
InnoDB 会把数据分成多个组,第一组中只有一个最小记录,其他组由4~5条记录组成,看下边的示意图:
最小记录的 n_owned 值为 1 ,这就代表着以最小记录结尾的这个分组中只有 1 条记录,也就是最小记录本身。
最大记录的 n_owned 值为 5 ,这就代表着以最大记录结尾的这个分组中只有 5 条记录,包括最大记录本身还有我们自己插入的 4 条记录。
如何使用页目录 中查找记录?
因为各个槽代表的记录的主键值都是从小到大排序的,所以我们可以使用 二分法 来进行快速查找。
4个槽的编号分别是: 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,所以确定主键值为 5 的记录在 槽2 对应的组中。此刻我们需要找到 槽2 中主键值最小的那条记录,然后沿着单向链表遍历 槽2 中的记录。但是我们前边又说过,每个槽对应的记录都是该组中主键值最大的记录,这里 槽2 对应的记录是主键值为 8 的记录,怎么定位一个组中最小的记录呢?别忘了各个槽都是挨着的,我们可以很轻易的拿到 槽1 对应的记录(主键值为 4 ),该条记录的下一条记录就是 槽2 中主键值最小的记录,该记录的主键值为 5 。所以我们可以从这条主键值为 5 的记录出发,遍历 槽2 中的各条记录,直到找到主键值为 6 的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。
File Tailer (8字节)
File Trailer是文件尾部,校验页是否完整。
File Trailer由 8 个字节组成,可以分成2个小部分:
- 前4个字节代表页的校验和;
这个部分是和 File Header 中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为 File Header 在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在 File Header 中的校验和就代表着已经修改过的页,而在 File Trialer 中的校验和代表着原先的页,二者不同则意味着同步中间出了错。 - 后4个字节代表页面被最后修改时对应的日志序列位置(LSN)
这个部分也是为了校验页的完整性的。
数据页增删改
数据页里面 User Records并不是一直在同一个数据页。
在对页中的记录进行增删改操作的过程中,我们必须通过一些诸如记录移动的操作来始终保证下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值。这个过程我们也可以称为 页分裂 。