06第六章:MySQL数据库中的一条数据是如何存储的?
在MySQL的学习之路中,我们已经掌握了表的创建、SQL语句的执行,但你有没有想过一个问题:当我们执行 INSERT INTO 插入一条数据后,这条数据在底层到底是如何被存储的?它以什么格式存在磁盘上?为什么有的字段能存很长的内容,有的却会被限制?今天这一章,我们就结合InnoDB源码(基于MySQL 8.0版本),深度解析一条MySQL数据的存储全过程,从行格式到数据页,从元数据到底层存储,帮你彻底搞懂数据存储的核心逻辑。
注:MySQL的存储引擎有很多,但InnoDB是目前默认且最常用的引擎(支持事务、索引、MVCC等核心特性),因此本文所有分析均围绕InnoDB展开,这也是面试中考察的重点。
一、InnoDB 的行格式(Row Format):数据存储的“容器”
InnoDB 在物理层面存储表数据时,并不是直接将用户插入的列值堆砌在一起,而是将每一行(row)按照固定的“行格式”组织起来,再存储到数据页(data page)中。简单来说,行格式就是一条数据的“包装规范”,定义了数据的组织方式、元数据的存储规则,不同的行格式对应不同的存储优化策略。
1. InnoDB 支持的四种行格式
在InnoDB源码中,行格式的定义位于 storage/innobase/include/row0types.h 文件中,通过枚举row_format_t 定义了四种行格式,对应不同的版本和使用场景,具体如下表所示:
| 行格式 | 简介 | 源码标识 | 适用场景 |
|---|---|---|---|
| REDUNDANT | 最早的行格式(MySQL 5.0 之前),采用“冗余”设计,存储了较多重复的列信息,空间利用率低,目前已不推荐使用。 | ROW_FORMAT_REDUNDANT | 兼容MySQL 5.0之前的旧版本,新项目绝对不建议使用。 |
| COMPACT | MySQL 5.0 起默认格式,核心优势是“高效节省空间”,精简了冗余信息,同时保留了基本的元数据,是目前最常用的行格式之一。 | ROW_FORMAT_COMPACT | 大多数常规业务场景,对空间利用率有一定要求,且不需要存储超大字段。 |
| DYNAMIC | MySQL 5.7 起成为常用格式,是COMPACT的优化版本,最大特点是支持大字段(如TEXT、BLOB)的页外存储,进一步提升空间利用率。 | ROW_FORMAT_DYNAMIC | 存在大字段(TEXT、BLOB)的场景,如博客内容、文件二进制数据存储等。 |
| COMPRESSED | 与DYNAMIC类似,支持大字段页外存储,但在此基础上增加了数据页压缩(使用zlib算法),进一步节省磁盘空间,但会增加CPU解压开销。 | ROW_FORMAT_COMPRESSED | 磁盘空间紧张,且CPU资源充足的场景(如归档数据、日志数据存储)。 |
👉 实际开发中,我们优先选择 COMPACT 或 DYNAMIC 即可:常规业务用COMPACT,有大字段用DYNAMIC。
行格式的配置方式非常简单,创建表时指定 ROW_FORMAT 参数即可,示例如下:
1 | -- 创建使用COMPACT行格式的表 |
提示:MySQL 8.0 中,默认行格式已改为 DYNAMIC(可通过 show variables like 'innodb_default_row_format'; 查看),这也是为了更好地适配大字段存储场景。
二、COMPACT 行格式的结构
COMPACT 行格式是学习InnoDB数据存储的核心,也是面试中高频考察的知识点。不管是DYNAMIC还是COMPRESSED,其核心结构都基于COMPACT演变而来,因此我们重点拆解COMPACT行格式的结构。
一条记录(一行数据)在COMPACT行格式下,从前往后分为4个部分,结构如下(源码中对应 row_compact_t 结构体):
| 变长字段长度列表 | NULL标志位 | 记录头信息 | 实际数据(列数据) |
接下来,我们逐一拆解每个部分的作用、存储规则,结合源码细节让大家理解更透彻。
1️⃣ 变长字段长度列表 (Variable-length field length list)
在MySQL中,可变长类型的字段非常常见,比如 VARCHAR、VARBINARY、TEXT、BLOB 等,这些字段的长度不是固定的(比如 VARCHAR(255) 可以存1个字符,也可以存255个字符)。为了准确读取这些字段的数据,InnoDB 会在一行数据的最开头,专门存储这些可变长字段的长度信息,这就是“变长字段长度列表”。
核心规则(源码对应 row_compact_write_var_len 函数):
- 顺序是 逆序存放:最后一个可变长列的长度,会存放在变长字段长度列表的最前面。这是因为InnoDB读取数据时,会从后往前解析,逆序存放能提升读取效率(源码中通过指针偏移实现快速定位)。
- 长度占用字节数:根据字段的最大长度决定
- 若字段最大长度 ≤ 255(如 VARCHAR(20)),则用 1 字节存储长度(1字节可表示0-255的数值);
- 若字段最大长度 > 255(如 VARCHAR(255)、TEXT),则用 2 字节存储长度(2字节可表示0-65535的数值)。
实例演示:
创建一张COMPACT行格式的表,包含两个可变长字段:
1 | CREATE TABLE user ( |
插入一条数据:INSERT INTO user VALUES (1, '张三', 'zhangsan@163.com');
此时,变长字段长度列表的存储顺序为(逆序):
email长度(1字节) | name长度(1字节)
假设 email 长度为16(”zhangsan@163.com“ 共16个字符),name 长度为2(”张三” 是2个字符,utf8mb4编码下占8字节,但这里存储的是“字符数”吗?不!注意:变长字段长度列表存储的是 字节数,不是字符数。因为MySQL存储时,字符会被编码为字节(如utf8mb4编码下,一个中文占4字节),所以这里 name 的长度是 2×4=8 字节,存储为1字节(8≤255);email 的长度是16字节,存储为1字节。
因此,实际的变长字段长度列表为:0x10(16的十六进制) | 0x08(8的十六进制)。
2️⃣ NULL 标志位 (NULL bitmap)
当表中的列允许为 NULL 时,InnoDB 不会将 NULL 值直接存储在实际数据中(这样会浪费空间),而是用一个“位图”来标记哪些列的值是 NULL,这个位图就是“NULL标志位”。
核心规则(源码对应 row_compact_write_null_bits 函数):
- 每 8 个允许为 NULL 的列,占用 1 个字节(8位,每一位对应一个列,1表示该列值为NULL,0表示非NULL)。
- 若列不允许为 NULL(即定义时加了 NOT NULL),则该列不会出现在 NULL 标志位中,无需占用位图空间。
- NULL 标志位的字节数 = ceil(允许为NULL的列数 / 8)(ceil表示向上取整)。
实例演示:
假设一张表有 10 个列,其中 6 个列允许为 NULL,4 个列不允许为 NULL(NOT NULL)。
则允许为NULL的列数为6,NULL标志位占用的字节数 = ceil(6/8) = 1 字节(8位足够标记6个列)。
若允许为NULL的列数为9,则 NULL标志位占用的字节数 = ceil(9/8) = 2 字节(第一个字节标记8个列,第二个字节标记第9个列)。
提示:NULL标志位仅标记允许为NULL的列,即使这些列的值不为NULL,也会占用对应的位(位值为0);若允许为NULL的列值为NULL,则对应位值为1。
3️⃣ 记录头信息(Record Header):数据的“管理身份证”
每条记录都有一个固定长度的“记录头信息”,用于InnoDB的内部管理(如链表管理、事务控制、MVCC等),固定占用 5 字节(40位)。这部分信息是InnoDB实现高级特性的关键,源码中对应 rec_header_t 结构体,每一位的含义都有明确定义。
记录头信息的40位,分为6个部分,具体如下表(重点记忆前5个):
| 字段 | 长度(位) | 作用(源码解读) |
|---|---|---|
| delete_flag | 1 | 标记记录是否被删除(0=未删除,1=已删除)。注意:InnoDB删除记录时,不会真正物理删除,只是将该位置为1,后续通过“垃圾回收”(purge)清理,这也是MVCC的基础。 |
| min_rec_flag | 1 | 标记该记录是否为B+树叶子页中的最小记录(InnoDB的B+树叶子页会有一个“最小记录”和“最大记录”作为边界,方便查询定位)。 |
| n_owned | 4 | 当前记录拥有的记录数,用于InnoDB的“分组管理”(将叶子页中的记录分成多个组,每个组有一个“拥有者”记录,提升查询效率)。 |
| heap_no | 13 | 记录在数据页中的位置编号(heap_no=0 是最小记录,heap_no=1 是最大记录,用户插入的记录从 heap_no=2 开始)。 |
| record_type | 3 | 记录类型:0=普通记录(用户插入的记录),1=最小记录,2=最大记录,3=索引记录(非叶子页的索引记录)。 |
| next_record | 16 | 下一条记录的偏移量(相对于当前记录的位置),InnoDB通过这个字段将同一数据页中的所有记录串联成一个双向链表,方便顺序遍历。 |
这里补充一个源码细节:记录头信息的存储顺序,在源码中是按“高位在前、低位在后”的方式存储的,因此读取时需要进行位运算解析(对应 rec_get_xxx 系列函数,如 rec_get_delete_flag 获取删除标记)。
4️⃣ 实际列数据(User Data):用户真正要存储的数据
这一部分是一行数据的核心,存放的是用户定义的所有列的值(包括主键、普通列、外键等),存储规则如下:
- 固定长度列(如 INT、CHAR、DATE 等):直接按顺序存储,占用固定的字节数(如 INT 固定4字节,CHAR(10) 固定10字节,即使存储的字符不足10个,也会用空格填充)。
- 可变长度列(如 VARCHAR、TEXT 等):实际数据的存储顺序与表定义的顺序一致,但长度由前面的“变长字段长度列表”指定,无需填充空格(节省空间)。
- NULL 值:不会存储实际数据,仅在“NULL标志位”中标记对应位为1,以此节省空间(这也是 VARCHAR 和 CHAR 的重要区别之一:VARCHAR 可以存NULL,且不占用额外空间;CHAR 存NULL时,仍会占用固定长度的空间)。
- 隐藏列:InnoDB会自动为表添加3个隐藏列(源码中对应
row_id_t、trx_id_t、roll_ptr_t),用于事务和MVCC管理:- row_id:行ID(6字节),若表没有主键,InnoDB会用这个列作为默认主键;
- trx_id:事务ID(6字节),标记插入/修改该记录的事务ID;
- roll_ptr:回滚指针(7字节),指向该记录的undo日志,用于事务回滚和MVCC读取。
三、记录的额外信息与真实数据:底层存储的本质
通过上面的拆解,我们可以发现:一行数据的存储,不仅仅包含用户插入的真实业务数据,还包含了大量的“额外信息”(变长字段长度列表、NULL标志位、记录头信息、隐藏列)。这些额外信息虽然不存储业务数据,但却是InnoDB实现事务、索引、MVCC、垃圾回收等高级特性的基础。
我们用一张表格总结两者的区别与作用:
| 类型 | 包含内容 | 核心作用 |
|---|---|---|
| 额外信息 | 变长字段长度列表、NULL标志位、记录头信息、隐藏列 | 1. 描述数据的存储格式(如变长字段长度);2. 实现数据页内的链表管理(next_record);3. 支持事务和MVCC(trx_id、roll_ptr);4. 标记记录状态(delete_flag)。 |
| 真实数据 | 用户定义的列值(主键、普通列、外键等) | 存储实际的业务数据,供用户查询、修改、删除。 |
这里有一个关键结论:InnoDB 的“行”不是孤立存在的,而是依附于“数据页”(一个数据页默认16KB),一行数据的所有信息(额外信息+真实数据)都存储在一个数据页中,当数据量超过一个数据页时,会自动分配新的数据页,这也是MySQL能高效管理大量数据的核心原因之一。
四、VARCHAR(n) 中的 n 最大取值是多少?(面试高频题)
这是一个非常经典的面试题,很多人容易混淆“n是字符数还是字节数”,甚至不知道存在单行字节限制。结合前面的行格式知识,我们彻底搞懂这个问题。
核心结论(源码依据:storage/innobase/include/row0row.h 中 ROW_FORMAT_MAX_ROW_SIZE 定义):
- VARCHAR(n) 中的 n 表示 字符数,不是字节数。例如:VARCHAR(10) 表示最多可以存储10个字符,不管每个字符占用多少字节(utf8mb4编码下,一个字符最多4字节)。
- MySQL 对单行数据的最大字节限制为 65535 字节(这是InnoDB和MyISAM引擎的共同限制),这个限制包含:所有列的真实数据、变长字段长度列表、NULL标志位,但不包含记录头信息和隐藏列。
- 因此,VARCHAR(n) 的最大取值,取决于编码格式(每个字符占用的字节数)和其他列的占用空间。
举例计算(以最常用的 utf8mb4 编码为例):
utf8mb4 编码下,一个字符最多占用4字节,因此:
VARCHAR(n) 最大理论字符数 = 65535 / 4 = 16383.75,向下取整为 16383 个字符。
但实际开发中,我们无法取到16383,因为:
- 变长字段长度列表会占用空间(VARCHAR(16383) 的长度是16383×4=65532字节,需要2字节存储长度);
- 如果表中还有其他列(如主键INT,占用4字节),会进一步占用单行字节空间;
- 若存在允许为NULL的列,NULL标志位也会占用1-2字节。
因此,实际开发中,VARCHAR(n) 的n建议不超过16380,避免触发单行字节限制。
补充:若使用 utf8 编码(一个字符最多3字节),VARCHAR(n) 最大理论字符数 = 65535 / 3 ≈ 21845,但同样受其他列和额外信息的影响。
五、行溢出(Row Overflow)与 MySQL 的处理机制
我们知道,InnoDB 的一个数据页默认是16KB(16384字节),而有些字段(如 TEXT、BLOB)可能会存储非常大的数据(比如几MB的文本、图片二进制数据),此时一行数据的总字节数会超过16KB,无法全部存储在一个数据页中,这种情况就叫做“行溢出”(Row Overflow)。
InnoDB 针对行溢出,根据不同的行格式,采用了不同的处理机制(源码对应 row_overflow.h中的相关函数),核心思路是“将大字段移出主数据页,主数据页只保留指针”,从而保证数据页的高效利用。
1. 行溢出的处理机制(按行格式分类)
| 行格式 | 溢出存储方式 | 优势与不足 |
|---|---|---|
| COMPACT | 大字段的前 768 字节存放在主数据页(与其他列数据一起),其余部分存放到专门的“溢出页”(overflow page),主数据页中只保存一个 20 字节的指针,指向溢出页。 | 优势:读取大字段的前768字节时,无需访问溢出页,提升读取效率;不足:主数据页仍会占用一定空间,若大字段较多,主数据页可能会存储更多指针。 |
| DYNAMIC | 整个大字段都存放在溢出页中,主数据页中只保存一个 20 字节的指针,指向溢出页(没有768字节的限制)。 | 优势:主数据页空间利用率极高,能存储更多的普通记录;不足:读取任何大字段数据,都需要访问溢出页,增加一次磁盘IO。 |
| COMPRESSED | 与DYNAMIC类似,整个大字段存放在溢出页中,主数据页保存20字节指针,但额外对主数据页和溢出页的数据进行压缩(zlib算法)。 | 优势:磁盘空间占用最少;不足:压缩和解压需要消耗CPU资源,读取效率略低。 |
注:REDUNDANT 行格式也支持行溢出,但因已淘汰,此处不做详细说明。
2. 行溢出的读取流程
无论哪种行格式,读取溢出字段的流程都大致相同,分为两步(源码对应row_overflow_read 函数):
- InnoDB 先读取主数据页中的记录,找到溢出字段对应的 20 字节指针;
- 通过指针定位到溢出页,从溢出页中加载大字段的完整数据,拼接后返回给用户。
提示:行溢出会增加磁盘IO次数(主数据页+溢出页),因此实际开发中,尽量避免在数据库中存储超大字段(如几MB的图片、视频),建议将这些大文件存储在对象存储(如OSS)中,数据库中只存储文件的URL地址。
六、总结图示与核心要点复习
1. COMPACT 行格式完整图示
为了方便大家记忆,我们用一张图示总结COMPACT行格式的完整结构(从左到右):
+————————————————————–+
| 变长字段长度列表 | NULL标志位 | 记录头信息 | 实际列数据 |
| (逆序存放) | (是否为NULL) | (管理信息) | (用户真实数据) |
+————————————————————–+
具体到实际记录的示意(以之前的user表为例):
| email_len(1字节) | name_len(1字节) | null_bits(1字节) | header(5字节) | id(4字节) | name(8字节) | email(16字节) |
2. 核心要点复习
| 内容 | 关键点 |
|---|---|
| 行格式 | InnoDB支持4种:REDUNDANT(淘汰)、COMPACT(常用)、DYNAMIC(常用,大字段)、COMPRESSED(压缩存储)。 |
| COMPACT结构 | 核心四部分:[变长字段长度列表][NULL标志位][记录头信息][实际数据],变长字段长度逆序存放。 |
| 记录额外信息 | 变长字段长度列表(存变长字段字节数)、NULL位图(标记NULL值)、记录头信息(5字节,管理用)、隐藏列(3个,事务/MVCC用)。 |
| VARCHAR(n)最大取值 | n是字符数,utf8mb4编码下最大约16383,受单行65535字节限制,实际取值需扣除其他列和额外信息占用空间。 |
| 行溢出 | 行总字节数超过16KB时触发,大字段移至溢出页,主数据页保存20字节指针;COMPACT存前768字节,DYNAMIC全部移出。 |
| 不同行格式溢出差异 | COMPACT:大字段前768字节存主页,其余存溢出页;DYNAMIC/COMPRESSED:大字段全部存溢出页。 |
七、拓展思考
- 为什么InnoDB的行格式要设计成“变长字段长度列表逆序存放”?
答:因为InnoDB读取数据时,是从后往前解析的(先读实际数据,再读元数据),逆序存放变长字段长度,能快速定位到每个变长字段的起始位置,提升读取效率(避免从头遍历长度列表)。
- 为什么NULL值不直接存储,而是用NULL标志位标记?
答:为了节省空间。如果每个NULL值都存储占位符(如0),会浪费大量空间;用1位标记一个NULL值,8个NULL值仅占用1字节,空间利用率极高。
- 行溢出会影响查询性能吗?如何优化?
答:会影响。行溢出需要多一次磁盘IO(访问溢出页),导致查询变慢。优化方案:① 避免存储超大字段,用对象存储替代;② 选择合适的行格式(大字段用DYNAMIC);③ 拆分表,将大字段单独放在一张表中(分表优化)。
以上就是MySQL中一条数据的存储全过程,结合InnoDB源码解析了行格式、元数据、行溢出等核心知识点,这些内容不仅是面试高频考点,也是理解InnoDB引擎工作原理的基础。下一章,我们将讲解数据页的结构,看看多行数据是如何组织在数据页中的,敬请期待!
一键获取完整项目代码(包含本文所有示例SQL、源码片段),关注专栏即可领取。



