跳到主要内容
预计阅读 25 分钟

数据的住所 —— InnoDB记录结构与数据页

你往数据库里插入一行数据,它最终会被写到磁盘上的文件里。但这行数据在文件里到底长什么样?是像文本文件那样一行一行地排列吗?今天我们深入InnoDB的内部,看看数据真正的”住所”是什么样的。

📋 开篇自测:你已经知道多少?

  1. 一行数据在InnoDB中是怎样存储的?除了你定义的列,InnoDB还会偷偷加上哪些”隐藏信息”?
  2. InnoDB从磁盘读数据时,是一行一行地读还是一批一批地读?每批多大?
  3. VARCHAR类型声明了最大长度后,实际存储时会占满这个长度吗?

一、行记录格式——一行数据的”身份证”

当你执行INSERT INTO users (name, age) VALUES ('张三', 25);时,InnoDB不会简单地把”张三,25”写进磁盘。它会把这行数据包装成一种特定的格式,我们称之为行记录格式(Row Format)

InnoDB支持四种行记录格式:

格式特点
CompactMySQL 5.0引入,较紧凑
Redundant最古老的格式,为兼容保留
DynamicMySQL 5.7.9+默认,处理大字段更好
Compressed对数据进行压缩存储

其中Dynamic是MySQL 5.7.9+的默认格式,我们主要学习这种。实际上Dynamic和Compact的结构非常相似,只在处理超长字段时有些区别。

一行数据的内部结构

让我们用一个具体的例子来说明。假设有这张表:

CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(20),
    department VARCHAR(30),
    salary DECIMAL(10,2)
) ROW_FORMAT=DYNAMIC;

INSERT INTO employees VALUES (1, '李明', '技术部', 15000.00);

这条记录在InnoDB中被存储为以下结构(从左到右):

┌─────────────────┬──────────────────┬────────────────┬──────────────┬────────────┐
│ 变长字段长度列表  │   NULL标志位      │  记录头信息     │  隐藏列       │  真实数据   │
│ (Variable Length │ (NULL Bitmap)    │ (Record Header)│ (Hidden Cols)│ (User Data)│
│  Field Lengths)  │                  │                │              │            │
└─────────────────┴──────────────────┴────────────────┴──────────────┴────────────┘

我们逐一拆解:

1. 变长字段长度列表

VARCHAR、TEXT、BLOB这些类型的字段,每行存的内容长度不一样。InnoDB需要知道每个变长字段的实际长度才能正确读取数据。

比如name列,虽然声明了VARCHAR(20),但”李明”只占6字节(UTF-8下每个中文字符3字节)。InnoDB会在记录开头记下每个变长字段的实际长度。

这就像寄快递时在包裹外面标注实际重量——虽然箱子最大能装20公斤,但这次只装了3公斤,收费按实际重量来。

注意:长度列表中各字段的长度是逆序存放的。 也就是说,最后一个变长列的长度反而存在最前面。

2. NULL标志位

如果某个列允许为NULL,InnoDB用一个位图来标记这行数据中哪些列的值是NULL。每个允许NULL的列占一个bit位。

比如表中有3个允许NULL的列,就用3个bit。某列的bit为1表示该列值为NULL,为0表示非NULL。

这样做的好处是:NULL值不需要在真实数据区域占用空间。一个bit就够了。

3. 记录头信息

这是一块固定5字节的区域,包含了一些元信息:

  • delete_flag:标记这条记录是否被删除(删除数据时InnoDB不会立刻擦除,而是先打上删除标记)
  • min_rec_flag:B+树非叶子节点中的最小记录标记
  • n_owned:该记录”拥有”的记录数(和数据页内的分组机制有关)
  • heap_no:这条记录在数据页中的排序位置
  • record_type:记录类型(普通记录、B+树非叶子节点记录、最小记录、最大记录)
  • next_record:指向下一条记录的偏移量

其中next_record非常重要——它把页面内的所有记录串成了一个单向链表

4. 隐藏列

InnoDB会在真实数据列之前偷偷加上几个隐藏列:

隐藏列大小用途
DB_ROW_ID6字节行ID(如果你没定义主键,InnoDB用这个做主键)
DB_TRX_ID6字节事务ID(记录最后一次修改该行的事务编号)
DB_ROLL_PTR7字节回滚指针(指向undo日志中该行的历史版本)

如果你已经定义了主键(比如id列),InnoDB就不会生成DB_ROW_ID。但DB_TRX_IDDB_ROLL_PTR是一定会有的——它们对事务和MVCC至关重要,后面的章节会详细讲。

5. 真实数据

终于到了你真正存的数据——1, '李明', '技术部', 15000.00。各列按照建表时定义的顺序紧密排列。

🤔 想一想 如果一张表有20个VARCHAR列,但某一行中有15个列的值是NULL,InnoDB实际需要为这些NULL值花费多少存储空间?


二、数据页——数据的”公寓楼”

InnoDB不会每次读写一行数据都跑一趟磁盘。那样效率太低了。它把数据组织成一个个数据页(Page),每次磁盘I/O以页为单位。

一个页的大小默认是16KB。

这就像搬家公司运货:不会一件一件地搬,而是把一批货物装进一个标准集装箱(16KB的页),一箱一箱地运。

-- 查看InnoDB的页大小
SHOW VARIABLES LIKE 'innodb_page_size';
-- 默认值:16384(字节)= 16KB

一个数据页的内部结构

一个16KB的数据页,内部被划分为7个部分:

┌───────────────────────────────┐
│      File Header (38字节)      │ ← 文件头:页的身份证
├───────────────────────────────┤
│      Page Header (56字节)      │ ← 页头:页的统计信息
├───────────────────────────────┤
│    Infimum + Supremum (26字节) │ ← 最小/最大虚拟记录
├───────────────────────────────┤
│                               │
│       User Records (变长)      │ ← 你的数据行就住在这里
│                               │
├───────────────────────────────┤
│       Free Space (变长)        │ ← 空闲空间
├───────────────────────────────┤
│    Page Directory (变长)       │ ← 页目录(加速页内查找)
├───────────────────────────────┤
│      File Trailer (8字节)      │ ← 文件尾:校验用
└───────────────────────────────┘

我们挑几个重要的来详细说明。

File Header(文件头)

这38个字节里藏了很多关键信息,其中最重要的两个字段:

  • FIL_PAGE_PREV:前一个页的页号
  • FIL_PAGE_NEXT:后一个页的页号

看到没?每个页都记录了自己的”前邻居”和”后邻居”。这意味着多个数据页之间通过这两个字段形成了一个双向链表

Infimum和Supremum

每个数据页中有两条特殊的虚拟记录:

  • Infimum(下确界):比页内所有真实记录都小的虚拟记录
  • Supremum(上确界):比页内所有真实记录都大的虚拟记录

它们就像一个班级的”虚拟第一名”和”虚拟最后一名”,用来标定记录范围的边界。

页面内所有的记录通过next_record指针串成一个单向链表,顺序是:

Infimum → 记录1 → 记录2 → 记录3 → ... → Supremum

User Records(用户记录区)

这就是你的数据真正居住的地方。每插入一条记录,就从Free Space中划出一块空间来存放这条记录。随着记录不断插入,Free Space越来越小,User Records越来越大。

Page Directory(页目录)

如果一个页里有几百条记录,查找某条记录时需要从Infimum开始沿链表逐个遍历吗?那也太慢了。

InnoDB的解决方案是页目录。它把页内的记录分成若干个”组”,然后在页目录中记录每组最后一条记录的地址偏移量,这些偏移量叫做槽(Slot)。分组有明确的规则:Infimum所在的组只有1条记录,Supremum所在的组有1-8条记录,其余中间组都是4-8条记录。

查找记录时,先在页目录的槽中进行二分查找,快速定位到目标记录所在的分组,然后在分组内逐个遍历(最多遍历8条记录)。

打个比方:你去图书馆找一本书。不用从第一个书架开始一本本翻——先看楼层指示牌(页目录)定位到楼层,再看书架号定位到分区,最后在一小段书架上找到目标书。

页目录的槽:  [Slot0] [Slot1] [Slot2] [Slot3] [Slot4]
              ↓       ↓       ↓       ↓       ↓
记录分组:    [组0]   [组1]   [组2]   [组3]   [组4]
            1条     4条     5条     6条     4条
       (Infimum组)                    (Supremum组)

⚠️ 常见误区 误区一:DELETE删除数据后磁盘空间会立刻释放。 InnoDB删除一行数据时,只是在记录头信息中把delete_flag设为1,并不会立刻回收空间。这条”已删除”的记录会进入一个”垃圾链表”,后续插入新记录时可以重用这些空间。这就是为什么大量删除数据后,表的磁盘文件大小可能并不会减小。

误区二:一个页只存一张表的数据。 虽然在最常见的情况下是这样,但如果你使用系统表空间且存了多张表的数据,一个页理论上可能混存不同表的记录。不过使用独立表空间(file-per-table,MySQL 5.6.6+的默认设置)时,每张表的数据都在自己的文件中。


三、行溢出——数据太胖住不下怎么办

一个页只有16KB,约16000多字节。如果你有一个VARCHAR(65535)类型的列,一行数据可能就超过了16KB。这时候一行数据在一个页里放不下,怎么办?

InnoDB的处理策略取决于行格式:

Dynamic格式(默认)

当一行数据太大以至于一个页放不下时,Dynamic格式会把溢出的大字段完全存到独立的溢出页中。原始页中只存一个20字节的指针,指向溢出页的位置。

数据页                     溢出页链
┌──────────────┐          ┌──────────────┐     ┌──────────────┐
│ id: 1        │          │ 大字段数据    │ →   │ 大字段数据    │
│ name: '...'  │          │ (前16KB)     │     │ (续...)      │
│ bio: [指针]───┼─────────→│              │     │              │
│              │          └──────────────┘     └──────────────┘
└──────────────┘

Compact格式

Compact格式会尽量在数据页中存储大字段的前768字节,剩余部分存到溢出页。

什么时候会触发行溢出?

关键判断标准:InnoDB要求每个数据页至少能存放2条记录。 如果一行数据太大,导致一个页连2条记录都放不下,就必须进行行溢出处理。

-- 实验:创建一个有大字段的表
CREATE TABLE big_content (
    id INT PRIMARY KEY AUTO_INCREMENT,
    content LONGTEXT
) ROW_FORMAT=DYNAMIC;

-- 插入一段很长的内容
INSERT INTO big_content (content)
VALUES (REPEAT('MySQL学习笔记', 10000));

-- 查看这条记录的实际大小
SELECT id, LENGTH(content) AS bytes FROM big_content;

🤔 想一想 VARCHAR(M)中的M到底代表什么?如果表的字符集是utf8mb4,VARCHAR(50)最多能存多少字节的数据?这个限制和行溢出阈值有什么关系?


四、记录之间的关系——微观世界中的秩序

让我们用一个完整的例子来看看多条记录在一个数据页里是怎样组织的。

CREATE TABLE scores (
    id INT PRIMARY KEY,
    student_name VARCHAR(20),
    score INT
);

INSERT INTO scores VALUES (5, '王五', 88);
INSERT INTO scores VALUES (2, '李四', 92);
INSERT INTO scores VALUES (8, '赵六', 75);
INSERT INTO scores VALUES (1, '张三', 95);
INSERT INTO scores VALUES (4, '孙七', 83);

虽然我们插入的顺序是5、2、8、1、4,但在数据页内部,记录通过next_record指针按照主键值的大小排成有序链表:

Infimum → [id=1] → [id=2] → [id=4] → [id=5] → [id=8] → Supremum

注意,物理存储顺序和逻辑顺序不一定一致。记录在User Records区域中可能是按插入顺序排列的(id=5, 2, 8, 1, 4),但通过next_record指针,逻辑上形成了按主键排序的链表。

这就像你在本子上随意记了几个人的信息,但在每条记录旁边标注了”下一条去找第几行”,从而形成了一个有序的阅读顺序。

当页面内记录太多时,InnoDB会把记录分成若干组,并在页目录中创建对应的槽,从而支持二分查找,加速页内检索。

页面分裂——公寓楼住满了

当一个数据页中的Free Space用完后,新的记录就放不下了。这时InnoDB会进行页面分裂(Page Split)

  1. 申请一个新的数据页
  2. 把当前页一半的记录搬到新页中
  3. 更新双向链表,把新页插入到合适的位置

页面分裂是一个比较昂贵的操作。这也是为什么使用自增主键(AUTO_INCREMENT)通常是个好选择——新记录总是追加到最后,减少了页面分裂的概率。如果你用UUID作主键,插入位置随机分散,会导致频繁的页面分裂。

-- 好的做法:使用自增主键
CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(32),
    amount DECIMAL(10,2)
);

-- 不太好的做法:使用UUID作为主键
CREATE TABLE orders_bad (
    id CHAR(36) PRIMARY KEY,  -- UUID
    order_no VARCHAR(32),
    amount DECIMAL(10,2)
);

⚠️ 常见误区 误区:数据在磁盘上是按主键顺序连续存储的。 虽然逻辑上记录按主键排序,但物理上各个数据页可以分散在磁盘的不同位置。页与页之间靠双向链表串联(通过FIL_PAGE_PREV和FIL_PAGE_NEXT),而非物理上紧挨着。不过InnoDB会尽量让逻辑相邻的页在物理上也相邻,以提高顺序读的性能。


五、动手观察——用工具窥探数据页

理论讲了这么多,我们来动手验证一下。

-- 创建一个测试表并插入数据
CREATE DATABASE page_demo;
USE page_demo;

CREATE TABLE t (
    id INT PRIMARY KEY,
    a VARCHAR(100),
    b VARCHAR(100)
) ENGINE=InnoDB ROW_FORMAT=COMPACT;

INSERT INTO t VALUES (1, 'aaaa', 'bbbb');
INSERT INTO t VALUES (2, 'cccc', 'dddd');
INSERT INTO t VALUES (3, 'eeee', 'ffff');

-- 查看表的行格式
SHOW TABLE STATUS LIKE 't'\G

-- 查看InnoDB的一些表空间信息
SELECT * FROM information_schema.INNODB_TABLESPACES
WHERE NAME LIKE 'page_demo%'\G

-- 查看每行数据的大致长度
SELECT id, a, b,
       LENGTH(a) + LENGTH(b) AS user_data_bytes
FROM t;

除了SQL层面的观察,你还可以使用专门的工具直接解析InnoDB的.ibd文件,从二进制层面验证我们前面学到的页结构知识。

使用ibd2sdi查看表结构元数据(MySQL 8.0+)

MySQL 8.0开始,表的数据字典信息(SDI)直接存储在.ibd文件中。你可以用自带的ibd2sdi工具提取:

# 找到表对应的.ibd文件(Linux下通常在MySQL数据目录中)
# 例如:/var/lib/mysql/page_demo/t.ibd

# 提取SDI信息(JSON格式输出)
ibd2sdi /var/lib/mysql/page_demo/t.ibd

输出会包含表的列定义、字符集、行格式等元数据,可以验证你对表结构的理解。

使用innodb_space深入分析页结构(innodb_ruby)

innodb_space是开源工具innodb_ruby提供的命令行工具,可以解析.ibd文件中每一页的详细结构。注意:该项目长期未活跃更新,对 MySQL 8.0 较新版本生成的 .ibd 文件兼容性可能有限。如果遇到解析问题,可以优先使用上面介绍的 ibd2sdi(MySQL 8.0 自带)或 MySQL Shell 的 util.checkTable 等官方工具。

# 安装innodb_ruby(需要Ruby环境)
gem install innodb_ruby

# 查看表空间中所有页的类型概览
innodb_space -f /var/lib/mysql/page_demo/t.ibd space-page-type-regions

# 查看某一页的详细结构(比如第3页,通常是第一个数据页)
innodb_space -f /var/lib/mysql/page_demo/t.ibd -p 3 page-dump

# 查看页目录中的槽信息
innodb_space -f /var/lib/mysql/page_demo/t.ibd -p 3 page-directory-summary

# 查看页内所有记录
innodb_space -f /var/lib/mysql/page_demo/t.ibd -p 3 page-records

通过page-dump的输出,你可以实际看到File Header的38字节、Page Header的56字节、Infimum和Supremum记录,以及每条用户记录的变长字段长度列表、NULL标志位、记录头信息等细节——这些正是我们前面学到的所有概念的直接印证。

🤔 想一想 InnoDB的默认页大小是16KB。假设每行数据平均占200字节,一个数据页大约能存多少行?如果一张表有100万行数据,大约需要多少数据页?这些页的总大小是多少?


📝 掌握度自测

  1. InnoDB为每一行数据自动添加了哪些隐藏列?它们分别有什么用途?
  2. 一个数据页的默认大小是多少?页内的记录是通过什么方式保持按主键排序的?
  3. 页目录(Page Directory)的作用是什么?它如何加速页内的记录查找?
  4. 什么是行溢出?Dynamic行格式下,溢出数据是如何处理的?
  5. 为什么使用自增主键比UUID主键更有利于InnoDB的插入性能?

💡 自我评估

  • 答对5题:你已经理解了InnoDB数据存储的底层逻辑,为后面学习索引打下了坚实基础
  • 答对3-4题:核心概念到位,建议回顾页目录和行溢出部分
  • 答对0-2题:这章内容比较底层,建议结合实际SQL操作对照理解,不必死记每个字段的字节数

购买课程解锁全部内容

让查询飞起来:MySQL 从索引到主从高可用

¥29.90