为了避免一条一条读取磁盘数据,InnoDB采取页的方式,作为磁盘和内存之间交互的基本单位。一个页的大小一般是16KB。
InnoDB为了不同的目的而设计了多种不同类型的页。比如:存放表空间头部信息的页、存放undo日志信息的页等等。我们把存放表中数据记录的页,称为索引页or数据页。
数据页结构
数据页代表的这块16kb大小的存储空间可以被划分为7个部分,有的部分占用的字节数是确定的,有的不确定的。具体描述如下表:
| 名称 | 中文名 | 占用空间 | 简单表述 |
|---|---|---|---|
| File Header | 文件头部 | 38Byte | 页的一些通用信息 |
| Page Header | 页面头部 | 56Byte | 数据页专有的一些信息 |
| Infinum + Supremum | 最小记录和最大记录 | 26Byte | 两个虚拟的行记录 |
| User Records | 用户记录 | 不确定 | 实际的行记录内容 |
| Free Space | 空闲空间 | 不确定 | 页中尚未使用的空间 |
| Page Directory | 页面目录 | 不确定 | 也中的某些记录的相对位置 |
| File Trailer | 文件尾部 | 8Byte | 校验页是否完整 |
- 一开始生成页的时候是没有
User Records的,每当插入一条记录都会从Free Space中申请一个记录大小的空间到User Records。 - 当
Free Space部分的空间全部被User Records部分替代掉后,就意味着这个页使用完了,如果后面还有新的记录插入,就需要申请新的页。
User Records(用户记录)
deleted_flag
DELETE命令删除记录,并不会真的将它从磁盘中删除,而是仅仅打一个标记,然后把该条记录加入到「垃圾链表」里,垃圾链表占用的空间称为「可重用空间」,以后如果在这个位置插入新的记录就可以重用这部分空间了。如果一个页内所有的记录都被删除了,那么这个页就称为「可重用的页」
min_rec_flag
InnoDB引擎组织数据的形式采用了B+树,用户记录存储在叶子节点,目录项(也可叫索引项)存储在非叶子节点,一个个节点就是一个个页,同一个非叶子节点内最小的目录项该比特位为1,其余均为0。
n_owned
InnoDB引擎页大小默认是16KB,同一个页内可能会存储很多的用户记录,甚至上千条。为了提高页内的检索效率,InnoDB会将记录划分为多个不同的组,组内记录值最大的一条称为“大哥”,其余的都是“小弟”,“大哥”会利用该属性来记录组内的记录数量,各个组的“大哥”的值会按照顺序被记录在页内的「Page Directory」位置。
heap_no
用户记录存储在页的「User Records」部分,MySQL将这部分结构称作堆(Heap),每申请一块记录空间,都会为其分配一个heap_no,越靠前的记录heap_no越小,越靠后的记录heap_no越大。
record_type
| record_type | 说明 |
|---|---|
| 0 | 用户自己插入的记录,或二级索引叶子节点记录。 |
| 1 | B+树非叶子节点目录项记录,冗余的索引项记录。 |
| 2 | 页内虚拟的最小记录:Infimum |
| 3 | 页内虚拟的最大记录:Supremum |
next_record
用户记录会根据主键值排序并构建一条单向链表,链表就是通过该属性来构建的。它代表当前记录的真实数据到下一条记录的真实数据的距离,值为正数代表下一条记录在后面,值为负数代表下一条记录在前面。MySQL规定,页中Infimum的下一条记录是本页中主键值最小的记录,主键值最大的记录next_record一定指向Supremum。
Page Directory(页目录)
目录制作过程
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
- 每个组的最后一条记录(也就是组内最大记录)的头信息中的
n_owned属性表示该记录拥有多少条记录,也就是该组共有几条记录。 - 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是
Page Directory,也就是页目录。页面目录中的这些地址偏移量本称为槽(slot)。
分组规则
- 对于Infimum记录所在的分组只能有1条记录。
- 对于Supremum记录所在的分组只能在1~8条记录之间。
- 剩下的记录所在的分组只能在4~8条记录之间
分组步骤
- 初始情况下,一个数据页中只有Infimum记录和Supremum记录这两条,所以分为两个组。
- 之后每当插入一条记录时,都会从页目录中找到对应记录的主键值比待插入记录的主键值大,并且差值最小的槽,然后把该槽对应的n_owned加1。
- 当一个组中的记录数等于8时,当再插入一条记录的时候,会将组中的记录拆分成两个组(一个组中4条记录,另一个组中5条记录)。并在拆分过程中,会在Page Directory中新增一个槽,并记录这个新增分组中最大的那条记录的偏移量。
- 每个组的最后一条记录(即:也是这个组里,最大的那条记录)——“带头大哥”,其余的记录均为“组内小弟”;“大哥”记录的头信息中的n_owned属性表示该组内共有几条记录,而“小弟”的n_owned属性都为0;
- 将“大哥”在页面中的地址偏移量取出来,按顺序存储到靠近Page Trailer的地方。这个地方就是Page Directory。
- Page Directory中的这些地址偏移量被称为槽(Slot),每个槽占用2个字节。一个正常的页面为16KB,即:16384字节。而2个字节可以表示的地址偏移量范围是0~(2^16-1),即:0~65535。所以2个字节表示一个槽足够了。
Page Directory就是由多个槽组成的。记录和页目录的关系,如下所示,分为2组:
-
页目录生成完毕后,则可以通过二分法快速进行查找。
通过二分法确定该记录所在分组对应的Slot,然后找到该Slot所在分组中主键值最小的那条记录。每个槽对应的都是组内主键值最大的记录,那么怎么定位一个组中主键值最小的记录呢?答:由于每个槽都是挨着的,所以,我们可以通过找到前一个槽中的最大主键值记录,这个记录的下一条记录(next_record),就是本槽的最小主键值记录。
-
在一个数据页中查找指定主键值的记录时,过程分为两步:
- 通过记录的next_record属性遍历该槽所在组中的各个记录。
Page Header(页面头部)
Page Header是专门针对数据页记录的各种状态信息。
| 名称 | 大小 | 描述 |
|---|---|---|
| PAGE_N_DIR_SLOTS | 2bits | Page Directory中槽位的数量 |
| PAGE_HEAP_TOP | 2bits | 第1位:本记录是否为紧凑型记录 |
| PAGE_N_HEAP | 2bits | 剩余15位:本页的堆中记录的数量(包含Infimum和Supremun和标记删除记录)第1位:本记录是否为紧凑型记录 |
| PAGE_FREE | 2bits | 每个已删除的记录通过next_record组成一个单向链表,他们的空间可以被重新利用,PAGE_FREE表示该链表头节点对应记录在页面中的偏移量 |
| PAGE_GARBAGE | 2bits | 已删除记录占用的字节数 |
| PAGE_LAST_INSERT | 2bits | 最后插入记录的位置 |
| PAGE_DIRECTION | 2bits | 记录插入的方向 |
| PAGE_N_DIRECTION | 2bits | 一个方向连续插入的记录数量 |
| PAGE_N_RECS | 2bits | 该页中用户记录的数量(不包含Infimum和Supremum和被删除记录) |
| PAGE_MAX_TRX_ID | 8bits | 修改当前页的最大事务id,该值仅在二级索引(除主键索引外)页面中定义 |
| PAGE_LEVEL | 2bits | 当前页在B+树中所处的层级,从0层开始 |
| PAGE_INDEX_ID | 8bits | 索引ID,表示当前页属于哪个索引 |
| PAGE_BTR_SEG_LEAF | 10bits | B+树叶子节点段的头部信息,仅在B+树的根页面中定义 |
| PAGE_BTR_SEG_TOP | 10bits | B+树非叶子节点段的头部信息,仅在B+树的根页面中定义 |
File Header(文件头部)
| 名称 | 大小 | 描述 |
|---|---|---|
| FIL_PAGE_SPACE_OR_CHKSUM | 4bits | 表示页的“校验和” |
| FIL_PAGE_OFFSET | 4bits | 页号 |
| FIL_PAGE_PREV | 4bits | 上一个页的页号 |
| FIL_PAGE_NEXT | 4bits | 下一个页的页号 |
| FIL_PAGE_LSN | 8bits | 页面被最后修改时对应的LSN(重做日志写入的总量)值 |
| FIL_PAGE_TYPE | 2bits | 该页的类型 |
| FIL_PAGE_FILE_FLUSH_LSN | 8bits | 仅在系统表空间的第一个页中定义,代表文件至少被刷新到了对应的LSN值 |
| FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 4bits | 该页属于哪个表空间 |
页的类型:
| 名称 | 十六进制 | 描述 |
|---|---|---|
| FIL_PAGE_TYPE_ALLOCATED | 0x0000 | 最新分配,还未使用 |
| FIL_PAGE_UNDO_LOG | 0x0002 | undo日志页 |
| FIL_PAGE_INODE | 0x0003 | 存储段的信息 |
| FIL_PAGE_IBUF_FREE_LIST | 0x0004 | Change Buffer空闲列表 |
| FIL_PAGE_IBUF_BITMAP | 0x0005 | Change Buffer的一些属性 |
| FIL_PAGE_TYPE_SYS | 0x0006 | 存储一些系统数据 |
| FIL_PAGE_TYPE_TRX_SYS | 0x0007 | 事务系统数据 |
| FIL_PAGE_TYPE_FSP_HDR | 0x0008 | 表空间头部信息 |
| FIL_PAGE_TYPE_XDES | 0x0009 | 存储区的一些属性 |
| FIL_PAGE_TYPE_BLOB | 0x000A | 溢出页 |
| FIL_PAGE_INDEX | 0x45BF | 索引页,也就是我们所说的数据页 |
File Trailer(文件尾部)
为了防止把数据同步到磁盘过程中出现断点等情况,为了检测一个页是否完整,设计了一个File Trailer部分。这个部分可以分为两个小部分:
-
前4个字节代表页的校验和。
当一个页在内存中被修改时,在刷新到磁盘之前首先是要计算出checksum值的。由于File Header在页面的前边,所以File Header中的checksum会被优先刷新到磁盘,当完全写完后,checksum的值再被写到File Trailer。如果页面刷新成功,那么File Header和File Trailer的checksum值应该是一致的。否则,就意味着刷新期间发生了错误
-
后4个字节代表页面被最后修改时对应的日志序列位置(LSN)
正常情况下File Trailer的这部分值应该与File Header的FIL_PAGE_LSN的后4的字节相同。这部分也是用于校验页的完整性的
页的分裂
说起数据页免不了会牵扯到页分裂到问题。假设你现在已经有两个数据页了,并且你正在往第二个数据页中写数据,关于B+Tree,我们知道B+Tree中的叶子结点之间是通过双向链表关联起来的。
在InnoDB索引的设定中,要求主键索引是递增的,这样在构建索引树的时候才更加方便。你可以脑补一下。如果按1、2、3...递增的顺序给你这些数。是不是很方便的构建一棵树。然后你可以自由自在的在这棵树上玩二分查找。 那假设你自定义了主键索引,而且你自定义的这个主键索引并不一定是自增的。
然后随着你将数据写入。就导致后一个数据页中的所有行并不一定比前一个数据页中的行的id大。这时就会触发页分裂的逻辑。
页分裂的目的就是保证:后一个数据页中的所有行主键值比前一个数据页中主键值大。
经过分裂调整,可以得到下面的这张图
数据检索
现假设一个页仅能存下三条记录,随着用户记录不断插入,InnoDB不断申请新的索引页,最终结构如下图所示:
现在我们要查询
id=10的记录,只能从页1开始一个页一个页的往后找。InnoDB会先将页1从磁盘加载到内存,然后遍历用户记录,判断是否存在id=10的记录,没有找到则继续加载页2,然后重复前面的过程,这就是全表扫描。
页内的记录是有序的,通过将多条记录划分成一组,将每个组里最大的那条记录的地址偏移量填充到Page Directory的槽里,通过二分法即可快速定位到组。
整个过程最耗时的操作其实是将页从
磁盘加载到内存里,这需要发起系统调用来读取磁盘数据。这些页在物理上可能还不是连续的,机械硬盘随机读的效率是非常低的,如果每次检索数据都要全表扫描一次,这是完全不能接收的。
索引目录(B+Tree)
InnoDB直接借鉴了Page Directory的设计,将每个索引页内最小的主键值提取出来,给所有的索引页再建立一个目录。
假设表的每条记录平均占用约200字节,那么一页16KB可以存储约16*1024/200=80条记录。假设表有一亿条记录,那么约需要1250000个页才能容纳所有记录。
现在要给这些页建立目录,假设主键用BIGINT类型占用8字节,页号INT类型占用4字节,记录头信息占用5字节,那么一条目录项记录约占用17字节。光是存储目录项记录就需要约1250000*17/1024/1024=20MB的空间,远远超过了一个页的大小。
应该使用多个页存储,经过计算,需要约1250000*17/(16*1024)=1300个页来存储目录项记录。遍历1300个页开销还是太大了,继续给这1300个页再建立一个目录,只需要1300*17/(16*1024)=2个页就够了。
有索引的情况下,InnoDB通过主键查找记录的流程。先将B+树的根节点页面加载到内存,通过Page Directory使用二分法快速定位到分组,遍历组内的目录项,通过页号定位到第二层页节点,将该节点页加载到内存,重复前面的过程,直到定位到叶子节点页,最终获取到记录。加载数据页的个数,其实就是B+树的高度,而且InnoDB B+树有个特点,就是根节点一旦确定就不会改变,这样InnoDB就可以将根节点页做缓存了,进一步减少页的加载次数。