写在前面
本文章是学习掘金小册《MySQL 是怎样运行的:从根儿上理解 MySQL》 之后整理的,文章大量使用和借鉴了该小册的内容。另外小册很不错,讲解十分到位,推荐阅读。
如果你学习或使用过MySQL,那么或多或少知道页
的概念,它是InnoDB
管理存储空间的基本单位,一个页的大小一般是16KB
,而一个页
中又存储了多条记录。这篇文章将从单条记录
到页
,带你了解MySQL中数据存储的秘密。
InnoDB记录存储结构--行格式
我们知道MySQL
中真正存储数据的是存储引擎,因为MySQL
中存储的数据一般都是比较多的,内存肯定是无法存储的,所以数据被存储在磁盘上。表中的一条一条数据(又:一条一条记录)在磁盘上是如何存储的呢?
记录在磁盘上的存放方式也被称为行格式
或者记录格式
;目前有4种不同类型的行格式
,分别是Compact
、Redundant
、Dynamic
和Compressed
行格式,下面我们将详细介绍Compact
的结构。
COMPACT行格式
可以看到上图中包含两部分:记录的额外信息,记录的真实数据。
其中记录真实数据是真正存储数据的部分,而记录额外信息存储了记录的额外信息(或者叫元数据),又分为三块不同的空间:
- 边长字段长度
- NULL值列表
- 记录头信息
变长字段长度列表
MySQL中的VARCHAR(M)
、VARBINARY(M)
、各种TEXT
类型,各种BLOB
类型,这些数据类型称为变长字段
,变长字段中存储多少字节的数据是不固定的。
在Compact
行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表,各变长字段数据占用的字节数按照列的顺序逆序存放。
假如有一条记录包含两列变长字段,其中c1
列存储的值为'eeee'
,占用的字节数为4
,c2
列存储的值为'fff'
,占用的字节数为3
。数字4
可以用1个字节表示,3
也可以用1个字节表示,所以整个变长字段长度列表
共需2个字节。
NULL值列表
我们知道表中的某些列可能存储NULL
值,如果把这些NULL
值都放到记录的真实数据
中存储会很占地方,所以Compact
行格式把这些值为NULL
的列统一管理起来,存储到NULL
值列表中。
如果表中没有允许存储 NULL 的列,则 NULL值列表 也不存在了,否则将每个允许存储NULL
的列对应一个二进制位,二进制位按照列的顺序逆序排列,二进制位表示的意义如下:
- 二进制位的值为
1
时,代表该列的值为NULL
。 - 二进制位的值为
0
时,代表该列的值不为NULL
。
其中,二进制位按照列的顺序逆序排列。
MySQL
规定NULL值列表
必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补0
。
假如有一个表只有3个值允许为NULL
的列,对应3个二进制位,不足一个字节,所以在字节的高位补0
,效果就是这样:
以此类推,如果一个表中有9个允许为NULL
,那这个记录的NULL
值列表部分就需要2个字节来表示了。
注意:对于定长字段 CHAR(M) 类型的列来说,当列采用的是定长字符集(如
ascii
)时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集(如gbk
或utf8
)时,该列占用的字节数也会被加到变长字段长度列表。
另外有一点还需要注意,变长字符集的
CHAR(M)
类型的列要求至少占用M
个字节,而VARCHAR(M)
却没有这个要求。比方说对于使用utf8
字符集的CHAR(10)
的列来说,该列存储的数据字节长度的范围是10~30个字节。即使我们向该列中存储一个空字符串也会占用10
个字节,这是怕将来更新该列的值的字节长度大于原有值的字节长度而小于10个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片。
记录头信息
除了变长字段长度列表
、NULL值列表
之外,还有一个用于描述记录的记录头信息
,它是由固定的5
个字节组成。5
个字节也就是40
个二进制位,不同的位代表不同的意思,如图:
这些二进制位代表的详细信息如下表:
名称 | 大小(单位: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 |
表示下一条记录的相对位置 |
-
delete_mask
这个属性标记着当前记录是否被删除,占用1个二进制位,值为
0
的时候代表记录并没有被删除,为1
的时候代表记录被删除掉了。啥?被删除的记录还在
页
中么?是的。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后把其他的记录在磁盘上重新排列需要性能消耗,所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表
,在这个链表中的记录占用的空间称之为所谓的可重用空间
,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。 -
min_rec_mask
B+树的每层非叶子节点中的最小记录都会添加该标记。
-
n_owned
每个组最后一条记录中的
n_owned
值,就代表着这个分组中记录数量。后面还会涉及到。 -
heap_no
这个属性表示当前记录在本
页
中的位置,从图中可以看出来,我们插入的4条记录在本页
中的位置分别是:2
、3
、4
、5
。是不是少了点啥?是的,怎么不见heap_no
值为0
和1
的记录呢?其实
InnoDB
自动给每个页里边儿加了两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为伪记录
或者虚拟记录
。这两个伪记录一个代表最小记录
,一个代表最大记录
。由于这两条记录不是我们自己定义的记录,所以它们并不存放在
页
的User Records
部分,他们被单独放在一个称为Infimum + Supremum
的部分(也就是最小记录和最大记录,详见后面),如图所示:从图中我们可以看出来,最小记录和最大记录的
heap_no
值分别是0
和1
,也就是说它们的位置最靠前。 -
record_type
这个属性表示当前记录的类型,一共有4种类型的记录,
0
表示普通记录,1
表示B+树非叶节点记录,2
表示最小记录,3
表示最大记录。 -
next_record
这玩意儿非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。比方说第一条记录的
next_record
值为32
,意味着从第一条记录的真实数据的地址处向后找32
个字节便是下一条记录的真实数据。如果你熟悉数据结构的话,就立即明白了,这其实是个链表
,可以通过一条记录找到它的下一条记录。但是需要注意注意再注意的一点是,下一条记录
指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定 Infimum记录(也就是最小记录) 的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录),为了更形象的表示一下这个next_record
起到的作用,我们用箭头来替代一下next_record
中的地址偏移量:
你会不会觉得next_record这个指针有点儿怪,为啥要指向记录头信息和真实数据之间的位置呢?为啥不干脆指向整条记录的开头位置,也就是记录的额外信息开头的位置呢?因为这个位置刚刚好,向左读取就是记录头信息,向右读取就是真实数据。我们前边还说过变长字段长度列表、NULL值列表中的信息都是逆序存放,这样可以使记录中位置靠前的字段和它们对应的字段长度信息在内存中的距离更近,可能会提高高速缓存的命中率。
记录真实数据
记录的真实数据
除了name
、address
等这些我们自己定义的列的数据以外,MySQL
会为每个记录默认的添加一些列(也称为隐藏列
),具体的列如下:
列名 | 是否必须 | 占用空间 | 描述 |
---|---|---|---|
DB_ROW_ID |
否 | 6 字节 |
行ID,唯一标识一条记录 |
DB_TRX_ID |
是 | 6 字节 |
事务ID |
DB_ROLL_PTR |
是 | 7 字节 |
回滚指针 |
这里需要提一下InnoDB
表对主键的生成策略:优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个Unique
键作为主键,如果表中连Unique
键都没有定义的话,则InnoDB
会为表默认添加一个名为row_id
的隐藏列作为主键。所以我们从上表中可以看出:InnoDB存储引擎会为每条记录都添加 transaction_id 和 roll_pointer 这两个列,但是 row_id 是可选的。
行溢出数据
VARCHAR(M)最多能存储的数据
我们知道对于VARCHAR(M)
类型的列最多可以占用65535
个字节,如果我们使用ascii
字符集的话,一个字符就代表一个字节:
- 如果该
VARCHAR
类型的列没有NOT NULL
属性,那最多只能存储65532
个字节的数据,因为真实数据的长度可能占用2个字节,NULL
值标识需要占用1个字节。 - 如果
VARCHAR
类型的列有NOT NULL
属性,那最多只能存储65533
个字节的数据,因为真实数据的长度可能占用2个字节,不需要NULL
值标识。
如果VARCHAR(M)
类型的列使用的不是ascii
字符集,那M
的最大取值取决于该字符集表示一个字符最多需要的字节数。在列的值允许为NULL
的情况下,gbk
字符集表示一个字符最多需要2
个字节,那在该字符集下,M
的最大取值就是32766
(也就是:65532/2),也就是说最多能存储32766
个字符;utf8
字符集表示一个字符最多需要3
个字节,那在该字符集下,M
的最大取值就是21844
,就是说最多能存储21844
(也就是:65532/3)个字符。
上述所言在列的值允许为NULL的情况下,gbk字符集下M的最大取值就是32766,utf8字符集下M的最大取值就是
21844,这都是在表中只有一个字段的情况下说的,一定要记住一个行中的所有列(不包括隐藏列和记录头信
息)占用的字节长度加起来不能超过65535个字节!
复制代码
记录中的数据太多产生的溢出
- 在
Compact
和Redundant
行格式中,对于占用存储空间非常大的列,在记录的真实数据
处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中,然后记录的真实数据
处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页。 - 如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前
768
个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做行溢出
,存储超出768
字节的那些页面也被称为溢出页
。
最后需要注意的是,不只是 VARCHAR(M) 类型的列,其他的 TEXT、BLOB 类型的列在存储数据经常也会发生行溢出
。
InnoDB数据页结构
文章开头简单提了一下页
的概念,它是InnoDB
管理存储空间的基本单位,一个页的大小一般是16KB
。InnoDB
为了不同的目的而设计了许多种不同类型的页
,比如存放表空间头部信息的页,存放Insert Buffer
信息的页,存放INODE
信息的页,存放undo
日志信息的页等等等等。今儿个我们聚焦的是那些存放我们表中记录的那种类型的页,官方称这种存放记录的页为索引(INDEX
)页,鉴于我们还没有了解过索引是个什么东西,而这些表中的记录就是我们日常口中所称的数据
,所以目前还是叫这种存放记录的页为数据页
吧。
数据页代表的这块16KB
大小的存储空间可以被划分为多个部分,不同部分有不同的功能,各个部分如图所示:
在页的7个组成部分中,我们自己存储的记录会按照我们指定的行格式
存储到User Records
部分。但是在一开始生成页的时候,其实并没有User Records
这个部分,每当我们插入一条记录,都会从Free Space
部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records
部分,当Free Space
部分的空间全部被User Records
部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了,这个过程的图示如下:
Page Directory(页目录)
每个页都有一个分组的概念,就是将该页中的数据再分组。它能进一步提高我们在页内部查找的效率。
对于最小记录(Infimum)所在的分组只能有 1 条记录,最大记录(Supremum)所在的分组拥有的记录条数只能在 1~8 条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。
将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页
的尾部的地方,这个地方就是所谓的Page Directory
,也就是页目录
。页面目录中的这些地址偏移量被称为槽
(英文名:Slot
),所以这个页面目录就是由槽
组成的。这个东西有什么用?它可以帮助我们快速查找某条数据。
- 每个组最后一条记录中的
n_owned
值,就代表着这个分组中记录数量。
所以在一个数据页中查找指定主键值的记录的过程分为两步:
- 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
- 通过记录的
next_record
属性遍历该槽所在的组中的各个记录。
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页定义 |
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
每一个
页
都有一个单独的页号,就跟你的身份证号码一样,InnoDB
通过页号来可以唯一定位一个页
。 -
FIL_PAGE_TYPE
这个代表当前
页
的类型,我们前边说过,InnoDB
为了不同的目的而把页分为不同的类型,我们上边介绍的其实都是存储记录的数据页
,其实还有很多别的类型的页,具体如下表:类型名称 十六进制 描述 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 溢出页 FIL_PAGE_INDEX
0x45BF 索引页,也就是我们所说的 数据页
我们存放记录的数据页的类型其实是
FIL_PAGE_INDEX
,也就是所谓的索引页
。 -
FIL_PAGE_PREV
和FIL_PAGE_NEXT
我们前边强调过,
InnoDB
都是以页为单位存放数据的,有时候我们存放某种类型的数据占用的空间非常大(比方说一张表中可以有成千上万条记录),InnoDB
可能不可以一次性为这么多数据分配一个非常大的存储空间,如果分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV
和FIL_PAGE_NEXT
就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,而无需这些页在物理上真正连着。需要注意的是,并不是所有类型的页都有上一个和下一个页的属性,不过我们本集中唠叨的数据页
(也就是类型为FIL_PAGE_INDEX
的页)是有这两个属性的,所以所有的数据页其实是一个双链表,就像这样:
File Trailer
我们知道InnoDB
存储引擎会把数据存储到磁盘上,但是磁盘速度太慢,需要以页
为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候中断电了咋办,这不是莫名尴尬么?为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),设计InnoDB
的大叔们在每个页的尾部都加了一个File Trailer
部分,这个部分由8
个字节组成,可以分成2个小部分:
-
前4个字节代表页的校验和
这个部分是和
File Header
中的校验和相对应的。每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header
在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header
中的校验和就代表着已经修改过的页,而在File Trailer
中的校验和代表着原先的页,二者不同则意味着同步中间出了错。 -
后4个字节代表页面被最后修改时对应的日志序列位置(LSN)
这个部分也是为了校验页的完整性的,只不过我们目前还没说
LSN
是个什么意思,所以大家可以先不用管这个属性。
这个File Trailer
与File Header
类似,都是所有类型的页通用的。
最后
内容还是比较多的,如果你是第一次接触这些东西,看下来难免会有点懵。最后我们总结一下。
数据页之间通过指针连接组成一个双向链表
,数据页中的记录会按照主键值从小到大的顺序组成一个单向链表
,每个数据页都会为存储在它里边儿的记录生成一个页目录
,在通过主键查找某条记录的时候可以在页目录
中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。页和记录的关系示意图如下:
近期评论