InnoDB 磁盘组织结构之行格式 (Row Format) 详解

最近开新坑,打算重新复习下 MySQL 原理。本文是系列文章的第一篇,因此先介绍下 InnoDB 引擎架构。

1 InnoDB 存储引擎

InnoDB 包括位于内存中的实例层,和位于磁盘中的物理层两部分。

实例层分为线程和内存两部分:

  • InnoDB 重要的线程有 Master Thread,I/O Thread、Purge Thread 和 Page Cleaner Thread 等。
  • 内存部分包括 buffer pool、change buffer、log buffer 等。

物理层在逻辑上分为系统表空间、用户表空间和 redo 日志:

  • 系统表空间里有 ibdata 文件和一些 Undo 日志,ibdata 文件里有 Change Buffer 段、Double Write 段、回滚段、索引段、数据字典段和 Undo 信息段。
  • 用户表空间是指以 .ibd 为后缀的文件,文件中包含 change buffer 的 bitmap 页、叶子页(这里存储真正的实际行数据)、非叶子页。
  • redo 日志中包括多个 redo 文件,这些文件循环使用,当达到一定存储阈值时会触发 checkpoint 刷脏页操作,同时也会在 MySQL 实例异常宕机后重启,InnoDB 表数据自动还原恢复过程中使用。

本文我们重点关注 InnoDB 的物理层。

2 InnoDB 磁盘文件存储结构

InnoDB 磁盘文件是按照以下层级划分的:

1
Tablespaces -> Segment -> Extent -> Page -> Row
  • Tablespaces:表空间,即一个一个的文件。
    • 用于存储表的记录和索引,一个 Tablespaces 文件包含多个 Segment。
  • Segment:段,用于管理多个 Extent。
    • 分为数据段 (Leaf node segment)、索引段 (Non-leaf node segment) 和回滚段(Rollback segment)。
    • 一个表的 Spaces 至少会有两个 segment,一个管理数据,一个管理索引。
    • 每额外创建一个索引,会增加两个 segment。
  • Extent:分区,一个区固定包含 64 个连续的页,大小为一般为 1M。
    • 当表空间不足需要分配新的页资源时,不会一页一页申请,而是以区为单位(尽可能保证更多的数据在物理存储介质上连续)。
  • Page:页,用于存储多个行记录,大小一般为 16Kb,包含多种页类型,比如:数据页、Undo 页、系统页、事务数据页、BLOB 对象页等。
  • Row:行记录,用于记录字段的值、事务 ID (Trx Id)、回滚指针 (Roll Pointer)、字段指针 (Field Pointer) 等信息。

接下来,我们从最下一层的行格式开始,逐步深入 InnoDB 的实现。

3 InnoDB 行格式 (Row Format)

我们知道,无论 InnoDB 在内存中的数据结构多么复杂,最终都必须逐行存储到磁盘中。而 InnoDB 将表中的每行数据组织到磁盘的方式,就是行格式。一个设计优秀的行格式,会极大地提高数据的读写性能,例如在单个 page 中容纳更多行、查询和索引查找可以更快地工作、缓冲池中所需的内存更少、写入更新时所需的 I/O 资源更少等。

InnoDB 存储引擎支持四种行格式:REDUNDANT、COMPACT、DYNAMIC 和 COMPRESSED:

Row Format紧凑存储特性增强型长列数据的页外存储大索引前缀压缩支持支持的表空间类型所需的文件格式
REDUNDANTNoNoNoNosystem, file-per-table, generalAntelope & Barracuda
COMPACTYesNoNoNosystem, file-per-table, generaAntelope & Barracuda
DYNAMICYesYesYesNosystem, file-per-table, generalBarracuda
COMPRESSEDYesYesYesYesfile-per-table, generalBarracuda

DYNAMIC 和 COMPRESSED 是 MySQL 5.7 引入的新格式。

接下来,我们详细介绍这几种行格式的特性。

4 COMPACT 行格式

COMPACT 行格式(紧凑形行格式)相较于 REDUNDANT 格式,将数据的空间占用率降低了约 20%,而 CPU 使用率几乎保持不变。因此,它适合在磁盘 I/O 有瓶颈的场景下使用。其格式如下:

COMPACT 行格式

COMPACT 格式的行记录包括扩展数据实际数据两部分。

4.1 扩展数据

扩展数据用于描述这一行的数据的格式,如果没有这些必要的空间占用,InnoDB 将无法解析这些行数据。

4.1.1 变长字段长度列表

每当我去学习某个事物的原理时,我都会先设想如果要我亲自实现它,我会采取怎样的方法。我们知道,MySQL 拥有多种变长数据类型,例如 VARCHARBLOBTEXT 等,这些类型的数据长度并不固定。若要解析这些数据,一种方法是通过特殊的分隔符进行切割,另一种方法则是事先记录数据的长度,而 InnoDB 选择了后者。

COMPACT 行格式在每行数据的开头逆序列出了每个可变行数据占用的字节数(前提是该表存在可变数据类型)。

假设一个表中有四个字段,类型分别是 varcharintvarcharvarchar,表中某行数据的内容如下:

姓名年龄性别说明
xiaoma18malecheerful

那么磁盘中该行数据的开头将连续存储这条记录中 说明性别姓名 这三列数据的长度,即 0x080406

COMPACT 可变行列表

在这个示例中,我们列举的数据长度很短,可以用一个字节的空间完整表示,而现实中 varchar 类型的数据往往很大,所以很可需要用两个字节来表示一个列的数据长度。

COMPACT 行格式在列数据的一部分存储在外部溢出页,或者该列的最大长度超过 255 字节且实际长度超过 127 字节时,使用两个字节表示字段长度。

  • varchar(M) + utf8mb4 为例,这种场景下该字段最多占用 4 x M 字节的空间,当 M 大于 64 时,就会使用两个字节来表示。
  • 当列中的数据太长时,COMPACT 行格式会将数据的前 768 字节的前缀放入行内,而余下部分则存储在外部溢出页中。同时,它会将 20 字节大小的溢出页地址存储在行内。这种情况下该长度记录会在 2 个字节的空间里存储 768 + 20 这个值。
外部溢出页

为了实现高效存取,B+ 树的叶子节点大小通常是固定的。然而,如果某一行数据过长,会导致叶子节点无法容纳足够多的行数据。因此,InnoDB 并未简单地将所有行数据全部存储在单一行内,而是仅存储部分数据以确保每个叶子节点中的数据行数相对合理,而数据行剩余的部分数据则存储在外部的溢出页中。

如果一个溢出页无法容纳所有数据,则会分配多个溢出页,并通过链表连接起来(当然,优先选择物理上连续的溢出页)。

注意:二级索引
由于二级索引的 value 是主键列,如果主键长度是可变的,即便二级索引的 key 都是不可变的,也必须在头部可变列表中保存主键列的长度。

4.1.2 NULL 值列表

在存在允许为 NULL 的列的情况下,COMPACT 行格式会将该行中所有值为 NULL 的列统一管理起来,以节省空间。

COMPACT 行通过逆序 bit 序列,记录所有允许为 NULL 的列的值,1 表示 NULL,0 表示非空。

假设某表有四个允许为空的列 C1、C2、C3、C4,然后有以下一行数据:

C1C2C3C4
c1nullc3c4

那么该行对应的 NULL 值列表为 0000 0010由于 NULL 值列表必须字节对齐,所以在这个例子中,占用 4 个比特位的 NULL 值列表高位补充了 4 个 0

NOT NULL 修饰的列、主键等不为空的列,不在统计范围。

如果某行所有的列都不为空,则取消 NULL 值列表。

4.1.3 行记录头信息

COMPACT 行格式的行记录头信息固定由 40 个比特位(5 字节)组成,从高地址到低地址,其不同的比特位所代表的信息如下表所示:

名称大小 (单位:bit)说明
预留位 11未使用
预留位 21未使用
delete_mask1标记该记录是否被删除
min_rec_mask1是否是 B+ 树的每层非叶子节点中的最小记录
n_owned4表示当前记录拥有的记录数
heap_no13表示当前记录在记录堆的位置信息
record_type3表示当前记录的类型:
0 表示普通记录
1 表示 B+ 树非叶子节点记录
2 表示最小记录
3 表示最大记录
next_record16表示下一条记录的相对位置
  • record_type 的获取方式如下:
    1
    2
    3
    4
    5
    6
    7
    
    /*
    * rec: 记录地址
    * REC_NEW_STATUS:等于3,即 rec 向左偏移 3 字节(跨过了 next_record)
    * REC_NEW_STATUS_MASK: 等于 00000111,即 rec 左侧第三字节中的低三位
    */
    rec_get_bit_field_1(rec, REC_NEW_STATUS,
    			  REC_NEW_STATUS_MASK, REC_NEW_STATUS_SHIFT);

4.2 实际数据

COMPACT 行格式中的实际数据部分包括实际行数据InnoDB 虚拟列两部分。

4.2.1 InnoDB 虚拟列

这些虚拟列如下表所示:

列名是否必须占用空间说明
DB_ROW_ID6 字节行 ID,唯一标识一条记录
DB_TRX_ID6 字节事务 ID
DB_ROLL_PTR7 字节回滚指针
  • DB_ROW_ID:InnoDB 优先使用用户提供的主键,当用户表未定义主键,再选择其中一个 Unique 键作为主键。如果表中既没有主键,也没有唯一键,则为每行数据生成一个 6 字节大小DB_ROW_ID 虚拟列作为主键。

  • 从左往右看,这些虚拟列位于实际行数据的左侧,即高地址处。其排布关系如下:

    1
    2
    3
    
    +-----------+-----------+-------------+---------+-----------+
    | DB_ROW_ID | DB_TRX_ID | DB_ROLL_PTR | column1 | column... |
    +-----------+-----------+-------------+---------+-----------+

4.2.2 实际行数据

COMPACT 行格式对实际行数据也做了压缩处理,主要特性如下:

  • 如果是 VARCHAR(M) 类型的列,则不做特殊处理;
  • 如果是 CHAR(N) 类型的定长列,且字符集为 utf8mb3utf8mb4 这类可变长字符集:
    • 如果 字符集长度 x 字符数量 < N,则将行内数据填充到 N 字节;
    • 如果 字符集长度 x 字符数量 > N,则尝试删除字符末尾的填充(毕竟不是所有的字符都会占满字符集的最大字节数),此时:
      • 如果删除填充后,数据 总字节数 < N,则将行内数据填充到 N
      • 如果删除填充后,数据 总字节数依旧 > N,则将数据填充至 字符集长度 X N
注意!
定长列 + 可变长字符集,会被当做可变长度字段处理,其长度也会存储到头部的可见长度列表中,数据量超过阈值时也会被存储在外部的溢出页。

总之,COMPACT 行格式会尽力将定长类型 + 变长字符集类型的记录占用的字节数,压缩到 N 字节。实在做不到再像其他格式那样直接填充到 N x 字符集最大字节数 字节,当数据量超过阈值还会将数据的一部分移动到外部溢出页。

CHAR(N) 最少占用 N 字节空间

如果 CHAR(N) 中的数据天然小于 N 字节,依旧选择占用 N 字节空间,这么做是为了防止空间碎片。

这样设计,当下次更新这行数据时,如果膨胀后的体积依旧不超过 N 字节,那么就地修改这行数据即可,而无需占用新的磁盘空间。

4.3 为什么逆序存储头信息

通过上文的学习我们可以发现,InnoDB 行数据的整个头信息都是逆序存储的。正常情况我们设计一个通信协议,往往是先顺次存储头信息,紧接着再存储实际数据列表,例如:

1
2
3
4
5
6
正常方向
=======================================>>>

+----------+----------+--------+--------+
| Header 1 | header 2 | data 1 | data 2 |
+----------+----------+--------+--------+

而 InnoDB 行格式却是头信息跟实际数据从一个中点向两边散开了:

1
2
3
4
5
                   两边散开
<<<===================X================>>>
+----------+----------+--------+--------+
| header 2 | Header 1 | data 1 | data 2 |
+----------+----------+--------+--------+

以数据插入过程的实现为例,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
omit_size = REC_N_NEW_EXTRA_BYTES; // 5 字节
extra_size = rec_offs_extra_size(offsets) - omit_size;
...
// 先将 rec 左侧的内容落盘
memcpy(b, rec - rec_offs_extra_size(offsets), extra_size);
b += extra_size;
// 再将 rec 右侧的内容落盘
memcpy(b, rec, rec_offs_data_size(offsets));
b += rec_offs_data_size(offsets);
...
COMPACT 行记录指针

如此一来,程序向左读取即可获取到头信息,向右读取即可获取到实际数据,更加方便。

4.4 为什么逆序存储变长字段长度列表

当程序读取可变长度列表时,CPU 会从其首地址开始,将一大块内存数据载入 Cache Block。因此逆序存放有助于被读取的可变长度列跟靠前的实际数据一起被添加到内存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
             read
              |-------------------------+
              v        load  cache      |
+------+------+==========================--+-------+
| len3 | len2 | len1 | ... | data1 | data2 | data3 |
+------+------+------+-----+-------+-------+-------+
              |                    |
              +----------+---------+
                         |
                  data1 cache hit

5 REDUNDANT 行格式

REDUNDANT 行格式是 MySQL5.0 以前的老格式,了解即可,现在已经很少使用了。其格式如下:

REDUNDANT 行格式

它的设计相对简单,通过头部的偏移量列表保存每列数据的行内末尾偏移量,这样一来两个相邻的偏移量作差,即可求得每列数据的字节数。需要注意,其偏移量列表也是逆序存放的。

REDUNDANT 行格式的头信息相较 COMPACT 更长,占用 6 个字节(48 位):

名称大小 (单位:bit)说明
预留位 11未使用
预留位 21未使用
delete_mask1标记该记录是否被删除
min_rec_mask1是否是 B+ 树的每层非叶子节点中的最小记录
n_owned4表示当前记录拥有的记录数
heap_no13表示当前记录在记录堆的位置信息
n_fields10表示记录中列的数量
1byte_offs_flag1标记字段长度偏移列表中每个列对应的偏移量是使用 1 字节还是 2 字节表示的
next_record16表示下一条记录的相对位置

相较于 COMPACT,REDUNDANT 行格式多了 n_fields1byte_offs_flag 这两个属性,少了 record_type 这个属性。

5.1 1byte_offs_flag

REDUNDANT 行格式利用头信息中的 1byte_offs_flag 记录长度偏移列表中每个列对应的偏移量使用 1 字节还是 2 字节表示。如果为 1 则表示使用 1 字节表示;如果为 0 表示使用 2 个字节表示。其取值情况如下:

  • 如果行内所有列的总数据长度不超过 127 字节,则用 1 字节表示每列的偏移量;
  • 如果行内所有列的总数据长度超过了 127 字节,但小于等于 32767(0x7FFF,两字节的最大有符号数),则用 2 字节表示每列偏移量;
  • 如果行内所有列总长度超过了 32767,则行内保存 768 字节数据 + 20 字节的溢出页指针,依旧使用 2 字节表示每页偏移量。

5.2 定长列的存储格式

REDUNDANT 行格式中的定长列没有做任何压缩,以 CHAR(M) 为例,如果字符集的类型为 utf8mb4,那么每个列固定占用空间 M x 4 字节(前提是不超过阈值,不会触发外部溢出页存储)。

5.3 NULL 值处理

REDUNDANT 行格式没有设置单独的 NULL 值列表,而是将偏移量列表中每个元素的最高位设置成了 NULL 比特位。如果某个列的值为 NULL,则将列表中表示该列的字节的最高位设置为 1。

需要注意的是:

  • REDUNDANT 行格式的实际行数据中的定长列,即便为 NULL 也会白白占用相应的空间;
  • 如果是变长类型的列,则不去存储。
    • 变长列中的 NULL 值对应的偏移量,与其上一个(逆序)列的相同。

6 DYNAMIC 行格式

DYNAMIC 是 MySQL 5.7 默认的行格式,支持所有表空间。它的大部分存储特性与 COMPACT 相同,只是在处理溢出页和支持大型索引键前缀方面有所增强。

DYNAMIC 行格式在达到数据长度触发阈值时,将数据直接存储到外部页,而行内仅存储 20 字节的指针。这种方法有效地避免了 B+树中的页面被单个行数据占用太多空间的问题。

7 COMPRESSED 行格式

COMPRESSED 也是 MySQL5.7 开始支持的行格式,它在 DYNAMIC 行格式的基础上,增加了对表数据以及索引数据的压缩功能,它只支持文件表空间和通用表空间,不支持系统表空间。


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容