别再只说“走 B+Tree”:一条 SQL 在 InnoDB 中的完整寻址过程

别再只说"走 B+Tree":一条 SQL 在 InnoDB 中的完整寻址过程

一条再普通不过的主键查询,背后究竟发生了什么?

sql 复制代码
SELECT id, nickname, email, bio
FROM shop.user_profile
WHERE id = 42;

"通过 B+Tree 找到记录"当然没有错,但这句话跳过了最值得理解的部分:Server 怎样把 42 交给 InnoDB?InnoDB 怎样知道根页在哪里?目标页不在内存怎么办?进入一个 16KiB 页之后,Page Directory、记录链和 heap_no 怎样协作?最后,一段没有字段名和分隔符的 Compact 字节,又怎样还原成 idnicknameemailbio

本文以 MySQL 5.7 为基准,不分散讨论名词,而是让同一条查询从逻辑世界一路走到磁盘字节,再带着一行结果原路返回。

我们使用下面这张表:

sql 复制代码
CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4;

CREATE TABLE shop.user_profile (
    id       BIGINT UNSIGNED NOT NULL,
    nickname VARCHAR(20) NOT NULL,
    email    VARCHAR(100) NULL,
    bio      TEXT NULL,
    PRIMARY KEY(id)
) ENGINE=InnoDB
  ROW_FORMAT=COMPACT
  DEFAULT CHARSET=utf8mb4;

INSERT INTO shop.user_profile
VALUES (42, '小明', NULL, REPEAT('x', 12000));

为了让所有图使用同一套坐标,固定假设如下:

latex 复制代码
innodb_file_per_table = ON
table space_id        = 42
PRIMARY index_id      = 105
PRIMARY root page     = 3
target leaf page_no   = 128
target record heap_no = 5
bio 的主要内容位于页外页面

整条查询的路线如下。阅读时先不必记住每个实现名称,只需要抓住坐标不断变得具体这一条主线。

latex 复制代码
id=42
  → PRIMARY
  → space 42 / root page 3
  → space 42 / leaf page 128
  → heap_no 5
  → Compact 字段字节
  → Server 行缓冲区
  → 客户端

一条 SQL 首先寻找的是"坐标"

SQL 中的 id=42 是逻辑条件。它没有说明数据在哪个文件、哪一页,更没有说明记录位于页内哪个字节位置。

InnoDB 最终需要把它逐步转换成另一套坐标:

latex 复制代码
SQL 逻辑坐标:shop.user_profile → PRIMARY → id=42

物理存储坐标:space_id=42 → page_no=128 → heap_no=5 → 字段字节

这里有三个完全不同的编号:

  • space_id 标识一个逻辑表空间。
  • page_no 标识表空间中的一个页面。
  • heap_no 标识某个页面记录堆中的一条记录。

因此,单独说"第 128 页"是不完整的,因为不同表空间都可以有自己的第 128 页;单独说"heap_no=5"同样没有意义,因为每个索引页都可以存在自己的第五条堆记录。真正的物理坐标必须逐级组合:

latex 复制代码
(space_id=42, page_no=128, heap_no=5)

不过,在形成这组坐标之前,MySQL 需要先回答一个更基础的问题:表定义、引擎对象和物理页面分别保存在哪里?

.frm、InnoDB 字典和 .ibd 不是三份相同的数据

在 MySQL 5.7 数据目录中,经常能看到这些文件:

latex 复制代码
data/
├── shop/
│   ├── user_profile.frm
│   └── user_profile.ibd
├── ibdata1
├── ib_logfile0
└── ib_logfile1

它们不只是后缀不同,而是处在完全不同的职责层次。

.frm 保存 Server 眼中的表定义

shop/user_profile.frm 保存列、类型、索引定义等表结构信息。Server 打开表后,相关定义通常会进入内存缓存,并不是每执行一条 SQL 都重新读取 .frm

.frm 只知道"这张表长什么样",不知道 id=42 位于哪个 InnoDB 页面,也不能独立解释聚簇记录中的隐藏事务字段。

InnoDB 内部字典保存引擎对象之间的映射

InnoDB 需要把 Server 的表和索引映射为自己的对象:

latex 复制代码
shop.user_profile
    ↓
InnoDB 表对象
    ↓
PRIMARY 索引对象
    ↓
space_id=42 / root page=3

这些元数据在内存中表现为 dict_table_tdict_index_t。其中最关键的字段关系非常直接:

cpp 复制代码
struct dict_table_t {
    table_id_t id;
    // InnoDB 内部表编号

    table_name_t name;
    // shop/user_profile

    uint32_t space;
    // 聚簇索引所在表空间,本例为 42
};

struct dict_index_t {
    index_id_t id;
    // PRIMARY 的内部索引编号,本例为 105

    dict_table_t* table;
    // 反向指向 user_profile 表对象

    unsigned space : 32;
    // 索引树所在表空间,本例为 42

    unsigned page : 32;
    // B+Tree 根页页号,本例为 3
};

源码位置:

  • mysql-server-5.7/storage/innobase/include/dict0mem.h

.ibd 保存真正的 InnoDB 页面

innodb_file_per_table=ON 时,shop/user_profile.ibd 是表空间 42 的物理承载文件。聚簇索引页、行记录、管理页和页外 BLOB 内容最终都以页面形式落在这里。

三者的关系可以归纳为:

latex 复制代码
.frm
  解决:Server 如何理解表结构

InnoDB 字典
  解决:表、索引、space_id 和 root page 如何关联

.ibd
  解决:页面与记录字节实际放在哪里

缺少任何一层都无法完整执行查询:只有 .frm 找不到物理记录;只有 .ibd 不知道页面字节对应哪些列;只有字典映射也没有实际数据。

file_per_table 改变承载文件,不改变页面寻址模型

innodb_file_per_table=ON 时:

latex 复制代码
space_id=42
    ↓
shop/user_profile.ibd

当它关闭时,表和索引页面可能由系统表空间文件承载。变化的是物理文件,而不是 InnoDB 的逻辑地址:

latex 复制代码
(space_id, page_no)

因此,"这张表有没有独立 .ibd"和"页面是否有 space_id/page_no"是两个问题。即使多个表空间共享物理文件,InnoDB 仍然先使用逻辑页面坐标,再由文件层把坐标转换为具体文件和偏移。

.ibd 不是一堆行,而是一组有编号的页面

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

latex 复制代码
1 page   = 16 KiB
64 pages = 1 extent = 1 MiB

页是读写和缓存的基本单位。即使查询最终只需要一条几十字节的记录,InnoDB 也会把它所在的完整页面读入内存。

区是连续 64 页组成的空间分配单位;段则是一棵索引拥有的页面集合。一个索引通常分别管理:

latex 复制代码
非叶子段:根页和内部节点页
叶子段:叶子页

所以,表、索引、段、区、页和记录之间不是简单的文件目录关系,而是一条所有权链:

latex 复制代码
表
  → 索引
      → 叶子段 / 非叶子段
          → 区
              → 页
                  → 记录

段不是一块连续文件区域。随着 B+Tree 增长,一个段拥有的区和零散页可以位于表空间不同位置;真正把它们组织起来的是表空间管理结构。

表空间需要一套"空间账本"

如果 .ibd 只是连续页面,那么分配新页时必须回答:

latex 复制代码
哪些区完全空闲?
哪些区还有部分空闲页?
某个区属于哪个段?
某个段已经拥有了哪些区和零散页?

InnoDB 使用四类结构协作回答这些问题。

FSP_HDR:表空间总账

FSP_HDR 位于表空间第 0 页,保存:

latex 复制代码
space_id
表空间当前大小
完全空闲区链表
部分使用共享区链表
已经用满的共享区链表
INODE 页链表

磁盘上的结构并不是普通 C++ 对象,而是一段按固定偏移解释的字节:

cpp 复制代码
typedef byte fsp_header_t;
// FSP Header 在磁盘上就是一段字节

#define FSP_SPACE_ID 0
// 当前表空间编号

#define FSP_SIZE 8
// 当前表空间包含多少页

#define FSP_FREE 24
// 完全空闲 Extent 链表入口

#define FSP_FREE_FRAG (24 + FLST_BASE_NODE_SIZE)
// 还有空闲页、但尚未归属于某个段的共享 Extent

#define FSP_FULL_FRAG (24 + 2 * FLST_BASE_NODE_SIZE)
// 已经用满的共享 Extent

#define FSP_SEG_INODES_FULL (32 + 3 * FLST_BASE_NODE_SIZE)
// INODE Slot 已经全部占用的 INODE 页链表

#define FSP_SEG_INODES_FREE (32 + 4 * FLST_BASE_NODE_SIZE)
// 仍有空闲 INODE Slot 的 INODE 页链表

源码位置:

  • mysql-server-5.7/storage/innobase/include/fsp0fsp.h

FSP_HDR 保存的是链表入口,不会把整个表空间每一页的状态都直接塞进 Page 0。区的详细状态由 XDES 负责。

XDES:一个区的状态卡

每个 Extent 都有对应的 XDES 描述项。它记录:

latex 复制代码
这个区是完全空闲、共享使用,还是属于某个段
所属 segment_id
64 个页面的空闲位图

位图可以理解为一串紧凑的开关。每一位或每一组位对应一个页面状态,不需要为 64 个页面分别保存完整对象。

latex 复制代码
page 0  → bits 0...
page 1  → bits 1...
...
page 63 → bits 63...

当段要从某个区取一页时,InnoDB 读取对应 XDES 位图,找到一个空闲 page_no,再把该页标记为已使用。

INODE:一个段的空间清单

每个段都有一个 INODE Slot。它保存:

latex 复制代码
segment_id
FSEG_FREE      完全空闲的段专属区
FSEG_NOT_FULL  已使用一部分的段专属区
FSEG_FULL      已经用满的段专属区
Fragment Page Array

小索引如果一开始就独占 1MiB Extent,会浪费大量空间。因此段刚创建时,可以先从表空间共享区借用单独页面,这些页面号记录在 Fragment Page Array 中。随着段增长,再改为整区分配。

latex 复制代码
段很小时:使用 Fragment Page
段增长后:拥有完整 Extent

IBUF_BITMAP:Change Buffer 所需的页面辅助状态

IBUF_BITMAP 经常与 XDES 混淆,但它不负责记录"整个区属于谁"。它按页面保存 Change Buffer 所需的辅助状态。

Change Buffer 解决的是另一类问题:修改非唯一二级索引时,如果目标二级索引页不在 Buffer Pool,InnoDB 可以先记录待合并变化,不必立刻把目标页读入内存。

为了判断某页能否参与这种缓冲、页面大致还有多少空间以及是否存在待合并变化,需要 IBUF_BITMAP 按页保存紧凑状态。

当前查询读取的是 PRIMARY 聚簇索引,不会走 Change Buffer;介绍它只是为了说明为什么 .ibd 的固定管理页面中会出现 IBUF_BITMAP。它与 XDES 的分工是:

latex 复制代码
XDES
  关心区归属和区内页面是否空闲

IBUF_BITMAP
  关心 Change Buffer 所需的逐页辅助状态

四种结构放在一起后,关系就清楚了:

管理页在 .ibd 中如何排列

在默认 16KiB 页面下,表空间每 16384 页形成一个 XDES 管理区域,也就是 256MiB:

latex 复制代码
16384 × 16 KiB = 256 MiB

第一个区域开头通常可以看到:

latex 复制代码
Page 0:FSP_HDR + 第一组 XDES
Page 1:IBUF_BITMAP
Page 2:INODE

page 16384,新的 XDES 管理区域开始;page 16385 再次出现对应的 IBUF_BITMAP。只有 Page 0 保存整个表空间的 FSP Header,后续 XDES 页不会复制一份新的表空间总账。

这也说明 .ibd 不是"从头到尾全是用户记录"。它同时包含管理页面、索引页面和页外数据页面。

PRIMARY 叶子段怎样得到一个新页面

假设 PRIMARY 叶子段需要一个新页面。InnoDB 不会从文件中随便找 16KiB 空洞,而是沿着空间账本完成一次状态转换。

如果段已经有一个未用满的专属区:

latex 复制代码
读取叶子段 INODE
  → 从 FSEG_NOT_FULL 找到一个区
  → 根据 XDES 位图选择空闲 page_no
  → 标记页面已使用
  → 更新段内区状态和统计

如果段没有可用区:

latex 复制代码
从 FSP_FREE 取得一个完全空闲区
  → 修改 XDES:FREE 变为 FSEG
  → 写入所属 segment_id
  → 挂入段的区链表
  → 从 64 页位图中分配一页

至此,page 128 才不再只是文件中的一段未知字节,而是一个由 PRIMARY 叶子段拥有、由表空间账本管理的正式索引页。

打开一个 16KiB 页面,先看到所有文件页共有的外壳

InnoDB 从磁盘读取的不是"若干条行",而是完整 16KiB 页面。无论页面内部是索引、空间管理信息还是 BLOB 内容,最外层都遵循共同布局:

latex 复制代码
FIL Header
    页面身份和前后连接

Page Body
    由页面类型决定具体结构

FIL Trailer
    页面完整性校验相关信息

FIL Header 中与本次查询直接有关的字段包括:

cpp 复制代码
#define FIL_PAGE_SPACE_OR_CHKSUM 0
// 校验信息;旧格式中也曾保存 space id

#define FIL_PAGE_OFFSET 4
// 当前页面在表空间中的 page_no

#define FIL_PAGE_PREV 8
// 同层前一个页面

#define FIL_PAGE_NEXT 12
// 同层后一个页面

#define FIL_PAGE_LSN 16
// 页面最后一次修改相关的 LSN

#define FIL_PAGE_TYPE 24
// 页面类型,例如 FIL_PAGE_INDEX、FSP_HDR、XDES、BLOB

源码位置:

  • mysql-server-5.7/storage/innobase/include/fil0fil.h

查询拿到 (space_id=42,page_no=128) 后,可以先确认页面身份和类型,再按照索引页格式解释 Page Body。页面的前后页号还会让范围扫描在相邻叶子页之间移动。

索引页内部同时存在记录区、空闲区和目录

索引页的 Page Body 不是一段简单的行数组,而是多个方向同时增长的结构:

从低地址到高地址,可以看到:

latex 复制代码
Page Header
Infimum
Supremum
用户记录与已删除记录空间
Free Space
Page Directory
FIL Trailer

Page Header 保存当前页的管理信息:

cpp 复制代码
#define PAGE_N_DIR_SLOTS 0
// Page Directory 有多少个槽

#define PAGE_HEAP_TOP 2
// 记录堆当前使用到哪里

#define PAGE_N_HEAP 4
// 记录堆中有多少条记录,包括系统记录

#define PAGE_FREE 6
// 已删除记录链表入口

#define PAGE_N_RECS 16
// 当前用户记录数

#define PAGE_LEVEL 26
// B+Tree 层级;0 表示叶子页

源码位置:

  • mysql-server-5.7/storage/innobase/include/page0page.h

用户记录从页面前部向后增长,Page Directory 从页面尾部向前增长,中间剩余区域是尚未分配的 Free Space。PAGE_FREE 指向的不是从未使用过的空间,而是已删除记录留下、可以复用的记录空间链。

"索引有序"不等于记录字节连续有序

同一个索引页上同时存在三种顺序:

物理写入顺序

记录最初被放到页面的哪个位置,取决于当时可用空间。删除和重新插入还会复用旧空间,因此物理地址不一定按主键递增。

键值记录链顺序

每条 Compact 记录头中保存 next_record,把页面中的记录按索引键连接成单链表。查找和页内扫描依赖的是这条链,而不是物理地址自然递增。

Page Directory 槽位顺序

如果每次查找都从最小记录开始沿链扫描,页面记录越多,比较次数越多。Page Directory 从有序记录中选择部分记录作为槽位入口,先二分定位一个小组,再在组内顺链比较。

因此,一个索引页可以同时满足:

latex 复制代码
物理空间灵活复用
记录按键值保持逻辑有序
查找不必从头线性扫描

Infimum 和 Supremum 把边界变成普通链表问题

每个索引页都存在两条系统记录:

latex 复制代码
Infimum:比本页所有用户记录都小
Supremum:比本页所有用户记录都大

它们不保存业务列,却让很多边界情况不需要特殊分支:

latex 复制代码
空页:Infimum → Supremum

非空页:Infimum → 第一条用户记录 → ... → Supremum

查找小于本页最小键的目标时,前驱仍然是 Infimum;查找大于本页最大键的目标时,后继仍然是 Supremum。扫描走到 Supremum,也就统一表示"本页结束"。

page_no 找页面,heap_no 找页面中的记录

现在可以把两个编号放进同一张图:

latex 复制代码
page_no=128
  定位表空间 42 中的索引页

heap_no=5
  标识该页记录堆中的目标记录

heap_no 不是主键,也不是查询结果中的行号。它不会告诉我们记录值是什么,只负责给页内记录一个稳定的堆编号。真正按主键顺序移动依赖的是 next_record

页面结构已经准备好,现在让 id=42 真正进入 InnoDB。

Server 先把逻辑键转换为 InnoDB 搜索请求

本文从 Server 已经决定使用 PRIMARY 开始。执行器掌握的是:

latex 复制代码
table = user_profile
index = PRIMARY
key   = id 42
match = EXACT

Handler 的入口接收 Server 格式的键和返回缓冲区:

cpp 复制代码
int ha_innobase::index_read(
    uchar* buf,
    // 找到记录后,把 Server 格式的行写入这里

    const uchar* key_ptr,
    // Server 编码的主键值 42

    uint key_len,
    // 主键字节长度

    enum ha_rkey_function find_flag
    // HA_READ_KEY_EXACT:要求精确匹配
);

接着,Handler 把 Server 的 key 字节转换成 InnoDB 索引字段格式:

cpp 复制代码
row_sel_convert_mysql_key_to_innobase(
    m_prebuilt->search_tuple,
    // 输出:InnoDB 搜索元组 (42)

    m_prebuilt->srch_key_val1,
    m_prebuilt->srch_key_val_len,
    index,
    (byte*) key_ptr,
    (ulint) key_len,
    m_prebuilt->trx
);

源码位置:

  • mysql-server-5.7/storage/innobase/handler/ha_innodb.cc

精确匹配会被转换为"先找到第一条大于等于目标的记录,再验证是否相等":

cpp 复制代码
case HA_READ_KEY_EXACT:
case HA_READ_KEY_OR_NEXT:
    return PAGE_CUR_GE;
// 先定位第一条 key >= 42 的记录

if (find_flag == HA_READ_KEY_EXACT) {
    match_mode = ROW_SEL_EXACT;
    // 定位后还必须检查 key 是否等于 42
}

准备好搜索元组和搜索方式后,Handler 把同一块返回缓冲区交给 InnoDB 行搜索:

cpp 复制代码
ret = row_search_mvcc(
    buf,
    // 最终写入 Server 行格式的缓冲区

    mode,
    // PAGE_CUR_GE

    m_prebuilt,
    // table、index、search_tuple、pcur、trx

    match_mode,
    // ROW_SEL_EXACT

    0
);

这样,点查和范围下界可以共享同一种定位原语:

latex 复制代码
先找第一条 >= 目标的记录
精确查询再检查是否相等

游标不是客户端结果集,而是 InnoDB 的当前位置

InnoDB 需要在 B+Tree 中连续移动,因此要保存:

latex 复制代码
当前在哪棵索引
当前位于哪个页面
当前指向页面中的哪条记录
采用什么搜索方式

这就是索引游标。它不是客户端逐行获取结果时使用的 Cursor。

核心数据结构只保留与当前链路有关的字段:

cpp 复制代码
struct row_prebuilt_t {
    dict_table_t* table;
    // 当前表:user_profile

    dict_index_t* index;
    // 当前索引:PRIMARY

    trx_t* trx;
    // 当前事务

    dtuple_t* search_tuple;
    // 搜索元组:(42)

    btr_pcur_t* pcur;
    // 可保存和恢复位置的持久游标
};

struct btr_cur_t {
    dict_index_t* index;
    // 当前 B+Tree

    page_cur_t page_cur;
    // 当前页内位置
};

struct page_cur_t {
    rec_t* rec;
    // 当前记录

    buf_block_t* block;
    // 当前记录所在的 Buffer Pool 页面
};

源码位置:

  • mysql-server-5.7/storage/innobase/include/row0mysql.h
  • mysql-server-5.7/storage/innobase/include/btr0pcur.h
  • mysql-server-5.7/storage/innobase/include/btr0cur.h
  • mysql-server-5.7/storage/innobase/include/page0cur.h

游标刚开始位于 PRIMARY 根页附近,搜索结束时则保存:

latex 复制代码
block = space 42 / page 128
rec   = heap_no 5 对应记录

非叶子页通过"分隔键 + 子页号"选择下一层

dict_index_t 已经给出 PRIMARY 根页:

latex 复制代码
space_id = 42
root page_no = 3

InnoDB 从这组坐标开始:

cpp 复制代码
const ulint space = dict_index_get_space(index);
// 取得 PRIMARY 所在的表空间 42

page_id_t page_id(space, dict_index_get_page(index));
// 组成根页坐标:(42,3)

非叶子记录不保存完整业务行,主要保存分隔键和子页号。假设根页内容如下:

latex 复制代码
最小键 → page 64
30     → page 96
40     → page 128
70     → page 192

查找 42 时,选择不大于 42 的最大分隔键 40

latex 复制代码
40 ≤ 42 < 70

所以进入 page 128。子页号保存在非叶子记录最后一个字段:

cpp 复制代码
field = rec_get_nth_field(
    rec,
    offsets,
    rec_offs_n_fields(offsets) - 1,
    &len
);
// 取得非叶子记录最后一个字段

page_no = mach_read_from_4(field);
// 将 4 字节解释为子页号

page_id.reset(space, page_no);
// 下一轮读取子页面

源码位置:

  • mysql-server-5.7/storage/innobase/include/btr0btr.ic
  • mysql-server-5.7/storage/innobase/btr/btr0cur.cc

如果 B+Tree 更高,同一个步骤会重复多次,直到 PAGE_LEVEL=0 的叶子页。

页面不在内存时,逻辑页坐标必须先变成文件偏移

游标已经知道目标页是:

latex 复制代码
(space_id=42, page_no=128)

但索引页只能在内存中解析。根页、非叶子页和叶子页每次被访问时都要先取得对应的 Buffer Pool 页面;下图只放大目标叶子页 page 128 的缺页分支。InnoDB 先以 page_id 查找 Buffer Pool:

如果命中,直接取得对应 buf_block_t;如果未命中,则从表空间文件读取完整页面:

cpp 复制代码
block = buf_page_hash_get_low(buf_pool, page_id);
// 按照 (space_id,page_no) 查找内存页

if (block == NULL) {
    buf_read_page(page_id, page_size);
    // 未命中:从表空间文件读取
}

在当前假设中:

latex 复制代码
innodb_file_per_table = ON
page_size             = 16 KiB
页面未压缩

表空间文件与偏移为:

latex 复制代码
space 42 → shop/user_profile.ibd

offset = page_no × page_size
       = 128 × 16 KiB
       = 2 MiB

源码计算未压缩页面偏移:

cpp 复制代码
offset = ((os_offset_t) cur_page_no << UNIV_PAGE_SIZE_SHIFT)
         + byte_offset;
// 默认 16KiB 页时,相当于 page_no × 16384 + byte_offset

源码位置:

  • mysql-server-5.7/storage/innobase/buf/buf0buf.cc
  • mysql-server-5.7/storage/innobase/fil/fil0fil.cc

磁盘上的 16KiB 内容进入一个 Buffer Pool Frame。Frame 不是另一种页面格式,它只是磁盘页在内存中的容器。

Page Directory 先缩小范围,记录链再完成最后几次比较

获得 page 128 的 Frame 后,InnoDB 不会从 Infimum 开始扫描整页,而是先对 Page Directory 二分。

假设目录槽为:

latex 复制代码
slot 0 → Infimum
slot 1 → id 26
slot 2 → id 61
slot 3 → Supremum

搜索过程是:

latex 复制代码
比较 slot 1:42 > 26
比较 slot 2:42 < 61

得到范围:id 26 ~ id 61

目录槽只负责圈定一小组记录,不会指向每一条用户记录。接着沿 next_record 比较:

latex 复制代码
id 26
  → id 35:35 < 42,继续
  → id 42:42 = 42,命中

源码也明确分成两段:

cpp 复制代码
/* 先对 Page Directory 二分 */
while (up - low > 1) {
    mid = (low + up) / 2;
    slot = page_dir_get_nth_slot(page, mid);
    mid_rec = page_dir_slot_get_rec(slot);
    // 比较搜索元组和槽记录
}

/* 再在相邻槽之间沿记录链线性查找 */
while (page_rec_get_next_const(low_rec) != up_rec) {
    mid_rec = page_rec_get_next_const(low_rec);
    // 逐条比较,直到找到相邻上下界
}

源码位置:

  • mysql-server-5.7/storage/innobase/page/page0cur.cc

最终,页游标保存:

latex 复制代码
block = page 128 的 Buffer Pool Block
rec   = id 42 的 Compact 记录

由于本次使用"第一条大于等于目标"的定位方式,边界场景也能统一处理:

latex 复制代码
目标小于最小值 → 停在第一条用户记录,检查不相等
目标位于两条记录之间 → 停在后一条记录,检查不相等
目标恰好存在 → 停在目标记录,检查相等
目标大于本页所有记录 → 停在 Supremum

如果查询是范围扫描,找到起点后不需要为每条记录重新从根页搜索。页内沿 next_record 继续,走到 Supremum 后,再通过 FIL Header 的 FIL_PAGE_NEXT 进入右侧叶子页:

latex 复制代码
页内连接:记录头 next_record
跨页连接:FIL_PAGE_NEXT / FIL_PAGE_PREV

到这里,索引查找已经结束。游标指向 heap_no=5,但真正的挑战才刚刚显现:它拿到的仍然只是一段 Compact 字节,而不是四个已经命名的列值。

rec_t* 指向第一列,而不是整条记录的开头

Compact 记录由两部分组成:

latex 复制代码
记录额外信息
    +
字段数据

rec_t* 指向第一个字段 id 的数据起点。记录头、NULL 位图和变长字段长度位于它前面的低地址区域:

latex 复制代码
低地址
  ↓
变长字段长度数组
NULL 位图
5 字节固定记录头
          ← rec_t* 指向这里
id
DB_TRX_ID
DB_ROLL_PTR
nickname
email
bio
  ↓
高地址

源码正是从 rec 向前计算两个指针:

cpp 复制代码
const byte* nulls = rec - (1 + REC_N_NEW_EXTRA_BYTES);
// REC_N_NEW_EXTRA_BYTES = 5
// 越过 5 字节固定记录头,开始读取 NULL 位图

const byte* lens = nulls - UT_BITS_IN_BYTES(index->n_nullable);
// 再越过 NULL 位图,开始读取变长字段长度数组

源码位置:

  • mysql-server-5.7/storage/innobase/rem/rem0rec.cc

这解释了一个关键事实:只有记录字节还不够。解析器还需要索引元数据,才能知道有多少个可空字段、哪些字段固定长度、哪些字段需要读取长度数组。

聚簇记录会插入 InnoDB 隐藏字段

表定义的业务字段顺序是:

latex 复制代码
id → nickname → email → bio

PRIMARY 聚簇记录的内部字段顺序却是:

latex 复制代码
id
→ DB_TRX_ID
→ DB_ROLL_PTR
→ nickname
→ email
→ bio

其中:

latex 复制代码
DB_TRX_ID    6 字节,记录最近修改该行的事务编号
DB_ROLL_PTR  7 字节,指向该行相关的 Undo 信息

当前表有显式主键 id,所以不需要 DB_ROW_ID。只有没有合适聚簇键时,InnoDB 才会生成额外的 6 字节行编号。

源码构建聚簇索引内部字段时,先复制主键,再加入两个系统列,最后加入尚未包含的普通列:

cpp 复制代码
dict_index_copy(new_index, index, table, 0, index->n_fields);
// 先复制用户定义的主键字段 id

dict_index_add_col(
    new_index,
    table,
    dict_table_get_sys_col(table, DATA_TRX_ID),
    0
);
// 加入 DB_TRX_ID

dict_index_add_col(
    new_index,
    table,
    dict_table_get_sys_col(table, DATA_ROLL_PTR),
    0
);
// 加入 DB_ROLL_PTR

if (!indexed[col->ind]) {
    dict_index_add_col(new_index, table, col, 0);
}
// 加入 nickname、email、bio

源码位置:

  • mysql-server-5.7/storage/innobase/dict/dict0dict.cc

隐藏字段属于 InnoDB 的内部记录,不会作为查询结果返回客户端。

NULL 位图只为允许 NULL 的列分配位

当前表只有两个字段允许为 NULL:

sql 复制代码
email VARCHAR(100) NULL,
bio   TEXT NULL

因此 NULL 位图只需要两个有效位:

latex 复制代码
bit 0 → email
bit 1 → bio

当前记录中:

latex 复制代码
email = NULL
bio   ≠ NULL

所以位图低位为:

latex 复制代码
bit 1  bit 0
 bio    email
  0       1

NULL byte = 00000001

解析到 email 时,源码设置 SQL NULL 标志,但不读取字段长度,也不推进数据偏移:

cpp 复制代码
if (*nulls & null_mask) {
    len = offs | REC_OFFS_SQL_NULL;
    // offsets[] 中标记当前字段为 SQL NULL

    goto resolved;
    // 不读取变长长度
    // 不增加字段数据偏移
}

所以 email=NULL 的真实开销是 NULL 位图中的一个 bit,而不是在数据区保存字符串 NULL 或预留最大列长度。

变长字段长度为什么反向保存

这条记录的变长字段包括:

latex 复制代码
nickname
email
bio

由于 email=NULL,长度数组中只需要保存:

latex 复制代码
nickname 实际长度
bio 本地字段长度与 external 标志

解析器按照索引字段顺序处理:

latex 复制代码
id → DB_TRX_ID → DB_ROLL_PTR → nickname → email → bio

因此它遇到的第一个变长字段是 nickname。为了从紧邻 NULL 位图的位置立即取得它的长度,物理字节采用逆序排列:

latex 复制代码
物理地址从左到右:

[bio 长度 2B][nickname 长度 1B][NULL 位图][固定记录头]
                                   ↑
                            解析器从这里向左读

nickname='小明' 在 UTF-8 中占 6 字节。它定义为 VARCHAR(20) utf8mb4,最大字节数为:

latex 复制代码
20 × 4 = 80 字节

不超过 255,因此长度始终使用 1 字节。

大字段的长度规则更复杂:最大长度超过 255 的字段,实际长度为 0~127 且未页外存储时仍可以使用 1 字节;实际长度达到 128 或带 external 标志时使用 2 字节。

cpp 复制代码
len = *lens--;
// 从紧邻 NULL 位图的位置向左消费长度字节

if (DATA_BIG_COL(col) && (len & 0x80)) {
    len <<= 8;
    len |= *lens--;
    // 两字节长度编码

    if (len & 0x4000) {
        len = offs | REC_OFFS_EXTERNAL;
        // 标记字段有页外内容
    }
}

5 字节记录头把记录接入页内管理结构

NULL 位图和长度数组负责解释字段,固定记录头则负责把记录接入记录堆、记录链和 Page Directory。

它的位布局是:

latex 复制代码
第 1 字节:info_bits 4 bit + n_owned 4 bit
第 2~3 字节:heap_no 13 bit + status 3 bit
第 4~5 字节:next_record 16 bit

代入当前记录:

latex 复制代码
delete_mark = 0
min_rec     = 0
n_owned     = 0
heap_no     = 5
status      = ORDINARY
next_record = 到 id 50 的相对偏移

n_owned=0 是因为当前示例的 Page Directory 槽记录是 id 26id 61id 42 不负责管理一个目录组。

next_record 不是 C++ 内存指针,而是当前记录到下一条有序记录的相对偏移。源码读取两字节后,在同一页面内计算下一条记录地址:

cpp 复制代码
field_value = mach_read_from_2(rec - REC_NEXT);
// 从 rec_t* 前面读取 next_record

return page_start
       + offset_inside_page(rec + field_value);
// 当前记录地址 + 相对偏移
// 得到下一条键值记录

源码位置:

  • mysql-server-5.7/storage/innobase/include/rem0rec.ic

于是,页内看到的:

latex 复制代码
id 42 → id 50

并不是抽象画法,而是记录头中真实偏移计算的结果。

12000 字节的 bio 为什么没有撑满叶子页

一个 16KiB 页还要容纳页头、其他用户记录、空闲空间、Page Directory 和页尾,不适合让一条 12000 字节字段长期独占整个页面。

在当前 ROW_FORMAT=COMPACT 下,移到页外的大字段在聚簇记录中保留:

latex 复制代码
768 字节本地前缀
    +
20 字节外部引用

其余内容保存在外部 BLOB 页面中:

本例为:

latex 复制代码
完整 bio      12000 B
本地前缀        768 B
外部内容      11232 B
外部引用         20 B

20 字节引用位于本地字段末尾:

cpp 复制代码
#define BTR_EXTERN_SPACE_ID 0
// 外部内容所在的 space_id,4 字节

#define BTR_EXTERN_PAGE_NO 4
// 第一个 BLOB 页的 page_no,4 字节

#define BTR_EXTERN_OFFSET 8
// BLOB Header 在页面内的偏移,4 字节

#define BTR_EXTERN_LEN 12
// 外部长度和标志,共 8 字节

#define FIELD_REF_SIZE 20
// 整个外部引用共 20 字节

源码位置:

  • mysql-server-5.7/storage/innobase/include/btr0cur.h
  • mysql-server-5.7/storage/innobase/include/page0size.h

读取 bio 时,InnoDB 先复制本地前缀,再从引用中取得外部坐标:

cpp 复制代码
space_id = mach_read_from_4(
    data + local_len + BTR_EXTERN_SPACE_ID
);
// 外部内容所在表空间

page_no = mach_read_from_4(
    data + local_len + BTR_EXTERN_PAGE_NO
);
// 第一个外部 BLOB 页

offset = mach_read_from_4(
    data + local_len + BTR_EXTERN_OFFSET
);
// BLOB 数据在页面中的起点

extern_len = mach_read_from_4(
    data + local_len + BTR_EXTERN_LEN + 4
);
// 外部部分实际长度

如果内容跨多个页面,就沿外部页面链继续读取,最终在内存中拼接:

latex 复制代码
768 B 本地前缀 + 11232 B 外部内容 = 12000 B

offsets[] 是解析时生成的内存导航表

物理记录中没有保存"id 指针""nickname 指针"和"bio 指针"。InnoDB 使用两个输入计算每个字段的边界:

latex 复制代码
索引元数据
  提供字段顺序、固定长度、nullable 和变长属性

记录额外信息
  提供本行 NULL、实际长度和 external 标志

解析当前记录得到:

字段 起点 终点 状态 本地长度
id 0 8 普通 8
DB_TRX_ID 8 14 普通 6
DB_ROLL_PTR 14 21 普通 7
nickname 21 27 普通 6
email 27 27 SQL_NULL 0
bio 27 815 EXTERNAL 788

其中:

latex 复制代码
bio 本地长度 788
= 本地前缀 768
+ 外部引用 20

offsets[] 只存在于解析过程的内存中。它不是记录中额外保存的一组字段指针,而是 InnoDB 结合元数据和记录头计算出的导航表。

现在也可以算出当前记录在 page 128 中实际占用的空间。

字段数据区:

latex 复制代码
id                 8
DB_TRX_ID           6
DB_ROLL_PTR         7
nickname            6
email               0
bio 本地前缀      768
bio 外部引用       20
---------------------
合计              815 字节

额外信息区:

latex 复制代码
bio 长度            2
nickname 长度       1
NULL 位图           1
固定记录头          5
---------------------
合计                9 字节

因此,这条记录在叶子页内约占:

latex 复制代码
815 + 9 = 824 字节

12000 字节的 bio 并没有全部挤在当前索引页中。

把这一整段解析过程收束到一张图,就是:

Compact 记录还要转换成 Server 行格式

Compact 记录的设计目标是索引比较、页内管理和空间利用,不是直接通过网络返回。InnoDB 需要按照 Server 提供的列模板逐列转换。

转换过程包括:

latex 复制代码
offsets[] 定位业务字段
  → 将 id 转换为 Server 整数格式
  → 把 nickname 写入目标列位置
  → 为 email 设置 Server NULL 位
  → 读取并拼接完整 bio
  → 跳过 DB_TRX_ID 和 DB_ROLL_PTR

源码先判断字段是否页外存储:

cpp 复制代码
if (rec_offs_nth_extern(offsets, field_no)) {
    data = btr_rec_copy_externally_stored_field(
        rec,
        offsets,
        page_size,
        field_no,
        &len,
        heap
    );
    // 将本地前缀与外部页面内容拼成完整字段
}

普通字段则直接按照 offsets[] 取得:

cpp 复制代码
data = rec_get_nth_field(rec, offsets, field_no, &len);
// 取得字段起点和长度

if (len == UNIV_SQL_NULL) {
    mysql_rec[null_byte_offset] |= null_bit_mask;
    // 在 Server 行缓冲区中设置 NULL 位
}

最后写入 Server 提供的返回缓冲区:

cpp 复制代码
row_sel_store_mysql_rec(
    buf,
    // Server 行缓冲区

    prebuilt,
    result_rec,
    // heap_no 5 对应的聚簇记录

    vrow,

    result_rec != rec,
    // 当前就是 PRIMARY 记录,因此这里为 false

    result_rec != rec ? clust_index : index,
    // 当前使用 PRIMARY index

    offsets,
    false
);

源码位置:

  • mysql-server-5.7/storage/innobase/row/row0sel.cc

写入完成后,Server 得到的是:

latex 复制代码
id       = 42
nickname = 小明
email    = NULL
bio      = 完整 12000 字节内容

而不是:

latex 复制代码
5 字节记录头
DB_TRX_ID
DB_ROLL_PTR
20 字节外部引用

这些内容始终封装在 InnoDB 内部。

一条查询的本质,是坐标收敛之后再还原为值

现在重新看最初的 SQL:

sql 复制代码
SELECT id, nickname, email, bio
FROM shop.user_profile
WHERE id = 42;

它首先经历的是坐标不断收敛:

latex 复制代码
shop.user_profile.id=42
  → PRIMARY
  → space_id=42
  → root page=3
  → leaf page=128
  → heap_no=5
  → rec_t*

随后方向反转,物理字节被逐步还原为列值:

latex 复制代码
rec_t*
  → NULL 位图和变长长度
  → offsets[]
  → id / nickname / email / bio 本地部分
  → 外部 BLOB 页面
  → 完整业务字段
  → Server 行缓冲区
  → 客户端

因此,"主键索引查到一行"并不是一个瞬间完成的动作。它至少包含以下连续转换:

latex 复制代码
Server 的逻辑键
  → InnoDB 搜索元组
  → B+Tree 页面坐标
  → 页内记录坐标
  → Compact 字段坐标
  → Server 列值

当这条链真正建立起来之后,.frm.ibd、表空间、段、区、页、Page Directory、游标、heap_no、Compact 记录和页外字段不再是一组需要背诵的名词。它们分别解决了同一条查询在不同层次上的问题,并通过前一步的输出自然连接到下一步。

相关推荐
SomeB1oody1 小时前
【RustyML入门】3.8. 正则化与归一化层
开发语言·后端·机器学习·rust·教程
zhiSiBuYu05171 小时前
Flask Session 与 Cookie 新手实战指南
后端·python·flask
2601_953988071 小时前
Ricon组态系统vs传统组态软件:为什么选择新一代Web组态平台
前端·后端·物联网·tcp/ip·数学建模·前端框架
IT_陈寒2 小时前
SpringBoot自动配置坑了我三天,原来漏了这个注解
前端·人工智能·后端
AINative软件工程2 小时前
模型路由别写死在代码里:Policy-as-Code 才是 LLM 成本和质量的刹车片
后端
小蒜学长2 小时前
“喵汪联盟”宠物领养系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·宠物
SomeB1oody3 小时前
【RustyML入门】3.7. 循环层
开发语言·后端·机器学习·rust·教程
东风破_12 小时前
ESLint 是什么?为什么你的项目需要它?
前端·后端·代码规范
嘻哈∠※12 小时前
0061基于 SpringBoot 的投稿与稿件处理系统设计与实现
java·spring boot·后端