InnoDB数据存储结构
本专栏学习内容来自尚硅谷宋红康老师的视频
有兴趣的小伙伴可以点击视频地址观看
1. 数据库的存储结构:页
索引结构给我们提供了高效的索引方式,不过索引信息以及数据记录都是保存在文件上的,确切来说是存储在页结构中。另一方面,索引是在存储引擎中实现的,MySQL服务器上的存储引擎负责对表中数据的读取和写入工作。不同的存储引擎中存放的格式
一般是不同的,甚至有的存储引擎都不用磁盘来存储数据。
由于InnoDB是MySQL的默认存储引擎,所以本章节讲解InnoDB存储引擎的数据存储结构
1.1 磁盘与内存交互的基本单位:页
之前的文章中也学到了,InnoDB将数据划分为若干个页,InnoDB中页的默认大小为16KB
以页
作为磁盘和内存之间交互的基本单位
,也就是一次最少从磁盘中读取16KB的内容到内存中,一次最少把16KB的内容刷新到磁盘中。也就是说,在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说数据库管理存储空间的基本单位是页,数据库I/O操作的最小单位也是页。一个页中可以存储多个行记录
1.2 页结构概述
页a、页b、页c…这些页可以不在物理结构上相连
,只要通过双向链表
相关联即可。每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表
,每个数据页都会为存储在它里边的记录生成一个页目录
,在通过主键查找某条记录的时候可以在页目录中使用二分法
快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录。
1.3 页的大小
不同数据库管理系统的页大小不同,MySQL的InnoDB存储引擎中,默认的页大小是16KB
mysql> show variables like '%innodb_page_size%';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| innodb_page_size | 16384 |
+------------------+-------+
1.4 页的上层结构
在数据库中还存在着区、段、和表空间的概念。
从小到大的包含关系依次为:行——页——区——段——表。
2. 页的内部结构
数据页16KB
大小的存储空间被划分为七个部分,分别是文件头、页头、最大最小记录、用户记录、空闲空间、页目录和文件尾,如下图所示:
名称 | 占用大小 | 说明 |
---|---|---|
File Header | 38字节 | 文件头,描述页的信息 |
Page Header | 56字节 | 页头,页的状态信息 |
Infimum + Supremum | 26字节 | 最大和最小记录,这是两个虚拟的行记录 |
User Records | 不确定 | 用户记录,存储行记录的内容 |
Free Space | 不确定 | 空闲记录,页中还没有被使用的空间 |
Page Directory | 不确定 | 页目录,存储用户记录的相对位置 |
File Trailer | 8字节 | 文件尾,校验页是否完整 |
我们可以把这7个结构分成3个部分
第一部分:File Header(文件头)和File Trailer(文件尾)
首先是文件通用部分
,也就是文件头
和文件尾
-
File Header(文件头)
-
FIL_PAGE_OFFSET
(4字节)页号,可以理解为身份证信息,可以通过页号快速的定位到页
-
FIL_PAGE_TYPE
(2字节)代表当前页的类型,举几个比较有代表性的类型
- FIL_PAGE_UNDO_LOG:Undo日志页
- FIL_PAGE_TYPE_SYS:系统页
- FIL_PAGE_INDEX:索引页,也就是我们所说的数据页
-
FIL_PAGE_PREV
(4字节)和FIL_PAGE_NEXT
(4字节)之前说到页与页之间是形成一个双向链表,PREV记录上一个页的页号,NEXT记录下一个页的页号
-
FIL_PAGE_SPACE_OR_CHKSUM
(4字节)页的校验和,先简单介绍一下
校验和
,通过某种算法将一个很长的字符串转换成短的字符串,用于比较两个字符串是否相同。同一个页中,文件头和文件尾分别记录一个
FIL_PAGE_SPACE_OR_CHKSUM
,记录的数值是一样的,举个例子,现在从内存中向磁盘刷新数据,内存中文件头和文件尾的校验和都是38,而磁盘中文件头和文件尾的校验和是23,在写入数据时,从文件头依次往下写,最后写文件尾,再次过程中,如果出现断电等异常情况导致一个页没有写完,那么磁盘中的文件头是38,而文件尾是23,则代表此次写入失败。 -
FIL_PAGE_LSN
(8字节)页面被最后修改时对应的日志序列位置
-
FIL_PAGE_FILE_FLUSH_LSN
(8字节)仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值
-
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID
(4字节)页属于哪个表空间
-
-
File Trailer(文件尾)
- 前4个字节代表页的校验和,这部分与文件头中的检验和相对应
- 后4个字节代表页面被最后修改时对应的日志序列位置,这个部分也是为了校验页的完整性的,如果文件头和文件尾的LSN值校验不成功的话,就说明同步过程出现了问题
第二部分:User Records(用户记录)、最大最小记录、Free Space(空闲空间)
第二部分是记录部分,页的主要作用是存储记录,这部分我们需要穿插着来学习,更便于理解,其中会包括着行格式中的记录头信息
-
Free Space(空闲空间)
每当我们插入一条记录,都会从空闲空间中申请一个记录大小的空间划分到用户记录部分,当空闲空间全部被用户记录划分走时,如果还有新的记录插入的话,就需要去申请新的页
-
User Records(用户记录)
User Records中的这些记录按照
指定的行格式
一条一条摆在User Records中,相互之间形成单链表。想要知道用户记录中的一条条数据是如何记录的,那就需要学习行格式的
记录头信息
。在之后的讲解中都会使用这张表中的数据
CREATE TABLE index_demo( c1 INT, c2 INT, c3 CHAR(1), PRIMARY KEY (c1) ) ROW_FORMAT = Compact;
以下是简化后的行格式示意图
这些记录头信息中的各个属性如下
名称 大小(单位:bit) 描述 delete_mask 1 标记该记录是否被删除 min_rec_mask 1 B+树的每层非叶子节点中的最小记录都会添加该标记 record_type 3 表示当前记录类型,0表示普通记录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录 heap_no 13 表示当前记录在记录堆的位置信息 n_owned 4 表示当前记录拥有的记录树 next_record 16 表示下一条记录的相对位置 为了理解各个属性,我们在表中插入4条记录
INSERT INTO index_demo VALUES(1,4,'a'),(2,9,'b'),(3,3,'c'),(4,8,'d');
这时候他们的行格式示意图是这样的
delete_mask
这个属性标记着当前记录是否被删除
- 值为0:代表记录并没有被删除
- 值为1:代表记录被删除掉了
被删除的记录为什么还要在页中存储?
这些记录虽然被删除了,但是它还在真实的磁盘上,如果这些记录被删除必须要立即从磁盘上移除的话,其他记录在磁盘上需要
重新排列,导致性能消耗
。所以只是打一个删除的标记而已,并且被删除掉的记录会组成一个所谓的垃圾链表
,在这个链表中的记录占用空间称之为记可重用空间
,之后如果有新记录插入到表中,可能把这些被删除的记录占用的存储空间覆盖掉。min_rec_mask
B+树的每层非叶子节点的最小记录都会添加钙标记,min_rec_mask值为1。我们插入的四条记录min_rec_mask值为1,意味着它们都不是B+树的非叶子节点中的最小记录。
record_type
这个属性表示当前记录的类型,从图中可以看出,我们自己插入的记录就是普通记录,它们的record_type值都是0,而最小记录和最大记录的record_type分别是2和3,至于record_type为1的情况,可以参考小黄的上一篇文章——索引的数据结构
heap_no
这个属性表示当前记录在本页中的位置
从图中可以看出,我们插入的4条记录在本页中的位置分别是2,3,4,5
MySQL会自动给每个页里加两个记录,一个记录代表最小记录,一个记录代表最大记录,它们的heap_no值分别为0和1,也就是说他们的位置最靠前
n_owned
页目录中每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为n_owned字段,在第三部分的页目录中我们会学习到
next_record
记录头信息中的这个属性非常重要,他表示从当前记录的真实数据到下一条记录的真实数据的
地址偏移量
。比如第一条数据的next_record值为32,意味着从第一条记录的真实数据的地址处向后移动32个字节便是下一条记录的真实数据。下图中用箭头代替偏移量对于删除数据来说,例如我们删除第二条记录,看哪些地方会有变化
- 第二条记录的delete_mask属性变为1
- 第二条记录的next_record属性变为0
- 第一条记录的next_record直接指向第三条记录
- 最大记录的n_owned属性变为4(页目录中会详细讲解)
那当我们重新插入一条id为2的记录时,他会覆盖第二条记录的位置,图示相当于没有改变之前的样子
第三部分:Page Directory(页目录)、Page Header(页头)
-
Page Directory(页目录)
在页中,记录是以
单向链表
的形式存储的,单向链表的特点是插入、删除特别方便,但是检索时效率不高
,也就是按照顺序查找的方式才能完成检索。因此在页结构中专门设计了页目录这个模块,专门给记录做一个目录
,通过二分查找法
进行检索,提升效率。具体分为以下几个步骤:-
将所有的记录分成几个组,这些记录包括最小记录和最大记录,但不包括标记为已删除的记录
-
第一组,也就是最小记录所在的分组,只有一个记录
最后一组,就是最大记录所在的分组,会有1-8条记录
其余的组记录数量在4-8条之间
这样做的好处是,除了第一组之外,其余组的记录数会
尽量平分
-
在每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为n_owned字段
-
页目录用来存储每组最后一条记录的地址偏移量
,这些偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot)
,每个槽相当于指针指向了不同组的最后一个记录
页目录分组的个数如何确定?
分组是按照下边的步骤进行的:
- 初始情况下一个数据页只有最小记录和最大记录两条记录,它们分别属于两个分组
- 之后每插入一条记录,都会从页目录中找到主键值比本记录主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8
- 在一个组中的记录数等于8后再插入一条记录时,会将组中的记录拆分为两个组,一个组中4条记录,另一个5条记录,这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量
页目录结构下如何快速查找记录?
查找一条记录时,当我们通过B+树定位到某一个页时,先去目录页中使用
二分法
查找槽,找到槽之后如果比该槽记录的值大则去后半部分找,如果比该槽记录的值小则去前半部分找。当定位到确切的槽之后,因为行之间是单向链表的形式,所以需要去该槽的前一个槽往下找。 -
-
Page Header(页头)
为了能得到一个数据页中存储的记录的状态信息,特意在页中定义了一个叫Page Header的部分,这个部分固定占用56个字节,专门存储各种状态信息。
名称 占用空间 描述 PAGE_N_DIR_SLOTS 2字节 页目录中的槽数量 PAGE_HEAP_TOP 2字节 还未使用的空间最小地址,也就是说从该地址之后就是空闲空间 PAGE_N_HEAP 2字节 本页中的记录的数量(包括最小记录、最大记录以及被标记为删除的记录) PAGE_FREE 2字节 第一个已经标记为删除的记录地址(各个已删除的记录通过next_record也会组成一个单链表,这个单链表中的记录可以被重新利用) PAGE_GARBAGE 2字节 已删除记录占用的字节数 PAGE_LAST_INSERT 2字节 最后插入记录的位置 PAGE_DIRECTION 2字节 记录插入的方向 PAGE_N_DIRECTION 2字节 一个方向连续插入的记录数量,如果方向改变,重置为0 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页定义
3. InnDB行格式
我们平时的数据以行为单位来向表中插入数据,这些记录在磁盘上的存放方式也被称为行格式
或记录格式
,InnoDB存储引擎设计了4种不同类型的行格式,分别是Compact
,Redundant
,Dynamic
,Compressed
MySQL8的默认行格式是Dynamic
mysql> select @@innodb_default_row_format;
+-----------------------------+
| @@innodb_default_row_format |
+-----------------------------+
| dynamic |
+-----------------------------+
3.1 Compact行格式
行格式示意图如下:
在学习Compact行格式之前,我们需要创建一张表来便于我们观察
#定义编码类型为ascii,主要是因为一个字符占用一个字节,方便理解
CREATE TABLE record_test_table (
col1 VARCHAR(8),
col2 VARCHAR(8) NOT NULL,
col3 CHAR(8),
col4 VARCHAR(8)
)CHARSET=ascii ROW_FORMAT=COMPACT;
INSERT INTO record_test_table(col1,col2,col3,col4)
VALUES ('zhangsan','lisi','wangwu','star'),
('tong','chen',NULL,NULL);
变长字段长度列表
MySQL支持一些变长的数据类型,比如VARCHAR、TEXT、BLOB等类型,这些数据类型修饰的列被称为变长字段
,变长字段中存储多少字节的数据不是固定的,所以我们需要在存储真实数据的时候顺便把这些数据占用的字节数也存进来。在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部分,形成一个变长字段长度列表
需要注意的是,这里面存储的变长长度和字段顺序是反过来的
以record_test_table表中的第一条记录举例,因为col1,col2,col3都是VARCHAR类型,所以这三个列的值的长度都需要保存到变长字段长度列表中
列名 | 存储内容 | 内容长度(十进制表示) | 内容长度(十六进制表示) |
---|---|---|---|
col1 | zhangsan | 8 | 0x08 |
col2 | lisi | 4 | 0x04 |
col3 | star | 4 | 0x04 |
因为这些长度值是按照列的逆序存放的,所以最后的效果就是040408
现在我们这一条行格式的示意图就变成了下面这种形式:
NULL值列表
Compact行格式会把可以为NULL的列统一管理起来,存在一个标记为NULL值列表中,如果表中没有允许存储NULL的列,则NULL值列表也不存在了。
在磁盘中,数据是需要对齐的,如果没有标注出NULL值的位置,就有可能在查询数据的时候出现混乱;如果使用一个特定的符号放到相应的位置,很浪费空间。所以直接在行数据的头部记录那些是非空数据,哪些是空数据。
- 二进制位的值为1时,代表该列的值为NULL
- 二进制位的值为0时,代表该列的值不是NULL
当然NULL值列表也是按照列的逆序存放的
,除此之外,非空列是不记录在内的
那么第二条数据的NULL值列表
记录头信息
这个在学习页的内部结构时有讲过,这里就不赘述了。
记录的真实数据
记录的真实数据除了我们自己定义的列的数据以外,还会有三个隐藏列:
列名 | 是否必须 | 占用空间 | 描述 |
---|---|---|---|
row_id | 否 | 6字节 | 行ID,唯一标识一条记录 |
transaction_id | 是 | 6字节 | 事务ID |
roll_pointer | 是 | 7字节 | 回滚指针 |
行ID之前我们有提到过,InnoDB引擎的表都必须有一个聚簇索引,也就是必须有一个主键,如果没有指明主键,那么他会找一个非空且唯一索引作为主键,如果也没有,则会为表默认添加一个名为row_id的隐藏列作为主键。
为了方便分析行记录的内部结构,需要创建表并且插入三条记录:
CREATE TABLE mytest (
col1 VARCHAR(10),
col2 VARCHAR(10),
col3 CHAR(10),
col4 VARCHAR(10)
)CHARSET=LATIN1 ROW_FORMAT=COMPACT;
INSERT INTO mytest(col1,col2,col3,col4)
VALUES ('a','bb','bb','ccc'),
('d','ee','ee','fff'),
('d',NULL,NULL,'fff');
这时候我们去MySQL存储路径下找到这张表对应的文件mytest.idb
大概在这个位置显示了插入的三条数据,大家可以输入0302来查找
分析一下第一条数据
03 02 01
:代表变长字段长度列表,其中col4是3,col2是2,col1是100
:NULL值列表,这条数据中没有NULL值,所以用00来代替00 00 10 00 2c
:记录头信息00 00 00 00 00 03 02
:row_id,mytest这张表没有主键也没有唯一非空索引,所以会自动生成00 00 00 00 21 30
:事务ID82 00 00 00 87 01 10
:回滚指针61
:col1的真实数据,a62 62
:col2的真实数据,bb62 62 20 20 20 20 20 20 20 20
:col3的真实数据,bb。col3是定长列,所以用20补齐空位63 63 63
:col4的真实数据,ccc
第二条记录与第一条相似,我们主要来看一下有NULL值的第三条记录
06
:NULL值列表,对于第三条数据来说他的空值记录倒序应该是0110,使用16进制解析就是06
3.2 Dynamic和Compressed行格式
这两种行格式与Compact行格式大体上相同,在行溢出的处理上不太一样。
什么是行溢出?
我们知道一个页的大小是16KB也就是16384字节,VARCHAR可以存储的最大字节数是65535字节(对于VARCHAR类型需要2个字节来存储真实的长度,并且如果是可以为NULL的需要一个NULL来存储NULL值,也就是说VARCHAR可以存储的最大字节数是65533字节)
即使是这样也远远超过了一个页可以存储的大小,出现这种情况我们就称之为行溢出
- Compressed和Dynamic两种记录格式对于出现行溢出的情况,在数据页中之存放20个字节的指针(指向溢出页的地址),实际的数据都存放在溢出页中
- Compact和Redundant两种格式会在记录的真实数据处存储一部分数据(存放768个前缀字节)
3.3 Redundant行格式
Redundant是MySQL5.0版本之前InnoDB的行记录存储方式,MySQL支持Redundant是为了兼容之前版本的页格式。
这个我们不用深入研究,具体的示意图如下:
4. 区、段与碎片区
4.1 为什么要有区?
B+树的每一层中的页都会形成一个双向链表,如果是以页为单位
来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离的非常远
,如果需要范围查询的话只需要定位最左边和最右边的记录,沿着双向链表一直扫描即可,而如果两个页之间的物理位置隔得非常远,就是所谓的随机I/O
,这是非常慢的,所以应该尽量让链表中相邻页的物理位置页相邻,这样进行范围查询时才可以使用所谓的顺序I/O
这就引入了区
的概念,一个区就是在物理位置上连续的64个页
,因为InnoDB的页大小默认是16KB,所以一个区的大小是1MB
。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区为单位分配
,甚至在表中数据特别多的时候,可以一次性分配多个连续的区。虽然可能造成一点点空间的浪费
,但是从性能角度看,可以消除很多随机I/O。
4.2 为什么要有段
对于范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,如果不加以区分,一个区中64个物理位置上连续的页可能有很多都是非叶子节点的页
,这样进行范围扫描的效果就大打折扣了。所以InnoDB对B+树的叶子节点
和非叶子节点
进行了区别对待,这里就引入了段(segment)
,也就是说一个索引会生成两个段,一个是数据段(叶子节点段)
,一个是索引段(非叶子节点段)
。除了这两个段之外,InnoDB还有为存储一些特殊的数据而定义的段,例如回滚段
。
段其实不对应表空间中某一个连续的物理区域,而是一个逻辑上的概念,由若干个零散的页面以及一些完整的区组成
4.3 为什么要有碎片区?
在默认情况下,一个InnoDB存储引擎的表只有一个聚簇索引,而一个索引会生成2个段,而段是以区为单位申请的,也就是说一个段会占用2MB
的空间,即使是一个存储记录比较少的表也要占用2MB空间,这太浪费了。
为了解决这种情况,InnoDB提出了一个碎片区(fragment)
的概念。在一个碎片区中,并不是所有的页都是为了存储同一个段的数据而存在的,而是碎片区中的页可以用于不同的目的。碎片区直属于表空间
所以此后为某个段分配存储空间的策略是这样的:
- 在刚开始向表中插入数据的时候,段是从某个碎片区以单个页面为单位来分配存储空间的
- 当某个段已经占用了
32个碎片区
页面后,就会申请以完整的区为单位来分配存储空间。
4.4 区的分类
区大体上可以分为4种类型
- 空闲的区(FREE):现在还没有用到这个区中的任何页面
- 有剩余空间的碎片区(FREE):表示碎片区中还有可用的页面
- 没有剩余空间的碎片区(FULL_FRAG):表示碎片区中的所有页面都被使用,没有空闲页面
- 附属于某个段的区(FSEG):每一个索引都可以分为叶子节点段和非叶子节点段
处于FREE、FREE、FULL_FRAG这三种状态的区都是独立的,直属于表空间。而处于FSEG状态的区是附属于某个段的。
5. 表空间
表空间可以看作是InnoDB存储引擎逻辑结构中的最高层,所有的数据都存放在表空间中。
表空间是一个逻辑容器
,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。表空间数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间
,独立表空间
,撤销表空间
,临时表空间
等。
-
独立表空间
每张表都有一个独立的表空间,也就是数据和索引信息都会保存在自己的表空间中。独立的表空间可以在不同的数据库之间进行
迁移
-
系统表空间
系统表空间的结构和独立表空间基本类似,只不过由于整个MySQL进程只有一个系统表空间,在系统表空间中会额外记录一些有关整个系统信息的页面,这部分是独立表空间中没有的。