存储结构
开篇:MySQL 数据是怎么存在磁盘上的?
你可能以为,一条 INSERT 语句执行后,数据就像写文本文件一样一行行追加到磁盘里。实际上,InnoDB 的存储远比这复杂——它有一套精心设计的层级结构,就像俄罗斯套娃一样层层嵌套。
理解这套结构,能帮你回答很多实战问题:为什么一页是 16KB?为什么自增主键比 UUID 快?Buffer Pool 到底缓存了什么?碎片是怎么来的?
一、InnoDB 的存储层次(表空间→段→区→页→行)
InnoDB 的数据在磁盘上按照 表空间 → 段 → 区 → 页 → 行 五个层次组织。就像套娃一样,每个层次都包含下一个层次。
1.1 行(Row)——最底层
行就是我们平时操作的一条条记录。每行数据按照特定的"行格式"存储(后面会详细讲),除了我们定义的字段外,InnoDB 还会自动加上几个隐藏列:
db_row_id(6 字节):隐藏主键,仅在表没有显式主键时生成db_trx_id(6 字节):最后修改这行记录的事务 IDdb_roll_ptr(7 字节):回滚指针,指向 undo log 中这行的上一个版本
1.2 页(Page)——最核心的概念
页是 InnoDB 磁盘和内存交互的最小单位,默认大小 16KB。
这意味着:
- 读数据时,最少读 16KB——即使你只需要一行
- 写数据时,最少写 16KB
- 一个 B+ 树节点就是一个页
- 页内的记录按主键从小到大排列,用单向链表连接
- 页和页之间用双向链表连接
为什么是 16KB 而不是 4KB 或 64KB?这是一个在磁盘 I/O 效率和内存利用率之间权衡的经验值。太小的话,一次 I/O 读到的数据太少,B+ 树一个节点存不了几个 key,树会变高;太大的话,会浪费内存和带宽,而且写入时的原子性更难保证。
可以通过 innodb_page_size 参数调整(支持 4KB/8KB/16KB/32KB/64KB),但一般不建议改,因为修改后需要重建整个表空间。
1.3 区(Extent)——保证顺序 I/O
一个区 = 64 个连续的页 = 64 x 16KB = 1MB。
为什么需要区?想象一下 B+ 树的叶子节点:做范围查询时需要沿着叶子节点的链表顺序扫描。如果这些叶子页分散在磁盘的天南海北,扫描就变成了随机 I/O,非常慢。
有了区之后,InnoDB 可以一次分配一整块连续的磁盘空间给某个索引,让相邻的叶子页在物理上也相邻,范围扫描时变成顺序 I/O,速度可以快一个数量级。
碎片区(Fragment Extent)的巧妙设计:
如果每个段上来就以区(1MB)为单位分配空间,那一张只有几条记录的小表,光一个聚簇索引就要分配 2 个段 x 1MB = 2MB,太浪费了。
InnoDB 的解决方案是引入碎片区:碎片区中的页可以属于不同的段,混合使用。刚开始插入数据时,段以单个页为单位从碎片区中申请空间;当某个段已经占用了 32 个碎片区页面后,才会切换到以完整区为单位分配——这是一个渐进式的策略,小表省空间,大表保性能。
1.4 段(Segment)——分离叶子和非叶子
段是一个逻辑概念,不对应磁盘上一块连续的区域。InnoDB 把 B+ 树的叶子节点和非叶子节点分开存放在不同的段中:
- 数据段(叶子段):存放 B+ 树的叶子节点
- 索引段(非叶子段):存放 B+ 树的非叶子节点
为什么要分开?因为范围查询只扫描叶子节点。如果叶子和非叶子混在一起,物理上就不连续了,顺序 I/O 的优势就没了。
这意味着:一个索引 = 2 个段。所以不建议创建过多索引——每多一个索引,就多两个段的开销。
1.5 表空间(Tablespace)——最顶层容器
表空间是最顶层的逻辑容器,主要有以下几种:
| 表空间类型 | 文件 | 说明 |
|---|---|---|
| 系统表空间 | ibdata1 | 存放系统信息、数据字典、undo 日志等 |
| 独立表空间 | 表名.ibd | 每个表一个文件,MySQL 5.6+ 默认开启 |
| 撤销表空间 | undo_001 等 | 存放 undo 日志(MySQL 8.0 默认独立) |
| 临时表空间 | ibtmp1 | 存放临时表数据 |
推荐使用独立表空间(innodb_file_per_table=ON)。好处是:删表时直接删文件就能回收磁盘空间,不像系统表空间那样只增不减。
二、数据页结构(16KB 的秘密)
一个 16KB 的数据页内部被划分为 7 个部分:
| 部分 | 大小 | 作用 |
|---|---|---|
| File Header | 38 字节 | 页号、前后页指针、页类型、校验和 |
| Page Header | 56 字节 | 页内记录数、空闲空间起始位置、目录槽数等 |
| Infimum + Supremum | 26 字节 | 页内最小记录和最大记录(虚拟的边界哨兵) |
| User Records | 不固定 | 真正存放行记录的区域 |
| Free Space | 不固定 | 尚未使用的空间 |
| Page Directory | 不固定 | 页内记录的"目录",支持二分查找 |
| File Trailer | 8 字节 | 校验和,确保页写入完整(与 File Header 中的校验和对比) |
几个值得理解的要点:
File Header 中的前后页指针 构成了页与页之间的双向链表。正是通过这条链表,B+ 树同一层的所有页被串成了有序序列。
Page Directory(页目录) 把页内的记录分成若干组,每组记录一个"槽"(slot)。查找时先对槽做二分查找定位到组,再在组内遍历(组内最多 8 条记录),大大加快了页内搜索。
Infimum 和 Supremum 是两条虚拟记录:Infimum 比页内所有记录都"小",Supremum 比所有记录都"大"。它们是单向链表的起止哨兵,简化了边界处理。
Free Space 随着记录的插入会逐渐缩小。记录删除后空间不会立即回收,而是标记为"可复用",新插入的记录会优先使用这些"废弃"空间。
数据页和 B+ 树的关系
B+ 树的每个节点就对应一个数据页:
- 根节点 → 一个数据页(常驻内存)
- 非叶子节点 → 一个个数据页(存 key + 子页指针)
- 叶子节点 → 一个个数据页(存完整行记录)
InnoDB 的所有磁盘读写都以页为单位。一次查询最多经过 2~3 个非叶子页 + 1 个叶子页,也就是 2~3 次磁盘 I/O(根页面常驻内存不算)。
三、行格式(记录在页内怎么存)
行格式决定了一条记录在数据页内的物理存储方式。InnoDB 支持 4 种行格式:
3.1 Compact 格式(MySQL 5.0 默认)
一条 Compact 记录包含两大部分:
┌─────────────────────┬──────────────────────────┐
│ 记录的额外信息 │ 记录的真实数据 │
├─────────────────────┼──────────────────────────┤
│ 变长字段长度列表 │ 隐藏列 + 列1 + 列2 + ... │
│ NULL 值列表 │ │
│ 记录头信息(5字节) │ │
└─────────────────────┴──────────────────────────┘- 变长字段长度列表:记录每个 VARCHAR 列实际用了多少字节
- NULL 值列表:用位图标记哪些列是 NULL,省去了存 NULL 值的空间
- 记录头信息:包含
record_type(0=普通,1=目录项,2=Infimum,3=Supremum)、下一条记录的偏移等
对于超长字段(大文本、BLOB 等),Compact 在行内保留前 768 字节,剩余部分存到溢出页,行内用 20 字节存溢出页地址。
3.2 Dynamic 格式(MySQL 5.7+ 默认,推荐)
和 Compact 结构基本相同,核心区别在溢出处理:
- Compact:行内保留前 768 字节 + 20 字节溢出指针
- Dynamic:行内只存 20 字节指针,数据全部放到溢出页
好处:行更紧凑,一个数据页能存更多记录,B+ 树更矮,查询更快。
3.3 Compressed 格式
在 Dynamic 基础上增加了页级压缩,可以减小磁盘占用。代价是读写时需要压缩/解压,增加 CPU 开销。适合磁盘紧张但 CPU 富余的场景。
3.4 行格式对比
| 格式 | 紧凑存储 | 大字段溢出策略 | 压缩 | 默认版本 |
|---|---|---|---|---|
| Redundant | 否 | 行内 768B + 溢出页 | 否 | 5.0 之前 |
| Compact | 是 | 行内 768B + 溢出页 | 否 | 5.0~5.6 |
| Dynamic | 是 | 行内仅存指针 | 否 | 5.7+,推荐 |
| Compressed | 是 | 行内仅存指针 | 是 | 特殊场景 |
四、Buffer Pool:内存与磁盘的桥梁
InnoDB 的数据存在磁盘上,但磁盘太慢了(机械盘寻道约 10ms,SSD 约 0.1ms,而内存只要几十 ns)。如果每次查询都要读磁盘、每次修改都要写磁盘,性能不可接受。
Buffer Pool 就是 InnoDB 在内存中开辟的一块缓存区域,用来缓存从磁盘读入的数据页。它是 InnoDB 性能的命脉。
4.1 基本概念
- Buffer Pool 是一块连续的内存空间,默认 128MB(生产环境通常调到总内存的 50%~80%)
- 缓存单位是数据页(16KB)
- 不仅缓存数据页,还缓存索引页、undo 页、插入缓冲等
-- 查看当前 Buffer Pool 大小
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- 在线调整 Buffer Pool 大小
SET GLOBAL innodb_buffer_pool_size = 536870912; -- 512MB4.2 读过程
这也是一种预读思想:你只查一行,InnoDB 会把整个 16KB 的页都读进来,因为同一页里的其他行很可能马上也会被用到(空间局部性)。
4.3 写过程
写比读复杂,因为涉及到数据一致性和持久化:
几个关键点:
- 修改只在内存中进行,不会立即写回磁盘(性能关键)
- 被修改过的页标记为脏页
- 后台线程(Page Cleaner Thread)异步刷脏页到磁盘
- 刷盘时机:脏页比例超过
innodb_max_dirty_pages_pct_lwm(默认 10%)、MySQL 空闲时、MySQL 正常关闭时 - InnoDB 还有自适应刷新机制,根据 redo log 生成速度动态调整刷盘频率
你可能会担心:脏页还没刷盘时 MySQL 崩了怎么办?别担心——InnoDB 在修改数据前会先写 redo log(Write-Ahead Logging),崩溃恢复时重放 redo log 就能恢复数据。这属于事务和日志的范畴。
4.4 Buffer Pool vs Query Cache
很多人容易混淆这两个缓存:
| 维度 | Buffer Pool | Query Cache |
|---|---|---|
| 缓存什么 | 数据页(原始数据) | 查询结果(SQL 文本 → 结果集的映射) |
| 所属层级 | 引擎层(InnoDB 专属) | Server 层(所有引擎通用) |
| 失效策略 | LRU 淘汰 | 表有任何写操作就全部失效 |
| 现状 | 核心组件,一直在用 | 5.7 标记废弃,8.0 已删除 |
Query Cache 被废弃的原因:只要表有任何 INSERT/UPDATE/DELETE,该表的所有缓存结果就全部失效。在写多读少的场景下,Cache 不停地建了又清、清了又建,反而成了性能瓶颈。
五、页分裂、页合并与存储碎片
5.1 页分裂
当向一个已满的数据页中间插入新记录时,InnoDB 会把这个页一分为二,把部分记录移到新页——这就是页分裂。
页分裂的代价:
- 数据搬迁带来额外 I/O
- 新页可能在物理上离原页很远,破坏了顺序 I/O
- 分裂可能沿 B+ 树向上传播(目录页也可能满)
- 分裂后两个页各半满,空间利用率下降
主要触发原因:主键不是自增的(如 UUID),导致新记录要插到中间位置。
5.2 页合并
当大量删除导致某个页过于稀疏(剩余数据不到页容量的一半左右),InnoDB 可能把两个相邻的稀疏页合并成一个,减少 B+ 树的页数。页合并同样有 I/O 开销。
5.3 存储碎片的来源与治理
频繁的 DML 操作会产生碎片:
| 操作 | 碎片成因 |
|---|---|
| INSERT | 非顺序主键导致页分裂,新旧页物理不连续 |
| UPDATE | 行变长后原位放不下,被移到别处,原位留下空洞 |
| DELETE | 只标记删除,空间不立即释放 |
碎片的危害:浪费磁盘空间、增加随机 I/O、拖慢查询和备份。
如何减少碎片:
- 使用自增主键,避免页分裂
- 固定长度字段优先用 CHAR
- 用逻辑删除代替物理删除
- 批量插入代替逐条插入
如何清理碎片:
-- 方法1:OPTIMIZE TABLE(会锁表,选低峰期)
OPTIMIZE TABLE your_table;
-- 方法2:ALTER TABLE 重建(效果相同)
ALTER TABLE your_table ENGINE = InnoDB;如何查看碎片量:
SHOW TABLE STATUS LIKE 'your_table';
-- Data_free 字段表示未使用空间(字节),值越大碎片越多
-- 或者用 information_schema
SELECT table_name, data_free
FROM information_schema.tables
WHERE table_schema = 'your_db' AND data_free > 0;六、常见面试题精选
Q1:InnoDB 支持哪几种行格式?
四种:Redundant、Compact、Dynamic、Compressed。核心区别在于大字段的溢出策略和是否压缩。5.7+ 默认 Dynamic,推荐使用。Dynamic 对比 Compact 的优势在于:大字段在行内只存指针,一个数据页能装更多行。
Q2:Buffer Pool 的读写过程是怎样的?
读:先查 Buffer Pool,命中直接返回;未命中则从磁盘读整页(16KB)放入 Buffer Pool 缓存。
写:把目标页加载到 Buffer Pool,在内存中修改,标记为脏页。后台线程异步将脏页刷回磁盘。脏页比例超过低水位(默认 10%)时开始主动刷盘;MySQL 正常关闭时全部刷盘。
Q3:什么是页分裂?如何避免?
向已满的数据页中间插入记录时,页一分为二。它会产生额外 I/O、破坏物理连续性、降低空间利用率。
核心避免方法:使用自增主键,确保新记录始终追加到最后一页,不会触发中间插入。
Q4:MySQL 的数据一定存在硬盘上吗?
不一定。MySQL 支持 Memory 存储引擎(ENGINE = MEMORY),数据和索引都存在内存中,读写极快。但重启后数据丢失,仅适合临时数据或缓存场景。
小结
| 概念 | 一句话总结 |
|---|---|
| 行 | 一条记录,按行格式存储(推荐 Dynamic) |
| 页 | 16KB,磁盘 I/O 的最小单位,B+ 树一个节点 = 一个页 |
| 区 | 64 个连续页 = 1MB,保证顺序 I/O |
| 段 | 叶子段 + 非叶子段,让同类节点物理相邻 |
| 表空间 | 最顶层容器,推荐独立表空间(一表一文件) |
| Buffer Pool | 内存缓存区域,所有读写都先过它 |
| 脏页 | Buffer Pool 中被修改但还没刷到磁盘的页 |
| 页分裂 | 页满了要在中间插入时触发,自增主键可避免 |
| 碎片 | 增删改产生的空间浪费,OPTIMIZE TABLE 可清理 |