InnoDB记录结构

MySQL 服务器上负责对表中数据的读取和写入工作的部分是 存储引擎 ,而服务器又支持不同类型的存储引擎。真实数据在不同存储引擎中存放的格式一般是不同的,甚至有的存储引擎比如 Memory 都不用磁盘来存储数据,也就是说关闭服务器后表中的数据就消失了。

InnoDB页简介

InnoDB 是一个将表中的数据存储到磁盘上的存储引擎,所以即使关机后重启我们的数据还是存在的。

真正处理数据的过程是发生在内存中的,所以需要把磁盘中的数据加载到内存中。

InnoDB 采取的方式是:将数据划分为若干个页,以页作为磁盘和内存之间交互的基本单位,InnoDB中页的大小一般为 16 KB。也就是在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。

InnoDB行格式

我们平时是以记录为单位来向表中插入数据的,这些记录在磁盘上的存放方式也被称为 行格式 或者 记录格式 。

设计 InnoDB 存储引擎的大叔们到现在为止设计了4种不同类型的 行格式 ,分别是 Compact 、 Redundant 、Dynamic 和 Compressed 行格式

指定行格式的语法

可以在创建或修改表的语句中指定 行格式 :

CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
ALTER TABLE 表名 ROW_FORMAT=行格式名称

COMPACT行格式

image-20240805221534182

一条完整的记录其实可以被分为 记录的额外信息记录的真实数据 两大部分

记录的额外信息

这部分信息是服务器为了描述这条记录而不得不额外添加的一些信息,这些额外信息分为3类,分别是 变长字段长度列表NULL值列表记录头信息

变长字段长度列表

MySQL 支持一些变长的数据类型,比如 VARCHAR(M) 、 VARBINARY(M) 、各种 TEXT 类型,各种 BLOB 类型,我们也可以把拥有这些数据类型的列称为 变长字段

变长字段中存储多少字节的数据是不固定的,我们在存储真实数据的时候需要顺便把这些数据占用的字节数也存起来。

变长字段占用的存储空间分为两部分

  • 真正的数据内容
  • 占用的字节数

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

假设有以下数据:

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

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

01 03 04

image-20240805222120414

c1 、 c2 、 c4 列中的字符串都比较短,也就是说内容占用的字节数比较小,用1个字节就可以表示,但是如果变长列的内容占用的字节数比较多,可能就需要用2个字节来表示。具体用1个还是2个字节来表示真实数据占用的字节数,InnoDB 有它的一套规则,我们首先声明一下 W 、 M 和 L 的意思:

  • 某个字符集中表示一个字符最多需要使用的字节数为 W
  • 对于变长类型 VARCHAR(M) 来说,这种类型表示能存储最多 M 个字符(注意是字符不是字节),这个类型能表示的字符串最多占用的字节数就是 M×W 。
  • 它实际存储的字符串占用的字节数是 L

确定使用1个字节还是2个字节表示真正字符串占用的字节数的规则就是这样:

  • 如果 M×W <= 255 ,那么使用1个字节来表示真正字符串占用的字节数
  • 如果 M×W > 255 ,则分为两种情况:
    • 如果 L <= 127 ,则用1个字节来表示真正字符串占用的字节数。
    • 如果 L > 127 ,则用2个字节来表示真正字符串占用的字节数。

InnoDB在读记录的变长字段长度列表时先查看表结构,如果某个变长字段允许存储的最大字节数大于255时,该怎么区分它正在读的某个字节是一个单独的字段长度还是半个字段长度呢?
设计InnoDB的大叔使用该字节的第一个二进制位作为标志位:如果该字节的第一个位为0,那该字节就是一个单独的字段长度(使用一个字节表示不大于127的二进制的第一个位都为0),
如果该字节的第一个位为1,那该字节就是半个字段长度。
对于一些占用字节数非常多的字段,比方说某个字段长度大于了16KB,那么如果该记录在单个页面中无法存储时,InnoDB会把一部分数据存放到所谓的溢出页中,在变长字段长度列表处只存储留在本页面中的长度,所以使用两个字节也可以存放下来。

总结一下就是说:如果该可变字段允许存储的最大字节数( M×W )超过255字节并且真实存储的字节数( L )超过127字节,则使用2个字节,否则使用1个字节。

变长字段长度列表中只存储值为 非NULL 的列内容占用的长度,值为 NULL 的列的长度是不储存的 。

并不是所有记录都有这个 变长字段长度列表 部分,比方说表中所有的列都不是变长的数据类型的话,这一部分就不需要有。

NULL值列表

表中的某些列可能存储 NULL 值,如果把这些 NULL 值都放到 记录的真实数据 中存储会很占地方,所以 Compact 行格式把这些值为 NULL 的列统一管理起来,存储到 NULL 值列表中

  • 首先统计表中允许存储 NULL 的列有哪些。
  • 如果表中没有允许存储 NULL 的列,则 NULL值列表 也不存在了,否则将每个允许存储 NULL 的列对应一个二进制位,二进制位按照列的顺序逆序排列,二进制位表示的意义如下:
    • 二进制位的值为 1 时,代表该列的值为 NULL 。
    • 二进制位的值为 0 时,代表该列的值不为 NULL 。

假设表 record_format_demo 有3个值允许为 NULL 的列,所以这3个列和二进制位的对应关系就是这样:

image-20240805222723733

再一次强调,二进制位按照列的顺序逆序排列,所以第一个列 c1 和最后一个二进制位对应。

MySQL 规定 NULL值列表 必须用整数个字节的位表示,如果使用的二进制位个数不是整数个字节,则在字节的高位补 0 。

image-20240805222758583

以此类推,如果一个表中有9个允许为 NULL ,那这个记录的 NULL 值列表部分就需要2个字节来表示了

记录头信息

除了 变长字段长度列表 、 NULL值列表 之外,还有一个用于描述记录的 记录头信息 ,它是由固定的 5 个字节组成。 5 个字节也就是 40 个二进制位,不同的位代表不同的意思,如图:

image-20240805222859939

这些二进制位代表的详细信息如下表:

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

记录的真实数据

MySQL 会为每个记录默认的添加一些列(也称为 隐藏列 ),具体的列如下:

列名 是否必须 占用空间 描述
row_id 6 字节 行ID,唯一标识一条记录
transaction_id 6 字节 事务ID
roll_pointer 7 字节 回滚指针

实际上这几个列的真正名称其实是:DB_ROW_ID、DB_TRX_ID、DB_ROLL_PTR

InnoDB 表对主键的生成策略:优先使用用户自定义主键作为主键,如果用户没有定义主键,则选取一个 Unique 键作为主键,如果表中连 Unique 键都没有定义的话,则 InnoDB 会为表默认添加一个名为row_id 的隐藏列作为主键

InnoDB存储引擎会为每条记录都添加 transaction_id 和 roll_pointer 这两个列,但是 row_id 是可选的(在没有自定义主键以及Unique键的情况下才会添加该列)。

CHAR(M)列的存储格式

如果采用的是 ascii 字符集,这个字符集是一个定长字符集,也就是说表示一个字符采用固定的一个字节,对应CHAR(M)不会被存储在变长字段列表中,而是作为定长字段列表,为占用的字节用 0x20 空格字符填充。如果采用变长的字符集(也就是表示一个字符需要的字节数不确定,比如 gbk 表示一个字符要1 - 2 个字节,列的长度也会被存储到 变长字段长度列表 中

对于 CHAR(M) 类型的列来说,当列采用的是定长字符集时,该列占用的字节数不会被加到变长字段长度列表,而如果采用变长字符集时,该列占用的字节数也会被加到变长字段长度列表。

变长字符集的 CHAR(M) 类型的列要求至少占用 M 个字节,而 VARCHAR(M) 却没有这个要求

比方说对于使用 utf8 字符集的 CHAR(10) 的列来说,该列存储的数据字节长度的范围是10~30个字节。即使我们向该列中存储一个空字符串也会占用 10 个字节。

  • 这是怕将来更新该列的值的字节长度大于原有值的字节长度而小于10个字节时,可以在该记录处直接更新,而不是在存储空间中重新分配一个新的记录空间,导致原有的记录空间成为所谓的碎片。

Redundant行格式

image-20240805223915334

相比较于Compact格式,Redundant 行格式有什么不同的地方:

  • 字段长度偏移列表

    注意 Compact 行格式的开头是 变长字段长度列表 ,而 Redundant 行格式的开头是 字段长度偏移列表 ,与变长字段长度列表 有两处不同:

    • 没有了变长两个字,意味着 Redundant 行格式会把该条记录中所有列(包括 隐藏列 )的长度信息都按照逆序存储到 字段长度偏移列表 。
    • 多了个偏移两个字,这意味着计算列值长度的方式不像 Compact 行格式那么直观,它是采用两个相邻数值的差值来计算各个列值的长度
  • 记录头信息

Redundant 行格式的记录头信息占用 6 字节, 48 个二进制位,这些二进制位代表的意思如下:

名称 大小(单位:bit) 描述
预留位1 1 没有使用
预留位2 1 没有使用
delete_mask 1 标记该记录是否被删除
min_rec_mask 1 B+树的每层非叶子节点中的最小记录都会添加该标记
n_owned 4 表示当前记录拥有的记录数
heap_no 13 表示当前记录在记录堆的位置信息
n_field 10 表示记录中列的数量
1byte_offs_flag 1 标记字段长度偏移列表中每个列对应的偏移量是使用1字节还是2字节表示的
next_record 16 表示下一条记录的相对位置

与 Compact 行格式的记录头信息对比来看,有两处不同:

  • Redundant 行格式多了 n_field 和 1byte_offs_flag 这两个属性。
    • 当记录的真实数据占用的字节数不大于127(十六进制 0x7F ,二进制 01111111 )时,每个列对应的偏移量占用1个字节。
    • 当记录的真实数据占用的字节数大于127,但不大于32767(十六进制 0x7FFF ,二进制0111111111111111 )时,每个列对应的偏移量占用2个字节。
    • 有没有记录的真实数据大于32767的情况呢?有,不过此时的记录已经存放到了溢出页中,在本页中只保留前 768 个字节和20个字节的溢出页面地址(当然这20个字节中还记录了一些别的信息)
    • 为了在解析记录时知道每个列的偏移量是使用1个字节还是2个字节表示的,设计 Redundant 行格式的大叔特意在 记录头信息 里放置了一个称之为 1byte_offs_flag 的属性
      • 当它的值为1时,表明使用1个字节存储。
      • 当它的值为0时,表明使用2个字节存储。
  • Redundant 行格式没有 record_type 这个属性。

Redundant 行格式中 NULL 值的处理:

将列对应的偏移量值的第一个比特位作为是否为 NULL 的依据,该比特位也可以被称之为 NULL比特位 。也就是说在解析一条记录的某个列时,首先看一下该列对应的偏移量的 NULL比特位 是不是为 1 ,如果为 1 ,那么该列的值就是 NULL ,否则不是 NULL 。

这也就解释了上边介绍为什么只要记录的真实数据大于127(十六进制 0x7F ,二进制 01111111 )时,就采用2个字节来表示一个列对应的偏移量,主要是第一个比特位是所谓的 NULL比特位 ,用来标记该列的值是否为 NULL 。

对于值为 NULL 的列来说,该列的类型是否为定长类型决定了 NULL 值的实际存储方式

  • 如果存储 NULL 值的字段是定长类型的,比方说 CHAR(M) 数据类型的,则 NULL 值也将占用记录的真实数据部分,并把该字段对应的数据使用 0x00 字节填充。
  • 如果该存储 NULL 值的字段是变长数据类型的,则不在 记录的真实数据 处占用任何存储空间。

除了以上的几点之外, Redundant 行格式和 Compact 行格式还是大致相同的。

CHAR(M)列的存储格式

在 Redundant 行格式中十分干脆,不管该列使用的字符集是啥,只要是使用 CHAR(M) 类型,占用的真实数据空间就是该字符集表示一个字符最多需要的字节数和 M 的乘积,比方说使用 utf8 字符集的 CHAR(10) 类型的列占用的真实数据空间始终为 30 个字节,使用 gbk 字符集的 CHAR(10) 类型的列占用的真实数据空间始终为 20 个字节。由此可以看出来,使用 Redundant 行格式的 CHAR(M) 类型的列是不会产生碎片的。

行溢出数据

VARCHAR(M)最多能存储的数据

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

我们为了存储一个 VARCHAR(M) 类型的列,其实需要占用3部分存储空间:

  • 真实数据
  • 真实数据占用字节的长度
  • NULL 值标识,如果该列有 NOT NULL 属性则可以没有这部分存储空间

如果该 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个字节!

记录中的数据太多产生的溢出

如果某一列中的数据非常多的话,在本记录的真实数据处只会存储该列的前 768 个字节的数据和一个指向其他页的地址,然后把剩下的数据存放到其他页中,这个过程也叫做 行溢出 ,存储超出 768 字节的那些页面也被称为 溢出页。

image-20240805225513412

行溢出的临界点

MySQL 中规定一个页中至少存放两行记录(不然树就可能退化成了链表)

分析一下页中的空间都是如何利用的

  • 每个页除了存放我们的记录以外,也需要存储一些额外的信息,乱七八糟的额外信息加起来需要 136 个字节的空间(现在只要知道这个数字就好了),其他的空间都可以被用来存储记录。
  • 每个记录需要的额外信息是 27 字节。

假设一个列中存储的数据字节数为n,那么发生 行溢出 现象时需要满足这个式子:

136 + 2×(27 + n) > 16384

求解这个式子得出的解是: n > 8098 。也就是说如果一个列中存储的数据不大于 8098 个字节,那就不会发生行溢出 ,否则就会发生 行溢出 。不过这个 8098 个字节的结论只是针对只有一个列的 demo 表来说的,如果表中有多个列,那上边的式子和结论都需要改一改了,所以重点就是:你不用关注这个临界点是什
么,只要知道如果我们想一个行中存储了很大的数据时,可能发生 行溢出 的现象。

Dynamic和Compressed行格式

这俩行格式和 Compact 行格式挺像,只不过在处理 行溢出 数据时有点儿分歧,它们不会在记
录的真实数据处存储字段真实数据的前 768 个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址,就像这样:

image-20240805225834362

Compressed 行格式和 Dynamic 不同的一点是, Compressed 行格式会采用压缩算法对页面进行压缩,以节省空间。

总结

  1. 页是 MySQL 中磁盘和内存交互的基本单位,也是 MySQL 是管理存储空间的基本单位。

  2. 指定和修改行格式的语法如下:
    CREATE TABLE 表名 (列的信息) ROW_FORMAT=行格式名称
    ALTER TABLE 表名 ROW_FORMAT=行格式名称

  3. InnoDB 目前定义了4种行格式
    COMPACT行格式

image-20240805225914654

Redundant行格式

image-20240805230015061

Dynamic和Compressed行格式

这两种行格式类似于 COMPACT行格式 ,只不过在处理行溢出数据时有点儿分歧,它们不会在记录的真实数据处存储字符串的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。另外, Compressed 行格式会采用压缩算法对页面进行压缩。

  1. 一个页一般是 16KB ,当记录中的数据太多,当前页放不下的时候,会把多余的数据存储到其他页中,这种现象称为 行溢出 。