为什么不能把数据当成连续平铺的纯文本在磁盘与操作系统的物理交互中硬件层面的最小 I/O 交互单位通常是 4KB 的磁盘扇区簇而在 InnoDB 存储引擎中内存与磁盘交互的基本物理单位被统一定义为16KB 数据页Page。如果存储引擎简单地将每一行数据按照紧密相邻的方式追加平铺到磁盘文件中这种理想化的连续物理布局在面对现实的动态写场景时会迅速失效。当业务表包含VARCHAR或TEXT这类长度不确定的变长列时某一行字段内容的修改扩容会直接导致其物理体积膨胀。如果各行之间在物理上紧挨在一起当前行的体积变大就必须把其后方所有的行数据在磁盘上整块向后挪动带来巨大的数据级联搬迁开销。数据删除操作同样无法直接通过在物理磁盘上将对应字节擦除或填零来完成。直接擦除旧数据并在原地留下物理空洞不仅会导致后续写入的数据难以精准匹配残留空洞的尺寸而且会产生大量难以利用的离散物理碎片。因此InnoDB 必须在微观层面上为每一行记录设计精密的定位与状态控制结构这便是行格式Row Format同时在宏观层面上以 16KB 数据页作为容器利用链表与槽位在逻辑上解耦记录的物理相对位置。一行记录在底层是如何拼装成字节的在 MySQL 5.7 及以后的版本中默认采用的是DYNAMIC行格式它脱胎于早期经典的COMPACT格式两者在基础行的物理骨架上高度一致。一条完整的 Compact 记录在物理磁盘上从左向右被划分为两大核心板块记录的额外信息与记录的真实数据。[ 变长字段长度列表 ] [ NULL 值列表 ] [ 记录头信息 (5B) ] ──基准点── [ 系统隐藏列 (DB_ROW_ID/TRX_ID/ROLL_PTR) ] [ 真实列数据 (col1, col2...) ] ────────── 额外信息 (向左负偏移读取) ────────── ────────── 真实数据 (向右正偏移读取) ──────────这种两段式的物理布局并非随意排布而是为了兼顾可变长度字段的高效寻址与紧凑的空间占用。如上图所示整条记录的寻址中心点位于记录头与真实数据之间的分界线。这个基准点向左是元数据控制区向右则是真实业务数据区。变长字段长度列表在定义数据库表结构时诸如VARCHAR(M)、VARBINARY(M)以及各类TEXT、BLOB等字段类型所占用的物理字节数均不是固定的。为了让存储引擎在连续读取数据流时准确切分出每个变长列的物理边界InnoDB 会在记录开头开辟一块区域将每个非 NULL 的变长字段所占用的实际字节长度依序记录下来。变长字段长度列表为什么采用逆序存储当存储引擎读取数据时上一条记录的指针直接定位在当前记录的基准点上。向右读取真实数据时字段是按照列定义的先后顺序从左到右排列的如col1、col2、col3而变长字段长度列表在额外信息中则是反向排列的离基准点最近的向左偏移量恰好对应最左侧的变长列col1的长度向左第二个字节则对应col2的长度。这种镜像对称的设计使得 CPU 在解析列数据时可以从中心基准点同时向左右两侧对称展开寻址无需每次都跳到整条记录的最开头再顺向计算偏移提升了 CPU 缓存行的空间局部性。关于变长字段长度列表所占用的物理尺寸InnoDB 遵循一套确定性的判定逻辑设该变长字段字符集允许的最大字节数为WWW例如ascii的W1W1W1gbk的W2W2W2utf8mb4的W4W4W4字段定义允许的最大字符数为MMM则该字段最大可能占用的字节数为M×WM \times WM×W设该字段在当前记录中实际存储的文本字节长度为LLL若M×W≤255M \times W \le 255M×W≤255则存储引擎仅分配1 个字节来存储实际长度LLL。若M×W255M \times W 255M×W255当实际存储字节数L≤127L \le 127L≤127时使用1 个字节存储当实际存储字节数L127L 127L127时使用2 个字节存储。在这套编码规则中当使用 2 个字节时高字节的第 1 位最高有效位会被固定设置为1用来在物理流中与单字节长度进行区分。NULL 值列表如果一条记录中的某些列没有具体内容即存储为NULL如果依然在真实数据区为其分配空闲空间或存储默认标记会带来不必要的磁盘占用。Compact 行格式通过位图机制对可空列进行集中管理。NULL 值列表的物理构建流程遵循以下步骤提取候选列统计当前数据表中所有未声明NOT NULL约束的字段仅有这些允许为 NULL 的列才有资格进入位图位映射设置每个允许为 NULL 的列按其在表中的定义顺序分配 1 个 bit。如果该列在当前记录中实际为 NULL对应 bit 置为1若不为 NULL则置为0逆序排列与变长长度列表类似位图中 bit 的物理排列同样按列顺序从右向左逆序存放高位对齐补零位图必须按完整字节8 bit 的整数倍进行内存对齐。若可空列总数不是 8 的倍数高位统一用0填补对齐。NULL 值在底层到底占用多少磁盘空间如果一个字段的值为NULL该字段在后方的“记录真实数据区”中将完全不占用任何字节存储引擎仅在开头的 NULL 值列表中翻转 1 个二进制位。但如果整张表的所有字段全部被显式定义了NOT NULL约束Compact 行格式将直接在额外信息中彻底移除 NULL 值列表从而节省下至少 1 个字节的元数据开销。如果一张表包含 10 个允许为 NULL 的 VARCHAR 字段且每个字段实际长度都超过 127 字节此时这 10 个字段能存储的文本数据总和最大是多少字节在 MySQL 的物理规范中除TEXT、BLOB这类行外存储的大字段外包含所有列在内的单行记录真实数据与额外信息累加的最大物理上限固定为65535 字节。我们需要运用前面讲到的两套规则进行扣减首先扣减变长字段长度列表表中有 10 个变长字段且每个字段实际长度均超过 127 字节则每个字段的长度必须分配 2 个字节进行存储因此变长长度列表总共耗费10×22010 \times 2 2010×220字节。接下来扣减 NULL 值位图表中共有 10 个允许为 NULL 的列需要分配 10 个 bit。由于位图必须按完整字节8 bit 的整数倍向上取整因此 NULL 值列表占用⌈10/8⌉2\lceil 10 / 8 \rceil 2⌈10/8⌉2个字节剩余 6 位高位补零。扣除这两处元数据开销后这 10 个字段能存储的文本数据实际总上限为65535−20−265513 字节65535 - 20 - 2 65513 \text{ 字节}65535−20−265513字节真实数据区的 3 大系统隐式列紧随记录头信息之后的是记录的真实数据区。在存储用户自定义的业务列之前InnoDB 会按需在物理开头硬性注入 3 个系统隐式列系统隐式列名称物理占用大小是否必定存在核心系统功能DB_ROW_ID6 字节否仅当表无主键且无非空唯一键时生成充当聚簇索引主键保证行记录唯一寻址DB_TRX_ID6 字节是所有记录均包含记录最后一次插入或修改该行的事务 ID支撑 MVCC 可见性推算DB_ROLL_PTR7 字节是所有记录均包含回滚指针指向该行历史版本的 undo log 地址串联版本链记录头里的 5 个字节藏了什么秘密在额外信息的最末端、紧挨着真实数据基准点的位置固定存放着5 个字节共 40 bit的记录头信息Record Header。这 40 个二进制位是控制数据页内部寻址、逻辑删除与行状态运转的核心控制开关。--------------------------------------------------------------------------- | delete_mask | min_rec_mask | n_owned | heap_no | record_type | | (1 bit) | (1 bit) | (4 bit) | (13 bit) | (3 bit) | --------------------------------------------------------------------------- | next_record (16 bit) | -------------------------------------------------------------------------------记录头中各个物理标记位的工程职责如下delete_mask1 bit逻辑删除标志。当该位为0时表示记录处于有效状态当执行 SQL 的DELETE语句时InnoDB 将该位翻转为1标记此行已被删除。min_rec_mask1 bitB 树非叶子节点目录项的极值标记。若该记录为 B 树某非叶子节点中主键最小的目录项该位被置为1。n_owned4 bit页目录分组拥戴记录数。在页目录Page Directory的一个槽位所管辖的记录分组中只有该组主键最大的那条记录的n_owned有实际数值通常为 4~8该组内其他记录的n_owned统一为 0。heap_no13 bit记录在当前数据页内部堆中的物理排布序号。页内初始分配的虚拟极小记录Infimum的heap_no固定为 0虚拟极大记录Supremum固定为 1用户插入的数据行从 2 开始递增。record_type3 bit记录类型。0表示普通用户记录1表示 B 树非叶子节点的目录项记录2表示 Infimum 虚拟记录3表示 Supremum 虚拟记录。next_record16 bit下一条记录的相对偏移量。它记录了从当前记录的基准点跳跃到下一条记录基准点的相对字节差值。执行 DELETE 删除一条记录后为什么磁盘文件尺寸并没有立刻缩减当执行数据删除操作时存储引擎并不会在物理存储介质上将数据彻底擦除或执行磁盘缩容。在微观层面上InnoDB 仅仅是将当前记录头的delete_mask从0翻转为1随后存储引擎将该记录从正常数据链表中解绑并重新挂入当前页内的垃圾废弃链表Free List中。原记录所占用的物理空间保持完好等待后续有尺寸匹配的新数据执行INSERT时直接在原地进行空间复写。这种机制规避了频繁的数据挪动与磁盘碎片整理开销。为什么 next_record 记录的是相对偏移量而不是绝对物理地址如果记录的是内存或磁盘中的绝对物理地址那么一旦数据页被加载到 Buffer Pool 的不同内存区域或者页内进行局部碎片整理时所有记录保存的指针都必须进行全量指针重算与覆写。采用相对偏移量后寻址计算完全内敛在当前 16KB 数据页内部。同时16 个 bit 的带符号整数足以覆盖 64KB 的物理跨度寻址单个 16KB 数据页绰绰有余。顺着当前基准点加上next_record的有符号数值读取指针便能直接落入下一条记录的基准点。字段太大装不下怎么办InnoDB 的宏观物理单元是 16KB 的数据页。为了确保 B 树索引结构具备稳定的扇出比与树层高一个 16KB 数据页必须至少能够容纳 2 条记录。如果某条记录中包含一个极大的VARCHAR或TEXT字段导致单条记录的体积接近甚至超过了 16KB该页将无法再插入第二条记录。为了维护这个硬性约束InnoDB 引入了行溢出Row Overflow机制。在早期的 Compact 行格式中当字段长度超出阈值时InnoDB 会在当前数据页内保留该列的前768 字节前缀紧随其后附加一个20 字节的物理指针指向独立的溢出页即 Off-Page。后续超长的文本内容将被切分成多个块存储在专门的溢出页链表中。而在现代默认的Dynamic 行格式中这种设计得到了更彻底的优化对于发生行溢出的变长字段数据页内部的真实数据区完全不保留任何前缀数据仅仅在行内占用20 字节存储指向外部溢出页的物理指针将全部长文本内容彻底剥离至行外溢出页。这一改变大幅压缩了聚合索引树节点内部的行体积使每个数据页能够容纳更多的记录主键从而直接提升了 B 树的检索效率。16KB 数据页内部空间拓扑当单条记录完成组装后它们必须被放置在 16KB 的容器中统一调度。一个完整的 InnoDB 数据页在物理上被严格划分为7 大核心功能区域。这 7 大物理区域的划分和具体字节规格如下File Header38 字节记录页面的通用物理元数据。包括当前页号FIL_PAGE_OFFSET、上一页指针FIL_PAGE_PREV、下一页指针FIL_PAGE_NEXT、当前页校验和FIL_PAGE_SPACE_OR_CHKSUM以及页面类型数据页、Undo 页、Inode 页等。通过前后指针不同数据页之间在物理磁盘上可以处于离散位置但在逻辑上被串联成一个双向链表。Page Header56 字节专门记录数据页内部的状态统计信息。包括页目录中分配的槽位总数、页内第一条记录相对偏移、垃圾链表头指针、已分配记录的总数以及未分配空闲空间的起始地址。Infimum Supremum26 字节InnoDB 预设的两个虚拟哨兵记录。Infimum固定代表页内主键极小值Supremum固定代表页内主键极大值。两者在页面初始化时便固定占据 26 个字节限制了当前页内所有用户记录的逻辑边界。User Records存储用户真实业务数据的区域。每当插入一条新行时存储引擎便从尚未使用的空闲区域中切出一块空间分配至此处。Free Space数据页内部尚未被使用的连续空闲空间。Page Directory页目录存放槽位Slot的数组结构用于在页内通过二分查找快速定位记录它从数据页的最底端向上生长。File Trailer8 字节位于页面末端。前 4 个字节记录校验和后 4 个字节记录最后修改的 LSN日志逻辑序列号。为什么有了 File Header 还要在页尾加上 File Trailer操作系统的文件系统在执行磁盘物理 I/O 时单次刷盘的原子单位通常只有 4KB。而 MySQL 的数据页大小为 16KB这意味着一个数据页的落盘需要操作系统执行至少 4 次独立的扇区写入。如果服务器在写入前 8KB 数据后突发断电宕机导致该数据页在磁盘上处于“前一半是新数据、后一半是旧数据”的破损状态称为半写Partial Write。当数据库重启时校验程序会分别计算当前页的内存校验和并将其与File Trailer中的校验和以及File Header中的校验和进行交叉比对。若首尾校验和不匹配说明该页遭受了半写破坏InnoDB 将拒绝直接启动并依赖 Doublewrite Buffer双写缓冲区拉取原页副本实施修复。页内检索一条记录底层是如何利用页目录做二分查找的当单页内堆积了数百条记录时如果每次查询都要顺着单向链表从Infimum开始一个一个往后比对检索效率将退化为时间复杂度O(N)O(N)O(N)的全页线性扫描。为了在单向链表这种物理结构上实现时间复杂度为O(log⁡N)O(\log N)O(logN)的二分查找InnoDB 在页面的尾部设计了页目录Page Directory。页目录的槽位分组法则页目录在本质上是一个连续的物理数组数组的每一个元素被称为一个槽Slot每个槽固定占用 2 个字节。槽位的建立遵循以下硬性分组规则极小值独占虚拟记录Infimum独占第 0 个槽Slot 0其记录头部的n_owned属性值固定为1极大值受控虚拟记录Supremum所在槽拥有的记录条数限定在1 到 8 条之间正常记录受控除上述两个特殊槽外其余所有正常用户记录槽所管辖的记录条数必须严格保持在4 到 8 条之间指针指向最大者每个槽位中存储的 2 字节相对偏移量永远指向该组中主键最大的那条记录的基准点同时只有这条最大记录的记录头中的n_owned会写入该组拥有的记录总数组内其余记录的n_owned统一填0。页内二分查找的离散执行步骤在页目录与单向链表的协同下检索特定主键记录的物理过程由以下严格顺序的步骤构成确定槽位初始边界计算页目录的最小槽索引low 0最大槽索引high slot_count - 1数组二分计算中点通过二分公式计算中点槽位mid (low high) / 2读取mid槽位中保存的 2 字节物理指针提取主键极值比对根据指针定位到该槽对应分组的最大记录提取其主键值与目标查找键进行比对收缩槽位检索区间若目标值大于当前最大记录说明目标记录必位于后方的槽组中调整左边界low mid若目标值小于等于当前最大记录说明目标可能位于当前槽组中调整右边界high mid锁定目标所在槽组重复上述二分逻辑直到high - low 1此时high槽位即为目标记录所在的分组切入前序指针组内遍历通过low槽位获取前一组的最大记录指针读取该记录的next_record直接跳跃至high组的第一条记录组内线性微调比对顺着组内的next_record单向链表依次向后遍历由于单组记录数量被硬性约束在 4~8 条最多只需进行 8 次指针跳转和主键比对即可精确命中目标行或确认数据不存在。为什么不给每一条记录都分配一个独立的槽位从而彻底省去组内线性遍历如果每条记录都独占一个槽位槽位数组在物理上必须保持紧密连续才能支持二分查找。这样一来每当在中间插入或删除记录时后续所有槽位都必须在页目录中整体搬迁带来高昂的内存复制开销。因此将每组大小限定在 4~8 条正是空间占用、二分检索效率与动态维护开销之间的折中平衡点。总结把 Compact 行格式与 16KB 数据页结合起来看InnoDB 的底层物理存储逻辑非常清晰微观层面每一行记录通过基准点将元数据变长长度列表、NULL 值位图、记录头与真实数据区分开采用向左向右对称寻址的设计实现了空间复用与变长字段寻址宏观层面16KB 数据页通过Infimum与Supremum划定范围借助页底的连续槽位数组页目录支持二分查找同时利用记录头中的next_record相对偏移量将离散的物理存储串联成有序单向链表。这种分层设计既避免了连续平铺导致的级联数据搬迁又通过页目录二分查找克服了链表遍历的性能瓶颈。