likes
comments
collection
share

数据库存储系列(5)MySQL InnoDB 行存储格式

作者站长头像
站长
· 阅读数 10

开篇

今天我们来学习一下MySQL InnoDB 数据表中记录存储格式。

前面我们学习 bitcask 模型的时候有提到过,在 bitcask 的一条记录中,包含了用于校验的 CRC,时间戳,key size,value size,以及 key 和 value 的值一共 6 个部分。

数据库存储系列(5)MySQL InnoDB 行存储格式

其中把 key 和 value 的 size 单独存储一份就是为了能够将其快速读取到内存中。毕竟 bitcask 针对的还是基础的 KV 模型,这样的设计似乎已经足够。

而目前业界高度依赖的关系型数据库又是怎样存储记录的呢?

B+树的底层结构是否也需要在记录里体现?多个不同的列怎样存储?如何感知变长和定长的字段?

今天我们进来深入看一下 MySQL 的 InnoDB 引擎是怎样存储一个记录的(或者按照我们前一节关系代数的叫法,是一个 tuple)。

Page

InnoDB 是目前 MySQL 默认的存储引擎,支持事务。和 CPU 硬件层面以 Page 为单位来读写数据类似,InnoDB 也有这一层级的 Page 概念。通常 CPU 读取的页的单位是 4KB,而 InnoDB 中的页一般为 16 KB。我们可以认为所谓InnoDB 中的页,就是磁盘和内存之间交互的基本单位。即在一般情况下,一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。

这里就体现出了内存 vs 磁盘的 IO 层面的差距:

  • 内存是支持快速随机访问的,你需要什么数据,来一个指针直接就能跳过去,并且从 IO 的层面看,只是给你返回了这个数据。
  • 磁盘就不同了,比如上面提到的 InnoDB 的例子,一次读写最少是 16 KB,哪怕你只需要一行,也需要把整个 Page 加载出来,连带着 IO 的压力就上来了。

(其实这里很容易有另一个疑问,既然 CPU 的 Page 是小于 InnoDB 的 Page,那么理论上讲,是不是有可能出现后者写了一半就挂掉,这样数据的完整性就无法保证了。这里留个问题,在随后的文章中我们会介绍)

行存储格式

我们知道,MySQL 或者其他关系型数据库,从物理存储结构上来看,无非是一个或多个文件(比如 SQLite 就只有一个文件存储)。而 InnoDB 又是按照 16 KB 的 Page 大小来读写数据的,所以想搞明白数据是怎么存的,势必要先知道两点:

  • 每个 Page 的存储结构
  • 每条记录的存储结构

今天我们先来看看一行,或者说一条记录的存储结构。InnoDB 到目前位置设计了 4 种不同的格式来存储记录:

  • Compact
  • Redundant
  • Dynamic
  • Compressed

其中 Redundant 是 MySQL 5.0 之前使用的格式,现在用的很少了,这里我们略过。MySQL 5.6 之前的版本的默认行格式为 Compact,到了 5.7 及现在 8.0 的默认行格式改成了 Dynamic,参考官方说明 InnoDB Row Formats

The DYNAMIC row format offers the same storage characteristics as the COMPACT row format but adds enhanced storage capabilities for long variable-length columns and supports large index key prefixes.

When a table is created with ROW_FORMAT=DYNAMICInnoDB can store long variable-length column values (for VARCHARVARBINARY, and BLOB and TEXT types) fully off-page, with the clustered index record containing only a 20-byte pointer to the overflow page. Fixed-length fields greater than or equal to 768 bytes are encoded as variable-length fields. For example, a CHAR(255) column can exceed 768 bytes if the maximum byte length of the character set is greater than 3, as it is with utf8mb4.

Dynamic 其实是在 Compact 基础之上针对【行溢出】的场景做了一些优化,下面我们先来看看 Compact 的内核思想。

指定行格式

如果你认为当前版本的默认行格式不满足需求,可以在建表的时候加上 ROW_FORMAT=xxx 参数来指定自己这个表的行格式。当然也可以用 Alter Table 语句来做这件事。

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

ALTER TABLE 表名 ROW_FORMAT=行格式名称

Compact

在介绍实际 Compact 的格式前,我们还是先考虑一下。如果是我们自己来设计 MySQL 的行格式,我们会怎么做?

参考开篇的 bitcask 模型,我们很有可能也需要自己的各个列的 attribute_size, attribute_value。也肯定会需要一些元信息存储。比如来唯一标识这一行,应该会有个类似 row_id 的字段。比如这整个行记录的 size 想必也需要存储。至于 table 的各个列是什么类型可能在 table 的元信息里面存储,不需要在 row 这个层面处理。

粗略地来看,可能我们至少需要三个部分:

  • 元信息存储;
  • 各个列的 size;
  • 各个列的 value。

下面我们来看看真实的 Compact 格式是怎么做的: (这里引用小孩子老师的专栏图片)

数据库存储系列(5)MySQL InnoDB 行存储格式

可以看到,记录头信息是符合预期的,等价于我们一开始考虑的元信息存储。后面各个列的值也没毛病。重点在于这两个:

  • 变长字段长度列表

也容易理解,毕竟类似 bitcask 这样的 KV 模型,value本身就是一个变长的值,我们无法事先来评估可能是多少个字节。但 MySQL 中的数据类型很多样,只有比如 VARCHAR, TEXT, BLOB 这样的类型才是变长的。如果只是个 INTEGER, SMALLINT, CHAR 这些类型,其实他们占用的字节数是确定的,哪怕实际没用那么多,也会在前面补 0。

这部分不熟悉的同学建议看一下 MySQL 8.0 规范中支持的数据类型说明:Chapter 11 Data Types

所以,我们只需要维护【变长的字段长度列表】即可,其实作用上就相当于我们前面提到的【各个列的size】。

  • NULL 值列表

这个其实也是关系型数据库区分于 KV 结构的一个标志,NULL 值在关系模型中代表了任意类型的一个 Undefined 的值。对于 NULL 值的列,如果我们能提前标记好,就不必要在专门列值那里额外存储了,这样省空间。

而至于【记录头信息】其实是一条记录的重点,势必包含很多细节,我们待会儿再看下里面有什么。

变长字段长度列表

大体的思想我们前面已经提过,可以理解为,这些字段所占用的存储空间包括了 size + data 两个部分。

需要注意各变长字段数据占用的字节数按照列的顺序【逆序】存放。对于 size 比较大的字段,一个字节放不下(8位,最多 255)可能使用两个字节来标识真正字符串占用的字节数。

那么问题就来了,InnoDB 是怎么判断当前读到的这个字节,代表了是一个单独的字段长度还是半个字段长度?底层其实是使用该字节的第一个二进制位作为标志位:如果该字节的第一个位为0,那该字节就是一个单独的字段长度(使用一个字节表示不大于127的二进制的第一个位都为0),如果该字节的第一个位为1,那该字节就是半个字段长度。

这里还有一个特殊情况,假设我有一个类似 longblob 这样的字段,长度大于了16KB。回忆一下,这会导致一个 InnoDB page 甚至都放不下一条记录。这个时候怎么办呢?

此时InnoDB会把一部分数据存放到所谓的溢出页中,在变长字段长度列表处只存储留在本页面中的长度,所以使用两个字节也可以存放下来。

两个字节是多大?16位,也就是说可以表示 65536 个字节,也就代表着 64 MB。(这个数字有玄机,下面我们还会提到)

还有一个问题需要注意,我们现在有了 NULL 值列表,意味着如果有个变长字段是个 NULL,其实这个状态是会反映到 NULL 值列表里,所以在【变长字段长度列表】中是不包含这个字段的。而如果一个表中一个变长字段都没有,那么就没有【变长字段长度列表】这部分了。

CHAR/VARCHAR

之所以单独拎出来说这两个类型,是因为他们很容易误解。我们平常经常看到的 VARCHAR(n), CHAR(m),这里的 n 和 m 代表的是字符还是字节?

答案是:字符!

但字符就有意思了,我们知道,比如 ascii 字符集里面都是定长的字符,一个字符对应一个字节。但比如包含中文的 gbk 就可能用 1个字节,也可能用 2个字节,而 utf-8 则可能用到 3个字节。

这意味着什么呢?

意味着虽然 CHAR(m) 表面上看是定长的,但这个定,定的是字符,如果用的是 utf-8 的字符集,实际 CHAR(m) 要占用的字节数依然是不确定的!

变长字符集的CHAR(M)类型的列要求至少占用M个字节,而VARCHAR(M)却没有这个要求。比方说对于使用utf8字符集的CHAR(10)的列来说,该列存储的数据字节长度的范围是10~30个字节。

所以,从 InnoDB 的存储视角看,如果不是定长的字符集,那么即便是类似 CHAR(m) 这样的类型,也是变长的,也需要加入【变长字段长度列表】。

NULL 值列表

为什么要有这个 NULL 值列表?前面也提过,这样可以使我们用一个二级制位就能表示一个列的状态,如果是 NULL,就不用占存储空间了,而不是类似约定一个格式在列值那里标识。

所以,首先 InnoDB 做的是判断到底哪些列有可能为 NULL,排除主键以及被 NOT NULL 修饰的列。可能为 NULL 值的列才可以进入这个 NULL 值列表,否则没有意义。

滤完了列之后,如果发现这个结果集不为空,就给每个可能为 NULL 的列分配一个位(bit),和上面变长列表类似,这里也是按照列顺序逆序排列的。

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

最终会使用多少个 bit 来表示这个 NULL 值列表呢?

根据规范,NULL 值列表必须用【整数个字节的位】表示,如果用的【位】填不满完整的字节,就在高位补0。

记录头信息

记录头信息由固定的 5 个字节组成,我们来看看 InnoDB 用这 40 个位干了多少事:

数据库存储系列(5)MySQL InnoDB 行存储格式

预留位我们先忽略,目前没有实际意义。

  • 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 个位,两个字节,标识下一条记录的相对位置。

列值

列值本身没有什么复杂的,但跟我们一开始提到的 bitcask 列值存储不同的是,作为关系型数据库,MySQL 需要支持事务,支持回滚。此前的文章 数据库存储系列(2)事务隔离级别 我们也提到了聚簇索引中其实包含了隐藏列来支持这一点:

在 MySQL 的聚簇索引中,存在着两个隐藏列:trx_idroll_pointer

每个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给 trx_id 列,并把旧的版本写入到undo日志中,roll_pointer 列就相当于一个指针,可以通过它来找到该记录修改前的信息,指向 undo 日志。

每次我们对记录修改时,都会产生一条 undo 日志(本质上是为了事务回滚,一旦 MySQL 判断不需要回滚了就会删除这些日志,这也说明了长事务的危害,undo 日志会不断积累)。

这里知识就对上了,行存储的列值的部分,其实除了我们显式创建的列之外,还包含三个隐藏列:

  • DB_ROW_ID

可能有,6个字节,作为记录ID,唯一标识。之所以说可能有是因为:InnoDB 会优先用开发者自己定义的主键来做标识,如果没有则选取一个唯一键,若还没有,才会往表里添加这个隐藏列作为主键。

  • DB_TRX_ID

一定有,6个字节,事务ID,用于支持上面提到的 MVCC。

  • DB_ROLL_PTR

一定有,7个字节,回滚指针,同样是为了支持上面的 MVCC 场景。

所以,实际情况下,记录的真实数据是这样:

DB_ROW_ID + DB_TRX_ID + DB_ROLL_PTR + 列1的值 + 列2的值 + 列3的值 + ........

VARCHAR 容量

varchar 其实是我们经常使用的类型,最多可以占用 65535 个字节的存储空间。但事实上,如果你直接尝试创建一个 VARCHAR(65535) 的列是不会成功的,因为 MySQL 对一行记录的最大空间有限制:

除了BLOB或者TEXT类型的列之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过65535个字节。

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

而且,这 65535 个字节,不仅仅是指列值,还包括我们上面提到的【变长字段长度列表】以及【NULL值标识】。一个行中的所有列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过 65535个字节,也就是 64 M。

小结

数据库存储系列(5)MySQL InnoDB 行存储格式

今天我们了解了InnoDB 行存储的格式,以上是 Compact 的部分,其实 Dynamic 只是针对行溢出的场景做了优化,它不会在记录的真实数据处存储字段真实数据的前768个字节,而是把所有的字节都存储到其他页面中,只在记录的真实数据处存储其他页面的地址。

有一些统计指标很有用处,大家可以记一下:

  • MySQL 规定一个页中至少要放两行记录,当记录中的数据太多,当前页放不下的时候,会把多余的数据存储到其他页中,这种现象称为行溢出
  • 一列 VARCHAR 最多 64M(TEXT, BLOB 就更不用说了);
  • 一个 Page 包含 16K,16384 个字节;
  • 每个页除了存放我们的记录以外,也需要存储一些额外的信息,占用 132 个字节,其他的才能用来存记录;
  • 每个记录需要的额外信息是27字节:
    • 2个字节用于存储真实数据的长度;
    • 1个字节用于存储列是否是NULL值;
    • 5个字节大小的头信息;
    • 6个字节的row_id列;
    • 6个字节的transaction_id列;
    • 7个字节的roll_pointer列。

感谢阅读,欢迎在评论区交流!