COMPACT行格式
image.png

变长字段长度列表

在Compact行格式中, 把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位, 从而形成一个变长字段长度列表, 各变长字段数据占用的字节数按照列的顺序逆序存放.

  • 案例

建表
CREATE TABLE record_format_demo (c1 VARCHAR(10), c2 VARCHAR(10) NOT NULL,c3 CHAR(10), c4 VARCHAR(10) ) CHARSET=ascii ROW_FORMAT=COMPACT;
插入记录
NSERT INTO record_format_demo(c1, c2, c3, c4) VALUES('aaaa', 'bbb', 'cc', 'd')
因为record_format_demo表的c1、 c2、 c4列都是VARCHAR(10)类型的, 也就是变长的数据类型, 所以这三个列的值的长度都需要保存在记录开头

列名 存储内容 内容长度(十进制) 内容长度(十六进制)
c1 ‘aaaa’ 4 0x04
c2 ‘bbb’ 3 0x03
c4 ‘d’ 1 0x01

这些长度值需要按照列的逆序存放, 所以最后变长字段长度列表的字节串用十六进制表示的效果就是
0x01 0x03 0x04
image.png

NULL值列表

表中的某些列可能存储NULL值, Compact行格式把这些值为NULL的列统一管理起来, 存储到NULL值列表中

  1. 首先统计表中允许存储NULL的列有哪些
  2. MySQL规定NULL值列表必须用整数个字节的位表示, 如果使用的二进制位个数不是整数个字节, 则在字节的高位补0
    1. record_format_demo只有3个值允许为NULL的列, 对应3个二进制位, 不足一个字节, 所以在字节的高位补0, 效果就是这样:
      image.png
  3. 如果表中没有允许存储 NULL 的列, 则 NULL值列表 也不存在了, 否则将每个允许存储NULL的列对应一个二进制位, 二进制位按照列的顺序逆序排列
    1. 二进制位的值为1时, 代表该列的值为NULL。
    2. 二进制位的值为0时, 代表该列的值不为NULL。
  • 案例
    INSERT INTO record_format_demo(c1, c2, c3, c4) VALUES ('eeee', 'fff', NULL, NULL);

image.png

记录头信息

image.png

名称 大小(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 表示下一条记录的相对位置

数据页

一个数据页可以被大致划分为7个部分, 分别是

  • File Header, 表示页的一些通用信息, 占固定的38字节。
  • Page Header, 表示数据页专有的一些信息, 占固定的56个字节。
  • Infimum + Supremum, 两个虚拟的伪记录, 分别表示页中的最小和最大记录, 占固定的26个字节。
  • User Records: 真实存储我们插入的记录的部分, 大小不固定。
  • Free Space: 页中尚未使用的部分, 大小不确定。
  • Page Directory: 页中的某些记录相对位置, 也就是各个槽在页面中的地址偏移量, 大小不固定, 插入的记录越多, 这个部分占用的空间越多。
  • File Trailer: 用于检验页是否完整的部分, 占用固定的8个字节。

一个数据页拆分成了很多个部分,大体上来说包含了文件头、数据页头、最小记录和最大记录、多个数据行、空闲空间、数据页目录、文件尾部。
image.png
其中文件头占据了38个字节,数据页头占据了56个字节,最大记录和最小记录占据了26个字节,数据行区域的大小是不固定的,空闲区域的大小也是不固定的,数据页目录的大小也是不固定的,然后文件尾部占据8个字节。

Page Directory( 页目录)

记录在页中按照主键值由小到大顺序串联成一个单链表, 那如果想根据主键值查找页中的某条记录该咋办呢? 比如说这样的查询语句:
SELECT * FROM page_demo WHERE c1 = 3;
最笨的办法:从 Infimum记录(最小记录) 开始, 沿着链表一直往后找, 总有一天会找到, 在找的时候还能投机取巧, 因为链表中各个记录的值是按照从小到大顺序排列的, 所以当链表的某个节点代表的记录的主键值大于你想要查找的主键值时, 你就可以停止查找了, 因为该节点后边的节点的主键值依次递增。这个方法在存储了非常多的记录, 这么查找对性能来说还是有损耗的, 所以说这种遍历查找这是一个笨办法。
目录优化

  1. 将所有正常的记录(包括最大和最小记录, 不包括标记为已删除的记录) 划分为几个组。
  2. 每个组的最后一条记录(也就是组内最大的那条记录) 的头信息中的n_owned属性表示该记录拥有多少条记录, 也就是该组内共有几条记录。
  3. 将每个组的最后一条记录的地址偏移量单独提取出来按顺序存储到靠近页的尾部的地方, 这个地方就是所谓的Page Directory, 也就是页目录 。 页面目录中的这些地址偏移量被称为槽(Slot) , 所以这个页面目录就是由槽组成的。
    image.png

现在页目录部分中有两个槽, 也就意味着记录被分成了两个组, 槽1中的值是112, 代表最大记录的地址偏移量(就是从页面的0字节开始数, 数112个字节) ; 槽0中的值是99, 代表最小记录的地址偏移量。
注意最小和最大记录的头信息中的n_owned属性

  • 最小记录的n_owned值为1, 这就代表着以最小记录结尾的这个分组中只有1条记录, 也就是最小记录本身。
  • 最大记录的n_owned值为5, 这就代表着以最大记录结尾的这个分组中只有5条记录, 包括最大记录本身还有我们自己插入的4条记录。

槽中是偏移量其实代表的是指针。所以单纯从逻辑上看一下这些记录和页目录的关系
image.png
InnoDB对每个分组中的记录条数是有规定的: 对于最小记录所在的分组只能有 1 条记录, 最大记录所在的分组拥有的记录条数只能在 1~8 条之间, 剩下的分组中记录的条数范围只能在是 4~8 条之间。 所以分组是按照下边的步骤进行的:

  1. 初始情况下一个数据页里只有最小记录和最大记录两条记录, 它们分属于两个分组。
  2. 之后每插入一条记录, 都会从页目录中找到主键值比本记录的主键值大并且差值最小的槽, 然后把该槽对应的记录的n_owned值加1, 表示本组内又添加了一条记录, 直到该组中的记录数等于8个。
  3. 在一个组中的记录数等于8个后再插入一条记录时, 会将组中的记录拆分成两个组, 一个组中4条记录, 另一个5条记录。 这个过程会在页目录中新增一个槽来记录这个新增分组中最大的那条记录的偏移量。

当表中的记录越来越多时,可能某个数据页结构是这样的
image.png

页内查找

各个槽代表的记录的主键值都是从小到大排序的, 所以我们可以使用所谓的二分法来进行快速查找。 4个槽的编号分别是: 0、 1、 2、 3、 4, 所以初始情况下最低的槽就是low=0, 最高的槽就是high=4。 比方说我们想找主键值为6的记录, 过程是这样的:

  1. 计算中间槽的位置: (0+4)/2=2, 所以查看槽2对应记录的主键值为8, 又因为8 > 6, 所以设置high=2, low保持不变。
  2. 重新计算中间槽的位置: (0+2)/2=1, 所以查看槽1对应的主键值为4, 又因为4 < 6, 所以设置low=1, high保持不变。
  3. 因为high - low的值为1, 所以确定主键值为6的记录在槽2对应的组中。 此刻我们需要找到槽2中主键值最小的那条记录, 然后沿着单向链表遍历槽2中的记录。 但是我们前边又说过, 每个槽对应的记录都是该组中主键值最大的记录, 这里槽2对应的记录是主键值为8的记录, 但是各个槽都是挨着的, 可以很轻易的拿到槽1对应的记录(主键值为4) , 该条记录的下一条记录就是槽2中主键值最小的记录, 该记录的主键值为5。 所以可以从这条主键值为5的记录出发, 遍历槽2中的各条记录, 直到找到主键值为6的那条记录即可。 由于一个组中包含的记录条数只能是1~8条, 所以遍历一个组中的记录的代价是很小的

总结
所以在一个数据页中查找指定主键值的记录的过程分为两步:

  1. 通过二分法确定该记录所在的槽, 并找到该槽中主键值最小的那条记录。
  2. 通过记录的next_record属性遍历该槽所在的组中的各个记录。

    Page Header( 页面头部)

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

    File Header( 文件头部)

    | 名称 | 占用空间大小 | 描述 | | —- | —- | —- | | FIL_PAGE_SPACE_OR_CHKSUM | 4字节 | 页的校验和(checksum值) | | FIL_PAGE_OFFSET | 4字节 | 页号 | | FIL_PAGE_PREV | 4字节 | 上一个页的页号 | | FIL_PAGE_NEXT | 4字节 | 下一个页的页号 | | FIL_PAGE_LSN | 8字节 | 页面被最后修改时对应的日志序列位置 | | FIL_PAGE_TYPE | 2字节 | 该页的类型 | | FIL_PAGE_FILE_FLUSH_LSN | 8字节 | 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值 | | FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID | 4字节 | 页属于哪个表空间名称 |

它描述了一些针对各种页都通用的一些信息,其中最重要的是FIL_PAGE_TYPE属性,这个代表当前页的类型, 我们存放记录的数据页的类型其实是FIL_PAGE_INDEX, 也就是所谓的索引页

类型名称 十六进制 描述
FIL_PAGE_TYPE_ALLOCATED 0x0000 最新分配
FIL_PAGE_UNDO_LOG 0x0002 Undo日志页
FIL_PAGE_INODE 0x0003 段信息节点
FIL_PAGE_IBUF_FREE_LIST 0x0004 Insert
FIL_PAGE_IBUF_BITMAP 0x0005 Insert
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 BLOB页
FIL_PAGE_INDEX 0x45BF 索引页,

数据区

每个表空间就是对应了磁盘上的数据文件,在表空间里有很多组数据区,一组数据区是256个数据区,每个数据区包含了64个数据页,每个数据页16k,所以一个数据区是1mb

image.png

页分裂

一页数据写满了,就要换下一页去写,但是要求下一页记录里的主键值是比上一页大的。
自增id是很容易做到这一点的,但是有些情况下,不是自增id,比如uuid,那么下一页写数据的时候,就很可能产生页分裂,把值小的数据行移动到前面的页去,把前面页较大的数据行,移动到后面页去。这就是页分裂的现象。

  • 案例

10号数据页中用户记录最大的主键值是5(黄色), 而28号数据页中有一条记录的主键值是4, 因为5 > 4, 所以这就不符合下一个数据页中用户记录的主键值必须大于上一个页中用户记录的主键值的要求,这就产生了页分裂, 所以在插入主键值为4的记录的时候需要伴随着一次记录移动, 也就是把主键值为5的记录移动到28号数据页中, 然后再把主键值为4的记录插入到10号数据页中
image.png
image.png
页分裂 核心是要保证下一页的数据主键值要比上一页的要大。

行溢出

一个数据页大小是16k,如果一条记录过大可能会出现一个数据页不够的情况,但是MySQL中规定一个页中至少存放两行记录 (为了防止目录层级非常非常非常多, 而且最后的那个存放真实数据的目录中只能存放一条记录 ),所以对于溢出的行,MySQL需要进行行溢出处理,处理原则如下:
MySQL对于占用存储空间非常大的列, 在记录的真实数据处只会存储该列的一部分数据, 把剩余的数据分散存储在几个其他的页中, 然后记录的真实数据处用20个字节存储指向这些页的地址 , 从而可以找到剩余数据所在的页, 如图所示:
image.png
在MySQL中如果某一列中的数据非常多的话, 在本记录的真实数据处只会存储该列的前768个字节的数据和一个指向其他页的地址, 然后把剩下的数据存放
到其他页中, 这个过程也叫做行溢出, 存储超出768字节的那些页面也被称为溢出页
image.png

  • VARCHAR可以存储多少字符

VARCHAR(M)类型的列最多可以占用65535个字节。 其中的M代表该类型最多存储的字符数量, 如果使用ascii字符集的话, 一个字符就代表一个字节。MySQL对一条记录占用的最大存储空间是有限制的, 除了BLOB或者TEXT类型的列之外, 其他所有的列(不包括隐藏列和记录头信息) 占用的字节长度加起来不能超过65535个字节。这个65535个字节除了列本身的数据之外, 还包括一些其他的数据 , 比如说为了存储一个VARCHAR(M)类型的列, 其实需要占用3部分存储空间:

  • 真实数据
  • 真实数据占用字节的长度
  • NULL值标识, 如果该列有NOT NULL属性则可以没有这部分存储空间,如果该VARCHAR类型的列没有NOT NULL属性, 那最多只能存储65532个字节的数据, 因为真实数据的长度可能占用2个字节, NULL值标识需要占用1个字节

如果VARCHAR(M)类型的列使用的不是ascii字符集, 那M的最大取值取决于该字符集表示一个字符最多需要的字节数。 在列的值允许为NULL的情况下, gbk字符集表示一个字符最多需要2个字节, 那在该字符集下, M的最大取值就是32766(也就是:65532/2) , 也就是说最多能存储32766个字符; utf8字符集表示一个字符最多需要3个字节, 那在该字符集下, M的最大取值就是21844, 就是说最多能存储21844(也就是:65532/3) 个字符。

redo日志格式
image.png

各个部分的详细释义如下:
type: 该条redo日志的类型

  • MLOG_1BYTE(type字段对应的十进制数字为1) : 表示在页面的某个偏移量处写入1个字节的redo日志类型。
  • MLOG_2BYTE(type字段对应的十进制数字为2) : 表示在页面的某个偏移量处写入2个字节的redo日志类型。
  • MLOG_4BYTE(type字段对应的十进制数字为4) : 表示在页面的某个偏移量处写入4个字节的redo日志类型。
  • MLOG_8BYTE(type字段对应的十进制数字为8) : 表示在页面的某个偏移量处写入8个字节的redo日志类型。
  • MLOG_WRITE_STRING(type字段对应的十进制数字为30) : 表示在页面的某个偏移量处写入一串数据

space ID: 表空间ID。
page number: 页号。
data: 该条redo日志的具体内容。

Linux的存储系统

简单来说,Linux的存储系统分为VFS层、文件系统层、Page Cache缓存层、通用Block层、IO调度层、Block设备驱动层、Block设备层,如下图:
image.png