Dawn's Blogs

分享技术 记录成长

0%

MySQL高级 (4) InnoDB的数据存储结构

页概述

InnoDB将数据划分为若干个页,InnoDB中数据页的默认大小为16KB。数据库管理存储空间的基本单位是页,数据库IO操作的最小单位是页

页之间不在物理上相邻,通过双向链表相关联。数据页中的每条记录通过单向链表按照顺序连接

页的上层结构

数据库中,还存在着区(Extent)、段(Segment)和表空间(Tablespace)的概念。它们的关系如下:

结构关系

区(Extent):比页大一级的存储结构,在InnoDB中,一个区会分配64个连续的页。一个区的大小是64*16KB=1MB

段(Segment):由一个或者多个区组成,区在文件系统中是一个连续的空间,在段中区之间不要求相邻。段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。在创建数据表、索引的时候会创建相应的段

表空间(Tablespace):是一个逻辑容器,一个表空间中有一个或者多个段。数据库由一个或者多个表空间组成,表空间从管理上可以划分为系统表空间、用户表空间、撤销表空间、临时表空间等。

页的内部结构

页的内部结构可以分为7个部分,分别是文件头、页头、最大最小记录、用户记录、空闲空间、页目录、文件尾:

页结构示意图

这7个部分的功能概述如下:

页结构功能

完整的数据页结构

第1部分:File Header(文件头部)和File Rrailer(文件尾部)

File Header文件头部

描述各种页的通用信息。(比如页的编号、其上一页、下一页是谁等)

大小:38字节

1644576646887

FIL_PAGE_OFFSET(4字节)

每一个页都有一个单独的页号,InnoDB通过页号可以唯一定位一个页

FIL_PAGE_TYPE(2字节)

代表当前页的类型

页类型

FIL_PAGE_PREV(4字节)和FIL_PAGE_NEXT(4字节)

InnoDB都是以页为单位存放数据的,如果数据分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。

这样通过建立一个双向链表把许许多多的页就都串联起来了,保证这些页之间不需要是物理上的连续,而是逻辑上的连续。

数据页之间组成双向链表

FIL_PAGE_SPACE_OR_CHKSUM(4字节)

校验和

为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),这时可以通过文件尾的校验和与文件头的校验和做比对,如果两个值不相等则证明页的传输有问题,需要重新进行传输,否则认为页的传输已经完成

每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header中的校验和就代表着已经修改过的页,而在File Trailer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。这里,校验方式就是采用 Hash 算法进行校验

FIL_PAGE_LSN(8字节)

页面被最后修改时对应的日志序列(Log Sequence Number)位置

File Rrailer文件尾部

  • 前4个字节代表页的校验和:这个部分是和File Header中的校验和相对应的。

  • 后4个字节代表页面被最后修改时对应的日志序列位置(LSN):这个部分也是为了校验页的完整性的,如果首部和尾部的LSN值校验不成功的话,就说明同步过程出现了问题。

第2部分:User Records(用户记录)、最大最小记录、Free Space(空闲空间)

Free Space空闲空间

用户存储的记录会按照指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分

当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页

User Records用户记录

User Records中的这些记录按照指定的行格式一条一条摆在User Records部分,相互之间形成单链表

Infimum + Supremum最小最大记录

InnoDB规定的最小记录与最大记录这两条记录的构造十分简单,都是由5字节大小的记录头信息(同行格式中的记录头信息)和8字节大小的一个固定的部分组成的,如图所示:

最小最大记录结构示意图

这两条记录不是我们自己定义的记录,所以它们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分

第3部分:Page Directory(页目录)、Page Header(页面头部)

Page Directory页目录

为什么需要页目录?

在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。因此在页结构中专门设计了页目录这个模块,专门给记录做一个目录,通过二分查找法的方式进行检索,提升效率

使用页目录,进行二分查找:

  1. 将所有的记录分成几个组,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录
  2. 第 1 组,也就是最小记录所在的分组只有 1 个记录;最后一组,就是最大记录所在的分组,会有 1-8 条记录;其余的组记录数量在 4-8 条之间。这样做的好处是,除了第 1 组(最小记录所在组)以外,其余组的记录数会尽量平分
  3. 每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段
  4. 页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot),每个槽相当于指针指向了不同组的最后一个记录

页目录示意图

Page Header页面头部

为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,这个部分占用固定的56个字节,专门存储各种状态信息

页面头部

行格式

Compact行格式

MySQL默认设置为Compact行格式。一条完整的记录其实可以被分为记录的额外信息和记录的真实数据两大部分

Compact行格式示意图

变长字段长度列表

MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、TEXT类型,BLOB类型,这些数据类型修饰列称为变长字段,变长字段中存储多少字节的数据不是固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表

注意:这里面存储的变长长度和字段声明顺序是反过来的。比如两个varchar字段在表结构的顺序是a(10),b(15)。那么在变长字段长度列表中存储的长度顺序就是15,10,是反过来的

NULL值列表

Compact行格式会把可以为NULL的列统一管理起来,存在一个标记为NULL值列表中。如果表中没有允许存储 NULL 的列,则 NULL值列表也不存在了


为什么定义NULL值列表?

之所以要存储NULL是因为数据都是需要对齐的,如果没有标注出来NULL值的位置,就有可能在查询数据的时候出现混乱。如果使用一个特定的符号放到相应的数据位表示空置的话,虽然能达到效果,但是这样很浪费空间,所以直接就在行数据得头部开辟出一块空间专门用来记录该行数据哪些是非空数据,哪些是空数据,格式如下:

  1. 二进制位的值为1时,代表该列的值为NULL
  2. 二进制位的值为0时,代表该列的值不为NULL

因为主键肯定是非NULL且唯一的,在NULL值列表的数据中就会自动跳过主键

记录头信息(5字节)

1
2
3
4
5
6
CREATE TABLE page_demo(
c1 INT,
c2 INT,
c3 VARCHAR(10000),
PRIMARY KEY (c1)
) CHARSET=ascii ROW_FORMAT=Compact;

这个表中记录的行格式示意图:

1644578202509

这些记录头信息中各个属性如下:

记录头信息属性

简化后的行格式示意图:

简化后的记录头

delete_mask

这个属性标记着当前记录是否被删除,占用1个二进制位。

  • 值为0:代表记录并没有被删除
  • 值为1:代表记录被删除掉了

为什么要进行逻辑上的删除?

这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后其他的记录在磁盘上需要重新排列,导致性能消耗。所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉

min_rec_mask

B+树的每层非叶子节点中的最小记录都会添加该标记,min_rec_mask值为1

record_type

这个属性表示当前记录的类型,一共有4种类型的记录:

  • 0:表示普通记录
  • 1:表示B+树非叶节点记录
  • 2:表示最小记录
  • 3:表示最大记录

heap_no

这个属性表示当前记录在本页中的位置。用户记录从2开始


为什么用户记录的heap_no从2开始?

MySQL会自动给每个页里加了两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录。最小记录和最大记录的heap_no值分别是0和1,也就是说它们的位置最靠前

n_owned

页目录中每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段

next_record

它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量

注意,下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定Infimum记录(也就是最小记录)的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录)

数据页的详细表示

记录的真实数据

记录的真实数据除了我们自己定义的列的数据以外,还会有三个隐藏列

隐藏列


关于row_id:

一个表没有手动定义主键,则会选取一个Unique键作为主键,如果连Unique键都没有定义的话,则会为表默认添加一个名为row_id的隐藏列作为主键。所以row_id是在没有自定义主键以及Unique键的情况下才会存在的

行溢出

MySQL对一条记录占用的最大存储空间是有限制的,除BLOB或者TEXT类型的列之外, 其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节

这个65535个字节除了列本身的数据之外,还包括一些其他的数据,以Compact行格式为例,比如说我们为了存储一个VARCHAR(M)类型的列,除了真实数据占有空间以外,还需要记录的额外信息。如果该VARCHAR类型的列没有NOT NULL属性,那最多只能存储65532个字节的数据,因为变长字段的长度占用 2个字节,NULL值标识需要占用1个字节。如果有NOT NULL属性,那么就不需要NULL值标识,也就可以多存储一个字节,即65533个字节。

一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65533个字节,这样就可能出现一个页存放不了一条记录,这种现象称为行溢出

在Compact和Reduntant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据(前768字节),把剩余的数据分散存储在几个其他的页中进行分页存储,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页

页扩展

Dynamic和Compressed行格式

MySQL 8.0中,默认行格式就是Dynamic

Dynamic、Compressed行格式和Compact行格式相似,只不过在处理行溢出数据时有分歧

  • Compressed和Dynamic两种记录格式对于存放在BLOB中的数据采用了完全的行溢出的方式。如图,在数据页中只存放20个字节的指针(溢出页的地址),实际的数据都存放在Off Page(溢出页)中

完全行溢出

  • Compact和Redundant两种格式会在记录的真实数据处存储一部分数据(存放768个前缀字节)

Compressed行记录格式的另一个功能就是,存储在其中的行数据会以zlib的算法进行压缩,因此对于BLOB、TEXT、VARCHAR这类大长度类型的数据能够进行非常有效的存储

区、段、碎片区

为什么要有区?

页之间不是连续存放的,而是通过双向链表相连接的。

尽量使得逻辑上相邻的页物理上也相邻,所以引入区的概念。一个区是64个连续的页面,InnoDB中页的默认大小是16KB,所以一个区的大小是1MB

表中数据量大的时候,为某个索引分配空间不再按照以页为单位分配了,而是以区为单位分配。数据量特别多时,甚至可以一次性分配多个连续的区。这样可以使逻辑上相邻的页物理上也相邻,磁盘可以顺序IO,提高效率

为什么要有段?

范围查找实际上是对B+树叶子节点的顺序扫描。如果不区分叶子节点和分支节点,在进行范围查找时,叶子、分支节点都会被加载到内存中,效率会降低

InnoDB对B+树的叶子节点和分支节点区别对待,叶子节点的区的集合组成一个段,分支节点的区的集合组成一个段

除了叶子节点段和分支节点段之外,InnoDB还为存储一些特殊的数据而定义的段,如回滚段。常见的段有数据段(叶子节点)、索引段(非叶子节点)、回滚段

段不对应表空间中的一片连续的物理区域,而是一个逻辑上的概念,有若干个零散的页面完整的区组成

碎片区

考虑以完整的区为单位分配给某段对于数据量较小的表太浪费存储空间的情况,InnoDB提出了碎片区的概念。在一个碎片区中的页面可以用于不同的目的,比如一些页面用于段A,一些页面用于段B,一些页哪个段都不属于。碎片区直属于表空间,不属于任何一个段

为某个段分配存储空间的策略:

  • 刚开始向表中插入数据时,段是从某个碎片区以单个页面为单位分配存储空间的
  • 当某个段已经占用了32个碎片区页面后,就会申请以完整的区为单位分配存储空间

段更精确来说,应该是某些零散的页面以及一些完整的区的集合