Mysql:一行数据是怎么存储的?

一、先说结论

在 InnoDB 中,一张表本质上是一棵以主键组织的 B+Tree 聚簇索引

复制代码
表
└── 聚簇索引 B+Tree
    ├── 非叶子节点:保存索引键和页指针
    └── 叶子节点:保存完整的一行数据

因此,一行数据不是独立地"放在某个文件里",而是作为一条索引记录,存储在聚簇索引的叶子页中。二级索引则只保存索引列和对应的主键值。

二、从数据页到记录

InnoDB 按照"页"管理磁盘和内存中的数据。

默认情况下,一个 InnoDB 页是:

复制代码
16KB

也可以在初始化 MySQL 实例时配置为 4KB8KB16KB32KB64KB。同一个实例中的 InnoDB 表空间使用相同的页大小。

一个叶子页可以简化理解为:

复制代码
┌──────────────────────────────┐
│ 页头等管理信息                │
├──────────────────────────────┤
│ 记录1                         │
│ 记录2                         │
│ 记录3                         │
│ ...                           │
├──────────────────────────────┤
│ 剩余空间                      │
├──────────────────────────────┤
│ 页目录等管理信息              │
└──────────────────────────────┘

一个页通常包含多行记录。行越小,一个页能放下的记录越多,查询时需要读取的数据页通常就越少。

三、一行记录由什么组成

假设有表:

复制代码
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(20) NOT NULL,
    age INT NULL,
    status TINYINT NOT NULL,
    bio TEXT
) ENGINE = InnoDB;

插入一行:

复制代码
INSERT INTO users
VALUES (1, '张三', 20, 1, NULL);

在现代 MySQL 常用的 DYNAMICCOMPACT 行格式中,一条聚簇索引记录可以简化为:

复制代码
┌────────────────────┐
│ 变长字段长度列表    │
├────────────────────┤
│ NULL值位图          │
├────────────────────┤
│ 5字节记录头         │
├────────────────────┤
│ 用户定义的字段数据  │
├────────────────────┤
│ DB_TRX_ID:6字节    │
├────────────────────┤
│ DB_ROLL_PTR:7字节  │
└────────────────────┘

这是便于理解的简化表示。实际字节排列还与行格式、字段定义和索引类型有关。DYNAMIC 行格式沿用了 COMPACT 的基本记录结构,同时改进了长字段的页外存储。

四、变长字段长度列表

对于 VARCHARVARBINARY 等变长字段,InnoDB需要知道每个字段实际占用了多少字节。

例如:

复制代码
name VARCHAR(20)

VARCHAR(20) 表示最多保存 20 个字符,但并不是每行固定占用 20 个字符的空间。

如果保存:

复制代码
张三

utf8mb4 编码下,这两个汉字通常占用 6 个字节。因此记录中会保存:

复制代码
name实际数据:6字节
长度信息:记录实际字节长度

大多数变长字段的长度信息占 12 字节,取决于字段最大长度、实际长度以及是否使用页外存储。

所以:

复制代码
VARCHAR:按实际数据长度存储,并额外保存长度信息
CHAR:更接近固定长度存储,但还受到字符集和尾随空格规则影响

五、NULL 值位图

对于允许为 NULL 的字段,InnoDB 不会给每个字段都保存一个字符串 "NULL",而是用位图表示。

例如:

复制代码
age INT NULL,
bio TEXT NULL

这两个字段都允许为空,因此需要两个二进制位:

复制代码
age是否为NULL:0或1
bio是否为NULL:0或1

如果 bioNULL

复制代码
NULL位图:标记bio为NULL
bio字段数据:不再占用实际数据空间

需要注意:

复制代码
bio = NULL

和:

复制代码
bio = ''

不一样。

NULL:通过 NULL 位图表示,没有字段内容

空字符串:不是 NULL,需要保存长度为 0 的字段信息

如果索引中有 N 个可空字段,NULL 位图占用:

复制代码
CEILING(N / 8) 字节

例如有 9~16 个可空字段,就需要 2 字节。

六、记录头

COMPACTDYNAMIC 格式的每条索引记录包含一个 5 字节的固定记录头。

记录头中保存的是 InnoDB 管理记录所需的信息,例如:

记录是否被删除

记录在页中的组织信息

下一条记录的位置

记录类型

当前记录属于第几层等

它并不是用户定义的字段,但每条索引记录都需要这些管理信息。(dev.mysql.com)

七、用户字段数据

接下来是用户定义的非 NULL 字段值:

复制代码
id
name
age
status
bio

固定长度字段通常按照对应类型占用空间,例如:

复制代码
BIGINT   8字节
INT      4字节
TINYINT  1字节

因此示例记录中的部分数据可以简化为:

复制代码
id      = 1       约8字节
name    = 张三    约6字节,另有长度信息
age     = 20      约4字节
status  = 1       约1字节
bio     = NULL    通过NULL位图表示

实际占用空间还包括记录头、长度列表、NULL 位图和 InnoDB 隐藏字段。

八、InnoDB 的隐藏字段

聚簇索引记录除了用户定义的字段,还包含两个重要的隐藏字段。

DB_TRX_ID

占用 6 字节,保存最后一次插入或更新这条记录的事务信息。

它与 InnoDB 的 MVCC 多版本并发控制有关。

DB_ROLL_PTR

占用 7 字节,指向与这条记录相关的 Undo Log 信息。

当其他事务需要查看旧版本时,InnoDB 可以沿着 Undo 信息构建之前的记录版本。

简化理解:

复制代码
当前记录
   │
   └── DB_ROLL_PTR
          ↓
       上一个版本
          ↓
       更早版本

因此,InnoDB 所说的"一行数据"不只有业务字段,还携带事务和版本管理信息。(dev.mysql.com)

九、没有主键会怎样

InnoDB 需要一个键来组织聚簇索引,选择顺序大致是:

  1. 使用显式定义的主键。
  2. 没有主键时,选择第一个所有字段都为 NOT NULL 的唯一索引。
  3. 两者都没有时,InnoDB 创建隐藏聚簇索引,并为每行生成 6 字节的 DB_ROW_ID

因此,建议 InnoDB 表显式定义主键:

复制代码
id BIGINT PRIMARY KEY

否则 InnoDB 仍然会在内部创建隐藏主键,只是开发者无法直接使用它。

十、长字段怎么存储

假设有字段:

复制代码
content TEXT

它不一定完全存储在当前记录中。

当记录过大时,InnoDB可能把长字段内容放到单独的溢出页中:

复制代码
聚簇索引记录
┌───────────────────┐
│ id                │
│ title             │
│ content的20字节指针│──────┐
└───────────────────┘      │
                           ↓
                    ┌──────────────┐
                    │ Overflow Page│
                    │ content内容  │
                    └──────────────┘

现代 MySQL 默认通常使用 DYNAMIC 行格式。它可以把较长的 VARCHARVARBINARYTEXTBLOB 值完全放到页外,聚簇索引记录中保留一个 20 字节指针。

但不是所有 TEXT 都必然存到页外。InnoDB会根据字段长度、整行大小和页面空间决定;较短的值通常仍然直接保存在记录中。

十一、二级索引记录不是完整的一行

假设建立索引:

复制代码
CREATE INDEX idx_status
ON users(status);

聚簇索引叶子节点保存完整行:

复制代码
id + name + age + status + bio + 事务隐藏字段

而二级索引的叶子记录主要保存:

复制代码
status + 主键id

结构大致是:

复制代码
二级索引 idx_status
(status=1, id=1)
        │
        │ 通过主键查聚簇索引
        ↓
聚簇索引
(id=1, name='张三', age=20, status=1, ...)

所以执行:

复制代码
SELECT name
FROM users
WHERE status = 1;

可能需要:

  1. 查询 idx_status,得到主键 id
  2. 使用 id 查询聚簇索引。
  3. 从完整记录中取得 name

这就是回表

如果索引改为:

复制代码
CREATE INDEX idx_status_name
ON users(status, name);

查询需要的 statusname 和主键都在二级索引记录中,就可能直接返回结果,形成覆盖索引。

相关推荐
一米阳光86618 小时前
软考(中级)软件设计师核心笔记(3)数据库系统——概念、数据库设计
数据库·笔记·职场发展·软考·软件设计师
奈斯先生Vector8 小时前
告别工具碎片化:基于 Nano Banana 全模态 AI 聚合架构搭建“文本-图像-视频”自动化协同生产线
运维·数据库·人工智能·架构·自动化·aigc·音视频
漏刻有时8 小时前
PHP GeoJSON转PNG地图渲染程序开发笔记、源码解读、问题复盘与整改方案
android·笔记·php
-SOLO-9 小时前
解决VMware 显示比例被重置的问题
android
建筑工程企业管理系统10 小时前
erp工程项目管理系统落地价值:实现工程多项目成本精细化核算与管控
大数据·数据库·人工智能
alexhilton10 小时前
探究Android Views、Flutter和Compose如何渲染你的UI
android·kotlin·android jetpack
AI大模型-小华10 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
foolishlee11 小时前
Neon wal日志处理流程2
数据库
2601_9657984712 小时前
Is Hygia Good for Maid & Janitorial Sites? Technical Audit
服务器·网络·数据库