引言
为什么要理解"一行记录是如何存储的"?
在使用 MySQL 时,我们经常会遇到这些问题:
- 为什么 VARCHAR 过长会影响性能?
- NULL 字段真的"不占空间"吗?
- 为什么 InnoDB 推荐 使用自增主键?
- 行溢出(row overflow)是怎么发生的?
- B+Tree 中一行记录到底长什么样?
这些问题的答案,都指向同一个核心:
MySQL 是如何在磁盘中存储一行数据的
要真正理解这些问题,我们必须从 文件结构 → 表空间 → 页 → 行格式 逐层拆解。
一、MySQL 的数据到底存放在哪些文件里?
假设我们有一张表:
sql
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
user_id BIGINT,
amount INT,
remark VARCHAR(255)
) ENGINE=InnoDB;
在磁盘上,MySQL 至少会涉及以下文件。
2.1 db.opt ------ 数据库级别配置文件
-
作用:保存数据库默认字符集、排序规则
-
示例内容:
sql
default-character-set=utf8mb4
default-collation=utf8mb4_general_ci
2.2 t_order.frm ------ 表结构定义文件(MySQL 8.0 前)
-
保存表的结构信息
-
包括字段名、字段类型、索引定义等
MySQL 8.0 之后:
-
表结构元数据被统一存储到 数据字典表 中
-
.frm文件逐步退出历史舞台
2.3 t_order.ibd ------ InnoDB 独立表空间文件(重点)
真正存放 数据 + 索引 的地方是:
sql
t_order.ibd
是否生成这个文件,取决于一个核心参数。
2.4 innodb_file_per_table 参数
sql
innodb_file_per_table = ON
| 参数值 | 行为 |
|---|---|
| ON(默认) | 每张表一个 .ibd 文件 |
| OFF | 所有表共享系统表空间 ibdata1 |
生产环境强烈建议 ON
原因:
- 表可独立回收空间
- 避免 ibdata1 无限膨胀
- 便于迁移与维护
二、InnoDB 表空间的物理结构
.ibd 文件并不是"一坨连续数据",而是有明确层级结构:
sql
表空间(Tablespace)
└── 段(Segment)
└── 区(Extent)
└── 页(Page)
└── 行(Row)
我们逐层拆解。
三、页(Page)------ InnoDB 的最小存储与 IO 单位
4.1 页的基本概念
-
默认大小:16KB
-
InnoDB 所有读写,都是 以页为单位
没有"只读一行"这回事,至少读一页。
4.2 页的类型
| 页类型 | 说明 |
|---|---|
| 数据页 | 存放行记录 |
| 索引页 | B+Tree 节点 |
| Undo 页 | Undo Log |
| 系统页 | 数据字典 |
本文重点关注 数据页(Index Page)。
4.3 数据页内部结构(简化)
sql
Page Header
Page Directory
Infimum Record
User Records(真实行数据)
Supremum Record
其中,User Records 就是行真正存储的位置。
四、段(Segment)与区(Extent)
5.1 区(Extent)
-
一个区 = 64 个连续页
-
默认大小:64 × 16KB = 1MB
-
作用:减少磁盘随机 IO
5.2 段(Segment)
InnoDB 中常见段类型:
-
数据段(Leaf Segment)
-
索引段(Non-leaf Segment)
-
回滚段(Rollback Segment)
一个 B+Tree 至少包含两个段
五、InnoDB 支持的行格式(Row Format)
6.1 行格式类型
| 行格式 | 说明 |
|---|---|
| REDUNDANT | 老格式,已淘汰 |
| COMPACT | 经典格式(重点) |
| DYNAMIC | 大字段更友好 |
| COMPRESSED | 压缩存储 |
MySQL 5.7 / 8.0 默认使用 COMPACT / DYNAMIC
六、COMPACT 行格式详解(重点)
一条 InnoDB 行在 COMPACT 格式下,逻辑结构如下:
sql
变长字段长度列表
NULL 值列表
记录头信息
真实数据
七、为什么大字段
7.1 记录的额外信息
① 变长字段长度列表(逆序存放)
-
仅包含 VARCHAR / VARBINARY / TEXT 等变长字段
-
按 字段定义顺序的逆序存储
为什么要逆序?
因为:
- 读取记录时,从后向前解析字段更高效
- 避免解析时频繁移动指针
- 有利于 CPU cache 友好访问
② NULL 值列表
-
每个可为 NULL 的字段,占 1 bit
-
按字段顺序排列
-
如果字段都 NOT NULL,则不存在该列表
结论:
NULL 并不是"不占空间",只是占得很少
7.2 记录头信息(5 字节)
包含大量重要元信息:
| 字段 | 作用 |
|---|---|
| delete_mask | 是否被删除 |
| min_rec_mask | 最小记录标记 |
| n_owned | 分组信息 |
| heap_no | 堆中位置 |
| record_type | 记录类型 |
| next_record | 指向下一条记录 |
链表结构,页内行记录是"单向链表"
7.3 记录的真实数据
存放:
-
定长字段(INT、BIGINT)
-
变长字段的真实内容(或溢出指针)
7.4 隐藏字段(非常重要)
InnoDB 会为每行记录自动添加 3 个隐藏字段:
| 字段 | 大小 | 作用 |
|---|---|---|
| DB_ROW_ID | 6 字节 | 无主键时生成 |
| DB_TRX_ID | 6 字节 | 最近一次修改事务 |
| DB_ROLL_PTR | 7 字节 | 指向 Undo Log |
MVCC、事务回滚、可见性判断的核心基础
可能不直接存储在行内?
当一行记录 过大 时:
-
InnoDB 会将部分字段存入 溢出页(Overflow Page)
-
行内只保留 20 字节左右的指针
不同格式策略:
| 行格式 | 大字段策略 |
|---|---|
| COMPACT | 尽量行内 |
| DYNAMIC | 更早溢出 |
| COMPRESSED | 压缩后再存 |
总结
一行记录的完整存储链路
SQL 插入
↓
InnoDB 表空间(.ibd)
↓
段(Segment)
↓
区(Extent)
↓
页(Page,16KB)
↓
COMPACT 行结构
├─ 变长字段长度列表
├─ NULL 值列表
├─ 记录头信息
├─ 隐藏字段
└─ 真实数据