本文转载自InnoDB 的记录结构和页结构
概述
InnoDB
将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,中页的大小一般为16KB
。也就是在一般情况下,一次最少从磁盘中读取16KB
的内容到内存中,一次最少把内存中的16KB
内容刷新到磁盘中。
MySQL 里共有四种行格式:
Compact
Redundant
Dynamic
Compressed
指定行格式的方法
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
ALTER TABLE 表名 ROW_FORMAT=行格式名称
COMPACT 行格式
记录的额外信息分为3类:分别是变长字段长度列表
、NULL值列表
和记录头信息
变长字段长度列表
变长字段长度列表
是用来存储VARCHAR
、TEXT
等存储的字节数不确定的数据的长度。各变长字段数据占用的字节数按照列的顺序逆序存放,是逆序存放!
至于记录长度时用 1 个字节还是用 2 个字节,InnoDB 有一套规则。
- W:该字符集一个字符需要的字节数。
- M:该类型最多能存多少个字符。
- L:实际存储占用的字符数。
用 1 个字节还是用 2 个字节的规则是:
if M × W <= 255 {
用一个字节
} else {
if L <= 127 {
用一个字节
} else {
用两个字节
}
}
总结一下:如果该可变字段允许存储的最大字节数超过 255 字节并且真实存储的字节数超过 127 字节,使用 1 个字节,否则使用 2 个字节。
另外,长度表只保存非NULL
的长度,值为NULL
的长度是不存储的。
NULL 值表
用一位标记一个字段是否为 NULL,是 1 代表 NULL,0 代表 非BULL,逆序存放。整体以字节为最小单位,不足的高位补0。
记录头信息
- 两个预留位,没有使用。
-
delete_mask
:删除标记 -
min_rec_mask
:B+树的每层非叶子节点中的最小记录都会添加该标记 -
n_owned
:当前记录拥有的记录数 -
heap_no
:当前记录在记录堆的位置信息 -
record_type
:当前记录的类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录。(记录的大小就是主键的大小) -
next_record
:下一条记录的相对位置
记录的真实数据
除了真实的列数据以外,还有隐藏列
。
-
row_id
:行唯一标识ID(非必须) -
transaction_id
:事物ID -
roll_pointer
:回滚指针
真正列名称其实是:DBROWID、DBTRXID、DBROLLPTR
CHAR(M) 列的存储格式
由于字段长度列表存的是边长字段长度列表,所以当修改字段类型时这个列表的长度也会发生变化。以及在修改字符集时,也会导致长度列表的长度发生变化。由此会导致重新分配空间,在原有的存储空间产生碎片化。
Redundant 行格式
Redundant
行格式是MySQL 5.0
之前用的一种行格式,已经非常陈旧了,看看就好。
字段长度偏移列表
Compact
格式记录的是长度可变的字段的长度,而Redundant
记录的是所有字段的偏移量,一样是逆序存放。相邻两个字段偏移量的差就是该字段的长度。
记录头信息
- 两个预留位,没有使用。
-
delete_mask
:删除标记 -
min_rec_mask
:B+树的每层非叶子节点中的最小记录都会添加该标记 -
n_owned
:当前记录拥有的记录数 -
heap_no
:当前记录在页面堆的位置信息 -
n_field
:当前记录中列的数量 -
1byte_offs_flag
:标记字段长度偏移列表中的偏移量是使用1字节还是2字节表示的 -
next_record
:表示下一条记录的相对位置
和Compact
相比有两处不同:
- 多了
n_field
和1byte_offs_flag
。 - 少了
record_type
。
由于记录中没有 NULL 值表,记录 NULL 值的策略如下:
- 如果 NULL 的字段是长度不可变类型,直接用 0x00 填充。
- 如果 NULL 的字段是可变长度类型,那么在字段长度偏移列表中这个字段不占长度,即这个字段的偏移量和下一个字段的偏移量相同,真实数据区域不会存任何东西。
除了以上几点,和Compact
的格式大致还是相同的。
CHAR(M) 列的存储格式
Redundant
格式没有Compact
格式的碎片化问题。
行溢出数据
一条记录除BLOB
和TEXT
类型之外的列(不包括隐藏列和记录头信息),占用的字节数长度之和不能超过65535字节
。这65535字节
包含:
- 真实数据
- 真实数据的长度
- NULL 值标记,如果是 NOT NULL 则可以不存在
在Compact
和Reduntant
格式中,真实记录数据的位置
只会存储一部分数据,剩下的数据会存放在其他页中,并在真实记录数据的位置
留下20字节
的数据指向下一个页以及在下一个页的长度。
新的那些页称为溢出页
。
行溢出的临界点
MySQL
规定每个页最少存放两条数据(原因后面再说),所以临界值是:
- 每个记录需要的额外信息是27字节
-
2个字节
用于存储真实数据的长度 -
1个字节
用于存储列是否是NULL值 -
5个字节
大小的头信息 -
6个字节
的row_id列 -
6个字节
的transaction_id列 -
7个字节
的roll_pointer列
-
- 页额外的信息共
136个字节
所以只有一个列的表,行溢出的临界值是
136 + 2×(27 + n) > 16384
=> n > 8098
这个值本身没什么意义,只要有个概念就好了。
Dynamic 格式
MySQL 5.7
默认的行格式就是Dynamic
,它和Compact
仅仅是在行溢出时有区别。
在发生行溢出时,不会存放那768个字节
,而是把所有的字节都放到其他页去,只记录地址。
Compressed 格式
逻辑和Dynamic
一致,但增加了压缩算法,对页进行压缩,节省空间。
CHAR(M)中 M 过大的情况
以上四种格式都会把过长的字段当做变长字段看待。
InnoDB 页结构
页是InnoDB
的存储空间的基本单位,大小一般是16K
。
InnoDB
有许多不同的页:
- 表空间头部信息的页
- Insert Buffer 的页
- INODE 信息的页
- undo 日志的页
- ......
以下内容针对索引(INDEX
)页展开。
页结构快速浏览
(图中的单位疑似错误,应为字节)
大致的结构为:
-
File Header
:文件头部 -
Page Header
:页头部 -
Infimum + Supremum
:最小和最小两个虚拟行记录 -
User Records
:用户记录 -
Free Space
:空闲空间 -
Page Directory
:页面目录 -
File Trailer
:文件结尾
当页刚被创建出来时,User Records
是空的,每插入一条记录,就会从Free Space
划过来一片空间存储,当Free Space
用完时
记录和记录之间没有空隙,写在一起。
回头仔细看看记录的头信息:
- 当记录被删除时,
delete_mask
被标记为 1 代表这个记录已经被删除了,这些被删除的记录会组成一个垃圾链表
,称为可重用空间。后续插入时会直接覆盖。 > 将这个delete_mask位设置为1和将被删除的记录加入到垃圾链表中其实是两个阶段,后面细说。 -
min_rec_mask
:每层非叶子节点中的最小记录都会添加该标记,后面细说。 -
n_owned
:当前记录拥有的记录数,稍后细说。 -
heap_no
:该记录在本页中的位置。InnoDB
给表插入了两个虚拟记录,因此用户的记录是从 2 开始的。这两条记录没有被放在User Records
部分,而是单独放在Infimum + Supremum
部分。
-
record_type
:0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录。 -
next_record
:当前记录到下一条记录的真实数据开始的位置的偏移量,所以这玩意就是个链表。而链表的头尾就是最小记录和最大记录。当记录被删除后,类似链表删除,链表的指针会直接绕过这个节点。指针指到真实数据开始的位置,向左读取是头信息,向右读是真实数据,把变长字段长度列表和 NULL 值表放在最前面的原因也在此。这样读取更高效,无需解析各个字段的长度。
Page Directory(页目录)
根据主键查询记录时,按照记录的链表顺序查肯定是低效的。InnoDB
会制作该页的目录,制作的步骤:
- 将所有正常的记录(包括最大和最小记录,不包括标记为已删除的记录)划分为几个组。
- 每个组的最后一条记录(也就是组内最大的那条记录)的头信息中的
n_owned
属性表示该记录拥有多少条记录,也就是该组内共有几条记录。 - 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方,这个地方就是所谓的页目录。页目录中的这些地址偏移量被称为
槽(Slot)
,所以页目录就是由槽构成的。
InnoDB
规定,最小记录所在的组只能有1条
记录,最大记录所在的组的记录数在1~8条
之间,剩下的在4~8
条之间。分组步骤:
- 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
- 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的
n_owned
值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个
。 - 在一个组中的记录数等于
8个
后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条
记录,另一个5条
记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
有了页目录,查询时只要使用二分法先找到这个主键所在的组,然后再遍历这个组的几条数据就可以了。
Page Header(页头)
页头用来记录本页中存储了多少条记录,第一条记录在哪,页目录中存储了多少个槽等等。
- ??
PAGE_N_DIR_SLOTS
:在页目录中的槽数量 -
PAGE_HEAP_TOP
:还未使用的空间最小地址,也就是说从该地址之后就是,后面就是Free Space
-
PAGE_N_HEAP
:本页中的记录的数量(包括最小和最大记录以及标记为删除的记录) -
PAGE_FREE
:第一个已经标记为删除的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用) -
PAGE_GARBAGE
:已删除记录占用的字节数 - ??
PAGE_LAST_INSERT
:最后插入记录的位置 - ??
PAGE_DIRECTION
:记录插入的方向。假如新插入的一条记录的主键值比上一条记录的主键值比上一条记录大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就 - ??
PAGE_N_DIRECTION
:一个方向连续插入的记录数量。假设连续几次插入新记录的方向都是一致的,InnoDB
会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION
这个状态表示。 -
PAGE_N_RECS
:该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录) -
PAGE_MAX_TRX_ID
:修改当前页的最大事务ID,该值仅在二级索引中定义 -
PAGE_LEVEL
:当前页在B+树中所处的层级 -
PAGE_INDEX_ID
:索引ID,表示当前页属于哪个索引 -
PAGE_BTR_SEG_LEAF
:B+树叶子段的头部信息,仅在B+树的Root页定义 -
PAGE_BTR_SEG_TOP
:B+树非叶子段的头部信息,仅在B+树的Root页定义
(??的字段表示到目前为止需要知道的,剩下的后面再看)
File Header(文件头部)
- ??
FIL_PAGE_SPACE_OR_CHKSUM
:页的校验和 - ??
FIL_PAGE_OFFSET
:页号。InnoDB
里每个页单独一个页号,可以通过页号唯一确定一个页。 - ??
FIL_PAGE_PREV
:上一个页的页号 - ??
FIL_PAGE_NEXT
:下一个页的页号。各个页以双链表形式存储。并不是所有类型的页都有这两个属性,索引页是有的。
-
FIL_PAGE_LSN
:页面被最后修改时对应的日志序列位置(Log Sequence Number) - ??
FIL_PAGE_TYPE
:页的类型 -
FIL_PAGE_FILE_FLUSH_LSN
:代表文件至少被刷新到了对应的LSN值,仅在系统表空间的一个页中定义, -
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID
:页属于哪个表空间
(??的字段表示到目前为止需要知道的,剩下的后面再看)
File Trailer(文件结尾)
文件结尾用来校验数据完整性,共8个字节
。
-
前4个字节
代表页校验和,页面被修改时,校验和会被重新计算,写磁盘时头部的校验和会先被写进去,完全写完时尾部的校验和应与头部的相同,否则就代表写的过程中有错误。 -
后4个字节
代表页面被最后修改时对应的日志序列位置(LSN),这里暂时先不管是什么。