第6章 InnoDB文件组织

InnoDB 涵盖多种类型的文件,本章将重点关注其中三个:数据文件、重做日志文件以及回滚日志文件。这三个文件的内部组织结构极为精巧复杂,且所包含的内容相互关联。鉴于全面透彻地理解这三个文件颇具难度,本章将详细阐述不同文件中的内容,并结合实际SQL语句执行案例予以生动阐释,以帮助读者深入理解。

6.1 数据文件

在MySQL 数据库系统中,数据文件用于存储用户数据以及数据字典等元数据,主要分为系统数据文件和用户数据文件两大类。自MySQL 5.6.6 版本起,系统数据文件与用户数据文件默认情况下是分开存储的,这一设置可以通过 innodb_file_per_table 参数进行调整。系统数据文件通常在MySQL 的数据目录中以 ibdata1 命名,而用户数据文件则通常以 .ibd 为后缀,例如 test.ibd 文件。

接下来,我们将介绍数据在数据文件中的存储方式以及数据的增加、删除、修改和查询操作是如何在数据文件中实现的。为此,我们将深入分析数据文件的内部结构,并对数据文件中的各项操作进行详尽的阐述。

6.1.1 逻辑组织结构概览

试想一下,如果我们要将一个大表的数据存储到一个文件中,并且要能进行高效的查询和更新,这时应该怎么做?根据这个需求,我们拆解一下具体要实现的功能:

  • ❑ 若要将一行数据写入文件中,我们需要记录其在文件中对应的偏移量,这样下次才能将其读取出来。
  • ❑ 在进行数据写入时,如何合理分配存储空间成为关键问题。一种可行的方法是将数据按顺序连续写入文件。

在完成上述两个功能后,我们实际上已经能够将大型数据表简单地存储到文件中。然而,为了实现高效的数据查询,我们目前的实现方式尚不足以支持基于特定字段的快速检索。在这种情况下,我们不得不通过逐条读取文件中的记录,并逐一进行比较,才能找到所需的信息。因此,为了提高查询效率,我们有必要进一步进行以下工作。

首先将所有的行数据组织起来,根据我们查询的模型来确定一种数据结构。如果只有等值查询,可以采用哈希表数据结构;如果还有范围查询,可能采用B+ 树数据结构更合适。

根据我们的需求,需要在文件中维护一个B+ 树数据结构。B+ 树分为根节点和叶子节点,我们需要将表中所有的行记录根据B+ 树的模型串联起来。将每一行的数据存储到叶子节点上,根节点存储对应字段的值,通过字段值就能快速定位某条记录或者某个范围的记录。

为了构建一个B+ 树,必须在记录行中增加特定的附加信息。鉴于B+ 树的有序特性,其每一层级均需通过链表进行连接。因此,每条记录行中应增设一个字段,以记录文件中下一条记录的位置。此外,根节点所存储的字段值应指向相应的叶子节点,即需保存叶子节点在文件中的位置信息。

至此,我们似乎已经实现了高效的查询功能。那么,在实际操作中,应如何进行查询呢?B+ 树是存储于整个文件之中的,若要检索一条记录,是必须多次读取文件,还是一次性将文件内容全部载入内存?这两种方式似乎均非理想之选,那么我们还能采取哪些优化措施呢?实现高效查询之余,数据更新的设计又当如何考虑?

实际上,MySQL 在初始设计阶段同样面临了类似难题,因此采用了众多策略,主要可以划分为逻辑组织结构与物理组织结构两大类。其核心目标在于妥善管理用户数据,并确保数据处理的高效性,包括数据的增加、删除、修改和查询。接下来我们将探讨MySQL 的具体设计方法。

在MySQL 架构中,逻辑组织结构主要由两个部分构成。首先是表空间,它负责数据文件的管理;其次是段、区、页、行,这些元素共同作用于数据文件的空间分配以及数据的存储。

1. 表空间

在MySQL 数据库管理系统中,表空间是用于管理数据文件的结构,其类型多样,可包含一个或多个数据文件。具体而言,表空间分为以下5 种类型。

  • 系统表空间,即 innodb_system,在MySQL 内部负责存储系统级别的数据,如数据字典、双写缓冲区、插入缓冲区以及重做日志数据等。该系统数据文件名为 ibdata1,通常位于MySQL 数据目录内。当 innodb_file_per_table 参数被设置为 off 时,用户数据同样会被存储在系统表空间内。
  • 用户表空间,即用户创建的表所对应的存储空间。在MySQL 数据库系统中,当 innodb_file_per_table 参数被启用(即设置为 on)时,每个表都会被分配一个独立的表空间。该表空间内包含一个数据文件,以 test 表为例,MySQL 数据目录中会有一个名为 test.ibd 的文件。在某些情况下,若表名包含特殊字符,可能会导致在文件系统中无法直接定位到相应的数据文件。这是因为MySQL 数据库系统会对这些特殊字符进行编码转换,采用一套内部的 filename 字符集来处理这些字符。用户表空间的主要功能是存储用户插入的数据,并且还会保存一些元数据,例如段和区等。
  • 常规表空间,亦称共享表空间,具备在MySQL 数据目录以外创建表的能力,允许多个表将数据存储于同一表空间内。在实际应用中,该表空间的使用并不普遍。
  • 临时表空间,即 innodb_temporary,它包含一个名为 ibtmp1 的临时数据文件。该临时表空间在MySQL 服务启动时被创建,并在服务关闭时被移除。其主要功能是为临时表提供数据存储空间。在MySQL 5.7 版本之前,临时表的数据是存储在系统数据文件中的。然而,由于临时表可能导致系统数据文件占用过多磁盘空间,自MySQL 5.7 版本起,临时表的数据存储被转移到了专门的临时表空间中。
  • 回滚表空间,即 innodb_undo001,它由多个回滚数据文件组成,这些文件的命名遵循 undo001 这样的格式。要启用回滚表空间,必须在初始化时将 innodb_undo_tablespaces 参数的值设置为大于0,该参数定义回滚文件的数量。若该参数设置为0,则回滚段将被存储在系统数据文件中,后续也将无法启用。

版本差异说明

在MySQL 5.7 版本之前,回滚段的内容是存储在系统数据文件中的。然而,由于回滚段可能导致系统数据文件占用过多磁盘空间,MySQL 5.7 版本之后,回滚段的内容被转移到临时表空间中。在MySQL 5.7 版本中,innodb_undo_tablespaces 参数的默认值为0,而在MySQL 8.0 版本中,默认值被设置为2,这意味着回滚段默认存储在回滚表空间中,并且会创建两个回滚数据文件。

除了上述5 种表空间之外,在MySQL 内部,重做日志文件也是用表空间进行管理的。在MySQL 内部叫 innodb_redo_log,它一般包含2 个重做日志文件,在MySQL 数据文件目录中,通常命名为 ib_logfile0ib_logfile1(注意原文中写的是 ib_logfile0ib_logfile0,推测为笔误,应为 ib_logfile1)。重做日志文件主要是用来保证MySQL 事务的持久性,在数据写入到数据文件之前,都会将这个更改写入到重做日志文件中,在6.2 节会重点介绍。

2. 段、区、页、行

在MySQL 中,段是一个逻辑概念,用来管理区,一个段可以管理多个区。一个索引需要使用两个段来进行管理,一个用来管理根节点,一个用于管理叶子节点。区同样是一个逻辑概念,用来管理页,一个区可以管理多个页,默认情况下可以管理128 个页。页存储具体的数据,这些数据可能是用户数据,也可能是MySQL 管理相关的系统数据。行对应用户表中的每行数据,它只在聚簇索引的叶子节点页(也就是存储用户具体数据的索引页)存在。当然这里还会有二级索引,它们也存储具体的数据,但不是整行的,而是该索引中对应的字段的值和指向对应主键的位置,这在后续索引实现的章节中会详细介绍。它们之间的关系如图6-1 所示。

📊 图6-1 段、区、页、行关系图(原图为示意图,此处用文字描述补充)

图6-1 展示了段、区、页、行之间的层次关系:

  • 段(Segment):位于最顶层,管理多个区。段中维护多个链表:完全空闲区链表、部分空闲区链表、全部已使用区链表。
  • 区(Extent):每个区包含多个页(默认为64个页,16KB页大小)。区通过位图(XDES_BITMAP)记录每个页的使用状态(0/1)。
  • 页(Page):包含多行数据,所有行记录被串联起来(通过链表)。页中存储行记录,包含最小记录、记录、…、最大记录。
  • 行(Row):行记录包含元数据信息:偏移量、记录头、行ID、事务ID、回滚指针,然后是用户数据(第一列数据、…、第n列数据)。

关系:一个段包含多个区,一个区包含多个页,一个页包含多行。

一个表中可能有多个索引,那么表中就对应多个段。在图6-1 左上角的段中会维护多个链表,链表中挂着不同类型的区,有完全空闲的、部分空闲的以及被使用完的,在后续段的管理章节会重点介绍。在图6-1 右上角的区中其实存储的是对应页的比特位,通过比特位来确定该页是否被使用,在后续区的管理章节中会详细介绍。在图6-1 左下角的页中包含多行数据,所有行记录被串联起来,在后续页的管理中会重点介绍。在图6-1 右下角的行记录包含一些元数据信息和系统字段,最后才是存储的是用户具体的数据。MySQL 为什么这么存储一行数据呢?在6.1.3 节中会重点介绍。

在掌握了基本的逻辑概念之后,我们或许会产生一些疑问,例如段是如何对区进行管理的,区又是如何对页进行管理的,页又是如何被分配的,以及数据是如何存储到行中的。接下来,我将针对这些问题进行详细阐述。

6.1.2 逻辑组织结构管理

本小节将详细展开介绍表空间、段、区的管理。

1. 表空间的管理

在介绍表空间管理之前,我们需要先熟悉下MySQL 内部的三个结构体:

  • fil_system_t,用于管理所有的表空间。
  • fil_space_t,用于存储表空间相关信息,每个表空间对应一个。
  • fil_node_t,用于存储文件相关信息,每个文件对应一个。

在MySQL 中,由 fil_system_t 结构体来管理所有的表空间,一个表空间管理着一个或多个文件(fil_node_t)。例如,MySQL 中的重做日志表空间就管理着两个重做日志文件,分别是 ib_logfile0ib_logfile1。用户创建表 sbtest1 对应 sbtest1 表空间,它管理着 sbtest1.ibd 数据文件,表空间的架构如图6-2 所示。

📊 图6-2 表空间的架构

图6-2 展示了三个结构体的关系:

  • fil_system_t:包含 spaces(哈希表,以表空间ID hash)、name_hash(以名称hash)、space_list(所有表空间链表)、LRU(最近打开的无悬挂I/O文件链表,不含系统表空间和重做日志文件)、unflushed_spaces(需要刷盘的表空间链表)、n_open(当前打开文件数)、max_n_open(最大可打开文件数,受 table_open_cache 控制)。
  • fil_space_t:包含 idhandlechain(文件链表),例如:
    • name (Innodb_redo_log) → chain → fil_node_t (name: ib_logfile0) → fil_node_t (name: ib_logfile1)
    • name (sbtest1) → chain → fil_node_t (name: sbtest1.ibd)
  • fil_node_t:包含 nameidhandle 等。

在构建表空间的过程中,首先会生成一个 fil_space_t 结构体,用以存储与表空间相关的信息,并将该表空间纳入 fil_system_t 所管理的哈希表中。同时,在创建表空间时,也会创建数据文件,并随之生成 fil_node_t 结构体,用于保存数据文件的相关信息。此外,该数据文件会被添加到由 fil_space_t 所维护的链表中,具体是链表的 chain 字段所指向的部分。

明确了这三个结构体之间的对应关系后,我们来看下 fil_system_t 结构体具体包含哪些内容。fil_system_t 字段名称及说明如表6-1 所示,其中罗列了一些关键字段。至于更详尽的字段信息,读者可查阅MySQL 源码中的定义。

表6-1 fil_system_t 字段名称及说明

名称说明
spaces维护一个哈希表,里面存储表空间信息(fil_space_t),以表空间ID 进行哈希计算,将所有的表空间存储到该哈希表中
name_hash维护一个哈希表,里面存储表空间信息(fil_space_t),以表空间名称进行哈希计算,将所有的表空间存储到该哈希表中
space_list维护所有表空间链表
LRU维护一个最近打开的文件的链表,这些文件是没有悬挂I/O 的,如果有的话需要从该链表中移除。这里面维护的文件不包括系统表空间文件和重做日志文件
unflushed_spaces维护一个表空间链表,这些表空间中至少有一个文件满足 modification_counter > flush_counter 条件,满足条件后,master 线程在定期刷盘的时候会遍历 unflushed_spaces 链表,逐个进行刷盘(fil_flush_file_spaces
n_open当前打开了多少个文件
max_n_open最大可打开文件的数量,由 table_open_cache 参数控制。但在MySQL 中,如果 table_open_cache 小于300,max_n_open 会设置为300,大于300 则设置为 table_open_cache 的值

fil_space_t 关键字段:

名称说明
id表空间ID
chain维护一个文件链表,存储该表空间中所有的文件(fil_node_t
size存储表空间共有多少个页
size_in_header存储表空间前面系统页的数量
free_len存储 FSP_FREE 的长度,FSP_FREE 是维护空闲区的链表
free_limit存储 FSP_FREE_LIMIT 的值,FSP_FREE_LIMIT 存储的时候未初始化的最小的页编号
n_reserved_extents为索引分裂等保留的区的数量
space指向所属的表空间

fil_node_t 关键字段:

名称说明
name文件名
is_open文件是否打开
handle保存文件句柄等信息
is_raw_disk磁盘是分区还是裸设备
size该文件有多少个页
init_size默认为 FIL_IBD_FILE_INITIAL_SIZE 个页,其值为4
max_size该文件最多有多少个页
being_extended该文件是否正在扩容
modification_counter从MySQL 启动后记录该文件被写入过多少次
flush_counter存储上次刷盘时 modification_counter 的值,之前提到满足 modification_counter > flush_counter 就刷盘
atomic_write该文件是否激活了原子写,如果磁盘单次可以写入一个完整的页,那么可以关掉双写缓冲区,这个时候就是采用了原子写

2. 段的管理

在介绍段和区的管理之前,首先需要介绍 FSP_HEADER,它存储在数据文件中的第一个页中,页的类型为 FIL_PAGE_TYPE_FSP_HDR。`FSP_HEADFSP_HEADER 字段的详细解释如表6-2 所示。

表6-2 FSP_HEADER 字段的详细解释

名称偏移量说明
FSP_SPACE_ID0存储表空间ID
FSP_NOT_USED4保留字段,未使用
FSP_SIZE8记录表空间中的页数量
FSP_FREE_LIMIT12未初始化最小的页编号
FSP_SPACE_FLAGS16存储的是表的相关标识,比如行格式是 redundant 或者 compact,压缩页的大小等,详细参考 dict_table_t 结构体中的 flags 字段
FSP_FRAG_N_USED20FSP_FREE_FRAG 链表中所有区已使用的页的数量
FSP_FREE24空闲区的链表
FSP_FREE_FRAG24 + 16部分空闲区并且不属于任何段的链表
FSP_FULL_FRAG24 + 2×16完全被使用的区并且不属于任何段的链表
FSP_SEG_ID24 + 3×16第一个未被使用的段的ID,在创建段的时候在这里分配段的ID
FSP_SEG_INODES_FULL32 + 3×16段完全被分配的 inode 页链表
FSP_SEG_INODES_FREE32 + 4×16段部分被分配的 inode 页链表

根据 FSP_HEADER 所维护的元数据来看,其主要职责在于管理段和区的信息。所谓段,是指在创建时必须进行分配的逻辑结构。至于为何还需管理区,实际上区是隶属于段的,但 FSP_HEADER 对区的管理主要涉及独立页和系统页的分配。关于页的分配细节,将在后续章节中详尽阐述。

在MySQL 中段的信息保存到 segment inode 字段中,它的详细解释如表6-3 所示。

表6-3 segment inode 字段的详细解释

名称偏移量说明
FSEG_ID0用来存储段的 ID
FSEG_NOT_FULL_N_USED8用来存储 FSEG_NOT_FULL 指向的链表中所有已标记为使用的页的数量
FSEG_FREE12指向空闲区的链表
FSEG_NOT_FULL12 + 16指向不完全空闲区的链表
FSEG_FULL12 + 2×16指向区中页已完全使用的链表
FSEG_MAGIC_N(12 + 3×16)magic number
FSEG_FRAG_ARR(16 + 3×16)存储独立的页
FSEG_FRAG_ARR_N_SLOTSFSP_EXTENT_SIZE=64 / 2存储独立页的槽(Slot)数量,一般为32 个
FSEG_FRAG_SLOT_SIZE4存储独立页的页编号

这里重点说下以下几个字段:

  • FSEG_FREE,指向一个链表,该链表是用来保存空闲的区的,将所有空闲区的节点链接起来。在为段申请了空闲的区的时候,会将区的节点插入 FSEG_FREE 指向的链表上。再向段申请区的时候,会从 FSEG_FREE 中指向的链表获取空闲的区,当区中有页已经被使用了,则该区就不是完全空闲,将会从 FSEG_FREE 指向的链表中移除。
  • FSEG_NOT_FULL,也是指向一个链表,该链表是用来保存不完全空闲的区的,不完全空闲的区指的是区中有的页已经被使用。刚刚提到如果区中有页被使用了,将会从 FSEG_FREE 指向的链表中移除,移除之后会将该区再加入到 FSEG_NOT_FULL 指向的链表中。如果区中所有的页都被使用了,这个时候会将该区从 FSEG_NOT_FULL 指向的链表中移除。
  • FSEG_NOT_FULL_N_USED,保存总共使用了多少个页,一旦 FSEG_NOT_FULL 链表的区中的页被标记为使用,FSEG_NOT_FULL_N_USED 保存的值就加1。
  • FSEG_FULL,也是指向了一个链表,该链表是用来保存页被完全使用了的区的。刚刚提到,如果区中的页被完全使用了,则会将该区从 FSEG_NOT_FULL 指向的链表中移除。移除之后会将该区加入到 FSEG_FULL 指向的链表中。在释放页的时候,会将该页所属的区从 FSEG_FULL 指向的链表中移除,再将该区插入 FSEG_NOT_FULL 指向的链表中。同理,在释放区的时候,也会将该区从相应的链表中移除。
  • FSEG_FRAG_ARRFSEG_FRAG_ARR_N_SLOTSFSEG_FRAG_SLOT_SIZE,这三个字段组合起来使用,主要存储段的一些独立页的编号。MySQL 这样做是为了节省空间,在刚创建好表开始分配空间的时候,首先会直接从表空间,也就是 FSP_HEADER 中分配一些独立页,这个独立页的数量一般为32,是区拥有页数量的一半。在这些独立页都被使用完之后,才会让段去申请区。后续都是从对应的段管理的区中申请页。这里的 FSEG_FRAG_SLOT_SIZE 用4B 来存储页的编号,每隔4B 存储一个,总共存储 FSEG_FRAG_ARR_N_SLOTS 个页编号。

其实还有一个元数据信息跟段相关,那就是段头,段头主要用于定位 segment inode 的位置,它的字段详细解释如表6-4 所示。

表6-4 段头字段详细解释

名称偏移量说明
FSEG_HDR_SPACE0inode 所在表空间的ID
FSEG_HDR_PAGE_NO4inode 所在页的编号
FSEG_HDR_OFFSET8inode 在页中具体的偏移量
FSEG_HEADER_SIZE10该段头的长度

它一般存储在索引根节点页中,在索引需要分配页的时候,会首先找到段头从而拿到 segment inode 信息,就可以开始页的分配了。

段的创建其实就是创建 segment inode,并将里面的字段初始化,最终会存储到 inode 页中,一个 inode 页包含多个 segment inode。1 个索引会创建2 个段,首先会创建管理索引根节点的段,然后创建管理索引叶子节点的段,其创建过程如下:

  1. 首次建表的时候,创建第一个段会触发创建 inode 页(页类型为 FIL_PAGE_INODE),并且初始化所有的 segment inode
  2. inode 页中分配一个 segment inode
  3. 初始化 segment inode 每个字段的信息,从空间头中申请一个段的ID,也就是 FSP_SEG_ID
  4. 初始化段头,段头存储 segment inode 的位置信息,通过段头最终能找到对应 segment inode,最后段头会存储到索引根节点页中,根节点页也是此时创建的。

完成第一个段的创建后,开始创建第二个段,步骤跟上述一致,区别主要在于段头在系统页的偏移量不同,两个段头都存储到索引根节点页中,该根节点页就是创建第一个段的时候创建的根节点页。

当创建管理索引根节点的段时,段头的偏移量是 PAGE_HEADER + PAGE_BTR_SEG_TOP,创建管理索引叶子节点的段时,段头的偏移量是 PAGE_HEADER + PAGE_BTR_SEG_LEAFPAGE_HEADERPAGE_BTR_SEG_TOPPAGE_BTR_SEG_LEAF 在后续介绍索引时会详细介绍。

当需要使用段的时候,如果是使用管理索引根节点的段,则从索引根节点中获取段头的数据,偏移量是 PAGE_HEADER + PAGE_BTR_SEG_TOP。如果是使用管理索引叶子节点的段,则从索引根节点页获取段头数据,偏移量是 PAGE_HEADER + PAGE_BTR_SEG_LEAF。当获取到段头后,可以根据其记录的元数据信息获取到对应的 segment inode 位置,最终使用 segment inode 就可以使用段进行相关区或页的分配了。

上述创建段的逻辑主要在MySQL 目录中的 storage/innobase/fsp/fsp0fsp.cc 文件的 fseg_create_general 方法中。

3. 区的管理

前面已对段的管理机制进行了阐述,指出段内维护了若干链表,这些链表存储了区的节点信息。通过这些节点信息,可以定位到相应的区。接下来,我们将探讨区的元数据信息是如何存储的。在MySQL 系统中,区的元数据信息通过 XDES entry 进行管理。XDES entry 字段的详细解释如表6-5 所示。

表6-5 XDES entry 字段的详细解释

名称偏移量说明
XDES_ID0记录对应段的ID
XDES_FLST_NODE8最终指向的是一个地址,该地址存储了页编号和对应的偏移量,通过该地址可以定位到当前区的位置
XDES_STATE22记录该区的状态
XDES_BITMAP26区中的位图,用来描述页的状态
XDES_BITS_PER_PAGE28每个页对应多少个比特位

下面重点介绍如下字段。

  • XDES_FLST_NODE,指向的是一个地址,根据该地址最终能定位到当前区所在的位置,在之前介绍段的管理时,段中维护的几个链表其实就是保存的这个信息,XDES_FLST_NODE 就是其中链表里面的节点。
  • XDES_STATE,记录该区的状态,具体包括:
    • XDES_FREE(该区在空间头的空闲区链表中)
    • XDES_FREE_FRAG(该区在空间头的部分空闲区链表中)
    • XDES_FULL_FRAG(该区在空间头的完全被使用链表中)
    • XDES_FSEG(该区属于某个段)
  • XDES_BITMAP,保存了所有页的状态,标记页是否被使用。每个页用2 个位表示,在MySQL 用 xdes_set_bitxdes_get_bit 方法分别设置和获取位值,设置和获取位值的时候都需要加上 XDES_FREE_BIT 偏移量,该偏移量默认为0,这是由于一个页采用了2 个位保存的原因,具体计算方法这里不详细展开。
  • XDES_BITS_PER_PAGE,每个页对应两个位,分别是 XDES_FREE_BITXDES_CLEAN_BITXDES_FREE_BIT 表示空闲位点偏移量,XDES_CLEAN_BIT 表示保留字段。

XDES_BITMAP 维护了页是否被使用的信息,那么一个区可以管理多少个页?这个数量需要根据页的大小来确定,如下是对应的关系:

/** File space extent size in pages
page size | file space extent size
----------+-----------------------
   4 KB   | 256 pages = 1 MB
   8 KB   | 128 pages = 1 MB
  16 KB   |  64 pages = 1 MB
  32 KB   |  64 pages = 2 MB
  64 KB   |  64 pages = 4 MB
*/

页的默认大小为16KB,所以一个区默认能管理64 个页。区的元数据信息 XDES entry 存储在类型为 FIL_PAGE_TYPE_FSP_HDRFIL_PAGE_TYPE_XDES 的页中。每个 XDES entry 占用40B,一个 FIL_PAGE_TYPE_XDES 页最多可以管理16 384 个页,所以最终会存放16 384 ÷ 64 = 256 个 XDES entry,用于管理其随后物理相邻的256 个区。如果这些页用完,则又会创建一个 FIL_PAGE_TYPE_XDES 类型的页来保存区的信息,这些区来管理后面16 384 个页。

第0 个页的特殊性

第0 个页比较特殊,第0 个页的类型为 FIL_PAGE_TYPE_FSP_HDR,但它也保存了 XDES entry 信息,随后存储区的页的类型都为 FIL_PAGE_TYPE_XDESFIL_PAGE_TYPE_FSP_HDRFIL_PAGE_TYPE_XDES 的区别主要在头上,第0 个页还存储了空间头(FSP_HEADER)。

4. 页的分配机制

前面我们已经对段和区的管理进行了阐述。现在,让我们进一步探讨页的分配机制。在先前讨论 FSP_HEADER 时,我们了解到 FSP_HEADER 负责管理一组区域。因此,页的分配过程可以划分为两种不同的情况:①从 FSP_HEADER 中的区进行分配;②从常规的段管理的区中进行分配。

首先说明什么情况从 FSP_HEADER 中分配,主要是以下两种情况:

  • ❑ 在创建系统页(如 inode 页)的时候需要在这里分配,因为如果继续用段中的区来分配创建 inode 页,段的管理会比较混乱。
  • ❑ 在创建表的时候,MySQL 为了节省空间,一开始数据插入会从 FSP_HEADER 中分配32 个独立页,如果这32 个独立页使用完了,后续数据的插入会使用索引对应的段进行分配。

除了上述情况外,数据更新都会从常规的段管理的区中进行分配。首先会从自己的区中分配页,如果区中没有空闲的页,会向其他的区申请,如果所有的区都没有空闲页,则会向FSP_HEADER 申请区,申请成功后会挂在段的链表上,然后用这个区继续进行页分配。MySQL 为了保证物理上数据的连续性,在从FSP_HEADER 中申请区的时候会一次性申请4 个区。

MySQL 分配页的流程较为复杂,由于篇幅有限,这里没有详细说明,感兴趣的读者可以参考MySQL 源代码中的 fseg_alloc_free_page_low 方法。

6.1.3 物理组织结构

在先前章节中,我们已经对物理组织结构中的页和行的概念进行了概述,本小节将深入探讨页和行的构成及管理机制。同时,本小节还将详细阐述数据在索引中的组织方式,以及索引在数据文件中的组织方式。

1. 页的组织结构

在MySQL 中,页分为不同的类型,分别有不同的作用,存储不同的数据,页类型的名称、编号及说明如表6-6 所示。

表6-6 页类型的名称、编号及说明

名称编号说明
FIL_PAGE_INDEX17 855索引页,包括根节点或叶子节点,存储实际数据
FIL_PAGE_RTREE17 854索引页,存储GIS 空间地理类型数据
FIL_PAGE_UNDO_LOG2回滚页,存储回滚段数据
FIL_PAGE_INODE3inode 页,存储段数据
FIL_PAGE_IBUF_FREE_LIST4插入缓冲区空闲列表
FIL_PAGE_TYPE_ALLOCATED0新分配的页,主要用于插入缓冲区初始化页
FIL_PAGE_IBUF_BITMAP5插入缓冲区位图,用来描述后面的数据页的插入缓冲区信息,因为之前叫insert buffer,所以缩写为IBUF
FIL_PAGE_TYPE_SYS6系统页,主要用于存储段头信息,用于定位段所在位置
FIL_PAGE_TYPE_TRX_SYS7事务页,主要用于存储事务相关信息
FIL_PAGE_TYPE_FSP_HDR8存储File Space Header 和区相关数据
FIL_PAGE_TYPE_XDES9存储区相关数据
FIL_PAGE_TYPE_BLOB10存储未压缩的外部列数据,它可能是变长字段的值也可能是blob 类型的值
FIL_PAGE_TYPE_ZBLOB11存储压缩后的外部列数据,多个压缩的blob 页链接起来,链表中第一个页的类型为FIL_PAGE_TYPE_ZBLOB,后续页的类型为FIL_PAGE_TYPE_ZBLOB2
FIL_PAGE_TYPE_ZBLOB212
FIL_PAGE_TYPE_UNKNOWN13未知类型
FIL_PAGE_COMPRESSED14压缩页,存储压缩的用户数据
FIL_PAGE_ENCRYPTED15加密页,存储加密后的用户数据
FIL_PAGE_COMPRESSED_AND_ENCRYPTED16压缩加密页,先被压缩后又被加密
FIL_PAGE_ENCRYPTED_RTREE17存储GIS 空间地理数据的页,并且被压缩

了解完页的类型后,我们来介绍一般数据文件中主要包含哪些页。这里需要区分系统数据文件和用户数据文件。系统数据文件主要包含的页有数据字典、事务信息、回滚日志、双写缓冲区和插入缓冲区,其内部架构如图6-3 所示。

用户数据文件主要包含用户数据,还有一些与管理段和区相关的页,其内部架构如图6-4 所示。

图6-3 系统数据文件的内部架构

FIL_PAGE_TYPE_FSP_HDR
FIL_PAGE_IBUF_BITMAP
FIL_PAGE_INODE
FIL_PAGE_INDEX
FIL_PAGE_TYPE_SYS
FIL_PAGE_TYPE_TRX_SYS
FIL_PAGE_UNDO_LOG
FIL_PAGE_TYPE_ALLOCATED
FIL_PAGE_IBUF_FREE_LIST
双写缓冲区
双写缓冲区
...

图6-4 用户数据文件的内部架构

FIL_PAGE_TYPE_FSP_HDR
FIL_PAGE_IBUF_BITMAP
FIL_PAGE_INODE
...
FIL_PAGE_INDEX
FIL_PAGE_TYPE_XDES
FIL_PAGE_IBUF_BITMAP
...
FIL_PAGE_TYPE_XDES
FIL_PAGE_IBUF_BITMAP
...

虽然这些不同类型的页存储着不同的数据,不过这些页的头部和尾部都是一样的,FIL HEADER 的字段名称、大小及说明如表6-7 所示。

表6-7 FIL HEADER 的字段名称、大小及说明

名称大小/B说明
FIL_PAGE_SPACE_OR_CHKSUM4该字段目前用于存储该页的校验数据,在4.0.14 版本之前用于存储表空间ID
FIL_PAGE_OFFSET4用于存储该页的页编号
FIL_PAGE_PREV4指向上一个页,如果没有上一个页,则为FIL_NULL
FIL_PAGE_NEXT4指向下一个页,如果没有下一个页,则为FIL_NULL
FIL_PAGE_LSN8存储该页最新修改后的lsn
FIL_PAGE_TYPE2存储页的类型
FIL_PAGE_FILE_flush_LSN8主要用于系统表空间的第一个页,存储当前系统的lsn,在MySQL 正常关闭和主动设置 innodb_log_checkpoint_now 参数后才会更新。主要作用是在下次启动的时候,如果需要创建redo 文件,创建完成redo 文件后,会将当前系统的lsn 设置为FIL_PAGE_FILE_flush_LSN 的值,这么做是由于redo 文件不存在了,没有最新可参考的lsn 值。其他类型的页该字段为空,不过针对压缩页,该字段用于存储压缩页相关控制信息
FIL_PAGE_SPACE_ID4存储该页的表空间ID,在4.1 版本之前该字段名叫FIL_PAGE_ARCH_LOG_NO,主要用于存储最近归档日志号,跟FIL_PAGE_FILE_flush_LSN 配合使用
FIL_PAGE_END_LSN_OLD_CHKSUM8前4B 存储当前页的校验数据,后4B 存储lsn

Fil Trailer 内容

Fil Trailer 中主要包含两部分内容:一部分是页校验数据,主要用于校验页是否完整;另一部分是lsn,即重做日志的序列号,在刷盘的时候会将该页对应操作的最新lsn 记录到这个地方,xtrabackup 和MySQL 企业版增量备份时就是对比的这个lsn 来判断页是否发生过改变。

了解页的结构之后,我们来看每个页中的内容是如何存储的。因为篇幅有限,这里挑几个重点类型的页详细介绍,包括:FIL_PAGE_TYPE_FSP_HDR、FIL_PAGE_TYPE_XDES、FIL_PAGE_INODE、FSEG_INODE_PAGE_NODE、FIL_PAGE_INDEX。

在介绍区的时候,已经提到FIL_PAGE_TYPE_FSP_HDR 和FIL_PAGE_TYPE_XDES 是用来存储区的信息的,下面具体来看这两种页的内部架构。以FIL_PAGE_TYPE_FSP_HDR 页为例,如图6-5 所示。

图6-5 FIL_PAGE_TYPE_FSP_HDR 页的内部架构

FIL_PAGE_OFFSET
FIL_PAGE_SPACE_ID
XDES Entry 255
XDES Entry...
XDES Entry 0
FSP_HEADER
FIL HEADER
Fil Trailer
FSP_SEG_ID
XDES_ID
XDES_ID
XDES_ID
XDES_STATE
XDES_STATE
XDES_STATE
checksum
XDES_FLST_NODE
XDES_FLST_NODE
XDES_FLST_NODE
XDES_BITMAP
XDES_BITMAP
XDES_BITMAP
lsn
FSP_SPACE_ID
FIL_PAGE_PREV
FIL_PAGE_TYPE
FSP_SEG_INODES_FREE
FSP_SIZE
FIL_PAGE_NEXT
...
...
FSP_FREE
1
0
1
1
1
1
1
1
1
1
0
0
1
1
1
...
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1

其实FIL_PAGE_TYPE_XDES 相比FIL_PAGE_TYPE_FSP_HDR 页只是没有FSP_Header,其他完全一致。FIL HEADER、FSP_HEADER、XDES entry、Fil Trailer 都在前面的内容中详细介绍过,这里就不赘述了。

在介绍段的时候,提到了inode 页,指的就是类型为FIL_PAGE_INODE 的页。inode 页主要存储的是segment inode 信息,下面具体来看FIL_PAGE_INODE 页的内部架构,如图6-6 所示。

图6-6 FIL_PAGE_INODE 页的内部架构

FIL_PAGE_SPACE_ID   FIL_PAGE_TYPE
...
FIL_PAGE_OFFSET
FIL_PAGE_PREV
FIL_PAGE_NEXT
FSEG_INODE_PAGE_NODE
段节点0
段节点1
段节点2
段节点3
段节点4
段节点...
段节点84
页校验数据
lsn
FSEG_ID
FSEG_NOT_FULL_N_USED
FSEG_NOT_FULL
FSEG_MAGIC_N
FRAG SLOT 0
FRAG SLOT 1
FRAG SLOT 2
FRAG SLOT 3
FRAG SLOT 4
FRAG SLOT...
FRAG SLOT 31
FSEG_FULL
FSEG_FREE

注意

刚刚已经提到,每个页都有FIL HEADER,也就是页的头部。

FSEG_INODE_PAGE_NODE 用来链接到inode 页链表中,该链表由存储在系统页中的FSP_HEADER 维护,FSP_HEADER 中又分为FSP_SEG_INODES_FREE 和FSP_SEG_INODES_FULL。如果inode 页中的segment inode 已被用满,则插入FSP_SEG_INODES_FULL 链表中,否则插入FSP_SEG_INODES_FREE 链表中。

在FSEG_INODE_PAGE_NODE 后则是segment inode,segment inode 中的内容在段的管理中已经介绍过。每个inode 页管理85 个segment inode。最后一部分为FIL Trailer,这个之前也详细介绍过。

FIL_PAGE_INDEX 是索引页,它是用来存储用户插入表中的数据的。索引页相比其他页结构会稍微复杂些:一方面是因为索引页里面的很多内容都跟索引相关;另一方面是它需要将里面的数据管理起来,并且能够支持快速定位到具体的数据。索引叶子节点页的内部架构如图6-7 所示。

图6-7 索引叶子节点页的内部架构

FIL_PAGE_OFFSET
FIL_PAGE_PREV
FIL_PAGE_NEXT
FIL_PAGE_SPACE_ID
FIL_PAGE_TYPE
...
PAGE_N_DIR_SLOTS
PAGE_INDEX_ID
PAGE_LEVEL
PAGE_FREE
PAGE_N_RECS
...
1 5 9 13
2 6 10 14
3 7 11 15
4 8 12 n
rec rec rec rec
rec rec rec rec
rec rec rec rec
rec rec rec rec
Infimum
Supremum
Free Space
slot 0
页校验数据
lsn
slot 1
slot 2
slot 3
slot n
...
Fil Header
The Infimum and Supremum Records
User Records
Page Directory
Page Header
Free Space
Fil Trailer

下面是每个部分的介绍。

  • Fil Header。所有页都有,前面已经详细讲解了里面存储的内容。
  • Page Header。索引页独有,它记录了与索引页相关的元数据信息,比如索引ID、索引层级、该页有多少条记录等,其字段详细解释如表6-8 所示。
  • The Infimum and Supremum Records。最小和最大记录,一个页中所有的记录按照从小到大的顺序链接起来,Infimum 指向最小的记录,Supremum 指向最大的记录。从图6-7 中可以看到,Infimum 和Supremum 分别位于链表的最左端和最右端。
  • User Records。用户记录,索引页中的所有用户记录是按照顺序链接成一条单向链表的,它在逻辑上是有序的,但在物理存储上是无序的。在聚簇索引的叶子节点中,每条记录对应一行数据,如果是根节点,每条记录对应的是相应的指针,指针指向的是叶子节点页。二级索引情况有所不同,在二级索引中,根节点中的记录存储的指针指向的是叶子节点页,而叶子节点存储的是对应主键的值。所以在不同的情况下,存储的用户记录也不一样。最为复杂的就是聚簇索引存储的一行数据,一行数据可能对应多个列,而每个列有不同的数据类型,针对不同的数据类型有不同的存储方式。这会在行的组织结构章节中详细介绍。
  • Free Space。顾名思义就是空闲的空间,当用户插入数据时,如果可回收区没有可用的空间则从Free Space 中分配空间。
  • Page Directory。里面包含许多槽,它的作用就是快速定位数据,每个槽管理一个范围的记录。在定位每条具体记录的时候,需要从槽中先找到记录在哪个范围,然后再去遍历该槽管理的对应范围的记录链表。槽的存在使得在扫描的时候不用遍历整个页的链表,这大大提高了定位记录的效率。如果是全表扫描,槽在这里的意义就不是特别大,因为必须得扫描整个页的链表。不过,在MySQL 的逻辑中槽的存在并没有给全表扫描带来性能影响,这在后续介绍查询的时候会进一步解释。在MySQL 中,槽可以管理4 ~8 条记录,如果超过8 条记录则会进行分裂,也就是新增一个槽,原来的槽收缩到只管理4 条记录,后续的记录由新增的槽进行管理。如果删除了记录,那么对应的槽管理的范围也会减小,一旦小于4 会进行重新分配。具体的流程就是删除管理范围小于4 的槽,由它后面的槽来接管它管理的范围。比如上一个槽里面只剩3 条记录,下一个槽之前管理了4 条记录,那么重新分配之后下一个槽就管理7 条记录。索引页最开始创建的时候只有两个槽,分别为第0 个槽(指向Infimum 记录)和第1 个槽(指向Supremum 记录)。槽的总记录数存储在Page Header 的PAGE_N_DIR_SLOTS 字段中。
  • Fil Trailer。之前已详细介绍。

表6-8 Page Header 字段详细解释

名称大小/B说明
PAGE_N_DIR_SLOTS2存储槽的数量,初始值为2
PAGE_HEAP_TOP2指向用户已使用最后位置,后续的空间则为空闲空间,根据该值可以计算当前用户记录占用了多少空间
PAGE_N_HEAP2存储该页中总记录数量,包含正常的记录、标记删除的记录
PAGE_FREE2指向该页删除记录链表,数据页中删除的记录最终会插入该链表中,如图6-7 所示,PAGE_FREE 指向了一个删除记录的链表
PAGE_GARBAGE2存储被删除的记录的总占用字节数,这部分空间可以回收利用
PAGE_LAST_INSERT2指向最近插入的记录
PAGE_DIRECTION2存储当前记录插入是不是顺序插入
PAGE_N_DIRECTION2存储顺序插入的记录数,例如连续5 条记录从左顺序插入,这里记录值即为5。该值和PAGE_DIRECTION 配合使用,可以确认插入是不是连续顺序插入,从而会影响InnoDB 性能
PAGE_N_RECS2用户正常的记录数
PAGE_MAX_TRX_ID8存储该页最新的事务ID,主要用于二级索引
PAGE_LEVEL2存储索引页层级,叶子节点为0,依次往上,根节点索引层级最大
PAGE_INDEX_ID8存储索引ID,表示该页属于该索引
PAGE_BTR_SEG_LEAF10存储叶子节点的段头,在为叶子节点分配页的时候,需要
PAGE_BTR_SEG_TOP10存储根节点的段头,在为根节点分配页的时候,需要从这里拿到段的信息

2. 行的组织结构

在先前章节中,我们已经阐述了用户记录存储内容的差异性,鉴于其他情况下的数据存储相对简单,本节将专注于介绍聚簇索引叶子节点页中记录的格式。在探讨行记录格式之前,有必要先了解行记录可采用的几种格式。在MySQL 的InnoDB 存储引擎中,存在两种文件格式,它们由 innodb_file_format 参数所控制。在MySQL 5.7.6 版本之前,默认采用的是Antelope 格式,而自5.7.6 版本起,默认转为Barracuda 格式。需要注意的是,这一变化仅适用于用户表空间的数据文件,系统数据文件中始终沿用Antelope 格式。

Antelope 与Barracuda 文件格式分别对应两种行格式,其中Antelope 支持REDUNDANT 和COMPACT 行格式,而Barracuda 则支持DYNAMIC 和COMPRESSED 格式。接下来,我们将逐一探讨这些行格式的具体存储方式。

首先,无论采用何种行格式,其基本结构是一致的,如图6-8 所示。

图6-8 行格式的基本结构

如果每个字段起始偏移量的长度为1B,则值为1
该记录所拥有的记录数量,主要是跟槽配合使用
是否为预先定义的最小记录
删除标记
未使用
未使用
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
0 0 1
deleted_flag
min_rec_flag
n_owned
heap_no
n_fields
1byte_offs_flag
offset 0
offset 1
offset n
rowid
record header
transaction id
roll pointer
column 0
column 1
column n
NULL
next rec
1 1 4 13 10 16
描述
名称
大小/bit
此录中的字段数量,范围是1~1023
索引页堆中记录的序号
指向下一条记录
字段开始的偏移量
额外的位
系统字段
字段内容
空值位图

在MySQL 中,行记录中的内容主要分为以下7 部分:

  • offset。记录的是每个字段开始的偏移量,记录在磁盘中其实是以二进制数据的形式存在的,要解析该段数据中某个字段的值,就得知道该字段的开始位置和长度。这里记录的偏移量就是对应字段的开始位置,并且根据偏移量能计算出对应的字段长度—用下一个偏移量减去当前的偏移量,就得到字段长度了。在图6-8 中,最开始存储的是偏移量数组,其实就是对应后面每个列的开始位置以及相应的长度。

  • NULL。空值位图,只在COMPACT、DYNAMIC、COMPRESSED 格式下才有,该位图信息标记了列是否为空。

  • record header。通常称之为行记录头,它总共存储了48 位,合计9 个字段,具体内容参考图6-8。里面有几个重要的字段:deleted_flag,在记录标记删除的时候会将该位设置为1;n_owned,该字段标识它拥有几条记录,跟槽配合使用,槽管理一个范围的记录,在最后一条记录上会将该范围的数量存储到n_owned字段上,其他记录n_owned字段默认情况是0;n_fields,标识该条记录共有多少个字段,也就是有多少列;next_rec,指向下一条记录的指针,它将所有记录串成一条单向链表。

  • rowid。在表没有主键的情况下,会生成虚拟的唯一id,也就是rowid,该rowid作为聚簇索引的键。该字段只在没有主键的情况下存在。

  • transaction id。每次操作该记录的时候,就会把当前对应的事务id 记录到该字段中。该字段在每条记录中都存在。

  • roll pointer。每次操作该记录的时候,就会把当前操作对应的回滚段指针记录到该字段中。在需要回滚的时候读取该字段,拿到对应的回滚记录进行回滚。该字段在每条记录中都存在。

  • column。存储每列具体的数据。

刚刚已经详细介绍了行记录的格式,那么之前提到的不同行记录之间的区别在哪里呢?InnoDB 行格式的详细解释如表6-9 所示。

表6-9 InnoDB 行格式的详细解释

文件格式行格式区别
AntelopeREDUNDANT存储所有字段的偏移量,小于768B 的数据存储到行中,大于768B 在外部页进行存储,用20B 存储外部页的地址
AntelopeCOMPACT只存储变长字段的偏移量,小于768B 的数据存储到行中,大于768B 在外部页进行存储,在offset 字段后用20B 存储外部页的地址,新增一个字段存储NULL 标志位
BarracudaDYNAMIC只存储变长字段的偏移量,超过空闲页空间的一半就会存储到外部页,在offset 字段后用20B 存储外部页的地址,新增一个字段存储NULL 标志位
BarracudaCOMPRESSEDCOMPRESSED 跟DYNAMIC 本身存储的格式是一致的,只是COMPRESSED 会进行压缩

这里可能有个问题,对于COMPACT、DYNAMIC、COMPRESSED 行格式只存储变长字段的偏移量,那其他字段的偏移量如何得到?

前面提到,解析各字段值时,必须了解每个字段的起始偏移量及长度。对此,MySQL 实施了若干优化措施。尽管未存储定长字段的起始偏移量,但在解析过程中,MySQL 能够计算出定长字段的起始偏移量。由于定长字段的长度是固定的,因此在计算偏移量时,只需累加定长字段的长度,即可确定下一个字段的起始偏移位置。

接下来介绍一下MySQL 中外部页的存储机制。在MySQL 内部,该机制被称为off-page。每次进行数据插入或更新操作时,系统会评估是否有必要将数据存储至外部页。外部页通常采用FIL_PAGE_TYPE_BLOB 类型的页面。若数据需要压缩处理,则会使用FIL_PAGE_TYPE_ZBLOB 类型的页面。图6-9 所示为包含外部页的行结构,其中第1 列数据(column 1)列专门用于标识外部页存储。

图6-9 包含外部页的行结构

偏移量0  偏移量1  偏移量n
外部页
外部页
外部页
空值
记录头
行ID
事务ID
回滚指针
第0 列数据
第1 列数据
第n 列数据

实际上,若要存储一条记录,我们仅需保存该记录中每个字段的数据,以及它们各自的偏移量和字段长度。可以观察到,为了适应不同场景的需求,MySQL 在行记录中嵌入了多种元数据和系统字段信息。例如,为了支持回滚操作,它加入了事务ID 和回滚日志指针;为了满足索引特性,又增加了一些元数据信息。或许在未来版本中还会引入新的特性,相应地,行记录的格式也将随之调整。

至此,我们深入探讨了表空间、段、区、页、行的概念。通过这些内容的学习,读者应该能够理解页在MySQL 的数据写入过程中是如何被分配的,以及数据是如何被存储到每一行中的。总体而言,MySQL 中的所有数据均存储于数据文件内,系统相关数据存放在系统数据文件中,用户数据则存放在用户数据文件中。表空间、段、区等逻辑概念实际上也存储在系统数据文件中,并在MySQL 启动时加载到内存中,利用内存中的相应结构进行管理,从而实现页的分配与管理。用户数据最终被存储在索引页中,表中的每一行数据对应索引页中的每条记录。当用户进行数据操作时,实际上是将相应的索引页数据加载到内存中进行处理,操作完成后,再将该页写回数据文件中。后续章节将对此进行更详细的阐述。

3. 索引中的数据

前面详细阐述了索引页的构成,明确指出索引页是用于存储用户详细数据的。实际上,一个数据文件内存在多个索引页,这些索引页进一步细分为索引根节点页和索引叶子节点页。接下来,我们将探讨数据是如何在索引结构中进行组织的。在MySQL 系统中,数据的组织依赖于索引,表内的所有数据均存储于聚簇索引的叶子节点中,聚簇索引的内部架构如图6-10 所示。

图6-10 聚簇索引的内部架构

1 1 37 109 110 182 ... ... ... 36 ... ... ... 110 37
p1
rec rec rec rec rec rec rec rec rec
p1 p2 p3 p3 p2

3 号数据页 第1 层
5 号数据页 第0 层
6 号数据页 第0 层
7 号数据页 第0 层

最小记录    最小记录    最小记录    最小记录
最大记录    最大记录    最大记录    最大记录

在图6-10 中,sbtest1 表包含182 条记录,其中3 号数据页作为根节点页,包含超过100 条记录,每条记录均指向相应的叶子节点页。5 号数据页、6 号数据页和7 号数据页分别对应各个叶子节点页,每个页面存储特定范围的数据。具体而言,5 号数据页存储的是id 为1 ~36 的数据,6 号数据页存储的是id 为37 ~109 的数据,7 号数据页存储的是id 为110 ~182 的数据。在叶子节点页中,每条记录实际上存储了具体的用户数据,即表中的一行数据。关于用户记录的详细说明已在前面章节中阐述。

4. 数据文件中的索引

下面介绍索引在数据文件中是怎样组织的。在图6-10 中,用户数据文件除去前三页系统管理页外,接下来依次排列的是聚簇索引的根节点索引页和叶子节点索引页。索引根节点页内的记录指向索引叶子节点页,所有索引叶子节点页共同构成一个双向链表,而每个索引页内的记录则形成一个单向链表,从而形成一个B+ 树结构。图6-10 是按照逻辑顺序绘制的,实际上物理存储并非顺序排列。此处描述的仅为单一索引结构,在一个数据文件中可能包含多个索引,这取决于表中索引的数量。然而,每个表中仅存在一个聚簇索引。

6.1.4 数据文件的更新操作

前面介绍了数据在数据文件中的组织,接下来介绍更新语句操作这些数据的流程。这里以 update sbtest1 set pad="zbdba" where id = 10; 语句为例,具体分析更新语句在数据文件中的操作流程。sbtest1 对应的表结构和索引如图 6-11 所示。

MySQL 内部将更新语句主要分为两个阶段:第一阶段检索数据,第二阶段更新数据。

1. 检索数据

首先来看如何找到该条记录。根据上一小节的知识,id=10 这条记录应存储在聚簇索引的叶子节点上。在 MySQL 中,寻找 id=10 这条记录的过程主要分为三个阶段。

阶段一:找到聚簇索引根节点页。首先需要找到聚簇索引的根节点在哪个页。第 4 章提到过,innodb_sys_indexes 表中记录了每个索引对应的根节点页编号。

阶段二:扫描聚簇索引根节点页。找到聚簇索引根节点页后,开始扫描该页,最终得到 id=10 这条记录指向的叶子节点页,因为数据最终存储在聚簇索引的叶子节点页上。扫描流程如下:

  1. 获取该页所有槽的数量。
  2. 搜索 id=10 这条记录在哪个槽中。索引检索流程如图 6-12 所示,每个槽管理 4 条记录,第 0 个槽管理 id 为 1 ~ 4 的数据,第 1 个槽管理 id 为 5 ~ 8 的数据,依此类推。整个搜索流程采用二分查找的思想,首先从中间开始,比较 10 和该槽管理的最大值。如果小于则往左边搜索,如果大于则往右边搜索,最终可以确定 10 这个值在哪个槽的管理范围内。
槽0: id 1-4
槽1: id 5-8
槽2: id 9-12
槽3: id 13-16
...
向左查找: 10 > 8  → 向右
向右查找: 10 < 12 → 向左
最终确定在槽2(id 9-12)

图6-12 索引检索流程

  1. 得到对应的槽后继续搜索,直至找到 id=10 这条记录。由于槽维护的是一个数据范围,这些数据是用单向链表串起来的,因此只需遍历该链表即可获取 id=10 的记录。索引顺序遍历的流程如图 6-13 所示,先从槽最开始的记录进行比较,第一条 id=9,跟 10 不相等,继续比较下一条,得到 id=10 这条记录。该条记录的 rec 值是一个指针,指向索引叶子节点,里面存储的是索引叶子节点的页编号。
链表: 9 -> 10 -> 11 -> 12 -> ...
比较: 10 > 9 → 继续;10 = 10 → 找到

图6-13 索引顺序遍历的流程

阶段三:扫描聚簇索引叶子节点页。得到叶子节点的页后,开始扫描该页,其流程与扫描根节点页的流程基本一致:

  1. 获取该页所有槽的数量。
  2. 搜索 id=10 这条记录在哪个槽中。
  3. 在对应的槽中继续搜索,直到找到 id=10 的这条记录。
  4. 得到 id=10 的这条记录,至此找到记录为 10 的这一行。

NOTE

要读取数据文件的内容,需要先将对应的数据页加载到内存——缓冲区。这里不详细介绍缓冲区缓存页和持久化页的流程,请参见 5.2 节。

接下来介绍另外两种常见的扫描方式:全表扫描范围查询

全表扫描:涉及对整个数据表进行数据检索。由于所有数据均存储于聚簇索引的叶子节点中,因此遍历聚簇索引的所有叶子节点页即可获取表中的全部数据。此外,聚簇索引还确保了记录的有序排列,从最小记录开始依次遍历链表中的后续记录,便能获取全表的记录。需要注意,这些叶子节点可能分布在多个页中。在 MySQL 中,每个页的 Fil Header 中包含指向前后页的指针,这些指针可以用来实现连续页的读取。当完成当前页的扫描后,系统会检查是否已到达最后一条记录。若检查结果为是,则系统将读取指向下一个页的指针,获取下一页的页编号,并继续进行数据扫描,直至扫描完所有的叶子节点页。

全表扫描的具体流程:

  1. 获取聚簇索引根节点所在页。
  2. 获取该页最小记录,也就是 Infimum 记录。
  3. 获取最小记录的下一条记录,该记录就是用户最小的记录,可以获取该记录指向的叶子节点页。
  4. 获取该叶子节点页的最小记录,也就是 Infimum 记录。
  5. 获取 Infimum 指向的下一条记录,该记录就是全表最小的记录。
  6. 依次扫描后续的记录就可以得到全表的记录。

范围查询:是指查找一个数据范围,例如 id > 100id < 100id > 10 AND id < 100,其核心在于先找到对应的边界值。

  • id > 100:需要先找到 id=100 的这条记录,其流程与等值查询基本一样。拿到这条记录后,继续扫描后续记录即可。
  • id < 100:需要先找到该表最小的记录,流程跟全表扫描时查找最小记录一样。拿到最小记录后继续扫描后续记录,每次扫描时都需要比较其 id 是否小于 100,如果不小于就停止扫描,说明已经到达边界值。
  • id > 10 AND id < 100:首先找到范围最小记录,即 id=10 的记录,流程与等值查询基本一样。找到这条记录后继续扫描后续记录,每次扫描都需要比较其 id 是否小于 100,如果不小于就停止扫描,说明已经到达边界值。

2. 更新数据

获取到具体的记录后开始更新操作。MySQL 中更新分为两种情况:

  • 乐观插入(in-place 方式):在原有的记录上修改。
  • 悲观插入:删除之前的记录,然后插入新的记录。

判断何时用乐观插入、何时用悲观插入的规则如下:以下两种情况使用悲观插入:

  • 判断更新的字段值的长度与原始长度是否相等,如果不相等则使用悲观插入。
  • 判断该字段是否为外部存储字段,如果是则使用悲观插入。

TIP

有些定长类型更新前后最终存储的长度都相等,这种情况直接使用乐观插入。

其他情况均使用乐观插入。继续以 id=10 的记录为例。

乐观插入(在原有记录上修改)的流程:

  1. 找到要修改的记录,即 id=10 的记录。
  2. 在修改之前记录回滚日志(详见 6.3 节)。
  3. 修改系统字段、事务 ID 和回滚段指针。
  4. 直接修改记录对应字段的值。修改后的行记录如图 6-14 所示,倒数第二个字段(对应表中的 pad 列)之前的值已经被改变,现在的值为 "zbdba"
  5. 修改之后写入 redo。

图6-14 修改后的行记录

悲观插入的流程(需要先删除数据,然后插入数据):

  1. 找到要修改的记录。
  2. 在修改之前记录回滚日志。
  3. 删除数据。数据删除流程如图 6-15 所示,id=10 这条记录首先会被移出链表,然后加入回收区,即 PAGE_FREE 指向的链表。

图6-15 数据删除流程图

  1. 插入数据。根据修改后的值新生成一条 id=10 的记录,并将这条记录插入链表中。数据插入流程如图 6-16 所示。

图6-16 数据插入流程

至此,一条数据在数据文件中的更新就完成了。这里只是简单介绍其主体流程,内部细节更为复杂,感兴趣的读者可以参考源码相关部分。

在本节中,我们深入探讨了数据在索引中的组织方式,以及数据文件中索引的结构,并详细阐述了数据更新的过程。先前我们提到,自行设计将大型数据表存储于文件时,采用 B+ 树结构来组织数据,但在此过程中会遇到多次读取文件的问题。MySQL 是如何进行优化的呢?实际上,MySQL 同样是利用 B+ 树来组织所有行记录的,但引入了页的概念,一个页可以包含多条记录。对于普通大小的表,在 MySQL 中的索引结构通常只有两层,因此定位一条数据通常只需读取两次数据文件,即提取两个页。在我们的设计中,数据读取是基于记录粒度进行的,而 MySQL 则采用基于页的粒度进行读取,这显著减少了读取次数。


6.2 重做日志文件

在 MySQL 数据库系统中,重做日志文件扮演着至关重要的角色,主要确保了数据操作的原子性和持久性。在事务提交之前,系统会先将重做日志写入磁盘,随后才能完成事务的提交。这一过程发生在事务提交的第一阶段,即 flush 阶段。系统参数 innodb_flush_log_at_trx_commit 默认值为 1,意味着每个事务在提交前都会执行日志的磁盘写入操作。当然,该参数的值可以根据需要进行调整。然而,调整后可能无法保证事务的原子性。因此,该参数的设置实际上是在原子性和系统性能之间进行权衡。在实际应用中,应根据具体的业务场景来决定参数的配置。如果没有特别的需求,建议保持默认设置。

在 MySQL 中,重做日志文件是分组的,每组默认包含两个日志文件,分别命名为 ib_logfile0ib_logfile1,这两个文件位于 MySQL 的数据目录下。在先前介绍表空间时已经提到,这两个重做日志文件实际上也是由表空间进行管理的。至于重做日志文件中具体记录了哪些内容,以及 MySQL 是如何利用这些日志文件的,本节将重点探讨。

6.2.1 总体架构

在 MySQL 启动过程中,系统会自动开启两个重做日志文件,这些文件对于系统崩溃后的恢复至关重要。随着 MySQL 的运行,所有的事务处理都会涉及重做日志,因此在整个数据库的生命周期中,对重做日志的处理操作极为频繁。那么,重做日志究竟包含哪些信息呢?重做日志的总体架构如图 6-17 所示。

图6-17 重做日志的总体架构

下面依次介绍各个部分。

1. 重做日志头

无论是重做日志头、检查点,还是具体的重做日志块,其大小均为 512B。MySQL 之所以采用这种设计,是为了确保每次写入操作均以 512B 为单位,而 512B 恰好对应于磁盘上一个扇区的容量。重做日志头字段的名称、偏移量及其说明如表 6-10 所示。

表 6-10 重做日志头字段的名称、偏移量及其说明

名称偏移量说明
LOG_HEADER_FORMAT0默认为 1,表示当前重做日志格式的版本。在之前的版本默认为 0,用于标示分组的 ID,默认只能支持一组。
LOG_HEADER_PAD14在当前版本未使用,在之前的版本用于填充空间,使重做日志头占满 512B。
LOG_HEADER_START_LSN8当前重做日志文件起始的 LSN。
LOG_HEADER_CREATOR16包含 ibbackup 字符串或者使用 mysqlbackup 创建出来的重做日志,这里存储日志创建的时间或者 MySQL 的版本。
LOG_HEADER_CREATOR_END48上述 LOG_HEADER_CREATOR 结束标志。
…空闲空间…共占用 460B。
checksum508存储校验值,占用 4B。

2. 检查点

接下来介绍两个检查点信息,对应图 6-17 中的 检查点 1检查点 2,每个均占用 512B。MySQL 中会定期执行检查点操作,其主要目的是记录当前已写入磁盘的日志序列号 LSN,从而标识出在此之前的所有日志记录均可被覆盖重写。检查点字段的名称、偏移量及说明如表 6-11 所示。

NOTE

虽然此处所指的检查点与 MySQL 中的检查点概念相同,但这里的 CHECKPOINT 是大写的,表示字段。之前提到的 checkpoint 是 MySQL 的一个动作,通常是小写的。

表 6-11 检查点字段的名称、偏移量及说明

名称偏移量说明
LOG_CHECKPOINT_NO0存储检查点编号
LOG_CHECKPOINT_LSN8存储 MySQL 执行检查点操作时的 LSN
LOG_CHECKPOINT_OFFSET16存储当前 LSN 在日志组的偏移位置
LOG_CHECKPOINT_LOG_BUF_SIZE24存储重做日志缓冲区的大小,默认为 16MB
checksum508存储校验值,占用 4B

实际上,两个检查点在字段上是相同的,但 MySQL 为何要维护两个呢?分析源码可知,当 LOG_CHECKPOINT_NO 为奇数时,写操作会指向检查点 2;而当 LOG_CHECKPOINT_NO 为偶数时,则写入检查点 1。这两个检查点通过偏移量来区分。例如,检查点 1 的起始位置设为 512B,检查点 2 的起始位置则为 1024B。

QUESTION

为什么 MySQL 采用两个检查点的设计?

若在检查点写入过程中发生崩溃,可能导致无检查点可用,因此两个检查点的设计提供一种冗余机制。此外,需要注意,检查点信息仅存储于 第一个 重做日志文件中。尽管第二个重做日志文件保留了检查点的位置信息,但并不写入具体数据。因此,在 MySQL 执行检查点操作时,仅会向第一个重做日志文件中的其中一个检查点写入相关信息。

3. 重做日志块

在检查点之后,便进入了实际存储日志的 重做日志块,每个块的大小为 512B。重做日志块主要由两部分组成:首先是重做日志块头,其次是重做日志块数据。重做日志块字段的名称、偏移量及说明如表 6-12 所示。

表 6-12 重做日志块字段的名称、偏移量及说明

名称偏移量说明
LOG_BLOCK_HDR_NO0存储重做日志块编号,在初始化重做日志块的时候,用当前的 LSN 计算出编号。
LOG_BLOCK_HDR_DATA_LEN4存储该重做日志块使用空间的大小。
LOG_BLOCK_FIRST_REC_GROUP6存储该重做日志块第一组日志记录开始的位置。
LOG_BLOCK_CHECKPOINT_NO8在重做日志块刷盘的时候存储 LSN,占用 4B。
log data12存储具体的重做日志,
checksum508存储校验值,占用 4B。

了解重做日志块头的存储结构之后,接下来需探究重做日志块数据部分的具体存储方式。在 MySQL 系统中,重做日志块数据包含多种类型的重做日志记录。这些记录类型如表 6-13 所示。

表 6-13 重做日志记录类型

名称编号说明
MLOG_1BYTE1存储 1B 的内容
MLOG_2BYTES2存储 2B 的内容
MLOG_4BYTES4存储 4B 的内容
MLOG_8BYTES8存储 8B 的内容
MLOG_REC_INSERT9存储插入记录相关信息
MLOG_REC_CLUST_DELETE_MARK10标记聚簇索引记录删除
MLOG_REC_SEC_DELETE_MARK11标记二级索引记录删除
MLOG_REC_UPDATE_IN_PLACE13原地更新
MLOG_REC_DELETE14删除记录
MLOG_LIST_END_DELETE15删除索引页记录链表中最后一个元素
MLOG_LIST_START_DELETE16删除索引页记录链表中第一个元素
MLOG_LIST_END_COPY_CREATED17复制页记录链表中最后一个元素到新创建的索引页中
MLOG_PAGE_REORGANIZE18ROW_FORMAT=REDUNDANT 格式重新组织索引页
MLOG_PAGE_CREATE19创建一个索引页
MLOG_UNDO_INSERT20存储插入回滚信息
MLOG_UNDO_ERASE_END21删除一个回滚页
MLOG_UNDO_INIT22初始化一个回滚页
MLOG_UNDO_HDR_DISCARD23丢弃一个更新回滚日志头
MLOG_UNDO_HDR_REUSE24重用一个插入回滚日志头
MLOG_UNDO_HDR_CREATE25创建一个回滚日志头
MLOG_REC_MIN_MARK26标记一个索引记录为预定义最小记录
MLOG_IBUF_BITMAP_INIT27初始化插入缓冲位图页
MLOG_INIT_FILE_PAGE29当前版本已经废弃,之前用于记录该文件页开始使用
MLOG_WRITE_STRING30存储一个字符串到页
MLOG_MULTI_REC_END31一个 MTR 事务包含多个重做日志记录,该类型为结束标志
MLOG_DUMMY_RECORD32填充记录,用于将重做日志块填充满
MLOG_FILE_CREATE33当前版本已被移除
MLOG_FILE_RENAME34当前版本已被移除
MLOG_FILE_DELETE35存储删除表空间文件操作相关内容
MLOG_COMP_REC_MIN_MARK36标记一个 compact 类型的索引记录为预定义最小记录
MLOG_COMP_PAGE_CREATE37创建一个 compact 类型的索引页
MLOG_COMP_REC_INSERT38插入一个 compact 类型记录
MLOG_COMP_REC_CLUST_DELETE_MARK39标记 compact 类型聚簇索引记录删除
MLOG_COMP_REC_SEC_DELETE_MARK40标记 compact 类型二级索引记录删除
MLOG_COMP_REC_UPDATE_IN_PLACE41compact 记录原地更新
MLOG_COMP_REC_DELETE42从索引页中删除一个 compact 类型的记录
MLOG_COMP_LIST_END_DELETE43删除索引页中 compact 类型记录链表中最后的记录
MLOG_COMP_LIST_START_DELETE44删除索引页中 compact 类型记录链表中开始的记录
MLOG_COMP_LIST_END_COPY_CREATED45复制 compact 类型记录链表到新创建的索引页
MLOG_COMP_PAGE_REORGANIZE46重新组织一个 compact 类型的索引页
MLOG_FILE_CREATE247记录创建一个 ibd 文件
MLOG_ZIP_WRITE_NODE_PTR48在一个压缩的非索引叶子节点页上写入一个记录的 node 指针
MLOG_ZIP_WRITE_BLOB_PTR49在一个压缩页上写入一个 blob 指针,指向外部存储列
MLOG_ZIP_WRITE_HEADER50写入压缩页头
MLOG_ZIP_PAGE_COMPRESS51压缩索引页
MLOG_ZIP_PAGE_COMPRESS_NO_DATA52压缩索引页并且不记录它的镜像
MLOG_ZIP_PAGE_REORGANIZE53重新组织一个压缩页
MLOG_FILE_RENAME254重命名一个表空间文件
MLOG_FILE_NAME55记录检查点后第一个使用的表空间文件
MLOG_CHECKPOINT56记录检查点后所有缓存的日志写入
MLOG_PAGE_CREATE_RTREE57创建一个 R-Tree 的索引页
MLOG_COMP_PAGE_CREATE_RTREE58创建一个 R-Tree 的 compact 类型索引页
MLOG_INIT_FILE_PAGE259文件页被使用,用于替换 MLOG_INIT_FILE_PAGE
MLOG_TRUNCATE60记录表正在被删除(truncated),只在 file-per-table 模式下才记录
MLOG_INDEX_LOAD61索引树被加载的时候独立页没有写重做日志

在 MySQL 中,不同的类型存储的数据和长度都不一样。在后面的章节中会根据相关内容详细介绍几个日志记录存储的内容。

6.2.2 更新操作的重做日志

在深入探讨重做日志记录的内容之前,先对重做日志记录的基本存储结构进行概述。重做日志记录主要由两大部分构成:重做日志记录头重做日志记录数据

重做日志记录头字段的名称、长度及说明如表 6-14 所示,默认所有的重做日志记录都包含这些信息。

表 6-14 重做日志记录头字段的名称、长度及说明

名称长度说明
日志类型1B存储重做日志记录类型
表空间 ID压缩存储,根据实际情况计算长度存储操作相关表空间的 ID
页编号压缩存储,根据实际情况计算长度存储操作相关页的编号

重做日志记录数据对于不同的重做日志记录,其内容有所不同。

接下来以如下 SQL 语句为例,看看执行它后会产生什么样的重做日志。

UPDATE sbtest1 SET pad = "zbdba" WHERE id = 10;

当我们执行完一条语句后,采用 db_recoveryhttps://github.com/zbdba/db-recovery)工具解析重做日志,看看里面都有哪些重做日志记录。

在此模拟的非原地更新机制中,sbtest1 表的 pad 字段被更改为 varchar 类型,且更新后的 pad 字段长度超过了更新前的长度。模拟非原地更新场景的目的是同时引入插入与删除操作。实际上,sbtest1 表中的 pad 字段原本是 char 类型,属于定长字段,其更新过程通常会采用原地方式进行。该 SQL 语句生成的重做日志记录如表 6-15 所示。

表 6-15 SQL 语句生成的重做日志记录

重做日志类型表空间 ID作用
MLOG_UNDO_HDR_CREATE0创建一个回滚日志头
MLOG_2BYTES0记录当前事务的回滚日志起始点,大小为 2B
MLOG_2BYTES0记录该回滚页空闲空间的起始点,大小为 2B
MLOG_2BYTES0记录该组回滚日志中第一个回滚日志的起始位置,大小为 2B
MLOG_MULTI_REC_ENDMTR 事务结束
重做日志类型表空间ID作用
MLOG_UNDO_INSERT0存储插入回滚信息
MLOG_COMP_REC_DELETE23从索引页中删除一个compact类型的记录
MLOG_COMP_REC_INSERT23插入一个compact类型记录
MLOG_FILE_NAME23记录检查点后第一个使用的表空间文件
MLOG_MULTI_REC_ENDMTR事务结束
MLOG_2BYTES0记录当前回滚日志的状态,大小为2B
MLOG_1BYTE0存储是否包含XA事务标志,大小为1B
MLOG_4BYTES0存储XA事务相关信息,大小为4B
MLOG_4BYTES0存储XA事务相关信息,大小为4B
MLOG_4BYTES0存储XA事务相关信息,大小为4B
MLOG_WRITE_STRING0将字符串写入页中,这里其实写的是事务ID信息
MLOG_2BYTES0记录当前回滚日志的状态,大小为2B,这里状态是可重用的状态
MLOG_4BYTES0将回滚日志加入到回滚日志历史链表中,修改链表回滚页中保存的相关节点地址
MLOG_2BYTES0将回滚日志加入到回滚日志历史链表中,修改链表回滚页中保存的相关节点地址
MLOG_4BYTES0将回滚日志加入到回滚日志历史链表中,修改链表回滚页中保存的相关节点地址
MLOG_2BYTES0将回滚日志加入到回滚日志历史链表中,修改链表回滚页中保存的相关节点地址
MLOG_4BYTES0将回滚日志加入到回滚日志历史链表中,修改链表回滚页中保存的相关节点地址
MLOG_2BYTES0将回滚日志加入到回滚日志历史链表中,修改链表回滚页中保存的相关节点地址
MLOG_4BYTES0将回滚日志加入到回滚日志历史链表中,修改链表回滚页中保存的相关节点地址
MLOG_2BYTES0将回滚日志加入到回滚日志历史链表中,修改链表回滚页中保存的相关节点地址
MLOG_4BYTES0更新回滚日志历史链表的长度
MLOG_8BYTES0将事务号写入到回滚页头中,大小为8B
MLOG_2BYTES0将记录是否标记删除记录到回滚页头中,大小为2B
MLOG_WRITE_STRING0将字符串写入页中,这里其实是将binlog位点信息写入事务页中
MLOG_MULTI_REC_ENDMTR事务结束

NOTE

上述表格有多项看起来是一样的,但实际的底层操作不一样,例如将回滚日志加入回滚日志历史链表中,修改链表回滚页中保存的相关节点地址。因为很多阶段都产生了不同类型的回滚日志,所以就对应了不同的重做日志记录。

在执行更新操作时,观察到生成了大量重做日志记录。这是由于在事务的生命周期内,每个阶段均会产生重做日志,其中大部分涉及对数据页(包括回滚页、索引页以及事务页)的修改。与数据操作密切相关的主要是三种类型的重做日志记录,即MLOG_UNDO_INSERTMLOG_COMP_REC_DELETEMLOG_COMP_REC_INSERT,接下来将详细阐述这三种重做日志记录所存储的信息。

MLOG_UNDO_INSERT 类型中主要记录是回滚日志。具体内容在6.3节中会详细介绍。

MLOG_COMP_REC_DELETE 中存储的是与从索引删除记录相关的内容,如表6-16所示。

MLOG_COMP_REC_INSERT 类型存储的是与向索引中插入一条记录相关的内容,如表6-17所示。

表6-16 MLOG_COMP_REC_DELETE 内部存储内容

名称长度/B
存储所有字段数量2
存储唯一键字段数量2
存储字段的长度2
存储字段的长度2
……2

表6-17 MLOG_COMP_REC_INSERT 内部存储内容

名称长度/B
存储所有字段数量2
存储唯一键字段数量2
存储字段的长度2
存储字段的长度2
……2
存储上一个记录在对应页中的偏移量2
写入记录结束段的长度长度压缩存储,具体存储字节根据实际情况而定
写入信息和状态位1
写入记录原始的偏移量长度压缩存储,具体存储字节根据实际情况而定
写入不匹配的索引1

从本节内容可见,重做日志文件所包含的信息相当复杂,且随着MySQL版本的更新,重做日志记录类型亦日益增多。尽管我们无须详细了解每种重做日志记录的具体内容,但必须理解它们的作用以及它们在事务处理流程中的角色。以本节所举的更新语句为例,重做日志主要记录了事务和回滚日志的相关信息。

与PostgreSQL等其他数据库系统中的重做日志不同,MySQL中的重做日志主要记录的是逻辑日志而非物理操作数据。这种设计的优势在于节省存储空间,并且减少了写入重做日志的数据量,从而提升写入性能。然而,这也带来了不便,例如MySQL并不记录每个重做日志记录的长度,这在开发db-recovery工具时给作者带来了极大的挑战,因为要完整解析重做日志文件,必须能够解析所有类型的重做日志记录。

尽管如此,逻辑日志的记录方式也有其不足之处,即记录的数据变更并不完整,因此单个事务提交后,重做日志本身并不具备实际意义,无法像全物理日志那样用作同步介质。因此,在主从同步方面,MySQL引入了binlog二进制文件,其详细内容将在第9章中进行介绍。


6.3 回滚日志文件

在6.2节中,我们已经提及了回滚日志的概念。回滚日志主要承担两项功能:首先,它为数据提供了多版本支持。MySQL的InnoDB存储引擎通过实现MVCC机制,利用回滚日志来构建数据的历史版本。当一个事务正在更新某行数据,而该数据在数据文件中已被修改但尚未提交时,后续的事务将只能访问到修改前的数据版本,这一过程正是通过回滚日志来实现的。其次,回滚日志为事务提供了回滚机制。在执行rollback命令或在MySQL发生异常崩溃并进行恢复时,如果事务未提交但其更改已写入数据文件,此时就需要借助回滚日志将数据恢复至先前的状态。

本节将深入探讨回滚日志的结构组成,并详细说明在事务执行过程中如何申请和记录回滚日志。

6.3.1 总体架构

为了深入理解回滚日志在MySQL中的运作机制,我们首先必须掌握回滚日志的结构组成。本小节将对回滚日志的结构组成进行详尽阐述。

先前我们提到,回滚日志可能存在于MySQL的系统表空间内。若已设置innodb_undo_tablespaces参数,则回滚日志会被存储到独立的回滚表空间中。此外,回滚日志亦可保存于临时表空间内。无论其存储位置如何,回滚日志实际上是以回滚页的形式保存在这些文件中的,这些回滚页在数据文件中被识别为FIL_PAGE_UNDO_LOG类型的页。因此,接下来我们将重点探讨回滚页的构成,其内部架构如图6-18所示。

图6-18 回滚页的内部架构(图示包含FIL_PAGE_OFFSET, FIL_PAGE_PREV, FIL_PAGE_NEXT, FIL_PAGE_SPACE_ID, FIL_PAGE_TYPE, TRX_UNDO_PAGE_TYPE, TRX_UNDO_STATE, TRX_UNDO_LAST_LOG, TRX_UNDO_FSEG_HEADER, TRX_UNDO_PAGE_LIST, TRX_UNDO_PAGE_START, TRX_UNDO_PAGE_FREE, TRX_UNDO_PAGE_NODE, 以及多个回滚日志、页尾校验数据等)

回滚页主要由以下5部分组成:

  • ❑ 页的头部
  • ❑ 回滚页头
  • ❑ 回滚段头
  • ❑ 回滚数据
  • ❑ 页尾

下面将详细介绍这5部分的内容。Fil Header和Fil Trailer在6.1.3节已经详述,这里就不再介绍。

首先看看回滚页头,它主要描述回滚页相关信息,例如回滚页的类型、回滚日志起始位置等。回滚页头字段名称、偏移量及说明如表6-18所示。

表6-18 回滚页头字段名称、偏移量及说明

名称偏移量说明
TRX_UNDO_PAGE_TYPE0回滚页的类型,可以是TRX_UNDO_INSERT或者TRX_UNDO_UPDATE。前者对应插入语句,后者对应更新和删除语句。这两种类型最终存储的回滚日志也不同
TRX_UNDO_PAGE_START2最近一个事务的回滚日志起始点
TRX_UNDO_PAGE_FREE4记录该回滚页空闲空间的起始点
TRX_UNDO_PAGE_NODE6主要存储上一个页和下一个页的地址,回滚段中所有的回滚页就是通过这样连接起来的

接下来是回滚段头,它只在回滚段的第一个页中存在#### 表6-19 回滚段头名称、偏移量及说明

名称偏移量说明
TRX_UNDO_STATE0该回滚段的状态,它可以是 TRX_UNDO_ACTIVE、TRX_UNDO_CACHED 等
TRX_UNDO_LAST_LOG2记录当前页最近一个回滚日志头的起始位置,如果没有就是 0
TRX_UNDO_FSEG_HEADER4记录文件段信息,用于分配 undo 页
TRX_UNDO_PAGE_LIST14保存一个链表,该链表的节点就是对应每一个回滚页头中的 TRX_UNDO_PAGE_NODE,该链表只在回滚段第一个回滚页中保存,这样从第一个回滚页就能快速遍历该回滚段中所有的回滚页

然后是回滚数据,里面存储的就是回滚日志。下面看看回滚日志组成的部分。回滚日志以一个事务为单位存储,一个事务可能包含多条回滚日志,所以 MySQL 中一个事务对应的回滚日志组成部分如下:

undo log header
undo log
undo log
......

每个事务对应一个回滚日志头和多个回滚日志,由这个回滚日志头来管理后面的多个回滚日志。回滚日志头的名称、偏移量及说明如表 6-20 所示。

表6-20 回滚日志头的名称、偏移量及说明

名称偏移量说明
TRX_UNDO_TRX_ID0存储事务 ID
TRX_UNDO_TRX_NO8存储事务编号,日志在回滚日志历史链表中才会定义
TRX_UNDO_DEL_MARKS16删除标记,只在类型为更新的回滚日志中存储,有删除标记就意味着后面要进行清理
TRX_UNDO_LOG_START18记录该组回滚日志中第一个回滚日志的起始位置
TRX_UNDO_XID_EXISTS20如果包含 XA 事务,就设置为 true
TRX_UNDO_DICT_TRANS21如果是创建表、索引或者删除就设置为 true,这些操作无法进行回滚
TRX_UNDO_TABLE_ID22存储对应的表 ID
TRX_UNDO_NEXT_LOG30存储下一个回滚日志头的起始位置
TRX_UNDO_PREV_LOG32存储上一个回滚日志头的起始位置
TRX_UNDO_HISTORY_NODE34如果回滚日志被加入到历史链表中,这里会存储相关信息,主要指向上一个和下一个回滚日志头

之前在介绍回滚页头的时候提到回滚页有两种类型,分别为 TRX_UNDO_INSERTTRX_UNDO_UPDATE。下面介绍这两种类型分别都存储了什么内容。

TRX_UNDO_INSERT 字段的名称、偏移量及说明如表 6-21 所示。

表6-21 TRX_UNDO_INSERT 字段的名称、偏移量及说明

名称偏移量说明
NEXT UNDO0存储下一个回滚日志的起始位置
UNDO LOG TYPE2存储回滚日志的类型,这里是 TRX_UNDO_INSERT_REC
UNDO NUMBER存储回滚日志编号
TABLE ID存储表 ID
UNIQ FILED OR PRIMARY FILED存储唯一键的字段长度和字段内容
PREV UNDO存储上一个回滚日志的起始位置

TRX_UNDO_UPDATE 字段的名称、偏移量及说明如表 6-22 所示。

表6-22 TRX_UNDO_UPDATE 字段的名称、偏移量及说明

名称偏移量说明
NEXT UNDO0存储下一个回滚日志的起始位置
UNDO LOG TYPE2存储回滚日志的类型,这里可以是 TRX_UNDO_UPD_EXIST_REC、TRX_UNDO_UPD_DEL_REC、TRX_UNDO_DEL_MARK_REC
UNDO NUMBER存储回滚日志编号
TABLE ID存储表 ID
INFO BITS存储更新或者删除记录对应的信息和状态位
TRX ID存储事务 ID
ROLL PTR存储更新这条记录的回滚指针
UNIQ FILED OR PRIMARY FILED存储唯一键的字段长度和字段内容
UPDATE FIELD LEN存储更新的列的数量
UPDATE FILED存储所有改变的字段的位置(该字段对应的列在行中属于第几列)、字段长度、字段内容
NEXT LEN存储下面记录的长度
DELETE OR UPDATE FILED记录为标记删除记录时或者更新记录会改变索引顺序时,存储这些字段的位置、字段长度、字段内容
PREV UNDO存储上一个回滚日志的起始位置

可以看到,这两种类型的区别主要在于存储的用户数据,TRX_UNDO_UPDATE 类型的回滚日志多存了一些改变前的字段的内容等。

6.3.2 回滚日志的管理

前面我们已经了解了回滚日志的存储机制。接下来,我们将探讨 MySQL 是如何对这些回滚日志进行管理的。在 MySQL 系统中,共配置了 128 个回滚段,其中包括 32 个临时回滚段和 96 个标准回滚段。每个回滚段进一步细分为 1024 个槽,每个槽对应一个回滚页。

MySQL 将这 128 个回滚段的元数据信息存储于系统事务页内,回滚段中所包含的回滚页则被分散存储于不同的数据文件中。例如,临时回滚段的回滚页存放在临时表空间的数据文件内,而标准回滚段的回滚页则存储于系统表空间的数据文件或回滚表空间的回滚数据文件中。回滚日志管理的总体架构如图 6-19 所示。

图6-19 回滚日志管理的总体架构(图示:系统表空间文件包含 FIL_PAGE_TYPE_FSP_HDR 和 FIL_PAGE_TYPE_TRX_SYS;事务页中有多个临时回滚段 slot 指向临时表空间文件中的回滚段头页;事务页中也有常规回滚段 slot 指向系统表空间或回滚表空间文件中的回滚段头页;每个回滚段头页下面跟有多个回滚页(undo page))

由于篇幅有限,图 6-19 中只展示了回滚日志相关的内容,例如系统表空间只展示了事务页(FIL_PAGE_TYPE_TRX_SYS),临时表空间或者回滚表空间也只是展示了回滚页。

首先我们来看在系统事务页中是如何保存 128 个回滚段信息的。

在事务页中共有 128 个槽,每个槽对应一个回滚段,在槽中记录对应回滚段头位于哪个表空间的哪个页下,由两个字段保存,保存回滚段的名称、偏移量及说明如表 6-23 所示。

表6-23 事务页中保存回滚段的名称、偏移量及说明

名称偏移量说明
TRX_SYS_RSEG_SPACE0存储回滚段头所在的表空间 ID
TRX_SYS_RSEG_PAGE_NO4存储回滚段所在表空间上的页编号

有了上述信息就可以定位到回滚段头的具体位置,从而读取出来。在 MySQL 初始化的时候会创建 128 个回滚段,创建回滚段的时候首先会创建一个段头页,这个页保存了回滚段头信息,回滚段头字段的名称、偏移量及说明如表 6-24 所示。

表6-24 回滚段头字段的名称、偏移量及说明

名称偏移量说明
TRX_RSEG_MAX_SIZE0回滚段最大的空间
TRX_RSEG_HISTORY_SIZE4回滚日志历史链表中回滚页的数量
TRX_RSEG_HISTORY8存储回滚日志历史链表上一个回滚段头和下一个回滚段头地址,组成节点被链接到回滚日志历史链表上
TRX_RSEG_FSEG_HEADER24指向文件段,文件段就是前面介绍的数据文件中的段,存储在 inode 页中,这里存储的也是该 inode 对应的表空间、页和偏移量信息。文件段在这里用来分配回滚页
TRX_RSEG_UNDO_SLOTS34这里共有 1024 个槽,每个槽占用 4B,用于存储对应的页编号

每创建一个回滚段就会创建一个回滚段头,创建回滚段头的时候就会申请一个页,这里称之为段头页,回滚段头就存储在段头页中。然后将段头页所在的表空间 ID 和段头页的页编号存储到系统事务页对应的槽中。

NOTE

这里只会将普通回滚段的回滚头存储到事务页中,临时回滚段不会存储,所以每次启动 MySQL 的时候就需要重新创建临时回滚段,而普通的回滚段只用在 MySQL 第一次初始化的时候创建。

介绍完 MySQL 是如何在文件中存储并且管理回滚段信息后,我们来看 MySQL 内存中是如何管理它的。

在 MySQL 启动的时候,会创建 128 个回滚段内存对象,用 trx_rseg_t 结构体表示。创建完 trx_rseg_t 对象后会将其加入 trx_sys_t 中的 rseg_array 数组,trx_sys_t 是 MySQL 集中管理的事务系统内存对象,可以在里面分配事务 ID 或者回滚段等。

回滚段内存对象 trx_rseg_t 字段的名称及说明如表 6-25 所示。

表6-25 trx_rseg_t 字段的名称及说明

名称说明
id回滚段 ID
mutex互斥锁,用来保护该结构体中除 id、space、page_no 之外的常量字段
space回滚段头存储的对应表空间的 ID
page_no页编号
page_size对应表空间的页大小
max_size页中允许的最大空间
curr_size页中当前的大小
update_undo_list更新回滚日志链表
update_undo_cached缓存的更新回滚日志链表,用于下次重新使用
insert_undo_list插入回滚日志链表
insert_undo_cached缓冲的插入回滚日志链表,用于插入重新使用
last_page_no回滚日志历史链表中最近一个没有被清理的回滚页编号
last_offset回滚日志历史链表中最近一个没有被清理的回滚日志头
last_trx_no回滚日志历史链表中最近一个没有被清理的事务号
last_del_marks设置为 true 表示回滚日志历史链表中最近一个回滚日志需要清理
trx_ref_count记录多少个事务使用过该回滚段
skip_allocation如果设置为 true,在分配回滚段的时候会跳过该回滚段

鉴于篇幅所限,这里不赘述 trx_sys_t 结构体的详细信息,仅需了解该结构体内部包含了一个回滚段数组即可。

现在我们了解了回滚日志文件的内部结构及其管理机制,在执行更新操作时,回滚日志是如何进行分配的?它又具体记录了哪些数据?接下来,我们以一条具体的更新语句为例来解答这两个问题:

update sbtest1 set pad="zbdba" where id = 10;

执行这条语句后,MySQL 会分配一个回滚段,也就是前面提到的从 trx_sys_t 结构中保存的回滚段数组中分配,这里的分配机制是轮询分配

在完成回滚段的分配之后,接下来的步骤是构建回滚日志内存对象。在此过程中,需要区分是更新回滚日志还是插入回滚日志类型。构建回滚日志内存对象时,将检查该回滚段是否含有可重用的更新回滚或插入回滚日志。具体而言,将从回滚段内存对象的 update_undo_cachedinsert_undo_cached 链表中搜寻是否存在符合条件的、可供重复使用的回滚日志内存对象。若存在,则可直接进行重用;若无可用对象,则需创建一个新的回滚日志内存对象。

创建完回滚日志内存对象后就会分配回滚页,并将分配的回滚页编号存储到对应的槽中(之前提到回滚段管理着 1024 个槽,每一个槽对应一个回滚页)。

有了回滚页后,MySQL 就可以写回滚日志了。一个回滚段有多个回滚页,这些回滚页组成一个双向链表,并且在第一个回滚页的回滚日志段中保存了该链表的信息。写满当前回滚页后会继续分配新的回滚页使用。

6.3.3 更新操作的回滚日志

了解了如何分配回滚页后,下面来介绍更新操作,继续以一条语句的执行为例:

update sbtest1 set pad="zbdba" where id = 10

更新语句回滚日志的详细信息如表 6-26 所示。

表6-26 更新语句回滚日志的详细信息

名称
NEXT UNDO2257
UNDO LOG TYPETRX_UNDO_UPD_EXIST_REC
UNDO NUMBER0
TABLE ID37
INFO BITS0
TRX ID19732
ROLL PTR
UNIQ FILED OR PRIMARY FILEDlen:4 value:10
UPDATE FILED LEN1
UPDATE FILEDPos:5 len:60 value:97046275580-16268360531-72945875826-47298238930-80480867635
NEXT LEN
DELETE OR UPDATE FILED
PREV UNDO2170

观察可知,回滚日志实际上并不记录更新行的所有字段,但这并不妨碍其作为历史版本的功能。MySQL 采取此策略是为了尽可能减少存储的数据量。由于回滚日志会被持久化至重做日志中,减少数据量不仅能提升性能,还能缩减存储空间。

NOTE

在使用 MySQL 时,频繁更新的表上执行长时间事务可能会导致回滚日志积累过多。若回滚日志存储于系统数据文件中,可能导致系统文件体积过大,且事后难以缩减。然而,MySQL 通过引入回滚表空间机制,已经解决了这一问题。

6.4 总结

本章详尽阐述了数据文件、重做日志文件以及回滚日志文件的功能与作用。在一次数据更新操作中,我们可以观察到不同文件所扮演的不同角色。用户数据的修改首先在数据文件中进行,而在这些修改被写入数据文件之前,相关的更新操作会被记录在重做日志和回滚日志文件中。此外,回滚日志的内容也会被持久化到重做日志文件中。重做日志的记录旨在确保操作的原子性和持久性,而回滚日志的记录则为实现多版本并发控制和回滚功能提供了支持。基于第 5 章所述的组件以及本章介绍的文件,MySQL InnoDB 提供了一个完整的事务存储引擎,能够满足大多数 OLTP 应用的需求。