InnoDB 存储结构:表空间、页、区、段、行格式与溢出页(111)

MySQL与关系型数据库约 22 分钟阅读12,723 字符
知识库文档 112 / 251

阅读时间:约 45 分钟 | 难度:中高级 | 编号:111


一、引言:为什么需要理解 InnoDB 的存储结构?

很多开发者对 MySQL 的认知停留在"建表、写 SQL、加索引"的层面。但当遇到以下问题时,对 InnoDB 存储结构的理解就变得至关重要:

  • 为什么一张表只能存 64TB 数据?
  • 为什么 VARCHAR(65535) 实际存不了那么多字符?
  • 为什么大字段(TEXT/BLOB)会导致性能下降?
  • 为什么行溢出了,查询就变慢了?
  • 为什么 COUNT(*) 在大表上这么慢?
  • 为什么 B+Tree 索引最多 4 层就能存几十亿行数据?

这些问题都指向同一个答案:InnoDB 的存储结构决定了数据如何被组织、存储和访问。理解存储结构,才能真正理解 InnoDB 的性能特征和设计约束。

本文将从物理存储的最小单元"页"出发,逐层向上构建完整的 InnoDB 存储体系:页 → 区 → 段 → 表空间。

111_fig1.png
111_fig1.png


二、InnoDB 存储结构全景

InnoDB 的存储结构是一个严格的层次体系:

code
表空间 (Tablespace)
  └── 段 (Segment)
        └── 区 (Extent) = 64 个连续的页
              └── 页 (Page) = 16KB(默认)
                    └── 行 (Row)

下面这张图展示了从表空间到数据行的完整层次关系:

层级 名称 大小 说明
1 表空间 (Tablespace) 最大 64TB 逻辑存储容器,对应一个 ibd 文件
2 段 (Segment) 由区组成 按功能分类:数据段、索引段、回滚段等
3 区 (Extent) 1MB (64 个页) 空间分配的基本单位
4 页 (Page) 16KB(默认) I/O 操作的最小单位
5 行 (Row) 可变 实际存储的数据记录

核心设计原则:

  • 页是磁盘 I/O 的最小单位:每次从磁盘读取或写入数据,至少操作一个完整的页(16KB)。这就解释了为什么即使只查询一行数据,InnoDB 也会把整个 16KB 的页加载到内存。
  • 区是空间分配的基本单位:当表需要扩展时,InnoDB 不是按页分配,而是按区(1MB/64 页)分配。这保证了数据在磁盘上的物理连续性,有利于顺序 I/O。
  • 段是逻辑概念:段不是物理连续的,而是由多个区组成的逻辑集合。不同功能的段(数据段、索引段)在物理上可能交错分布。

111_fig2.png
111_fig2.png


三、页(Page):InnoDB 存储的最小 I/O 单元

3.1 页的基本结构

InnoDB 的页(Page)是磁盘和内存之间交互的最小单位。每个页默认 16KB,由以下 7 个部分组成:

部分 大小 说明
File Header 38 字节 页的通用信息:页号、页类型、所属表空间、前后页指针
Page Header 56 字节 页的专有信息:记录数、空闲空间、槽位数量等
Infimum + Supremum 26 字节 虚拟的最小记录和最大记录,用于记录之间的逻辑排序
User Records 可变 实际存储的用户数据行
Free Space 可变 页中尚未使用的空间
Page Directory 可变 页内记录的槽位(Slot)数组,用于页内二分查找
File Trailer 8 字节 校验和,用于保证页完整性

关键设计:

(1)File Header 中的 FIL_PAGE_PREV 和 FIL_PAGE_NEXT

这两个字段将同一索引级别的所有页串联成一个双向链表。这是 B+Tree 索引中叶子节点之间可以快速进行范围扫描的基础——从一个叶子页可以顺着链表直接跳到下一个叶子页,不需要回到非叶子节点。

(2)Page Directory 的槽位(Slot)机制

InnoDB 在页内实现了类似"跳表"的结构。Page Directory 将页内的记录按顺序分组,每组包含 4~8 条记录,每个槽位指向组内最大记录。页内查找时,先通过二分查找定位到正确的槽位,然后在槽位对应的小组内顺序查找。

这种设计使得在 16KB 的页内查找一条记录只需要 O(log n) 的时间,而不是 O(n)。

(3)Infimum 和 Supremum

这是两条虚拟记录,分别代表页内的最小值和最大值。所有实际记录都按主键顺序插入到 Infimum 和 Supremum 之间。它们为页内记录提供了逻辑边界,简化了插入和删除操作的处理。

3.2 页的类型

InnoDB 中有多种页类型,每种服务于不同的目的:

页类型 十六进制 说明
FIL_PAGE_INDEX 0x45BF B+Tree 索引页(最常见)
FIL_PAGE_UNDO_LOG 0x0002 Undo 日志页
FIL_PAGE_INODE 0x0003 段信息页(存储段的 inode 信息)
FIL_PAGE_IBUF_FREE_LIST 0x0004 Insert Buffer 空闲列表页
FIL_PAGE_TYPE_ALLOCATED 0x0000 已分配但未使用的页
FIL_PAGE_TYPE_BLOB 0x0009 溢出页(存储大字段溢出数据)
FIL_PAGE_TYPE_TRX_SYS 0x0007 事务系统数据页
FIL_PAGE_TYPE_FSP_HDR 0x0008 表空间头部页(第一个页)
FIL_PAGE_TYPE_XDES 0x000A 区描述符页
FIL_PAGE_TYPE_RTREE 0x000B R-Tree 索引页(空间索引)

其中最需要关注的是 FIL_PAGE_INDEX(索引页),它是 B+Tree 索引的物理载体,占了数据库中绝大部分的页。

111_fig3.png
111_fig3.png


四、区(Extent):空间分配与物理连续性

4.1 为什么需要区?

如果按页为单位分配空间,一个 1GB 的表可能分散在磁盘的各个角落,导致严重的随机 I/O。InnoDB 通过"区"来解决这个问题:

  • 一个区 = 64 个连续的页 = 1MB
  • 每次分配空间时,至少分配一个区
  • 64 个页在磁盘上物理连续,可以顺序读写

这种设计使得全表扫描和范围查询时,InnoDB 可以一次性读取连续的 1MB 数据,而不是零散地读取 64 次 16KB 的页。

4.2 区的分配策略

InnoDB 对区的分配有一个巧妙的优化——碎片区(Fragment Extent):

  • 表初期:数据量小时,如果每次都分配 1MB 的区,会造成大量空间浪费。InnoDB 使用碎片区,从碎片区中按页分配,每次只分配 32 个页(8 个页一组,共 4 组)。
  • 表增长期:当表的数据量超过一定阈值后,InnoDB 切换为按区分配,每次分配完整的 1MB 区。

碎片区 → 完整区的切换时机:当某个段(Segment)已经使用了 32 个碎片页后,后续的空间分配就改为按完整区分配。

4.3 区的管理——XDES Entry

每个区都有一个对应的 XDES(Extent Descriptor)Entry,记录区的元数据:

字段 说明
Segment ID 该区属于哪个段
State 区的状态:空闲、碎片区中的空闲页、属于某个段的完整区
Page State Bitmap 64 位位图,标记区中每个页是否空闲

所有的 XDES Entry 存储在 XDES 页(FIL_PAGE_TYPE_XDES)中,每个 XDES 页可以存储 256 个 XDES Entry。

111_fig4.png
111_fig4.png


五、段(Segment):逻辑分组

5.1 段的概念

段是逻辑概念,不是物理连续的存储区域。段由多个区组成,这些区在物理上可能分散在表空间的不同位置。

InnoDB 中,每个索引对应两个段:

段类型 说明 存储内容
叶子节点段(Leaf Segment) 存储 B+Tree 叶子节点 实际数据行(聚簇索引)或索引键值+主键(二级索引)
非叶子节点段(Non-Leaf Segment) 存储 B+Tree 非叶子节点 索引键值+子页指针

为什么分开? 叶子节点和非叶子节点在物理上分离,有利于:

  • 减少磁盘寻道:叶子节点通常比非叶子节点大得多,分开存储可以让叶子节点在物理上更连续
  • 区分缓存策略:叶子节点访问频率远高于非叶子节点,可以更有针对性地配置缓存

5.2 段的 INODE 管理

每个段都有一个 INODE(Index Node)Entry,记录段的元数据:

字段 说明
FSEG_ID 段的唯一标识
FSEG_NOT_FULL_N_USED 不完全满的区列表(用于后续插入)
FSEG_FREE 完全空闲的区列表
FSEG_FULL 已满的区列表
FSEG_FRAG_ARR 碎片页数组(32 个碎片页)

三种区列表的设计意图:

  • FREE:已分配给段但尚未使用的区,优先从这里分配新页
  • NOT_FULL:部分使用的区,插入新记录时优先使用这些区中空闲的页
  • FULL:已满的区,不再接受插入,除非删除记录释放空间

这种分类管理使得 InnoDB 能够高效地找到可用的空闲页,而不需要扫描所有页。

5.3 一个表有多少个段?

以最简单的表为例:

sql
CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    INDEX idx_name (name)
);

这个表有以下段:

段 用途
数据段(叶子节点段) 聚簇索引的叶子节点,存储实际数据行
数据段(非叶子节点段) 聚簇索引的非叶子节点
idx_name 索引段(叶子节点段) 二级索引 idx_name 的叶子节点
idx_name 索引段(非叶子节点段) 二级索引 idx_name 的非叶子节点
回滚段(Rollback Segment) 存储 Undo 日志

总共 5 个逻辑段,加上系统表空间中的段(如 Insert Buffer 段、Double Write 段等),一个表涉及的实际段数量更多。

111_fig5.png
111_fig5.png


六、行格式(Row Format):一行数据如何存储

6.1 四种行格式概览

InnoDB 提供了四种行格式,每种有不同的存储策略:

行格式 引入版本 特点
REDUNDANT MySQL 4.1 之前 最古老,兼容性最好,已基本废弃
COMPACT MySQL 5.0 紧凑存储,节省空间
DYNAMIC MySQL 5.7 默认 类似 COMPACT,但对大字段(TEXT/BLOB)的溢出处理更灵活
COMPRESSED MySQL 5.7 在 DYNAMIC 基础上支持页级压缩

6.2 COMPACT 行格式详解

COMPACT 行格式由两部分组成:

(1)记录的额外信息

字段 大小 说明
变长字段长度列表 可变 记录每个 VARCHAR 等变长字段的实际长度(逆序存储)
NULL 值列表 1 字节/8 个可为 NULL 的列 位图标记哪些列是 NULL
记录头信息 5 字节 包含删除标记、记录类型、下一记录偏移等

(2)记录的真实数据

字段 说明
Row ID(DB_ROW_ID) 6 字节,当没有主键时自动生成
Transaction ID(DB_TRX_ID) 6 字节,最近修改该行的事务 ID(MVCC 的关键)
Roll Pointer(DB_ROLL_PTR) 7 字节,指向 Undo 日志中该行的上一个版本
用户数据列 实际的列值

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

这是一个精妙的设计。记录头信息和变长字段长度列表在记录的两端,向中间靠拢。逆序存储变长字段长度,使得指针可以从记录尾部开始向前读取列长度,与从记录头部开始读取列数据的方向一致,可以更高效地定位到某一列的位置。

code
┌──────────────────────────────────────────────────┐
│ 变长字段长度列表(逆序) │ NULL位图 │ 记录头 │ 列1 │ 列2 │ ... │
└──────────────────────────────────────────────────┘
         ← 读取方向 ←              → 读取方向 →

6.3 DYNAMIC 行格式

DYNAMIC 与 COMPACT 的结构几乎相同,唯一的区别在于对大字段溢出的处理:

  • COMPACT:当变长字段(VARCHAR、TEXT、BLOB)超过 768 字节时,只在行内存储前 768 字节 + 20 字节的溢出页指针,其余数据存储在溢出页中。
  • DYNAMIC:当变长字段超过 40 字节时,行内只存储 20 字节的溢出页指针,全部数据存储在溢出页中。

DYNAMIC 的优势:行内不存储大字段的实际数据,使得每个页可以容纳更多行,减少 I/O 次数,提高缓存效率。对于 TEXT/BLOB 字段较多的表,建议使用 DYNAMIC 格式。

6.4 COMPRESSED 行格式

COMPRESSED 在 DYNAMIC 的基础上,对整个页进行压缩(使用 zlib 算法)。压缩后,页的大小可以是 1KB、2KB、4KB 或 8KB(通过 KEY_BLOCK_SIZE 参数指定)。

注意:COMPRESSED 压缩的是整个页,而不是单行。压缩后的页在内存的 Buffer Pool 中同时存在压缩版本和未压缩版本,以平衡 CPU 解压开销和内存节省。

111_fig6.png
111_fig6.png


七、溢出页(Overflow Page):大字段去哪儿了

7.1 溢出机制

当一行数据太大,无法放入一个 16KB 的页时,InnoDB 会将部分数据溢出到额外的页中。这就是"溢出页"。

什么情况下会溢出?

一个页中至少要有两行数据(这是 InnoDB 的硬性要求)。因此,如果一行数据超过约 8KB(16KB / 2),就需要溢出。

具体来说,溢出取决于行格式和字段类型:

行格式 溢出阈值 行内存储
REDUNDANT 行 > 页大小/2 前 768 字节 + 20 字节指针
COMPACT 字段 > 768 字节 前 768 字节 + 20 字节指针
DYNAMIC 字段 > 40 字节 仅 20 字节指针
COMPRESSED 字段 > 40 字节 仅 20 字节指针

7.2 溢出页的存储结构

溢出页(FIL_PAGE_TYPE_BLOB)与普通索引页不同,它有自己独特的结构:

  • 不包含 User Records 和 Page Directory
  • 数据直接存储在页中
  • 如果一个大字段需要多个溢出页,这些溢出页通过链表连接

溢出页链表:大字段的第一个溢出页存储了指向下一个溢出页的指针,形成链表结构。读取完整的大字段时,需要遍历整个链表。

7.3 溢出的性能影响

溢出对性能的影响是显著的:

  1. 额外的 I/O:读取一个包含溢出字段的行,需要读取主索引页 + 一个或多个溢出页。如果一个大字段需要 10 个溢出页,就需要 11 次随机 I/O(1 个索引页 + 10 个溢出页)。
  2. Buffer Pool 污染:溢出页会占用 Buffer Pool 的空间,可能挤出热点数据页。
  3. 无法利用索引覆盖:溢出字段无法被覆盖索引优化,因为索引中只存储了溢出页指针,不是实际数据。

最佳实践:

  • 避免在频繁查询的列上使用 TEXT/BLOB
  • 将大字段拆分到独立的关联表中,只在需要时 JOIN
  • 对于确实需要大字段的表,使用 DYNAMIC 行格式以减少行内存储的溢出数据量

111_fig7.png
111_fig7.png


八、表空间(Tablespace):最后的容器

8.1 表空间类型

InnoDB 有三种表空间类型:

类型 说明 文件
系统表空间 存储 InnoDB 数据字典、Change Buffer、Double Write Buffer、Undo 日志 ibdata1
独立表空间 每个表一个独立的 .ibd 文件 table_name.ibd
通用表空间 多个表共享一个表空间 自定义 .ibd 文件
Undo 表空间 专门存储 Undo 日志 undo_001, undo_002
临时表空间 存储临时表数据 ibtmp1

8.2 独立表空间的内部结构

每个独立表空间文件(.ibd)的内部结构如下:

code
┌─────────────────────────────────┐
│ 文件头 (File Space Header)      │  ← 第 0 号页,包含表空间元数据
├─────────────────────────────────┤
│ 插入缓冲位图页 (Insert Buffer)  │  ← 第 1 号页
├─────────────────────────────────┤
│ INODE 页                        │  ← 第 2 号页,存储段信息
├─────────────────────────────────┤
│ 区描述符页 (XDES)               │  ← 第 3 号页开始
├─────────────────────────────────┤
│ 数据页 (INDEX 页)               │  ← 实际数据存储
├─────────────────────────────────┤
│ ...                             │
├─────────────────────────────────┤
│ 空闲页 (ALLOCATED)              │  ← 已分配但未使用的页
└─────────────────────────────────┘

第 0 号页(FSP_HDR) 是整个表空间的元数据核心,包含:

  • 表空间 ID
  • 表空间总大小
  • 第一个区的描述符
  • 文件链表信息

8.3 表空间的最大容量

InnoDB 表空间的最大容量是 64TB,这个限制来自:

  • 每个页 16KB
  • 页号用 4 字节无符号整数表示
  • 最大页数:2^32 = 4,294,967,296
  • 最大容量:4,294,967,296 × 16KB = 64TB

实际上,64TB 的限制在绝大多数场景下都不是问题。 一个 64TB 的表,即使每秒写入 100MB 数据,也需要约 7.5 天才能写满。真正需要关注的是数据量增长带来的性能退化——页分裂、索引层数增加、Buffer Pool 不够用等。

111_fig8.png
111_fig8.png


九、实战:从存储结构看性能优化

9.1 为什么 VARCHAR(65535) 实际存不了 65535 个字符?

MySQL 中 VARCHAR 的最大长度是 65535 字节。但实际可用的存储空间远小于这个值:

  • 变长字段长度列表:2 字节
  • NULL 值列表:1 字节/8 个 NULL 列
  • 记录头:5 字节
  • 隐藏列(DB_ROW_ID + DB_TRX_ID + DB_ROLL_PTR):6+6+7 = 19 字节
  • 其他列占用的空间

实际可用空间 ≈ 65535 - 2 - 1 - 5 - 19 - 其他列 ≈ 65508 字节

如果使用 UTF8MB4 编码(每个字符最多 4 字节),最多能存储:65508 / 4 ≈ 16377 个字符。

9.2 为什么 B+Tree 只有 3~4 层?

InnoDB 的 B+Tree 索引极少超过 4 层,原因在于每个页可以存储大量的索引条目:

  • 假设主键是 BIGINT(8 字节),页指针 6 字节,每个非叶子页可以存储约 16KB / (8+6) ≈ 1170 个索引条目
  • 假设每行数据 1KB,每个叶子页可以存储约 16 行
层数 非叶子节点 叶子节点 总行数
1 0 1 页 16
2 1 页 (1170 条目) 1170 页 18,720
3 1170 页 1,368,900 页 21,902,400
4 1,368,900 页 1,601,613,000 页 25,625,808,000

3 层就可以存 2000 万行,4 层可以存 250 亿行。这就是为什么 B+Tree 是数据库索引的标准数据结构。

9.3 为什么 COUNT(*) 在大表上慢?

COUNT(*) 在没有 WHERE 条件时,InnoDB 需要遍历所有行(通过最小的二级索引来减少扫描的页数,但仍然是全扫描)。这个过程需要:

  1. 从磁盘读取所有索引页到 Buffer Pool
  2. 如果 Buffer Pool 不够大,会发生页淘汰和重新读取
  3. 每页 16KB,一个 10GB 的表需要读取约 655,360 个页

约 10GB 的表,COUNT(*) 需要扫描约 65 万个页,这就是慢的原因。

9.4 页分裂与页合并

页分裂(Page Split):当向一个已满的页插入新记录时,InnoDB 会:

  1. 分配一个新页
  2. 将原页中约一半的记录移动到新页
  3. 更新父页中的指针
  4. 如果父页也满了,递归向上分裂

页分裂是写入性能下降的主要原因之一。使用自增主键可以让插入总是发生在 B+Tree 的最右侧,避免页分裂。

页合并(Page Merge):当删除大量记录导致页的填充率低于 50% 时,InnoDB 会尝试将相邻的两个页合并为一个页,释放空闲页。页合并会带来短暂的性能波动。


十、总结

InnoDB 的存储结构是一个精心设计的层次体系,每一层都有其特定的设计目的:

层次 核心价值 关键数字
页 I/O 最小单位,页内二分查找 16KB
区 空间分配单位,保证物理连续性 1MB (64 页)
段 逻辑分组,分离叶子/非叶子节点 按索引分
行格式 存储效率与溢出策略的权衡 4 种格式
溢出页 大字段的外存机制 BLOB 页链表
表空间 存储容器,独立管理 最大 64TB

一句话总结:理解 InnoDB 的存储结构,就是理解数据在磁盘上是如何组织的——这决定了 MySQL 的性能上限和优化空间。


十一、面试官视角提问

以下问题从面试官视角出发,考察候选人对 InnoDB 存储结构的理解深度和工程经验。不计入 40 分钟阅读时间。

Q1:InnoDB 的页大小默认是 16KB。如果改成 32KB,会有什么影响?在什么场景下应该考虑修改页大小?

Q2:你用 **SELECT * FROM big_table WHERE id BETWEEN 1 AND 1000** 查询了 1000 行数据。假设每行约 500 字节,这些数据分布在多少个页中?最少多少个页?最多多少个页?请解释你的计算过程。

Q3:一个表有一列 **TEXT** 类型的字段,存储平均 10KB 的 JSON 数据。查询时如果只 SELECT 其他列(不包含 TEXT 列),InnoDB 还会读取溢出页吗?为什么?如果会,怎么优化?

Q4:为什么 InnoDB 建议使用自增主键?如果使用 UUID 作为主键,对页分裂和存储结构有什么影响?请从页填充率、页分裂频率、B+Tree 层数三个角度分析。

Q5:你发现线上一个表的 ibd 文件有 100GB,但 **SELECT COUNT(*)** 显示只有 100 万行,平均每行才 1KB 左右,为什么 ibd 文件这么大?可能的原因有哪些?怎么排查和回收空间?

Q6:InnoDB 的 Buffer Pool 以页为单位管理内存。如果 Buffer Pool 设置为 8GB,可以缓存多少个页?如果一张热表有 200 万行,每行 1KB,能把整张表都缓存到 Buffer Pool 中吗?


作者注:本文为"Java 技术专家与架构师面试:250 个高频考点"系列第 111 篇,属于「08 MySQL 与关系型数据库」分类。


十二、面试官提问 · 参考答案

以下为第十一节中 6 道面试题的参考答案,从面试官视角评估候选人的理解深度。


Q1:InnoDB 页大小默认 16KB,改成 32KB 会有什么影响?

参考答案:

正面影响:

  • B+Tree 层数可能减少:更大的页可以容纳更多索引条目。32KB 页的非叶子节点可容纳约 32KB/(8+6) ≈ 2340 个条目(16KB 是 1170)。层数减少意味着更少的磁盘 I/O。
  • 顺序扫描效率提升:全表扫描时,每次 I/O 读取 32KB 而不是 16KB,吞吐量更高。
  • 大行存储更友好:如果一行数据接近 8KB(16KB 页的溢出阈值),32KB 页可以避免溢出,将整行存储在单个页中。

负面影响:

  • Buffer Pool 利用率下降:如果大部分行只有几百字节,32KB 的页中大量空间被浪费。Buffer Pool 以页为单位管理,大页意味着缓存效率降低。
  • 页分裂代价更高:32KB 页分裂时需要移动的数据量翻倍,写入性能下降。
  • 磁盘空间浪费:对于小表,每个区从 1MB 变成 2MB(64 × 32KB),空间浪费更严重。
  • Redo Log 压力增大:每次页修改产生的 Redo Log 更多(因为修改的单位是 32KB)。
  • 不兼容:页大小在 MySQL 初始化时设定,修改需要重新初始化数据库。

适用场景: 数据仓库类应用,以大行(含 TEXT/BLOB)和全表扫描为主的场景。不适合高并发 OLTP 场景。

面试官评分要点: 候选人能否从 Buffer Pool、B+Tree、I/O 三个维度分析,而不是只回答"大页更快"或"大页更慢"。


Q2: 查询 1000 行,分布在多少个页中?

参考答案:

最少页数:

如果 1000 行数据在物理上完全连续,且每行 500 字节,则每页可容纳约 16KB / 500B ≈ 32 行。1000 行需要 1000 / 32 ≈ 32 个页。但加上页的固定开销(File Header、Page Header 等约 200 字节),实际每页可用空间约 15.8KB,每页约 31 行。所以最少约 33 个页。

最多页数:

如果数据极端碎片化(频繁的随机插入和删除导致页分裂),每页可能只有 1~2 行。最坏情况下 1000 行分布在 1000 个页中。

实际场景:

  • 使用自增主键,数据按主键顺序插入 → 接近最少页数
  • 使用 UUID 主键,随机插入导致大量页分裂 → 分布在更多的页中
  • 表经历过大量 DELETE 后未 OPTIMIZE → 页填充率低,分布在更多页中

关键因素:

  • 主键类型(自增 vs UUID)
  • 页填充率(默认页分裂时各 50%,可通过 innodb_fill_factor 调整)
  • 是否有删除操作导致页空洞

面试官评分要点: 候选人能否给出"最少"和"最多"的计算逻辑,并关联到主键设计和页分裂的实际场景。


Q3:TEXT 字段存储 10KB JSON,只 SELECT 其他列(不包含 TEXT 列),InnoDB 会读取溢出页吗?

参考答案:

不会读取溢出页。 这是 InnoDB 行格式设计的精妙之处。

原因:

在 COMPACT 行格式中,TEXT 字段超过 768 字节时,行内只存储前 768 字节 + 20 字节溢出页指针。在 DYNAMIC 行格式中,行内只存储 20 字节溢出页指针。

当查询不包含 TEXT 列时:

  1. InnoDB 从索引页读取行数据
  2. 发现 TEXT 列只存储了溢出页指针(20 字节),但查询不需要该列
  3. InnoDB 跳过该列,不跟随指针去读取溢出页
  4. 只返回查询需要的其他列

验证方法:

sql
-- 开启 InnoDB 页 I/O 监控
SET GLOBAL innodb_monitor_enable = 'module_page';

-- 查询不含 TEXT 列
SELECT id, name FROM big_table WHERE id = 1;
-- 查看 page I/O 统计,只有 1 次索引页读取

-- 查询含 TEXT 列
SELECT id, name, json_data FROM big_table WHERE id = 1;
-- 查看 page I/O 统计,有 1 次索引页 + N 次溢出页读取

最佳实践: 将大字段拆分到独立表(垂直拆分),主表只存储常用列,大字段表只在需要时 JOIN。这样主表的行更小,每页可容纳更多行,Buffer Pool 效率更高。

面试官评分要点: 候选人是否理解"行内存储的是溢出页指针而非数据"这个关键点,以及是否知道垂直拆分的优化手段。


Q4:为什么 InnoDB 建议使用自增主键?UUID 主键对存储结构的影响?

参考答案:

从三个角度分析:

(1)页填充率

  • 自增主键:新行总是插入到 B+Tree 的最右侧。页满时才分裂,分裂后两页各约 50% 填充率,后续插入继续填充到接近 100%。平均页填充率可达 90%+。
  • UUID 主键:新行随机插入到 B+Tree 的任意位置。页分裂后,新数据可能不会继续填充该页,导致两页都只有 50% 填充率且不再增长。平均页填充率可能只有 60~70%。

(2)页分裂频率

  • 自增主键:页分裂只发生在最右侧的叶子节点,频率低。每次分裂只影响 1 个叶子页和其父页。
  • UUID 主键:页分裂发生位置随机,频率高。每次分裂可能需要更新父页、爷页甚至根页。大量随机分裂导致磁盘随机 I/O 激增。

(3)B+Tree 层数

  • 自增主键:页填充率高,每页容纳更多行,同样数据量下层数更少。1 亿行可能只需 3 层。
  • UUID 主键:页填充率低,每页容纳更少行,同样数据量下层数更多。1 亿行可能需要 4 层。

额外影响:

  • 二级索引变大:二级索引叶子节点存储主键值。UUID(36 字节)比 BIGINT(8 字节)大 4.5 倍,导致所有二级索引膨胀。
  • Buffer Pool 效率下降:UUID 主键的随机性导致热点数据分散在更多页中,Buffer Pool 命中率下降。
  • 磁盘顺序写变为随机写:这是最致命的性能差异。自增主键的插入是顺序 I/O,UUID 主键的插入是随机 I/O,吞吐量可能差 10 倍以上。

结论: 除非有特殊的分库分表需求(需要全局唯一 ID),否则始终优先使用自增 BIGINT 作为主键。

面试官评分要点: 候选人能否从页填充率、页分裂频率、B+Tree 层数三个维度给出量化分析,并提到二级索引和 Buffer Pool 的连锁影响。


Q5:表 ibd 文件 100GB,但 COUNT(*) 只有 100 万行,为什么 ibd 这么大?

参考答案:

可能原因:

(1)大量 DELETE 未回收空间

InnoDB 的 DELETE 操作只是标记删除,不会立即释放磁盘空间。被删除的行在页中留下"空洞"。只有通过 OPTIMIZE TABLE 或 ALTER TABLE ... ENGINE=InnoDB 重建表才能回收空间。

(2)大字段溢出

如果表中有 TEXT/BLOB 列,平均每行有 100KB 的数据溢出到溢出页:100 万行 × 100KB = 100GB。行本身只有 1KB,但溢出页占用了大量空间。

(3)索引膨胀

如果有多个二级索引,每个索引的叶子节点都存储了索引键值 + 主键。一个表有 5 个二级索引,每个索引的叶子节点大小可能接近数据本身。

(4)碎片化

频繁的 UPDATE(特别是更新变长字段导致行变长)和 DELETE 导致页中出现大量碎片空间。页的填充率可能只有 50%,导致实际使用的空间是数据量的 2 倍。

排查方法:

sql
-- 查看表和索引大小
SELECT table_name,
       ROUND(data_length/1024/1024/1024, 2) AS data_gb,
       ROUND(index_length/1024/1024/1024, 2) AS index_gb,
       ROUND(data_free/1024/1024/1024, 2) AS free_gb
FROM information_schema.tables
WHERE table_name = 'your_table';

-- 查看碎片率
SELECT table_name,
       ROUND(data_free / (data_length + index_length) * 100, 2) AS fragment_pct
FROM information_schema.tables
WHERE table_name = 'your_table';

回收空间:

sql
-- 在线 DDL 重建表(MySQL 5.6+,不阻塞读写)
ALTER TABLE your_table ENGINE=InnoDB;
-- 或
OPTIMIZE TABLE your_table;

注意:OPTIMIZE TABLE 会锁表并重建,在业务低峰期执行。重建后 ibd 文件会显著缩小。

面试官评分要点: 候选人能否给出多种可能原因(不只是碎片化),并知道排查命令和回收方法。


Q6:Buffer Pool 8GB,能缓存多少个页?200 万行每行 1KB 能全部缓存吗?

参考答案:

(1)能缓存多少个页?

8GB / 16KB = 524,288 个页。但实际可用的页数略少,因为 Buffer Pool 内部有管理结构(页哈希表、LRU 链表、Free 链表等)占用约 5% 的内存。

实际可用约 500,000 个页。

(2)200 万行每行 1KB 能全部缓存吗?

数据页计算:

每页 16KB,扣除页头开销约 200 字节,实际可用约 15.8KB。每行 1KB,每页约 15 行。200 万行 / 15 ≈ 133,334 个数据页。

非叶子节点页:

假设主键 BIGINT(8 字节),页指针 6 字节:

  • 1 层:1 个根页
  • 2 层:1 个根页 + 133,334 / 1170 ≈ 114 个中间页
  • 3 层:114 个中间页 + 133,334 个叶子页

总页数 ≈ 133,334 + 114 + 1 ≈ 133,449 个页。

二级索引:

如果有 2 个二级索引,每个索引的叶子节点存储索引键值 + 主键。假设每个索引键值约 50 字节 + 8 字节主键 = 58 字节,每页约 270 行。200 万行需要约 7,408 个叶子页。加上非叶子节点,每个二级索引约 7,500 个页。2 个二级索引 ≈ 15,000 个页。

总计: 133,449 + 15,000 ≈ 148,500 个页。

结论: 148,500 < 500,000,可以全部缓存。但需要预留空间给其他热点数据(如其他表的索引页、Undo 页、Change Buffer 等)。实际生产环境中,Buffer Pool 很少只给一张表使用。

面试官评分要点: 候选人能否给出完整的计算过程(数据页 + 非叶子节点 + 二级索引),并提到 Buffer Pool 的竞争和 LRU 淘汰机制。


全文完 | 编号 111 | 分类 08 MySQL 与关系型数据库