MySQL 数据到底存在哪里?从数据目录到 InnoDB 表空间,一层层揭开神秘面纱
摘要:每次执行 INSERT 都感觉数据"消失"进了数据库,可它实际存在磁盘的哪个角落?MySQL 的数据目录、.ibd 文件、表空间、区、段…这些概念之间到底是什么关系?本文将用通俗的比喻和图解,带你从文件系统一路走进 InnoDB 表空间的内部世界。
一、写一条 INSERT,数据去了哪里?
先来一个灵魂拷问:当你执行 INSERT INTO users VALUES (1, '张三', 'zhangsan@example.com') 时,这条记录最终去了磁盘的哪个角落?
答案藏在一条层层递进的路径里:
一条 INSERT 语句
│
▼
MySQL Server 接收 SQL
│
▼
InnoDB 存储引擎处理
│
▼
找到对应的表空间(.ibd 文件)
│
▼
在 B+ 树索引中找到插入位置 → 定位到具体的数据页
│
▼
将记录写入页面,标记为"脏页"
│
▼
后台刷盘线程将脏页写入磁盘 → 最终落到文件系统上的 .ibd 文件
这一路上涉及的概念——数据目录、表空间、区、段、页——就是本文要逐个拆解的内容。
二、数据目录:数据库的"家"
2.1 数据目录 vs 安装目录
很多初学者容易把这两个概念搞混,所以我们先来做一个清晰的区分:
┌─────────────────────────────────────────────────────────┐
│ MySQL 安装目录(如 /usr/local/mysql) │
│ ├── bin/ ← mysqld、mysql、mysqldump 等可执行文件 │
│ ├── lib/ ← 库文件 │
│ ├── include/ ← 头文件 │
│ └── share/ ← 错误消息、字符集配置 │
│ │
│ ⚠️ 这是程序的"身体",不存用户数据 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ MySQL 数据目录(如 /usr/local/var/mysql) │
│ ├── 数据库1/ ← 你创建的每个数据库是一个文件夹 │
│ ├── 数据库2/ │
│ ├── ibdata1 ← 系统表空间文件 │
│ ├── ib_logfile0 ← redo 日志 │
│ └── … ← 各种运行时产生的数据 │
│ │
│ ⚠️ 这是数据的"仓库",所有表记录都存在这里 │
└─────────────────────────────────────────────────────────┘
查看数据目录的准确位置:
mysql> SHOW VARIABLES LIKE 'datadir';
+—————+———————–+
| Variable_name | Value |
+—————+———————–+
| datadir | /usr/local/var/mysql/ |
+—————+———————–+
2.2 数据库在文件系统上的样子
每当你执行 CREATE DATABASE mydb 时,MySQL 在磁盘上做了两件事:
数据目录/
├── mysql/ ← mysql 系统库
├── information_schema/ ← 特殊处理,没有实际文件夹
├── performance_schema/
├── sys/
├── mydb/ ← 你创建的数据库
│ └── db.opt ← 数据库属性文件
├── ibdata1 ← InnoDB 系统表空间
├── ib_logfile0 ← redo 日志
└── ib_logfile1
2.3 表在文件系统上的三件套
建表 CREATE TABLE test (c1 INT) 时,MySQL 需要存储两类信息:
| 表结构 | 列名、数据类型、约束、索引、字符集等 | 表名.frm |
| 表数据 | 实际插入的用户记录 | 取决于存储引擎 |
.frm 是二进制文件,直接打开会看到乱码——它是给 MySQL 程序读的,不是给人读的。
2.4 InnoDB vs MyISAM:两种存储哲学
对于同一个 test 表,不同存储引擎产生的文件完全不同:
InnoDB 存储引擎(默认) MyISAM 存储引擎
mydb/ mydb/
├── test.frm ← 表结构 ├── test.frm ← 表结构
└── test.ibd ← 数据 + 索引 在一起 ├── test.MYD ← 数据文件(MY Data)
└── test.MYI ← 索引文件(MY Index)
理念:索引即数据 理念:索引和数据分离
关键差异:InnoDB 的聚簇索引叶子节点存储完整记录,所以数据和索引在同一个 .ibd 文件中。MyISAM 没有聚簇索引的概念,所有索引都是"二级索引",叶子节点存的是行号(而非主键),以此实现回表。
三、InnoDB 表空间:页面的大池子
3.1 为什么要有表空间?
前一篇文章讲过,InnoDB 以 16KB 的页 为基本单位管理存储空间,每个索引对应一棵 B+ 树,树的每个节点就是一个数据页。但问题是:成千上万个页分散在磁盘上,谁来统一管理它们?
于是"表空间"登场了。你可以把表空间想象成一个巨大的池子,池子里装满了页。插入记录时,就从池子里捞出对应的页来写入。表空间是一个抽象概念,最终对应文件系统上的一个或多个真实文件。
表空间(逻辑概念)
│
│ 映射到
▼
文件系统上的物理文件
(如 ibdata1、test.ibd)
│
│ 内部切分为
▼
若干个 16KB 的页
3.2 系统表空间 vs 独立表空间
InnoDB 提供两种主要的表空间类型:
| 文件 | ibdata1(可配置多个) | 表名.ibd |
| 作用范围 | 整个 MySQL 实例共享一个 | 每个表独享一个 |
| 数据存放 | 所有表的数据都可以放这里 | 每个表的数据各自独立 |
| MySQL 版本 | 5.5.7 ~ 5.6.6 默认 | 5.6.6+ 默认 |
| 配置参数 | innodb_data_file_path | innodb_file_per_table=1 |
配置系统表空间为多个文件:
[server]
# 创建 data1 和 data2 两个文件,各 512M,data2 不够时自动扩展
innodb_data_file_path=data1:512M;data2:512M:autoextend
切换表空间类型:
— 查看当前使用的表空间模式
SHOW VARIABLES LIKE 'innodb_file_per_table';
— 将已有表从独立表空间迁移到系统表空间
ALTER TABLE test TABLESPACE innodb_system;
— 将已有表从系统表空间迁移回独立表空间
ALTER TABLE test TABLESPACE innodb_file_per_table;
💡 最佳实践:现代 MySQL 8.0 默认使用独立表空间(innodb_file_per_table=ON),这有几个好处——删除表时直接回收磁盘空间(删 .ibd 文件即可),不同表的 IO 互不干扰,备份还原更灵活。
四、表空间的内部结构:区、段与碎片
表空间不是把页胡乱堆在一起,而是有一套精密的结构。
4.1 区(Extent):连续存储的秘密
回顾 B+ 树的范围查询:找到范围起点后,沿着叶子节点的双向链表顺序扫描即可。但如果链表上相邻的页在物理上离得很远,每次跳到下一页都是随机 IO,磁盘的磁头(或 SSD 的寻址)就要疲于奔命。
为了解决这个问题,InnoDB 引入了一个更大的分配单位——区(Extent)。
一个区 = 连续 64 个页 = 64 × 16KB = 1MB
┌──────────────────────────────────────┐
│ 一个区 (1MB) │
│ ┌────┐┌────┐┌────┐ … ┌────┐ │
│ │页0 ││页1 ││页2 │ │页63│ │
│ └────┘└────┘└────┘ └────┘ │
│ ←── 64 个页在物理上连续存储 ──→ │
└──────────────────────────────────────┘
当表中数据多了以后,分配空间就以区为单位而非以页为单位,这样同一个 B+ 树节点附近的页大概率物理相邻,范围扫描就成了顺序 IO。
🎯 扩展知识:为什么刚好是 64 个页?1MB 的大小是一个精心设计的平衡——既大到能显著减少随机 IO,又小到不会因为填不满而浪费太多空间。
4.2 段(Segment):叶子与非叶子的分流
如果只按区分配,叶子节点和非叶子节点的页会混在同一个区里,范围扫描时仍会扫到大量无关的非叶子页。于是 InnoDB 进一步提出了段(Segment):
一个索引 = 2 个段
│
├── 叶子节点段(Leaf Segment)
│ 存放 B+ 树叶子节点的所有页
│
└── 非叶子节点段(Non-Leaf Segment)
存放 B+ 树内节点的所有页
所以对于一个有 N 个索引的表,就有 2N 个段。比如:
- 1 个聚簇索引 → 2 个段
- 再加 1 个二级索引 → 再加 2 个段
- 总计 4 个段
每个段都以区为单位申请空间,叶子段和非叶子段的页物理上相互隔离,范围扫描时畅行无阻。
4.3 碎片区:小表的"精打细算"
问题来了:一个区默认 1MB,那一个只插了几十条记录的小表也需要 2MB(两个段各占一区)?这太浪费了。
InnoDB 的解决方案是碎片区(Fragment Extent):
小表初期(段占用 < 32 个分散页):
段 A 的零散页面 ──┐
段 B 的零散页面 ──┼── 都从一个"碎片区"里按页租用
段 C 的零散页面 ──┘
大表阶段(段占用 ≥ 32 个分散页):
段 A 直接申请完整的区(1MB),不再"拼租"
每个区有四种状态:
| FREE | 完全空闲,啥都没用 | 直属于表空间 |
| FREE_FRAG | 碎片区,还有空闲页可用 | 直属于表空间 |
| FULL_FRAG | 碎片区,已无空闲页 | 直属于表空间 |
| FSEG | 已分配给某个段 | 附属于段 |
如果把表空间比作一个集团军,段就是师,区就是团。FREE/FREE_FRAG/FULL_FRAG 状态的区就像独立团,直接听命于军部;而 FSEG 状态的区则是各师的直属团。
五、表空间的管理机制
5.1 XDES Entry:每个区的"身份证"
表空间里有成千上万个区,怎么记住每个区的状态?InnoDB 为每个区设计了一个 40 字节的 XDES Entry(Extent Descriptor Entry):
XDES Entry 结构(40 字节):
┌──────────────────┬──────────────────┬──────────┬────────────────────┐
│ Segment ID │ List Node │ State │ Page State Bitmap │
│ (8 字节) │ (12 字节) │ (4 字节) │ (16 字节) │
│ │ │ │ │
│ 该区属于哪个段 │ 前后指针,用于 │ FREE │ 128 个比特位 = │
│ (如果分配给段的话)│ 串联成链表 │ FREE_FRAG│ 64 组 × 2 位, │
│ │ │ FULL_FRAG│ 标记区内每个页 │
│ │ │ FSEG │ 是否空闲 │
└──────────────────┴──────────────────┴──────────┴────────────────────┘
每个组最多 256 个区,每个区一个 XDES Entry,所以需要 40 × 256 = 10240 字节。这些 XDES Entry 集中存储在每个组的第一个页面中。
5.2 链表王国:15 条链表的精密协作
InnoDB 用链表来管理所有区,而不是每次都遍历扫描。这些链表由 XDES Entry 通过 List Node 串联而成:
直属于表空间的 3 条链表(所有区都参与):
FREE 链表 → 串联所有 FREE 状态的区
FREE_FRAG 链表 → 串联所有 FREE_FRAG 状态的区
FULL_FRAG 链表 → 串联所有 FULL_FRAG 状态的区
每个段内部还有 3 条链表(只串联该段拥有的区):
FREE 链表 → 该段中全空闲的区
NOT_FULL 链表 → 该段中还有空页的区
FULL 链表 → 该段中已满的区
以一个只有聚簇索引的表为例:
表 t(仅有聚簇索引)
│
├── 叶子节点段 → FREE / NOT_FULL / FULL 链表 × 3
└── 非叶子节点段 → FREE / NOT_FULL / FULL 链表 × 3
加上直属于表空间的 3 条:FREE / FREE_FRAG / FULL_FRAG
加上 INODE 页的管理链表:SEG_INODES_FULL / SEG_INODES_FREE
总计:3 + 3×2 + 3 + 2 = 14+ 条链表
每有链表就有一个 List Base Node 结构(16 字节),记录了链表的头节点位置、尾节点位置和节点总数:
List Base Node:
├── List Length (4 字节)
├── First Node Page Number (4 字节) + Offset (2 字节)
└── Last Node Page Number (4 字节) + Offset (2 字节)
这些基节点存放在表空间头部页面的固定位置,访问任何一个链表都非常高效。
5.3 INODE Entry:每个段的"档案"
区有 XDES Entry 这个身份证,段也有自己的"户口本"——INODE Entry(192 字节):
INODE Entry 结构(192 字节):
┌────────────┬───────────────┬───────────────┬───────────────┬──────────────────────┐
│ Segment ID │ NOT_FULL_N_USED│ FREE 链表 │ NOT_FULL 链表 │ FULL 链表 │
│ (8 字节) │ (4 字节) │ List Base Node│ List Base Node│ List Base Node │
│ │ NOT_FULL 链表 │ (16 字节) │ (16 字节) │ (16 字节) │
│ │ 已使用页数 │ │ │ │
├────────────┴───────────────┴───────────────┴───────────────┴──────────────────────┤
│ Magic Number (4 字节) → 值为 97937874 表示已经初始化 │
├──────────────────────────────────────────────────────────────────────────────────┤
│ Fragment Array Entry × 32 (每个 4 字节) → 记录该段零散页面的页号 │
└──────────────────────────────────────────────────────────────────────────────────┘
一个 INODE 类型的页可以存放 85 个 INODE Entry。如果段太多、一个页放不下,就通过 SEG_INODES_FULL / SEG_INODES_FREE 链表串联更多的 INODE 页面。
5.4 Segment Header:索引如何找到自己的段
最后一个关键问题:每个索引有两个段(叶子段和非叶子段),索引怎么找到自己的段对应的 INODE Entry?
答案藏在索引根页面的 Page Header 部分:
INDEX 类型页面的 Page Header(部分字段):
┌─────────────────────────┬──────────┬──────────────────────────────────────┐
│ PAGE_BTR_SEG_LEAF │ 10 字节 │ B+ 树叶子节点段对应的 INODE Entry 地址 │
├─────────────────────────┼──────────┼──────────────────────────────────────┤
│ PAGE_BTR_SEG_TOP │ 10 字节 │ B+ 树非叶子节点段对应的 INODE Entry 地址 │
└─────────────────────────┴──────────┴──────────────────────────────────────┘
每个 Segment Header 记录一个精确地址:
├── Space ID (4 字节):INODE Entry 所在的表空间 ID
├── Page Number (4 字节):INODE Entry 所在的页号
└── Byte Offset (2 字节):INODE Entry 在页内的偏移量
这样,索引就能通过根页面中的 Segment Header 精确定位到叶子段和非叶子段的所有信息。
六、系统表空间与数据字典
6.1 系统表空间的特殊页面
系统表空间的整体结构和独立表空间类似,但多了几个记录整个系统信息的页面:
系统表空间布局(Space ID = 0):
页号 类型 用途
──────────────────────────────────────────
0 FSP_HDR 表空间头部信息 + 第1组 XDES Entry
1 IBUF_BITMAP Insert Buffer 位图
2 INODE INODE Entry 存储页
── 以下为系统表空间特有 ──
3 SYS Insert Buffer 头部
4 INDEX Insert Buffer 根页面
5 TRX_SYS 事务系统信息
6 SYS 第一个回滚段
7 SYS 数据字典头部 ⭐
── Doublewrite Buffer ──
64~127 双写缓冲区(第1区)
128~191 双写缓冲区(第2区)
6.2 InnoDB 数据字典
执行 INSERT INTO t VALUES (1, 'hello') 时,MySQL 需要验证:
- 表 t 是否存在?
- 列数量是否匹配?
- 该表的索引根页面在哪个表空间的哪个页?
这些"元数据"都存在 InnoDB 的内部系统表中:
InnoDB 的 4 个基本系统表(它们自己也是 B+ 树):
SYS_TABLES → 整个 InnoDB 中所有表的信息
SYS_COLUMNS → 所有列的信息(类型、长度、是否可空…)
SYS_INDEXES → 所有索引的信息(根页面位置、索引类型…)
SYS_FIELDS → 每个索引包含哪些列
这 4 张表的元数据(它们有哪些列、索引在哪里)硬编码在代码中,而它们的索引根页面位置记录在页号为 7 的 Data Dictionary Header 页面里:
Data Dictionary Header 的关键字段:
Max Row ID → 自增 row_id,全局共享
Max Table ID → 下次建表时分配给新表的 ID
Max Index ID → 下次建索引时分配给新索引的 ID
Max Space ID → 下次建表空间时分配给新表空间的 ID
Root of SYS_TABLES clust index → SYS_TABLES 聚簇索引根页面
Root of SYS_TABLE_IDS sec index → SYS_TABLES 的 ID 列二级索引根页面
Root of SYS_COLUMNS clust index → SYS_COLUMNS 聚簇索引根页面
Root of SYS_INDEXES clust index → SYS_INDEXES 聚簇索引根页面
Root of SYS_FIELDS clust index → SYS_FIELDS 聚簇索引根页面
6.3 information_schema:给用户开的"后门"
普通用户不能直接访问 SYS_* 内部系统表,但可以通过 information_schema 数据库中的 INNODB_SYS_* 表查看:
USE information_schema;
SHOW TABLES LIKE 'INNODB_SYS%';
— 结果:
— INNODB_SYS_TABLES ← 查看所有 InnoDB 表的信息
— INNODB_SYS_COLUMNS ← 查看所有列的定义
— INNODB_SYS_INDEXES ← 查看所有索引的信息
— INNODB_SYS_FIELDS ← 查看索引包含的列
— INNODB_SYS_TABLESPACES ← 查看所有表空间
— INNODB_SYS_DATAFILES ← 查看表空间对应的物理文件
— …
— 实战:查看某个表所在表空间的 ID
SELECT name, space FROM INNODB_SYS_TABLES WHERE name LIKE '%test%';
这些 INNODB_SYS_* 表不是真正的内部系统表,而是 MySQL 启动时从 SYS_* 表读取数据后填充的只读快照。
七、总结:一张图看清全貌
MySQL 数据存储层次结构
┌─────────────────────────────────────────────────────────────────┐
│ 数据目录 (datadir) │
│ /usr/local/var/mysql/ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ mydb/ │ │ testdb/ │ │ mysql/ │ … 数据库 │
│ │ ├ db.opt │ │ ├ db.opt │ │ (系统库) │ │
│ │ ├ t1.frm │ │ ├ t2.frm │ │ │ │
│ │ └ t1.ibd ─┼──┼──┼──→ 表空间 ──────────────┼─────────────── │
│ └────────────┘ │ └ t2.ibd │ └────────────┘ │
│ └────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
系统表空间 (ibdata1) 独立表空间 (t1.ibd)
每个实例只有一份 每个表一份
│ │
│ ┌─── 区 (Extent) ────────────────┐ │
└──│ 连续 64 个页 = 1MB │───┘
│ XDES Entry 管理每个区 │
│ FREE / FREE_FRAG / FULL_FRAG │
└────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
叶子节点段 (Leaf Segment) 非叶子节点段 (Non-Leaf Segment)
存放完整用户记录 存放目录项记录
INODE Entry 管理 INODE Entry 管理
FREE / NOT_FULL / FULL 链表 FREE / NOT_FULL / FULL 链表
│ │
└───────────────────┬───────────────────┘
▼
数据页 (16KB)
B+ 树的节点
真正的记录存储单元
核心要点回顾:
| 数据目录 | MySQL 存放所有数据的根路径 | 一栋大楼 |
| 数据库 | 数据目录下的一个子文件夹 | 大楼里的一层 |
| 表空间 | 管理页的逻辑容器,对应 .ibd 或 ibdata1 | 一层里的一个房间 |
| 区 (Extent) | 64 个连续页,分配空间的基本单位 | 房间里的一个书架 |
| 段 (Segment) | 索引的叶子/非叶子节点各自独立的区集合 | 书架按"小说/工具书"分区 |
| 页 (Page) | 16KB 的读写基本单位 | 书架上的一本书 |
| 数据字典 | InnoDB 内部系统表,记录表和索引的元数据 | 房间门口的目录索引 |
📚 延伸阅读:
- MySQL 官方文档: The InnoDB Storage Engine
- InnoDB 表空间管理源码分析
网硕互联帮助中心


评论前必须登录!
注册