大家好,上篇文章说了InnoDB中记录的存储结构,今天来讲讲InnoDB的数据页的结构。首先我们要了解什么是数据页,它是干什么用的。
当我们创建一个存储引擎为InnoDB的mysql数据库时,数据库里的数据信息是存储在磁盘上的,但是真正处理数据的过程是发生在内存中的,所以在我们对数据进行读写操作时,数据便会在磁盘和内存之间来回传递,InnoDB会把数据划分成若干个页,以页为基本单位将数据在磁盘和内存之间进行交互。InnoDB中页的大小一般是16KB。InnoDB有许多不同的页,比如存放表空间头部信息的页、存放Change Buffer信息的页、存放InnoDB信息的页、存放undo日志信息的页等,今天我们只浅谈一下存放表中数据的页,也就是数据页。
InnoDB数据页的存储空间大致分为7部分,具体信息如下所示:
名称 | 中文名 | 占用空间 | 简单描述 |
File Header | 文件头部 | 38 字节 | 页的一些通用信息 |
Page Header | 页面头部 | 56 字节 | 数据页专有的一些信息 |
Infimum + Supremum | 页面中的最小记录和最大记录 | 26 字节 | 两个虚拟的行记录 |
User Records | 用户记录 | 不确定 | 实际存储的行记录内容 |
Free Space | 空闲空间 | 不确定 | 页中尚未使用的空间 |
Page Directory | 页目录 | 不确定 | 页中的某些记录的相对位置 |
File Trailer | 文件尾部 | 8 字节 | 校验页是否完整 |
了解了页的大致结构以后,我们来讲一下记录在数据页中是如何存储的,话不多说,直接上图:
一开始新生成的数据页是没有User Records (用户记录)部分的,当有记录插入时,就会从Free Space(空闲空间)部分申请一部分空间来存储插入的记录,随着插入记录的增多,Free Space(空闲空间)部分的空间会全部被User Records (用户记录)部分替代,这个时候再新插入数据的时候便会新产生一个页。
上述过程就是记录存储到页的整体过程,是不是觉得很简单,下面我们再往更细的方面聊一聊。
上篇文章我们讲了一下InnoDB的行格式,其中有一部分叫记录头信息,这部分信息在记录存储到数据页的过程中有着至关重要的作用。下图是COMPACT行格式的结构图以及记录头信息部分的结构图,上篇文章有相关讲解,想了解的可以去看一下。
接下来我们将行格式简化一下,只显示本次讲的重要部分,简化后的结构如下图所示:
delete_mask(delete_flag): 这个属性标记着当前记录是否被删除,占用1个二进制位,值为0的时候代表记录并没有被删除,为1的时候代表记录被删除。(当我们删除一条记录时,这条记录并不会从磁盘中消失,只是打上删除标记)
min_rec_mask(min_rec_flag): B+树的每层非叶子节点中的最小记录都会添加该标记,之后会聊到,今天先不管这块。
n_owned: 为了方便寻找页中的某条记录,InnoDB将页中的记录进行分组,每组最后一条记录会将本组的记录数(不包括delete_mask为1的记录)存到n_owned中,组内其余的记录n_owned全部存0。
heap_no: 记录会在在数据页的User Records (用户记录)部分紧密的排列在一起,这些排列在一起的记录结构称为堆,heap_no是这条记录在堆中的相对位置。
注意:每个页中heap_no值为0和1的两条记录是固定的,0为页面中最小的记录(Infimum记录),1为页面中最大的记录(Supremum记录),这两条记录不是用户插入的真实记录,而是页自带的且每个页的这两条记录是相同的。
record_type: 表示当前记录的类型,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录。
next_record: 表示从当前记录的真实数据到下一条记录的真实数据的距离,整数表示下一条记录在当前记录前面,负数则反之。
注意:记录的前后顺序和插入的先后顺序无关,而是按照主键大小的顺序进行排列的。最小记录永远是第一条,最大记录永远是最后一条。
介绍完简化后记录头信息各个部分的作用后,我们就可以讲一下记录在页中具体的存储结构。
在上述内容讲到n_owned属性时,我们知道为方便查找记录,InnoDB将记录进行了分组,在分好组后,每组记录的最大偏移量会以槽的形式存到数据页中的Page Directory部分。在这里,假设我们往一张表中存了4条真实记录,那么InnoDB会将他们分为两组存放到页中,具体结构如下图所示:
在上图中,我们要了解以下几点:
- 最小记录所在的组只能有一条数据,就是最小记录本身。
- 最大记录所在的组记录条数必须是1-8条之间。
- 其他记录所在的组记录条数必须是4-8条之间。当组中记录超过8条时,会将组拆分成两个组,一个组4条,一个组5条,然后新生成的组会在数据页中的Page Directory部分新增一个槽,用来存放新生成组的最大记录的偏移量。
现在,我们假设数据页中有18条记录,它们分成了5组,以下图为例讲解一下InnoDB在页中查找记录的过程。
当我们想查找图中ID为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对应的组中。此刻我们可以拿到槽1对应的记录(主键值为4),然后向下找到槽2中主键值最小的那条记录,然后沿着单向链表遍历槽2中的记录。直到找到主键值为6的那条记录即可。
总结:在一个数据页中查找指定主键值的记录的过程分为两步:
1. 通过二分法确定该记录所在的槽,并找到该槽中主键值最小的那条记录。
2. 通过记录的 next_record 属性遍历该槽所在的组中的各个记录。
最后我们再简单说一下数据页结构中的 Page Header(页面头部) 和 File 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页定 |
File Header(文件头部) 是专门针对数据页记录的各种状态信息,比如页里头有多少个记录,页目录有多少个槽。不同类型的页都会以File Header 作为第一个组成部分,它描述了一些针对各种页都通用的一些信息,这个部分占用固定的38个字节,是由下边这些内容组成的:
名称 | 占用空间 | 描述 |
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 字节 | 页属于哪个表空间 |
到此,InnoDB数据页结构介绍完毕,本篇文章内容有点多,讲的不好的地方烦请大家多多指教,有什么问题欢迎大家在评论区进行讨论