云计算百科
云计算领域专业知识百科平台

MySQL 数据目录与 InnoDB 表空间底层存储全景揭秘

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 在磁盘上做了两件事:

  • 在数据目录下创建一个名为 mydb 的子文件夹
  • 在 mydb 文件夹里创建一个 db.opt 文件,记录该数据库的字符集和比较规则
  • 数据目录/
    ├── 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 表空间管理源码分析
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » MySQL 数据目录与 InnoDB 表空间底层存储全景揭秘
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!