2.2 表与索引的行格式
当我们拨开“表”、“页”的宏观外衣,最底层的颗粒度就是真正属于业务的行记录。一行记录在磁盘上到底长什么样?InnoDB 是抠细节的大师,它的指导思想是——“能省则省”。
一、 一行记录的通用解剖图
在大多数现代引擎格式中,一行物理记录的结构通常被分为“辅助头”和“真实数据”两大部分:
1. 变长字段长度列表
针对 VARCHAR、TEXT 这种变长的数据类型。因为不知道它们具体多长,只能在开头先告诉解析器:“紧随其后的第一个变长字段有 x 字节,第二个有 y 字节”。(逆序存放)
- 为什么要逆序? 这是一种以“记录头”为中心、两翼放射的对称设计。
- 解析加速:逆序让第 1 列的长度信息紧贴在记录头左侧,第 1 列的真实数据紧贴在记录头右侧。解析引擎只需要从记录头位置向左、向右分别偏移即可秒读前几列数据。
- 缓存优化:这极大提高了 CPU 缓存命中率。
2. NULL 值列表
既然“能省则省”,如果是 NULL 值,就一丁点物理空间都不给它留。InnoDB 用一串紧凑的二进制 bit 位来记录“这一行中哪些列是 NULL”。如果为 1,代表对应列为空。
3. 记录头信息 (Record Header)
仅仅靠上面两个还不够,它还维护了 5 字节左右的位图信息,包含了非常多关键机制需要的开关:
- 删除标记 (deleted_flag):当执行
DELETE时,InnoDB 不会去清空磁盘碎片,而是立刻把这一位置为 1(逻辑删除,留在日后复用或彻底回收)。 - 最小事务 ID 标记。
- 下一条记录的偏移指针 (next_record):依靠它把页内的数据串成一条单向链表。
4. 真实列数据
真正存放业务上的 INT, CHAR 等设定好的字段值。
5. 隐藏列 (隐藏的王牌)
不管你有没有自己建列,InnoDB 会无条件地往每一行强塞几根钉子(用于 MVCC、回滚、主键):
- DB_ROW_ID (6 字节):如果我们没有建主键,也没有唯一索引,就靠它来充当隐藏主键。
- DB_TRX_ID (6 字节):记录最后一个修改(或者插入)这行数据的事务 ID。这是多版本并发控制 (MVCC) 能够推演时空穿越的基础。
- DB_ROLL_PTR (7 字节):回滚指针。指引你顺藤摸瓜去 Undo Log 里找这一行的上一代历史版本。
二、 行格式的时代大演进
1. Redundant 与 Compact 时代
- Redundant (冗余格式):MySQL 5.0 以前的古老格式。非常死板且冗余,字段长度等信息存得铺张浪费,目前存在的唯一意义就是兼容。
- Compact (紧凑格式):从 5.1 开始引入,拉开了精打细算的序幕。上述的变长字段列表、NULL 值压缩,核心思想就是压缩头部的空间。
2. Dynamic 与 Compressed 时代 (当下主流)
MySQL 5.7 和 8.0 之后,默认采用了下面更强悍的格式:
- Dynamic (动态格式):它是 Compact 的再升级版。最核心的进化点在于对“行溢出”的处理极其聪明。它能让 B+ 树的树高保持克制,哪怕表里塞满了巨型文本。
- Compressed (压缩格式):在 Dynamic 基础上,不仅聪明,还顺带对页面使用了
zlib压缩算法。极大省磁盘,但代价是每次读取要解压产生 CPU 消耗,属于空间换时间的玩法。
三、 溢出页机制 (行溢出)
我们知道,一个数据页的大小通常固定为 16KB,而 InnoDB 有一条铁律:一页中至少要存放两行记录(否则 B+ 树就退化成链表了)。
这意味着如果某一行非常庞大(塞满了超大的 VARCHAR、TEXT 或 BLOB),可能半页都放不下,这就触发了行溢出。
如何处理溢出的“大胖子”? 那些多出的庞大数据,会被从常规的数据页里“踢”出去,扔到一种特殊的物理页——溢出页 (Off-page) 中。
针对这一机制,不同行格式有不同的解决态度:
- Compact / Redundant:藕断丝连 会在原本的行记录中存储这坨大文本的前 768 个字节(留个脑袋),然后在 768 字节的末尾放一个 20 字节的指针,指向后面庞大数据真正存放的溢出页。
- Dynamic / Compressed:完全甩锅(更优秀) 十分干脆。在原记录里不存任何实际文本内容,只留下一个纯指针,把所有的庞大数据100%存放到溢出页,如果溢出页还不够,溢出页与溢出页之间也会串成链表。 这样做的好处是使得核心的 B+ 树数据页能够塞进更多的纯指针/小数据行,使树的体积更扁平。