InnoDB记录存储结构-页
# InnoDB记录存储结构-页
# 1. InnoDB页简介
InnoDB
需要把内存中的内容刷新到磁盘上采取的方式是:
将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位。InnoDB中页的大小一般为 16 KB。
# 2. 不同类型的页简介
InnoDB
为了不同的目的而设计了许多种不同类型的页
,比如存放表空间头部信息的页,存放Insert Buffer
信息的页,存放INODE
信息的页,存放undo
日志信息的页等等。
存放我们表中记录类型的页,官方称这种存放记录的页为索引(INDEX)页,鉴于我们还没有了解过索引是个什么东西,而这些表中的记录就是我们日常口中所称的数据
,所以目前还是叫这种存放记录的页为数据页
吧。
# 3. 数据页结构的快速浏览
数据页代表的这块16KB
大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:
从图中可以看出,一个InnoDB
数据页的存储空间大致被划分成了7
个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。下边我们用表格的方式来大致描述一下这7个部分都存储一些啥内容(快速的瞅一眼就行了,后边会详细唠叨的):
名称 | 中文名 | 占用空间大小 | 简单描述 |
---|---|---|---|
File Header | 文件头部 | 38字节 | 页的一些通用信息 |
Page Header | 页面头部 | 56字节 | 数据页专有的一些信息 |
Infimum + Supremum | 最小记录和最大记录 | 26字节 | 两个虚拟的行记录 |
User Records | 用户记录 | 不确定 | 实际存储的行记录内容 |
Free Space | 空闲空间 | 不确定 | 页中尚未使用的空间 |
Page Directory | 页面目录 | 不确定 | 页中的某些记录的相对位置 |
File Trailer | 文件尾部 | 8字节 | 校验页是否完整 |
# 4. 记录在页中的存储
自己存储的记录会按照我们指定的行格式
存储到User Records
部分,当Free Space
部分的空间全部被User Records
部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。图示如下:
# 4.1. 记录头信息
我们先创建一个表:
CREATE TABLE page_demo(
c1 INT,
c2 INT,
c3 VARCHAR(10000),
PRIMARY KEY (c1)
) CHARSET=ascii ROW_FORMAT=Compact;
2
3
4
5
6
注意:把 c1 列指定为主键时,InnoDB
不会为我们去创建那个所谓的 row_id 隐藏列。而且我们为这个表指定了ascii
字符集以及Compact
的行格式。所以这个表中记录的行格式示意图就是这样的:
从图中可以看到,我们特意把记录头信息
的5个字节的数据给标出来了,说明它很重要,我们再次先把这些记录头信息
中各个属性的大体意思浏览一下(我们目前使用Compact
行格式进行演示):
名称 | 大小(单位:bit) | 描述 |
---|---|---|
预留位1 | 1 | 没有使用 |
预留位2 | 1 | 没有使用 |
delete_mask | 1 | 标记该记录是否被删除 |
min_rec_mask | 1 | B+树的每层非叶子节点中的最小记录都会添加该标记 |
n_owned | 4 | 表示当前记录拥有的记录数 |
heap_no | 13 | 表示当前记录在记录堆的位置信息 |
record_type | 3 | 表示当前记录的类型,0表示普通记录,1表示B+树非叶节点记录,2表示最小记录,3表示最大记录 |
next_record | 16 | 表示下一条记录的相对位置 |
由于我们现在主要在唠叨记录头信息
的作用,所以为了大家理解上的方便,我们只在page_demo
表的行格式演示图中画出有关的头信息属性以及c1
、c2
、c3
列的信息(其他信息没画不代表它们不存在啊,只是为了理解上的方便在图中省略了~),简化后的行格式示意图就是这样:
下边我们试着向page_demo
表中插入几条记录:
INSERT INTO page_demo VALUES(1, 100, 'aaaa'), (2, 200, 'bbbb'), (3, 300, 'cccc'), (4, 400, 'dddd');
为了方便大家分析这些记录在页
的User Records
部分中是怎么表示的,我把记录中头信息和实际的列数据都用十进制表示出来了(其实是一堆二进制位),所以这些记录的示意图就是:
看这个图的时候需要注意一下,各条记录在User Records
中存储的时候并没有空隙,这里只是为了大家观看方便才把每条记录单独画在一行中。我们对照着这个图来看看记录头信息中的各个属性是啥意思:
delete_mask:当前记录是否被删除,占用1个二进制位,值为
0
的时候代表记录并没有被删除,为1
的时候代表记录被删除掉了。min_rec_mask:B+树的每层非叶子节点中的最小记录都会添加该标记。
n_owned:这个暂时保密,稍后它是主角~
heap_no:这个属性表示当前记录在本页中的位置,从图中可以看出来,我们插入的4条记录在本
页
中的位置分别是:2
、3
、4
、5
。每个页里会自动增加两个记录,称为
伪记录
或者虚拟记录
。这两个伪记录一个代表最小记录
,一个代表最大记录
。这两条记录的构造都是由5字节大小的记录头信息
和8字节大小的一个固定的部分组成的,如图所示:由于这两条记录不是我们自己定义的记录,所以它们并不存放在
页
的User Records
部分,他们被单独放在一个称为Infimum + Supremum
的部分,如图所示:从图中我们可以看出来,最小记录和最大记录的
heap_no
值分别是0
和1
,也就是说它们的位置最靠前。record_type:这个属性表示当前记录的类型。
0
表示普通记录,1
表示B+树非叶节点记录,2
表示最小记录,3
表示最大记录。next_record:表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的
next_record
值为32
,意味着从第一条记录的真实数据的地址处向后找32
个字节便是下一条记录的真实数据。但是需要注意的一点是,下一条记录指得是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum记录(也就是最小记录) 的下一条记录就本页中主键值最小的用户记录,主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录) 。
从图中可以看出,记录按照主键从小到大的顺序形成了一个单链表:
如果我们把第2条记录删除掉:
DELETE FROM page_demo WHERE c1 = 2;
1删掉第2条记录后的示意图就是:
- 第2条记录并没有从存储空间中移除,而是把该条记录的
delete_mask
值设置为1
。
- 第2条记录的
next_record
值变为了0,意味着该记录没有下一条记录了。 - 第1条记录的
next_record
指向了第3条记录。 - 就是
最大记录
的n_owned
值从5
变成了4
。
所以,不论我们怎么对页中的记录做增删改操作,InnoDB始终会维护一条记录的单链表,链表中的各个节点是按照主键值由小到大的顺序连接起来的。
- 第2条记录并没有从存储空间中移除,而是把该条记录的
主键值为2
的记录被我们删掉了,但是存储空间却没有回收,如果我们再次把这条记录插入到表中,InnoDB
并没有因为新记录的插入而为它申请新的存储空间,而是直接复用了原来被删除记录的存储空间:
INSERT INTO page_demo VALUES(2, 200, 'bbbb');
当数据页中存在多条被删除掉的记录时,这些记录的next_record属性将会把这些被删除掉的记录组成一个垃圾链表,以备之后重用这部分存储空间。
# 5. Page Directory(页目录)
我们平常想从一本书中查找某个内容的时候,一般会先看目录,找到需要查找的内容对应的书的页码,然后到对应的页码查看内容。设计InnoDB
的大叔们为我们的记录也制作了一个类似的目录,他们的制作过程是这样的:
将所有正常的记录(包括最大和最小记录)划分为组。
每组的最后一条记录(也就是组内最大的那条记录)的n_owned属性表示该组内共有几条记录。
将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近
页
的尾部的地方,这个地方就是所谓的Page Directory
,也就是页目录
(此时应该返回头看看页面各个部分的图)。页面目录中的这些地址偏移量被称为槽
(英文名:Slot
),所以这个页面目录就是由槽
组成的。
比方说现在的page_demo
表中正常的记录共有6条,InnoDB
会把它们分成两组,第一组中只有一个最小记录,第二组中是剩余的5条记录:
从这个图中我们需要注意这么几点:
现在
页目录
部分中有两个槽,也就意味着我们的记录被分成了两个组,槽0
中的值是112
,代表最大记录的地址偏移量(就是从页面的0字节开始数,数112个字节);槽1
中的值是99
,代表最小记录的地址偏移量。注意最小和最大记录的头信息中的
n_owned
属性- 最小记录的
n_owned
值为1
,这就代表着以最小记录结尾的这个分组中只有1
条记录,也就是最小记录本身。 - 最大记录的
n_owned
值为5
,这就代表着以最大记录结尾的这个分组中只有5
条记录,包括最大记录本身还有我们自己插入的4
条记录。
- 最小记录的
为什么最小记录的n_owned
值为1,而最大记录的n_owned
值为5
呢?
每个分组中的记录条数是有规定的:对于最小记录所在的分组只能有 1 条记录,最大记录所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。
由于现在page_demo
表中的记录太少,无法演示添加了页目录
之后加快查找速度的过程,所以再往page_demo
表中添加一些记录:
INSERT INTO page_demo VALUES(5, 500, 'eeee'), (6, 600, 'ffff'), (7, 700, 'gggg'), (8, 800, 'hhhh'), (9, 900, 'iiii'), (10, 1000, 'jjjj'), (11, 1100, 'kkkk'), (12, 1200, 'llll'), (13, 1300, 'mmmm'), (14, 1400, 'nnnn'), (15, 1500, 'oooo'), (16, 1600, 'pppp');
这些记录被分成了5个组,如图所示:
所以在一个数据页中查找指定主键值的记录的过程分为两步:
通过二分法确定该记录所在的槽。
通过记录的
next_record
属性遍历该槽所在的组中的各个记录。
# 6. 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页定义 |
如果大家认真看过前边的文章,从PAGE_N_DIR_SLOTS
到PAGE_LAST_INSERT
以及PAGE_N_RECS
的意思大家一定是清楚的,如果不清楚,对不起,你应该回头再看一遍前边的文章。剩下的状态信息看不明白不要着急,饭要一口一口吃,东西要一点一点学(一定要稍安勿躁哦,不要被这些名词吓到)。在这里我们先唠叨一下PAGE_DIRECTION
和PAGE_N_DIRECTION
的意思:
PAGE_DIRECTION:最后一条记录插入方向。如果新插入的一条记录的主键值比上一条记录的主键值比上一条记录大,我们说这条记录的插入方向是右边,反之则是左边。
PAGE_N_DIRECTION:假设连续几次插入新记录的方向都是一致的,
InnoDB
会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION
这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。
# 7. File Header(文件头部)
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字节 | 页属于哪个表空间 |
FIL_PAGE_SPACE_OR_CHKSUM:当前页面的校验和(checksum)。
FIL_PAGE_OFFSET:页号。
FIL_PAGE_TYPE:当前页的类型,其他类型的页,具体如下表:
类型名称 十六进制 描述 FIL_PAGE_TYPE_ALLOCATED 0x0000 最新分配,还没使用 FIL_PAGE_UNDO_LOG 0x0002 Undo日志页 FIL_PAGE_INODE 0x0003 段信息节点 FIL_PAGE_IBUF_FREE_LIST 0x0004 Insert Buffer空闲列表 FIL_PAGE_IBUF_BITMAP 0x0005 Insert 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 BLOB页 FIL_PAGE_INDEX 0x45BF 索引页,也就是我们所说的数据页 我们存放记录的数据页的类型其实是
FIL_PAGE_INDEX
,也就是所谓的索引页
。FIL_PAGE_PREV:本页的上一个页号。
FIL_PAGE_NEXT:本页的下一个页号。
# 8. File Trailer
每个页的尾部都有一个File Trailer
部分,这个部分由8
个字节组成,可以分成2个小部分:
页的校验和(前4个字节):当页面在内存中修改了,在同步之前就要把它的校验和算出来,因为
File Header
在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header
中的校验和就代表着已经修改过的页,而在File Trialer
中的校验和代表着原先的页,二者不同则意味着同步中间出了错。页面被最后修改时对应的日志序列位置(LSN):这个部分也是为了校验页的完整性的。
这个File Trailer
与FILE Header
类似,都是所有类型的页通用的。