MySql InnoDB页结构
一、什么是页
页是 InnoDB 中磁盘管理的最小单位,所有对数据的读写操作,最终都是基于页来进行的。InnoDB每次从磁盘中读取数据到内存都是以也为单位进行。
- 默认大小:16KB。这是在 MySQL 编译或初始化时确定的,通常不建议修改。
- 作用:它是数据在磁盘和内存(Buffer Pool)之间传输的基本单元。也就是说,从磁盘读取数据到内存,或者从内存刷新数据到磁盘,都是以整个页为单位来操作的。
二、为什么需要页
如果没有页这个概念,数据库每次都需要读写精确的字节,这将导致极低的 I/O 效率。通过将数据组织成固定大小的页:
- 提高 I/O 效率:磁盘的顺序读写远快于随机读写。一次读写 16KB 的数据比多次读写几个字节要高效得多。
- 简化内存管理:InnoDB 的主要内存结构缓冲池(Buffer Pool) 就是由一个个 16KB 的页帧(Frame)组成的,每个帧正好可以存放一个从磁盘读上来的页。这使得内存管理变得非常简单和统一。
- 组织数据:页是更高层级数据结构(如索引、表空间)的构建块。
三、页的内部结构
一个 16KB 的页并不是全部用来存放用户数据的,它被划分成多个部分,各有其职。其通用结构如下图所示:
1.页头
File Header
是 InnoDB 数据页中一个固定长度的部分,它位于每个页面的最开始处。它的总长度为 38 个字节,包含了一些用于管理页面的通用信息。这些信息对于 InnoDB 引擎的崩溃恢复、数据一致性、缓冲池管理以及 B+ 树索引结构的维护都至关重要。
正是因为每个页都有这个标准的 File Header
,InnoDB 才能高效地将不同类型的页(如数据页、索引页、Undo 页、系统页等)统一管理。
File Header
由 8 个字段组成,其具体结构如下表所示:
名称 | 长度(字节) | 描述(作用) |
---|---|---|
校验和 FIL_PAGE_SPACE_OR_CHKSUM |
4 | 页的校验和(Checksum)。 1. 数据一致性验证:当页从磁盘读入内存时,InnoDB 会重新计算该页的校验和并与这个字段存储的值进行比较。如果不一致,说明页面可能在磁盘上已经损坏(例如由硬件故障引起)。 2. 在双写缓冲区(Doublewrite Buffer)机制中也起到重要作用,确保写入的完整性。 |
页号 FIL_PAGE_OFFSET |
4 | 页号(Page Number)。 这是该页在表空间内的唯一编号(类似于地址)。我们知道表空间被逻辑划分为连续分布的页,这个偏移量就是该页的位置。例如,第 0 页、第 1 页… 直到第 N 页。 |
上页页号 FIL_PAGE_PREV |
4 | 上一个页的页号。 这并非所有页都有。它主要用于 B+ 树索引结构中。对于叶子节点(Leaf Pages),它们通过 FIL_PAGE_PREV 和 FIL_PAGE_NEXT 组成了一个双向链表,以便进行高效的范围查询和全表扫描。对于非叶子节点,此字段通常为 0。 |
下页页号 FIL_PAGE_NEXT |
4 | 下一个页的页号。 同上,与 FIL_PAGE_PREV 配对使用,构成叶子节点间的双向链表。 |
日志序列号 FIL_PAGE_LSN |
8 | 日志序列号(Log Sequence Number)。 这是极其重要的一个字段。它记录了该页最后一次被修改所对应的重做日志(Redo Log)的 LSN。作用: 1. 崩溃恢复(Crash Recovery):在恢复过程中,InnoDB 会比较磁盘上页的 LSN 和重做日志中的 LSN。如果日志的 LSN 大于页的 LSN,说明这个页的修改还没有持久化到磁盘,需要应用重做日志进行恢复。 2. 检查点(Checkpoint):帮助确定哪些脏页(修改过但未写回磁盘的页)需要被刷新到磁盘。 |
页类型 FIL_PAGE_TYPE |
2 | 页的类型。 明确标识了该页的用途。常见的类型有: - 0x45BF :FIL_PAGE_INDEX,即B+树叶节点或数据页,这是最常见的类型。 - 0x0002 :FIL_PAGE_UNDO_LOG,Undo 日志页。 - 0x0003 :FIL_PAGE_INODE,索引节点页,存储段(Segment)的信息。 - 0x0004 :FIL_PAGE_IBUF_FREE_LIST,Insert Buffer 空闲列表页。 - 0x0005 :FIL_PAGE_IBUF_BITMAP,Insert Buffer 位图页。 - 0x0006 :FIL_PAGE_TYPE_SYS,系统页。 - 0x0007 :FIL_PAGE_TYPE_TRX_SYS,事务系统数据页。 - … 等等 |
文件刷新LSN FIL_PAGE_FILE_FLUSH_LSN |
8 | 文件刷新LSN。 这个字段仅在表空间的第 0 页(系统页)中有意义,在其他所有页中通常为 0。它记录了表空间被刷新到磁盘的全局 LSN,用于确保整个表空间的一致性。 |
表空间ID FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID |
4 | 该页所属的表空间ID(Space ID)。 每个独立的表空间(包括系统表空间和每个独立表空间 .ibd 文件)都有一个唯一的 Space ID。这个字段指明了当前页属于哪个表空间。 |
2.数据页头
Page Header 是紧跟在 File Header 之后的一个结构体,它专门用于存储数据页(类型为 FIL_PAGE_INDEX
)的状态信息。注意:不是所有类型的页都有 Page Header,它是数据页特有的。
- 位置:
File Header
(38 bytes) 之后。 - 大小:固定为 56 个字节。
- 作用:它记录了关于本页中存储的记录、目录槽、空闲空间等详细信息,是数据页的管理和控制中心。
3.数据行 & 最小行最大行
数据行并不是简单地以“列1值|列2值”的形式直接存储的。为了管理效率、支持可变长度列、处理NULL值、实现行级锁和事务等,InnoDB 为每一行数据添加了一个复杂的行格式(Row Format) 头信息。目前,InnoDB 主要支持四种行格式:
- REDUNDANT (冗余):较老的格式,已经淘汰,为了向后兼容。
- COMPACT (紧凑):MySQL 5.6 的默认格式,比 REDUNDANT 更节省空间。
- DYNAMIC (动态):MySQL 5.7 及 8.0 的默认格式。这是目前最常用和推荐的格式。
- COMPRESSED (压缩):在 DYNAMIC 基础上增加了压缩功能。
虽然它们有差异,但核心思想相似。我们以当前默认的 DYNAMIC 格式为例进行详细拆解。
一条完整的记录(数据行)主要包含两大部分:
- 记录头信息(Record Header):固定5字节,用于描述和管理记录本身的元数据。
- 记录的实际数据(Actual Data):包括所有列的值,以及一些必要的系统列。
PS:我看有的资料按额外信息+真实数据分类。应该是把可变长度列和Null值列归类到了额外信息,如下图:
(1)头信息
记录头是固定 5 字节,包含以下信息:
名称 | 长度 | 描述(作用) |
---|---|---|
预留位 1 | 1 bit | 未使用。 |
预留位 2 | 1 bit | 未使用。 |
deleted_flag |
1 bit | 删除标记。 极其重要。当一行被 DELETE 语句删除时,InnoDB 并不会立即从磁盘上物理删除它(因为要支持 MVCC)。而是仅仅将这个位设置为 1,标记该行为“已删除”。这些被标记的行形成了一个“垃圾链表”,后续由 Purge 线程在合适的时候进行真正的空间回收。 |
min_rec_flag |
1 bit | 最小记录标记。 标记该记录是否为 B+ 树非叶子节点层级中的最小记录。这是一个 B+ 树内部使用的标识。 |
n_owned |
4 bits | 拥有的记录数。 非常重要。在页目录(Page Directory) 结构中,每个槽(Slot) 会“拥有”几条记录,该槽所对应的最后一条记录的 n_owned 值就是它拥有的记录数量。其他记录的 n_owned 值为 0。这是一种稀疏目录结构,用于在页内快速二分查找。 |
heap_no |
13 bits | 在堆中的序号。 表示当前记录在本页中的位置编号。InnoDB 将页中的所有记录视为一个堆(Heap),并自动分配一个序号。注意:0 和 1 这两个 heap_no 值被预留给Infimum 和 Supremum 伪记录(后面会解释)。用户记录从 2 开始编号。 |
record_type |
3 bits | 记录的类型。 共有 4 种类型: - 0 :普通记录,即用户插入的数据行。 - 1 :B+ 树非叶子节点(索引节点)的记录。 - 2 :Infimum 伪记录。 - 3 :Supremum 伪记录。 Infimum 和 Supremum 是系统自动添加的两条虚拟记录,Infimum 代表比本页中任何主键值都小的值,Supremum 代表比任何值都大的值。它们构成了页内记录的边界。 |
next_record |
16 bits | 下一条记录的相对位置。 极其重要。这是一个2字节的指针,但它存储的不是绝对地址,而是从当前记录的真实数据开始位置到下一条记录的真实数据开始位置的偏移量。注意:这些记录通过 next_record 组成了一个单向链表,并且是按主键顺序排列的。deleted_flag =1 的记录也会从这个链表中移除。 |
(2)实际数据
头信息之后,就是各列的数据。它包含两部分:
a) 三个隐藏的系统列
除了用户定义的列,InnoDB 会为每行数据自动添加三个隐藏列:
列名 | 是否必须 | 长度 | 描述(作用) |
---|---|---|---|
DB_ROW_ID (行ID) |
否 | 6 字节 | 如果表没有定义主键,且没有定义唯一的非空索引,InnoDB 会自动使用这个隐藏列作为主键。 |
DB_TRX_ID (事务ID) |
是 | 6 字节 | 极其重要。记录创建这行记录(或最后一次修改它)的事务的ID。这是实现 MVCC (多版本并发控制) 的核心。 |
DB_ROLL_PTR (回滚指针) |
是 | 7 字节 | 极其重要。指向该行记录上一个版本的 Undo Log 记录的指针。通过这个指针,可以构建出该行在事务开始时的“旧版本”数据,从而实现非锁定读(快照读) 和回滚操作。 |
注意:即使有用户自定义的主键,DB_TRX_ID
和 DB_ROLL_PTR
这两个列也一定会存在。
b) 用户定义的列
接下来就是用户表中定义的各个列的数据。它们的存储方式有讲究:
- NULL 值处理:如果列允许为 NULL,在记录头之后、实际数据之前,会有一个 NULL 值列表(NULL bitmap) 来标记哪些列的值是 NULL,从而避免为 NULL 值分配存储空间。这是一个节省空间的关键设计。
- 可变长度列(如 VARCHAR, TEXT, BLOB):
- 在 DYNAMIC 格式下:如果行数据非常大,导致一个页存不下,InnoDB 会只在该页存储一个 20 字节的指针,指向存储了实际数据的溢出页(Off-Page)。这就是“动态”的含义。
- 在 COMPACT 格式下:会优先尝试将前 768 字节存储在数据页中,多余的才放入溢出页。
- 固定长度列(如 CHAR, INT, DATE):直接按定义的长度存储。
(3)行数据链表
行数据通过**next_record
维护成一个单向链表。链表头是最小行,heap_no
固定为0,record_type
为2,next_record
记录第一条用户数据的偏移量,数据区储存字符串Infimum。链表的尾是最大行,heap_no
固定为1,record_type
为3,next_record
**记录0,数据区储存字符串Supremum。
4.页目录
一个数据页大小16KB,可以存放很多条记录(比如上百条)。如果我们要根据主键查找某条记录,难道需要从页内的第一条记录开始,沿着单向链表一条一条往下找吗?(这种称为全页扫描)。这显然效率太低。
页目录的解决思路和我们看书时的目录一模一样:先通过目录快速定位到大概的章节(槽),然后再在这个章节里进行少量搜索,从而找到具体的内容(记录)。
(1)槽(Slot)是什么?
每个槽本质上是一个指针,它指向页内的某一条记录。
但槽指向的记录不是随意的,而是有精心设计的规则。
(2)槽的划分规则(核心)
InnoDB 对槽的管理遵循以下规则:
- 初始状态:当一个新页被创建时,页目录默认包含两个槽:
- Slot 0:指向 Infimum 伪记录(代表最小记录)。
- Slot 1:指向 Supremum 伪记录(代表最大记录)。
此时,页内只有两条系统记录。
- 插入记录与槽的分配:
- 当有新的用户记录插入时,它们会按主键顺序被插入到 Infimum 和 Supremum 之间的正确位置,并组成单向链表。
- InnoDB 有一个约定:一个槽最多可以“拥有” 4 到 8 条记录(包括它自己指向的那条)。
- 当记录不断插入,某个槽拥有的记录数超过 8 时,页目录就会新增一个槽来进行分担。
n_owned
字段的作用:- 每个分组的最后一条数据行的头信息中,**
n_owned
**记录了该槽中的数据数量。
- 每个分组的最后一条数据行的头信息中,**
(3)查找过程:二分查找
当我们根据主键值在页内查找一条记录时,过程如下:
- 定位槽:InnoDB 对页目录的槽数组进行二分查找。
- 比较目标是:要查找的主键值 和 每个槽所指向的记录的主键值。
- 二分查找会快速确定记录所在的最小边界槽。例如,通过比较,发现目标主键值大于 Slot N 指向的记录的主键,但小于 Slot N+1 指向的记录的主键。那么目标记录肯定在 Slot N 所“拥有”的记录范围内。
- 在组内线性查找:找到正确的槽(比如 Slot N)后,再通过
next_record
指针,在 Slot N 所拥有的这 4-8 条记录中进行线性遍历,很快就能找到目标记录(或者确认该记录不存在)。
5.页尾
File Trailer
位于每个 InnoDB 页的最后 8 个字节。它的主要作用是用于校验页数据的完整性和一致性,确保在从内存(缓冲池)刷新(Flush)到磁盘的过程中,页面没有因为硬件故障、操作系统问题或意外的宕机而发生损坏。
File Trailer
仅由两个字段组成:
字段名 | 大小 (字节) | 描述(作用) |
---|---|---|
页的校验和Checksum |
4 | 页的校验和。 这个值应该与页头部(File Header 中的 FIL_PAGE_SPACE_OR_CHKSUM 字段)存储的校验和完全一致。 |
日志序列号LSN |
4 | 日志序列号的后4字节。 这个值应该与页头部(File Header 中的 FIL_PAGE_LSN 字段)的后4字节完全一致。 |