1. 数据库的存储结构:页
<1> 磁盘与内存交互的基本单位:页
<2> 页结构概述
<3> 页的大小
<4> 页的上层结构
2. 页的内部结构
<1> 页的分类
<2> 页的结构
<3> File Header(文件头)
1. 作用
描述各种页的通用信息。(比如页的编号、其上一页、下一页是谁等)
2. 总体结构
3. FILE_PAGE_OFFSET
每一个页都有一个单独的页号,就跟你的身份证号码一样,InnoDB通过页号可以唯一定位一个页。
4. FILE_PAGE_TYPE
这个代表当前页的类型。
5. FILE_PAGE_PREV & FILE_PAGE_NEXT
InnoDB都是以页为单位存放数据的,如果数据分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许许多多的页就都串联起来了,保证这些页之间不需要是物理上的连续,而是逻辑上的连续。
6. FILE_PAGE_SPACE_OR_CHKSUM
<1> 作用
代表当前页面的校验和(checksum)。
文件头部和文件尾部都有属性:FIL_PAGE_SPACE_OR_CHKSUM
<2> 校验和
就是对于一个很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为校验和。在比较两个很长的字节串之前,先比较这两个长字节串的校验和,如果校验和都不一样,则两个长字节串肯定是不同的,所以省去了直接比较两个比较长的字节串的时间损耗。
<3> 作用
InnoDB存储引擎以页为单位把数据加载到内存中处理,如果该页中的数据在内存中被修改了,那么在修改后的某个时间需要把数据同步到磁盘中。但是在同步了一半的时候断电了,造成了该页传输的不完整。
为了检测一个页是否完整(也就是在同步的时候有没有发生只同步一半的尴尬情况),这时可以通过文件尾的校验和(checksum 值)与文件头的校验和做比对,如果两个值不相等则证明页的传输有问题,需要重新进行传输,否则认为页的传输已经完成。
每当一个页面在内存中修改了,在同步之前就要把它的校验和算出来,因为File Header在页面的前边,所以校验和会被首先同步到磁盘,当完全写完时,校验和也会被写到页的尾部,如果完全同步成功,则页的首部和尾部的校验和应该是一致的。如果写了一半儿断电了,那么在File Header中的校验和就代表着已经修改过的页,而在File Trailer中的校验和代表着原先的页,二者不同则意味着同步中间出了错。这里,校验方式就是采用 Hash 算法进行校验。
7. FILE_PAGE_LSN
页面被最后修改时对应的日志序列位置(英文名是:Log Sequence Number)
<4> File Trailer(文件尾)
1. FILE_PAGE_SPACE_OR_CHKSUM
前4个字节,这个部分是和File Header中的校验和相对应的。
2. FILE_PAGE_LSN
后4个字节,页面被最后修改时对应的日志序列位置(LSN):这个部分也是为了校验页的完整性的,如果首部和尾部的LSN值校验不成功的话,就说明同步过程出现了问题。
<5> Free Space(空闲空间)
我们自己存储的记录会按照指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录,都会从Free Space部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。
<6> User Space(用户记录)
用户记录按照指定的行格式存储在User Records部分,相互之间形成单链表。
<7>Infimum+Supremun(最大最小记录)
1. 记录比较大小
记录可以比大小,对于一条完整的记录来说,比较记录的大小就是比较主键的大小。比方说我们插入的4行记录的主键值分别是:1、2、3、4,这也就意味着这4条记录是从小到大依次递增。
2. 结构
InnoDB规定的最小记录与最大记录这两条记录的构造十分简单,都是由5字节大小的记录头信息和8字节大小的一个固定的部分组成的。这两条记录不是我们自己定义的记录,所以它们并不存放在页的User Records部分,他们被单独放在一个称为Infimum + Supremum的部分
<8> Page Directory(页目录)
1. 页目录,概述
在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。因此在页结构中专门设计了页目录这个模块,专门给记录做一个目录,通过二分查找法的方式进行检索,提升效率。
2.页目录,二分法查找
SELECT * FROM page_demo WHERE c1 = 3;
<1> 顺序查找方式
从Infimum记录(最小记录)开始,沿着链表一直往后找,总有一天会找到(或者找不到),在找的时候还能投机取巧,因为链表中各个记录的值是按照从小到大顺序排列的,所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时,你就可以停止查找了,因为该节点后边的节点的主键值依次递增。
如果一个页中存储了非常多的记录,这么查找性能很差。
<2> 使用页目录,二分法查找
- 将所有的记录分成几个组,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录。
- 第 1 组,也就是最小记录所在的分组只有 1 个记录;最后一组,就是最大记录所在的分组,会有 1-8 条记录;其余的组记录数量在 4-8 条之间。这样做的好处是,除了第 1 组(最小记录所在组)以外,其余组的记录数会尽量平分。
- 在每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为n_owned 字段。
- 页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot),每个槽相当于指针指向了不同组的最后一个记录。
3. 页目录,分组
<1> 分组规定
对于最小记录所在的分组只能有1条记录,最大记录所在的分组拥有的记录条数只能在1~8条之间,剩下的分组中记录的条数范围只能在是 4~8 条之间。
<2>分组步骤
- 初始情况下一个数据页里只有最小记录和最大记录两条记录,它们分属于两个分组。
- 之后每插入一条记录,都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽,然后把该槽对应的记录的n_owned值加1,表示本组内又添加了一条记录,直到该组中的记录数等于8个。
- 在一个组中的记录数等于8个后再插入一条记录时,会将组中的记录拆分成两个组,一个组中4条记录,另一个5条记录。这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。
4. 查找过程
<1> 通过二分法确定该记录所在的槽,并找到该槽所在分组中主键值最小的那条记录。
<2> 通过记录的next_record属性遍历该槽所在的组中的各个记录。
<3> 案例
- 现在页里一共有18条记录了(包括最小和最大记录),这些记录被分成了5个组。
- 初始情况下最低的槽就是low=0,最高的槽就是high=4。比方说我们想找主键值为6的记录,过程是这样的
- 计算中间槽的位置:(0+4)/2=2,查看槽2对应记录的主键值为8,又因为8 > 6,所以设置high=2,low保持不变。
- 重新计算中间槽的位置:(0+2)/2=1,查看槽1对应的主键值为4,又因为4 < 6,所以设置low=1,high保持不变。
- 因为high - low的值为1,所以确定主键值为6的记录在槽2对应的组中。此刻我们需要找到槽2中主键值最小的那条记录,然后沿着单向链表遍历槽2中的记录。
- 各个槽都是挨着的,可以拿到槽1对应的记录(主键值为4),该条记录的下一条记录就是槽2中主键值最小的记录,该记录的主键值为5。所以我们可以从这条主键值为5的记录出发,遍历槽2中的各条记录,直到找到主键值为6的那条记录即可。由于一个组中包含的记录条数只能是1~8条,所以遍历一个组中的记录的代价是很小的。
<9> Page Header
1. 概述
为了能得到一个数据页中存储的记录的状态信息,比如本页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,这个部分占用固定的56个字节,专门存储各种状态信息。
2. PAGE_N_DIRECTION
假如新插入的一条记录的主键值比上一条记录的主键值大,我们说这条记录的插入方向是右边,反之则是左边。用来表示最后一条记录插入方向的状态就是PAGE_DIRECTION。
3. PAGE_N_DIRECTION
假设连续几次插入新记录的方向都是一致的,InnoDB会把沿着同一个方向插入记录的条数记下来,这个条数就用PAGE_N_DIRECTION这个状态表示。当然,如果最后一条记录的插入方向改变了的话,这个状态的值会被清零重新统计。
3. InnoDB行格式
<1> 指定行格式
1. 创建表指定行格式
CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
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', 'songhk'), ('tong', 'chen', NULL, NULL);
2. 修改表指定行格式
ALTER TABLE 表名 ROW_FORMAT=行格式名称
<2> COMPACT行格式
分为记录的额外信息和记录的真实数据两大部分。
1. 变长字段长度列表
MySQL支持一些变长的数据类型,比如VARCHAR(M)、VARBINARY(M)、TEXT类型,BLOB类型,这些数据类型修饰列称为变长字段。变长字段中存储多少字节的数据不是固定的,所以我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。在Compact行格式中,把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表。
注意:这里面存储的变长长度和字段顺序是反过来的。比如两个varchar字段在表结构的顺序是a(10),b(15)。那么在变长字段长度列表中存储的长度顺序就是15,10,是反过来的。
以record_test_table表中的第一条记录举例:因为record_test_table表的col1、col2、col4列都是VARCHAR(8)类型的,所以这三个列的值的长度都需要保存在记录开头处,注意record_test_table表中的各个列都使用的是ascii字符集(每个字符只需要1个字节来进行编码)。
2. NULL值列表
<1> NULL值列表
Compact行格式会把可以为NULL的列统一管理起来,存在一个标记为NULL值列表中。如果表中没有允许存储 NULL 的列,则 NULL值列表也不存在了。
<2> 作用
之所以要存储NULL是因为数据都是需要对齐的,如果没有标注出来NULL值的位置,就有可能在查询数据的时候出现混乱。如果使用一个特定的符号放到相应的数据位表示空置的话,虽然能达到效果,但是这样很浪费空间,所以直接就在行数据得头部开辟出一块空间专门用来记录该行数据哪些是非空数据,哪些是空数据,格式如下:
<3> 规则
- 二进制位的值为1时,代表该列的值为NULL。
- 二进制位的值为0时,代表该列的值不为NULL。
<4> 案例
字段 a、b、c,其中a是主键,在某一行中存储的数依次是 a=1、b=null、c=2。那么Compact行格式中的NULL值列表中存储:01。第一个0表示c不为null,第二个1表示b是null。这里之所以没有a是因为数据库会自动跳过主键,因为主键肯定是非NULL且唯一的,在NULL值列表的数据中就会自动跳过主键。
record_test_table的两条记录的NULL值列表就如下:
INSERT INTO record_test_table(col1, col2, col3, col4) VALUES (‘zhangsan’, ‘lisi’, ‘wangwu’, ‘songhk’), (‘tong’, ‘chen’, NULL, NULL);
3. 记录头信息
<1> delete_mask
- 作用
这个属性标记着当前记录是否被删除,占用1个二进制位。 - 规则
值为0:代表记录并没有被删除
值为1:代表记录被删除掉了 - 被删除的记录
当记录被删除后,删除的记录还在真实的磁盘上。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后其他的记录在磁盘上需要重新排列,导致性能消耗。所以只是打一个删除标记而已,所有被删除掉的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为可重用空间,之后如果有新记录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉。
<2> min_rec_mask
B+Tree的每层非叶子节点(目录页)中的最小记录都会添加该标记,min_rec_mask值为1。
我们自己插入的四条记录的min_rec_mask值都是0,意味着它们都不是B+树的非叶子节点目录页)中的最小记录。
<3> record_type
这个属性表示当前记录的类型,一共有4种类型的记录:
0:表示普通记录
1:表示B+树非叶节点记录
2:表示最小记录
3:表示最大记录
从图中我们也可以看出来,我们自己插入的记录就是普通记录,它们的record_type值都是0,而最小记录和最大记录的record_type值分别为2和3。record_type为1表示B+树非叶节点记录
<4> heap_no
- 作用
这个属性表示当前记录在本页中的位置。 - 规则
从0开始,依次递增。
heap_no值为0和1的记录为MySQL自动给每个页里加入的两个记录,由于这两个记录并不是我们自己插入的,所以有时候也称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录。最小记录和最大记录的heap_no值分别是0和1,也就是说它们的位置最靠前。这两个伪记录就对应数据页中的Infimum和Supremun
<5> n_owned
【页目录中每个组】中【最后一条记录】的【头信息】中会存储该组一共有多少条记录,作为 n_owned 字段。
<6> next_record
- 作用
记录头信息里该属性非常重要,它表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量。
注意,下一条记录指得并不是按照我们插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录。而且规定Infimum记录(也就是最小记录)的下一条记录就是本页中主键值最小的用户记录,而本页中主键值最大的用户记录的下一条记录就是 Supremum记录(也就是最大记录)。下图用箭头代替偏移量表示next_record。
比如:第一条记录的next_record值为32,意味着从第一条记录的真实数据的地址处向后找32个字节便是下一条记录的真实数据。
- 删除记录操作流程
DELETE FROM page_demo WHERE c1 = 2;
第2条记录并没有从存储空间中移除,而是把该条记录的delete_mask值设置为1。
第2条记录的next_record值变为了0,意味着该记录没有下一条记录了。
第1条记录的next_record指向了第3条记录。
最大记录的n_owned值从 5 变成了 4 。 - 添加记录操作流程
INSERT INTO page_demo VALUES(2, 200, ‘tong’);
第2条记录空间重新利用,把该条记录的delete_mask值设置为0。
新插入的记录的next_record指向了第三条记录。
第1条记录的next_record指向了新插入的记录。
最大记录的n_owned值从 4 变成了 5 。
4. 记录的真实数据
- 自定义列
与表结构相同 - 隐藏列
- row_id
一个表没有手动定义主键,则会选取一个Unique键作为主键,如果连Unique键都没有定义的话,则会为表默认添加一个名为row_id的隐藏列作为主键。所以row_id是在没有自定义主键以及Unique键的情况下才会存在的。<3> Dynamic和Compressed行格式 - transaction_id
- roll_pointer
- 说明
InnoDB每行有隐藏列TransactionID和Roll Pointer。
<3> Dynamic和Compressed 行格式
1.行溢出
<1> 概念
InnoDB存储引擎可以将一条记录中的某些数据存储在真正的数据页面之外。
一个页的大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65533个字节,这样就可能出现一个页存放不了一条记录,这种现象称为行溢出。
在Compact和Reduntant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他的页中进行分页存储,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节中还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页。
2 Dynamic和Compressed行格式
在MySQL 8.0中,默认行格式就是Dynamic
Dynamic、Compressed行格式和Compact行格式挺像,只不过在处理行溢出数据时有分歧
Compact会在记录的真实数据处存储一部分数据(存放768个前缀字节)。
Compressed和Dynamic两种记录格式对于存放在BLOB中的数据采用了完全的行溢出的方式。如图,在数据页中只存放20个字节的指针(溢出页的地址),实际的数据都存放在Off Page(溢出页)中。
Compressed行记录格式的另一个功能就是,存储在其中的行数据会以zlib的算法进行压缩,因此对于BLOB、TEXT、VARCHAR这类大长度类型的数据能够进行非常有效的存储。
4. 区&段&碎片区
<1> 为什么要有区
<2> 为什么要有段
<3> 为什么要有碎片区
<4> 区的分类
5. 表空间
<1> 独立表空间
<2> 系统表空间