一、先说结论
在 InnoDB 中,一张表本质上是一棵以主键组织的 B+Tree 聚簇索引:
表
└── 聚簇索引 B+Tree
├── 非叶子节点:保存索引键和页指针
└── 叶子节点:保存完整的一行数据
因此,一行数据不是独立地"放在某个文件里",而是作为一条索引记录,存储在聚簇索引的叶子页中。二级索引则只保存索引列和对应的主键值。
二、从数据页到记录
InnoDB 按照"页"管理磁盘和内存中的数据。
默认情况下,一个 InnoDB 页是:
16KB
也可以在初始化 MySQL 实例时配置为 4KB、8KB、16KB、32KB 或 64KB。同一个实例中的 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 常用的 DYNAMIC 或 COMPACT 行格式中,一条聚簇索引记录可以简化为:
┌────────────────────┐
│ 变长字段长度列表 │
├────────────────────┤
│ NULL值位图 │
├────────────────────┤
│ 5字节记录头 │
├────────────────────┤
│ 用户定义的字段数据 │
├────────────────────┤
│ DB_TRX_ID:6字节 │
├────────────────────┤
│ DB_ROLL_PTR:7字节 │
└────────────────────┘
这是便于理解的简化表示。实际字节排列还与行格式、字段定义和索引类型有关。DYNAMIC 行格式沿用了 COMPACT 的基本记录结构,同时改进了长字段的页外存储。
四、变长字段长度列表
对于 VARCHAR、VARBINARY 等变长字段,InnoDB需要知道每个字段实际占用了多少字节。
例如:
name VARCHAR(20)
VARCHAR(20) 表示最多保存 20 个字符,但并不是每行固定占用 20 个字符的空间。
如果保存:
张三
在 utf8mb4 编码下,这两个汉字通常占用 6 个字节。因此记录中会保存:
name实际数据:6字节
长度信息:记录实际字节长度
大多数变长字段的长度信息占 1 或 2 字节,取决于字段最大长度、实际长度以及是否使用页外存储。
所以:
VARCHAR:按实际数据长度存储,并额外保存长度信息
CHAR:更接近固定长度存储,但还受到字符集和尾随空格规则影响
五、NULL 值位图
对于允许为 NULL 的字段,InnoDB 不会给每个字段都保存一个字符串 "NULL",而是用位图表示。
例如:
age INT NULL,
bio TEXT NULL
这两个字段都允许为空,因此需要两个二进制位:
age是否为NULL:0或1
bio是否为NULL:0或1
如果 bio 是 NULL:
NULL位图:标记bio为NULL
bio字段数据:不再占用实际数据空间
需要注意:
bio = NULL
和:
bio = ''
不一样。
NULL:通过 NULL 位图表示,没有字段内容
空字符串:不是 NULL,需要保存长度为 0 的字段信息
如果索引中有 N 个可空字段,NULL 位图占用:
CEILING(N / 8) 字节
例如有 9~16 个可空字段,就需要 2 字节。
六、记录头
COMPACT 和 DYNAMIC 格式的每条索引记录包含一个 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 需要一个键来组织聚簇索引,选择顺序大致是:
- 使用显式定义的主键。
- 没有主键时,选择第一个所有字段都为
NOT NULL的唯一索引。 - 两者都没有时,InnoDB 创建隐藏聚簇索引,并为每行生成
6字节的DB_ROW_ID。
因此,建议 InnoDB 表显式定义主键:
id BIGINT PRIMARY KEY
否则 InnoDB 仍然会在内部创建隐藏主键,只是开发者无法直接使用它。
十、长字段怎么存储
假设有字段:
content TEXT
它不一定完全存储在当前记录中。
当记录过大时,InnoDB可能把长字段内容放到单独的溢出页中:
聚簇索引记录
┌───────────────────┐
│ id │
│ title │
│ content的20字节指针│──────┐
└───────────────────┘ │
↓
┌──────────────┐
│ Overflow Page│
│ content内容 │
└──────────────┘
现代 MySQL 默认通常使用 DYNAMIC 行格式。它可以把较长的 VARCHAR、VARBINARY、TEXT 和 BLOB 值完全放到页外,聚簇索引记录中保留一个 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;
可能需要:
- 查询
idx_status,得到主键id。 - 使用
id查询聚簇索引。 - 从完整记录中取得
name。
这就是回表。
如果索引改为:
CREATE INDEX idx_status_name
ON users(status, name);
查询需要的 status、name 和主键都在二级索引记录中,就可能直接返回结果,形成覆盖索引。