Skip to content

InnoDB 存储结构:表空间、页与行格式 ​

标签
中间件/MySQL
中间件/InnoDB
字数
20442 字
阅读时间
80 分钟

InnoDB 把一张表的数据与索引都组织成 B+ 树,树的每个节点是一个固定大小的页。一条新写入的记录先落在某个页的 User Records 区,页又归属某个区、某个段、某个表空间文件。这条链路分三层:文件层(数据落在哪个文件、空间怎么分配与回收)、页层(一个页内部的字节布局与页内查找)、记录层(一条行记录在页内的字节格式)。

工程上最容易出问题的是页层与记录层:varchar(n) 的 n 到底能取多大、行长到什么程度触发溢出、随机主键为什么带来写放大、删表后磁盘为什么没变小。本篇按「文件 → 页 → 记录」的顺序把这三层的机制、参数与排查线索写全。索引键怎么在页之间走、回表与索引覆盖,属于索引组织的内容,见本专栏 03 篇。

1. 从文件到页:InnoDB 的逻辑存储层级 ​

本节先回答「数据落在哪个文件」,再回答「文件内部的空间怎么分」,最后给出不连服务器也能查的观测手段。三层里最容易被忽略的是区这一级:它不直接面向用户,却决定了范围扫描能不能走顺序 I/O。

1.1 磁盘上的文件:datadir 与 .ibd ​

每个数据库在数据目录下对应一个同名子目录,目录位置由 datadir 决定:

sql
mysql> SHOW VARIABLES LIKE 'datadir';

以 my_test 库的 t_order 表为例,5.7 的目录里能看到 db.opt、t_order.frm、t_order.ibd 三个文件:db.opt 存库的默认字符集与校验规则,.frm 存表结构定义,.ibd 存表的数据与索引。

MySQL 8.0 移除了 .frm 与 db.opt 这两个文件:表结构(列定义、索引定义、字符集等)被并入 InnoDB 数据字典,统一存在 mysql.ibd 里。所以在 8.0 上,一张表在数据目录里只剩下 t_order.ibd 一个文件(来源:MySQL 8.0 Reference Manual, 14.1)。观察文件时不要按 5.7 的记忆去找 .frm。

t_order.ibd 是「独立表空间」文件,也叫 file-per-table tablespace:一张表的数据、索引、以及它自己的 undo 信息都放在这一个文件里。它是否启用由 innodb_file_per_table 决定,默认开启(见 §7.2)。若关闭该参数,表就落在共享表空间 ibdata1 里。系统表空间的文件由 innodb_data_file_path 定义,默认是单个自动扩展的 ibdata1,初始略大于 12MB(来源:MySQL 8.0 Reference Manual, 17.8.1)。

判断一张表落在哪种表空间,最直接的办法是看数据目录下有没有对应的 .ibd:有就是独立表空间,没有则在共享表空间或通用表空间里。通用表空间(general tablespace)是 8.0 用 CREATE TABLESPACE 显式创建、可容纳多张表的共享表空间,与系统表空间的区别是它可以建在数据目录之外。

数据目录里还会看到 undo_001、undo_002 这类 undo 表空间文件(8.0 默认两个)以及 #innodb_temp 临时表空间目录。它们也是 InnoDB 的存储组件,排查表空间占用时不要漏掉;SELECT * FROM INFORMATION_SCHEMA.INNODB_TABLESPACES 会把这些表空间一并列出。

1.2 段、区、页、行:四层空间单位 ​

一个表空间文件内部按四层划分空间,从大到小依次是段、区、页、行:

text
   表空间(t_order.ibd 文件)
   └─ 段(segment)
       ├─ 索引段:B+ 树非叶子节点占用的区的集合
       ├─ 数据段:B+ 树叶子节点占用的区的集合
       └─ 回滚段:undo 数据占用的区的集合
            │
            └─ 区(extent,默认 1MB)
                 │  ├─ 页 0  ┐
                 │  ├─ 页 1  ├─ 64 个连续的 16KB 页拼成一个 1MB 区
                 │  └─ …    ┘
                 │
                 └─ 页(page,默认 16KB,读写的最小单位)
                      │
                      └─ 行(row,一条记录,按行格式编码)

几个关键量(来源:MySQL 8.0 Reference Manual, 17.11.2):

  • 页是读写的最小单位。 需要读一条记录时,InnoDB 把整页(默认 16KB)读入内存;写也是整页刷盘。这是后面所有「页内布局」「行溢出」讨论的前提。
  • 区的大小随页大小变。 页不超过 16KB 时,区固定 1MB,恰好是 64 个 16KB 页(8KB 页则 128 个,4KB 页 256 个);页为 32KB 时区是 2MB,页为 64KB 时区是 4MB。
  • 分配按「先散后整」推进。 一个段刚创建时,前 32 个页按页逐个分配给段;超过 32 个页之后改为整区分配,一次最多给段加 4 个区,以此保证数据的顺序性。
  • 每个索引配两个段。 非叶子节点一个段,叶子节点一个段,让叶子节点在磁盘上尽量连续,范围扫描时能走顺序 I/O。

区这个单位存在的理由就是顺序 I/O。B+ 树同层节点用链表连接,如果按页随机分配,链表中相邻的两个页在磁盘上相隔很远,范围扫描退化成随机 I/O。按区分配让相邻的页物理上也相邻,把随机 I/O 换回顺序 I/O。

把四个量代入可以估一个页能装多少记录。一个 16KB 页去掉固定头部(38 + 56 + 26 = 120 字节)与页尾(8 字节)后,约 16.2KB 用于记录;若一行约 200 字节,单页可放约 80 条。叶子页的容量决定了 B+ 树的扇出,这也是「单表多少行」估算的输入之一,具体推导见本专栏 03 篇。

段在实现上是「区号的集合」,由 inode 页(FIL_PAGE_INODE,类型号 3)记录它拥有哪些区、哪些页已被分配。表空间里还有一类页专门存其他页的位图(XDES 页,类型号 9),这类页不能被整块划给段,只能按页单独分配,这是「有些区不能整体分配给段」的原因(来源:MySQL 8.0 Reference Manual, 17.11.2)。

页与页之间还有一层细节:区内的页在文件里按页号连续排布,跨越多个区的页不保证连续。范围扫描沿链表走到区边界时,仍可能发生一次跳转;innodb_segment_reserve_factor(默认 12.5%)为段预留一部分空页,让 B+ 树增长时新页能尽量分配到相邻位置(来源:MySQL 8.0 Reference Manual, 17.11.2)。

1.3 共享表空间与独立表空间 ​

innodb_file_per_table 的默认值是 ON,即每张表落在自己的 .ibd 文件里。把它关掉,表就建在共享表空间 ibdata1 里。两者在空间回收上的差异是运维里最实际的差别:

text
   共享表空间(innodb_file_per_table=OFF)
   ┌───────────────────────────────────────────────────┐
   │ ibdata1:所有表的数据 + 索引 + 回滚段混在一个文件里   │
   │   表 A 表 B 表 C …                                   │
   │   DROP / TRUNCATE 表后,空间只在该文件内标记为可用,   │
   │   文件本身不缩小,磁盘占用不还给操作系统               │
   └───────────────────────────────────────────────────┘

   独立表空间(innodb_file_per_table=ON,默认)
   ┌───────────┐   ┌───────────┐   ┌───────────┐
   │ a.ibd     │   │ b.ibd     │   │ c.ibd     │
   │ 表 A 数据  │   │ 表 B 数据  │   │ 表 C 数据  │
   └───────────┘   └───────────┘   └───────────┘
     └─ TRUNCATE / DROP 后文件被删除,空间直接还给操作系统

官方文档把差别写得很直白:共享表空间里删表只在数据文件内留下可被 InnoDB 复用的空闲空间,文件不会缩小;独立表空间删表(或 TRUNCATE)后文件被删除,磁盘空间归还操作系统(来源:MySQL 8.0 Reference Manual, 17.6.3.2)。另一个副作用是表复制类的 ALTER TABLE(需要重建表)在共享表空间里可能把文件撑到「表数据 + 索引」那么大,多占的空间同样不归还。共享表空间里所有表还受同一个 64TB 上限约束;独立表空间是每张表各自 64TB。

迁移的含义有三处要分清:

  • 改参数不迁移老表。 innodb_file_per_table 只影响此后新建的表;已经建好的表不会因为参数变化而搬家。老表要搬家得显式重建。
  • 显式指定表空间可强制搬迁。 ALTER TABLE t TABLESPACE = innodb_file_per_table 把表移到独立表空间,TABLESPACE = innodb_system 移到系统表空间。这类操作会重建表,代价与表的体积成正比。
  • 跨实例导表依赖独立表空间。 .ibd 文件可以拷贝到另一个实例导入(配合 FLUSH TABLES ... FOR EXPORT 或 8.0 的 IMPORT TABLE),共享表空间无法单独搬运一张表。这也是「能独立表空间就独立」的一条实际理由。

跨实例导表用 transportable tablespace 流程:源库 FLUSH TABLES t FOR EXPORT 让 .ibd 落盘一致,拷贝 .ibd(8.0 起元数据内嵌在 SDI 里,通常不再需要随附 .cfg);目标库 IMPORT TABLE 时校验元数据。这条路径要求两端页大小、行格式一致,否则导入失败,与 §8.2、§7.3 的失败模式同源。关于 innodb_file_per_table 成为默认值的具体版本节点,本篇未核到官方依据,按「8.0 默认 ON」使用。

独立表空间还有一个可观测的收益:可以直接在文件系统层面看单表数据文件的大小。共享表空间下所有表混在一个文件里,单表容量只能靠 INFORMATION_SCHEMA.INNODB_TABLES 一类视图估算。做容量规划与告警时,独立表空间让「哪张表在涨」一眼可查。

1.4 观测手段 ​

排查存储结构时先用一批只读命令把事实固定下来:

sql
SELECT @@GLOBAL.innodb_page_size;                 -- 本实例的页大小,字节数
SHOW TABLE STATUS LIKE 't_order'\G                -- Row_format / Data_length / Index_length / Data_free
SELECT * FROM INFORMATION_SCHEMA.INNODB_TABLES    -- ROW_FORMAT / ZIP_PAGE_SIZE 等
 WHERE NAME = 'my_test/t_order';
SELECT * FROM INFORMATION_SCHEMA.INNODB_TABLESPACES
 WHERE NAME = 'my_test/t_order';                  -- FILE_SIZE / AUTOEXTEND_SIZE

页大小在实例初始化后就固定了,只能通过上面的系统变量读出来(见 §7.1)。从 8.0 起表的结构信息(SDI,Serialized Dictionary Information)以压缩 JSON 的形式内嵌在 .ibd 里,可以用 ibd2sdi t_order.ibd 直接从一个数据文件里读出库名、表名、列与索引定义,不必连服务器;innochecksum 用来校验页的校验和。判断「表空间文件为什么这么大」时,先看 INNODB_TABLESPACES.FILE_SIZE 与 SHOW TABLE STATUS 的 Data_free(可回收碎片字节数),再看表有多少个区。

2. 数据页的内部结构 ​

页是 InnoDB 读写的基本单位,本节逐段拆开一个 16KB 页的字节布局,从页头的链表指针一路讲到页目录的二分查找,最后给出页内查找的完整路径。理解这一段之后,行格式与溢出才有落脚点。

观测的目标是先确定三个不变量:页大小、行格式、表空间类型。三者在实例或表创建后就固定,先把它们读出来,再谈容量与碎片才有意义。8.0 还可以用 ibd2sdi --dump-file=... 把 .ibd 里的 SDI 导出成 JSON 再解析,用 innochecksum 核对页校验和。

2.1 七段式布局与字节偏移 ​

一个索引页固定 16KB(默认),内部按用途切成七段,各段位置和长度是固定的(来源:MySQL 8.0 源码 storage/innobase/include/fil0types.h、page0page.h、page0types.h):

text
   页内偏移 0                                                 16383
   ┌──────────────────────────────────────────────────────────────┐
   │ File Header          38 字节   所有页通用(页号、链表指针…)   │ 0
   ├──────────────────────────────────────────────────────────────┤
   │ Page Header          56 字节   索引页特有(记录数、堆顶…)      │ 38
   ├──────────────────────────────────────────────────────────────┤
   │ Infimum + Supremum   26 字节   两条虚拟记录(每条约 13 字节)   │ 94
   ├──────────────────────────────────────────────────────────────┤
   │ User Records         变长     真正的行记录,从偏移 120 起       │ 120
   ├──────────────────────────────────────────────────────────────┤
   │ Free Space           变长     尚未使用的空间                   │
   ├──────────────────────────────────────────────────────────────┤
   │ Page Directory       变长     槽,从页尾向前生长                │
   ├──────────────────────────────────────────────────────────────┤
   │ File Trailer         8 字节   校验和 + LSN 低 4 字节           │ 16376
   └──────────────────────────────────────────────────────────────┘
     └─ User Records 从低地址向上生长,Page Directory 从高地址向下生长,
        两者相向而行,中间夹着的就是 Free Space —— 空间耗尽即页满

三段长度固定:File Header 38 字节、Page Header 56 字节、Infimum 加 Supremum 共 26 字节,页尾 File Trailer 8 字节。中间的 User Records、Free Space、Page Directory 三段长度可变,由页内记录数决定。源码里的常量把边界钉死:FIL_PAGE_DATA = 38(File Header 之后、正文开始的位置),FIL_PAGE_DATA_END = 8(File Trailer 的长度),PAGE_HEADER 对应偏移 38,PAGE_DATA 对应偏移 94(Page Header 之后)。Page Header 之后紧跟 Infimum 记录,偏移 94 到 120 这 26 字节就属于这两条虚拟记录。

源码常量还给出了两处可核对的数:PAGE_DIR_SLOT_SIZE = 2(每个槽 2 字节)、PAGE_HEADER_PRIV_END = 26。后者说明 Page Header 里 26 字节之后的字段属于「段头」部分(PAGE_BTR_SEG_LEAF 与 PAGE_BTR_SEG_TOP),只在 B+ 树的根页里有效。

页在生成时并没有 User Records 这一段,全是 Free Space。每插入一条记录,就从 Free Space 申请一块记录大小的空间划给 User Records,页头里的 PAGE_HEAP_TOP 指向已用区域的顶端。当 Free Space 被用光,这个页就满了,再有记录插入就要申请新页。

页类型由 File Header 的 FIL_PAGE_TYPE 区分,InnoDB 为不同用途设计了多种页(来源:MySQL 8.0 源码 fil0types.h):

类型号常量用途
0FIL_PAGE_TYPE_ALLOCATED已分配但尚未使用
2FIL_PAGE_UNDO_LOGundo 日志页
3FIL_PAGE_INODE段空间管理(区分配的簿记)
5FIL_PAGE_IBUF_FREE_LISTchange buffer 空闲链表
10FIL_PAGE_TYPE_BLOB溢出(BLOB)页
17855FIL_PAGE_INDEXB+ 树索引页(0x45BF)

数据页与索引页都是 FIL_PAGE_INDEX 类型,二者结构完全相同,区别只在 PAGE_LEVEL 与记录内容:叶子页(PAGE_LEVEL = 0)存完整行记录,非叶子页存「最小键 + 子页页号」的目录项。这就是 B+ 树「内部节点只做路由、数据全在叶子」在页层面的体现。

七段的排布有一条固定原则:需要被随机访问或频繁更新的元数据放在页的两端(File Header、Page Header、File Trailer),只随页内数据量变化的部分放中间。这样记录区的增长不会移动头部字段的偏移,读页头只需按固定偏移一次寻址。User Records 向上、Page Directory 向下相向生长,则是让记录区与页内索引区共享同一块连续空闲空间,页内不留无法使用的空隙。

2.2 File Header:页的双向链表 ​

File Header 占页内偏移 0 到 37 的 38 字节,是所有类型页共用的结构。字段与偏移如下(来源:MySQL 8.0 源码 storage/innobase/include/fil0types.h):

text
   偏移  长度  字段                          含义
   ─────────────────────────────────────────────────────────────────
    0     4    FIL_PAGE_SPACE_OR_CHKSUM       页校验和
    4     4    FIL_PAGE_OFFSET               页号(表空间内唯一)
    8     4    FIL_PAGE_PREV                 上一页页号,FIL_NULL 表示无
   12     4    FIL_PAGE_NEXT                 下一页页号,FIL_NULL 表示无
   16     8    FIL_PAGE_LSN                  页最后一次修改时的 LSN
   24     2    FIL_PAGE_TYPE                 页类型,索引页 = 17855(0x45BF)
   26     8    FIL_PAGE_FILE_FLUSH_LSN       仅系统表空间第 1 个页使用
   34     4    FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID   所属表空间 ID
   ─────────────────────────────────────────────────────────────────
   合计 38 字节,正文从偏移 38(FIL_PAGE_DATA)开始

   同一层级的索引页,用 PREV / NEXT 串成双向链表
   ┌────────┐  NEXT   ┌────────┐  NEXT   ┌────────┐
   │ 页 5   │────────▶│ 页 6   │────────▶│ 页 7   │
   │        │◀────────│        │◀────────│        │
   └────────┘  PREV   └────────┘  PREV   └────────┘
     └─ 物理上可以不连续,逻辑上连续 —— 这正是用链表而非数组的理由

FIL_PAGE_PREV 与 FIL_PAGE_NEXT 是 4 字节的页号,不是内存指针。页号在表空间内唯一,4 字节可表示 2³² 个页,按 16KB 算正好是 64TB,这就是单个表空间上限 64TB 的由来(来源:MySQL 8.0 Reference Manual, 17.22)。同一层的页靠这两个指针双向串联,B+ 树的层序遍历、范围扫描都从页头开始。链表让物理位置不连续的页在逻辑上连续,也让按区分配(§1.2)成为「让链表相邻页也物理相邻」的优化手段。

FIL_PAGE_LSN 记录该页最后一次修改时的日志序列号,与 File Trailer 里的低 4 字节呼应(§2.7),用于崩溃恢复时判断页的新旧。FIL_PAGE_FILE_FLUSH_LSN 只写在系统表空间的第一个页里,用来记录「文件至少被刷到哪个 LSN」,是判断上次是否正常关闭的信息。FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 记录该页属于哪个表空间,页号只在表空间内唯一、跨表空间要连这个 ID 一起才能定位到唯一页。

页号从 0 开始分配,0 号页在系统或独立表空间里固定是表空间头页(FIL_PAGE_TYPE_FSP_HDR,类型号 8),记录表空间大小、区分配状态等信息,因此索引页的页号通常从较大的数字开始(页号起始细节以本实例的 INNODB_TABLESPACES 为准,待验证)。FIL_PAGE_PREV 与 FIL_PAGE_NEXT 只在同一层级之间串联,跨层不连;B+ 树靠「同层链表 + 层间父子指针」把顺序扫描与点查统一起来。

崩溃恢复时比较某页在 redo 里的 LSN 与页内 FIL_PAGE_LSN:前者更大说明还有未应用的变更,需要继续重放;相等则页已是最新。页的完整性校验靠页头与页尾两份校验和比对,校验算法由 innodb_checksum_algorithm 控制(该参数取值与默认值本篇未逐项核对)。File Header 与 Page Header 两段之间没有对齐填充,字段紧凑排列。

2.3 Page Header:页内状态 ​

Page Header 占偏移 38 到 93 的 56 字节,只有索引页有。它记录页内记录的组织方式,字段较多,排查时真正会用到的是这几个(字段位宽见 MySQL 源码 page0page.h 及各字段宏):

字段长度含义
PAGE_N_DIR_SLOTS2Page Directory 里槽的数量
PAGE_HEAP_TOP2已用区域的顶端偏移,即 User Records 与 Free Space 的分界
PAGE_N_HEAP2堆内记录数,含 Infimum/Supremum 与已删除记录
PAGE_FREE2垃圾链表头(被删记录串成的链表)
PAGE_GARBAGE2已删除记录占用的字节数
PAGE_LAST_INSERT2最后插入记录的位置
PAGE_DIRECTION2插入方向:向右(值增大)或向左(值减小)
PAGE_N_DIRECTION2同一方向连续插入的记录数
PAGE_N_RECS2页内有效记录数,不含虚拟记录与已删除记录
PAGE_MAX_TRX_ID8修改本页的最大事务 ID,仅二级索引使用
PAGE_LEVEL2页在 B+ 树中的层数,叶子为 0,向上递增
PAGE_INDEX_ID8本页所属索引的 ID
PAGE_BTR_SEG_LEAF10叶子段的段头信息,仅根页定义
PAGE_BTR_SEG_TOP10非叶子段的段头信息,仅根页定义

PAGE_N_RECS 与 PAGE_N_HEAP 的差值就是已删除但未清理的记录数;PAGE_GARBAGE 积累到一定程度会触发页内空间整理(把删除的记录真正移除、压缩空闲空间)。PAGE_DIRECTION 与 PAGE_N_DIRECTION 是 InnoDB 判断「当前插入是否为顺序插入」的依据:连续同向插入时它会做一次乐观的「向右插」快速路径,一旦方向变化就回退到可能触发页分裂的通用路径。这也解释了下文 §6 里自增主键为什么比随机主键便宜。

PAGE_MAX_TRX_ID 只在二级索引页里有意义,记录修改本页的最大事务 ID,MVCC 判断「页内是否可能存在对当前事务可见的旧版本」时先用它做粗筛。PAGE_LEVEL 是页在 B+ 树里的层号,叶子为 0,越靠近根越大,根页的 PAGE_LEVEL 就是树高减一。

User Records 与 Free Space 的分界由 PAGE_HEAP_TOP 描述。页内删除只置 delete_mask,被删记录先留在原位、串进 PAGE_FREE 指向的垃圾链表;只有当 PAGE_GARBAGE 积累到需要时才做页内整理,把有效记录重新紧凑排列、回收空闲空间。这解释了为什么大量删除后页不会立刻变小,也解释了 delete 的代价主要由后台整理承担。

PAGE_INDEX_ID 是页所属索引的标识,B+ 树定位时先用根页的该字段确认走的是哪棵索引;PAGE_MAX_TRX_ID 只在二级索引页有效,用于 MVCC 粗筛。PAGE_BTR_SEG_LEAF 与 PAGE_BTR_SEG_TOP 只在根页有值,分别指向该索引的叶子段与非叶子段。

2.4 Infimum 与 Supremum:两条虚拟记录 ​

每个索引页都有两条虚拟记录,占据偏移 94 到 119 的 26 字节。Infimum 是页内所有记录的「下界」,Supremum 是「上界」,任何用户记录键值都落在两者之间。它们的真实数据部分是固定字符串(infimum 与 supremum),不存储用户列。

这两条记录的作用是省掉链表边界判断。页内所有记录(含 Infimum、Supremum)按主键升序串成一条单向链表,Infimum 是链头,Supremum 的 next_record 为 0 表示链尾。有了固定的头尾,查找和插入不必对空页或首尾记录做特判。它们在堆里的相对编号 heap_no 固定为 0 和 1,用户记录的 heap_no 从 2 开始。

两条虚拟记录各 13 字节(5 字节记录头 + 8 字节固定字符串),合计 26 字节,与 §2.1 的偏移一致。它们也能被页目录管理:Infimum 单独成组(1 条),Supremum 自己所在组允许 1 到 8 条。

2.5 User Records 与 Free Space ​

User Records 区存放真正的行记录,每条记录按行格式(§3)编码后从 Free Space 申请空间落下。记录在页内的组织有两层:记录之间是一条单向链表,由每条记录头部的 next_record 串起来;链表按索引键升序,有主键就按主键排,没有主键但有唯一非空索引就按该索引排,两者都没有则按 InnoDB 自动生成的内部行号排。

排序依据的优先级与聚簇索引键的选择一致:主键 > 第一个非空唯一索引 > 内部生成的 row_id。这决定了页内记录的物理顺序,也决定了范围扫描能否直接沿链表推进。单向链表插入删除方便,检索却只能从头遍历,最坏要看完整条链。页内记录数可以达到几百上千条,逐条遍历不可接受,于是有了 Page Directory 这一段。

记录在页内落下后不再移动位置,next_record 的相对偏移让链表能在不搬动记录内容的前提下重排顺序——插入新记录只需修改前驱的 next_record 与自身的 next_record 两个值。页内整理(reorganize)是例外,它把记录紧凑重排、重算所有偏移,因此只在垃圾积累到阈值时才执行。

2.6 Page Directory:槽与稀疏索引 ​

Page Directory 是记录组的目录,槽(slot)从页尾向前生长,每个槽占 2 字节,存的是「组内最大记录」在页内的地址偏移(来源:MySQL 8.0 源码 page0page.h,PAGE_DIR_SLOT_SIZE = 2)。分组与查找的规则如下:

text
   Page Directory          User Records(组内按主键升序的单向链表)
   ┌────────┐
   │ 槽 0   │──▶ 组 0:[Infimum, 2, 4]           n_owned = 3
   ├────────┤
   │ 槽 1   │──▶ 组 1:[5, 8, 10, 11]            n_owned = 4
   ├────────┤
   │ 槽 2   │──▶ 组 2:[12, 13, 14, 15, Supremum] n_owned = 5
   └────────┘
        │
        └─ 槽记录的是「本组最大记录」的页内偏移;该记录头里的
           n_owned 字段记录本组有几条记录

   分组条数的规定(源码常量):
     首组(含 Infimum)        1 条
     末组(含 Supremum)       1 ~ 8 条
     其余各组                  4 ~ 8 条   (PAGE_DIR_SLOT_MIN/MAX_N_OWNED = 4/8)

为什么槽是稀疏的。 槽不指向组内每一条记录,只指向每组的最大记录。组内条数被限制在 4 到 8 条(首组 1 条、末组 1 到 8 条),所以定位到槽之后,组内遍历最多走 8 步。若每个槽都指向一条记录,槽本身就占满空间,插入删除还要同步维护大量槽;稀疏分组的代价是组内线性扫描,收益是把二分查找的对数级和组内遍历的常数级结合起来。

为什么二分后要先回退一组。 槽存的是组内最大记录的偏移,直接命中槽的边界值无法确定目标是否恰好在边界。查找的做法是二分定位到目标所在的组,从这个组的前一组的最大记录(也就是本组最小记录的前驱)出发,沿链表向后遍历,直到找到不小于目标的记录。这样既利用了有序性,又不必依赖「槽里存最小值」。

一个完整的查找轨迹(页内共有 5 个槽,编号 0 到 4,查主键 11):

text
   ① 槽范围 [0, 4],中点 (0+4)/2 = 2,取槽 2,其最大记录为 8
        11 > 8  → 目标在槽 2 之后
   ② 槽范围 [2, 4],中点 (2+4)/2 = 3,取槽 3,其最大记录为 12
        11 < 12 → 目标就在槽 3 这一组
   ③ 从槽 3 的前一槽(槽 2)的记录 8 出发,沿 next_record 向后遍历:
        9 → 10 → 11,命中
        组内最多 8 条,这段遍历的代价有界

查找的复杂度是「页内 O(log 槽数 + 组内常数)」。组内最多 8 条是硬约束,所以二分之后那段遍历的代价有界,不受页内总记录数影响。若一个页有 1000 条记录、平均每组 6 条,则约有 167 个槽,二分只需约 8 次比较就能定位到组,再线性扫最多 8 条——比沿链表平均扫 500 条快两个量级。这也解释了页目录为什么值得多占这几十到几百字节。

槽数组从页尾向前增长,与从页头向后增长的 User Records 相向占用空间,页头 PAGE_N_DIR_SLOTS 记录当前槽数。槽只存 2 字节偏移、不存记录内容,一个页放几百个槽也只占几百字节。某组条数超过 8 时,InnoDB 在组内再分出一个槽、把该组一分为二,并同步更新两侧最大记录的 n_owned;组内条数降到 4 以下时则与相邻组合并。分组在 4 到 8 之间浮动,是为了平衡「每次增删只维护一两个槽」与「二分精度」这两件事。页目录是页内的稀疏索引,与 B+ 树那层的页级稀疏索引是同一套思路在两个尺度上的复用。

页目录的规模可以直接从页头读出来:PAGE_N_DIR_SLOTS 乘以 2 就是 Page Directory 的字节数。槽指向的是「记录在页内的偏移」,而不是记录内容本身,所以更新一条记录的内容不会改动槽,只有记录在组间归属变化(插入、删除、组拆分)才动槽。当页目录区增长到与记录区相接,页就接近满了。

页目录的二分只在页内有意义。跨页时,B+ 树的非叶子节点先按同样的「最小键 + 子页号」把范围收窄到某一个叶子页,再在叶子页里做上面这套「二分槽 + 组内遍历」。两级稀疏索引叠加的结果是:一次点查的页内比较次数是 O(log 页内槽数),跨页部分是比较次数等于树高,总代价约等于树高加一个常数。

2.7 File Trailer:页的完整性校验 ​

File Trailer 占页内最后 8 字节(偏移 16376 到 16383),只有两个部分:4 字节校验和,以及 FIL_PAGE_LSN 的低 4 字节(源码常量 FIL_PAGE_END_LSN_OLD_CHKSUM = 8)。页没有独立的一段空间放它的全部内容校验值,做法是在页头 FIL_PAGE_SPACE_OR_CHKSUM 和页尾各存一份校验和。

刷盘顺序是先写页头再写页尾。断电后若页头与页尾的校验和不一致,说明这个页刷到一半就中断了,读到时判为损坏页。校验和覆盖整页内容,因此内容被改坏的页也会在这里暴露。innochecksum 就是离线核对这个校验和的工具。

页尾还存了 FIL_PAGE_LSN 的低 4 字节,正常情况下应与页头里的 FIL_PAGE_LSN 低 4 字节相同。恢复时用它判断页是否被完整写入:页头与页尾的 LSN 不一致同样说明写入不完整。

2.8 页内查找的完整路径 ​

把前面几段接起来,一次「在某个页里找一条记录」的流程是:

text
   目标键 K
      │
      ▼
   ① 从 Page Header 读出 PAGE_N_DIR_SLOTS,得到槽的数量
      │
      ▼
   ② 在槽数组上二分(槽按所指向记录的主键升序)
      │   └─ 找到第一个「最大记录 ≥ K」的槽 S
      ▼
   ③ 取槽 S 的前一槽 S-1 所指的记录,作为起点
      │
      ▼
   ④ 沿 next_record 单向链表向后遍历(最多 8 步)
      │
      ▼
   ⑤ 命中键 K 的记录 → 用记录头里的偏移取出真实数据列
      │
      └─ 走到本组末尾仍未命中则页内不存在该键

跨页的定位(先经 B+ 树内部节点选页、再进叶子页做上面这套)属于索引组织的内容,见本专栏 03 篇。

3. 行格式:一条记录怎么落盘 ​

行格式决定一条记录怎么编码成字节。本节先给出四代格式的对照与默认值演进,再逐段拆解 COMPACT 的额外信息、记录头与隐藏列,最后把各段拼成一条记录的完整字节布局。

3.1 四代行格式与默认值演进 ​

InnoDB 支持四种行格式。它们都基于「额外信息 + 真实数据」两段结构,差异主要在变长列与溢出的处理、以及是否支持压缩(来源:MySQL 8.0 Reference Manual, 17.10):

行格式引入与默认值变长列在页内保留支持的索引键前缀压缩
REDUNDANT5.0.3 之前的唯一(默认)格式前 768 字节≤ 767 字节否
COMPACT5.0.3 引入并成为默认,5.7.8 前为默认前 768 字节≤ 767 字节否
DYNAMIC5.7.9 起成为默认0 字节,仅 20 字节指针≤ 3072 字节否
COMPRESSED与 DYNAMIC 同期0 字节,仅 20 字节指针≤ 3072 字节是(页级 zlib)

默认值随版本变过三次:5.0.3 之前只有 REDUNDANT;5.0.3 到 5.7.8 默认是 COMPACT;5.7.9 起改为由 innodb_default_row_format 控制,默认 DYNAMIC(来源:MySQL Glossary, compact/redundant row format;MySQL Worklog WL#8307)。MySQL 8.0 沿用了 DYNAMIC。

innodb_default_row_format 的合法取值只有 DYNAMIC、COMPACT、REDUNDANT 三个,COMPRESSED 不能作为默认,因为它不能建在系统表空间里(来源:MySQL 8.0 Reference Manual, 17.10)。

这里有一个容易忽略的行为:建表时若不显式写 ROW_FORMAT(或写了 ROW_FORMAT=DEFAULT),任何重建表的操作会按当时的 innodb_default_row_format 把表改成默认行格式。ALTER TABLE 中需要重建的语句、OPTIMIZE TABLE 都会触发。在复制环境里,如果主库与从库的该参数不同,主库上一条隐式改行格式的 DDL 成功、从库可能失败;跨实例导表时行格式不匹配也会报 schema 不一致(来源:MySQL 8.0 Reference Manual, 17.10)。定行格式的可靠办法是看 SHOW TABLE STATUS 的 Row_format 列或 INNODB_TABLES.ROW_FORMAT,不靠记忆。

四种格式与两个历史 file format 相关:Antelope 只支持 REDUNDANT 与 COMPACT,Barracuda 支持另外两种并允许更长的索引键前缀。MySQL 5.7 起 innodb_file_format 参数被弃用、默认按 Barracuda 行为,8.0 已移除该参数。这段历史能解释为什么老库里的表常是 COMPACT:建表时没写行格式,就沿用了当时的默认。

选行格式的判据可以简化:新表默认就是 DYNAMIC,通常不必显式指定;只有需要与老实例(如 5.6 默认 COMPACT)对齐、或要显式控制大列溢出行为时才写 ROW_FORMAT。写 ROW_FORMAT=DYNAMIC 时索引键前缀上限随之放宽到 3072 字节,这对 utf8mb4 下建长前缀索引有直接影响。

3.2 COMPACT 的变长字段长度列表 ​

COMPACT 记录的额外信息由三部分组成:变长字段长度列表、NULL 值列表、记录头。变长字段长度列表存每个变长列「真实数据占用的字节数」,读取时先读这张表,才知道每个变长列取多少字节。

长度项在列表里逆序存放:最后定义的变长列的长度排在最前。用一张 ascii 字符集的表举例,列定义为 id int, name varchar(20), phone varchar(20), age int,name 与 phone 是变长列。对一行 (name='a', phone='123'):

text
   变长列声明顺序:  name(=1B)      phone(=3B)
   长度列表实际存放: 03             01
                      └── phone 在前,name 在后(逆序)

   为什么逆序:记录头里的 next_record 指向「下一条记录的头信息与真实数据之间」。
   读取时从记录头向右读真实数据、向左读额外信息,逆序能让
   「靠前的真实数据」和「它对应的字段长度」落在同一个 CPU cache line 内。

逆序的实际收益是缓存局部性:真实数据按列声明顺序排列,位置靠前的列的数据在低地址;把它们对应的长度项放在额外信息里靠近记录头的一侧(即高地址),两者在地理上更接近,一次 cache line 读取能同时拿到数据与其长度。NULL 值列表同样逆序存放。

关于长度项占几个字节,官方文档的说法比「超过 255 就用 2 字节」更细:每个变长列的长度占 1 或 2 字节,只有在下列情况才需要 2 字节——列的一部分被存到溢出页,或者列的最大长度超过 255 字节且实际长度超过 127 字节。对存在溢出页的列,这 2 字节表示「页内保留部分的长度 + 20 字节外部指针」,即页面内该列的本地部分(768 字节)加 20,指针本身存列的真实长度(来源:MySQL 8.0 Reference Manual, 17.10)。

变长字段长度列表不是每条记录都有:若索引里所有列都是定长且非空,记录头就没有可变长部分。全 int 列的表,每条记录不为此付任何字节;一旦加进去一个 varchar,每行都多出至少 1 字节的长度项。

变长字段长度列表只覆盖索引里出现的变长列。对聚簇索引,覆盖全部变长列;对二级索引,覆盖该索引列里变长的部分,以及被复制进来的变长主键列——官方明确即使二级索引建在定长列上,只要主键列变长,二级索引记录头也要带可变长部分记录这些长度(来源:MySQL 8.0 Reference Manual, 17.10)。这是「主键要短且定长」的又一条理由。

3.3 NULL 值列表:位图 ​

值为 NULL 的列不放进真实数据,改用一个位图表标记。规则是每个可空列按列顺序各占一个 bit(第一个可空列对应最低位),1 表示该列为 NULL,0 表示不为 NULL;位图必须占整数个字节,不足一个字节时在没有用到的位上补 0(来源:MySQL 8.0 Reference Manual, 17.10;位序由 MySQL 源码 rem0rec.cc 的 rec_init_offsets 决定)。

位图的字节数由「索引中可为 NULL 的列数 N」决定,占 CEILING(N / 8) 字节:1 到 8 个可空列占 1 字节,9 到 16 个占 2 字节。以下表为例(id 非空主键,name、phone、age 可为 NULL,共 3 个可空列):

text
   可空列(按声明顺序)各自对应一个 bit:
     name = bit0 (0x01)   phone = bit1 (0x02)   age = bit2 (0x04)
   一行全非 NULL:           0000 0000 = 0x00
   一行 age 为 NULL:         0000 0100 = 0x04(只在 age 对应的位置置 1)
   一行 phone、age 为 NULL:  0000 0110 = 0x06

位图的存在意味着:可空列越多,每条记录都要多付位图字节;把列都设为 NOT NULL,记录头里就没有 NULL 值列表这一段,每行至少省下 1 字节。表里若没有可空列,这一段直接消失,不占空间。这也是「尽量把列定义为 NOT NULL」的量化理由之一。

可空列数跨过 8 的倍数时位图会涨一字节:8 个可空列仍占 1 字节,第 9 个开始占 2 字节。对列很多的宽表,这一处的差异会体现在每一行上。

位图的位序按列顺序而不是倒序:第一个可空列对应最低位,最后一个可空列对应最高位。手工解析记录时按这个顺序对号,才不会把 NULL 归错列。

3.4 记录头信息:5 字节 ​

记录头的固定部分是 5 字节,位于变长字段长度列表与 NULL 值列表之后、真实数据之前(源码常量 REC_N_NEW_EXTRA_BYTES 即 5,PAGE_NEW_INFIMUM = PAGE_DATA + 5 印证了这一点)。5 字节共 40 位,字段分布如下:

字段位宽作用
预留位1未使用
预留位1未使用
delete_mask1删除标记。执行 DELETE 时置 1,记录并不立即物理移除
min_rec_mask1标记 B+ 树非叶子节点中最小目录项
n_owned4本组记录数,仅组内最大记录有效
heap_no13记录在页堆中的相对编号,0/1 归 Infimum/Supremum
record_type30 普通记录、1 非叶子目录项、2 最小记录、3 最大记录
next_record16下一条记录的相对偏移,构成页内单向链表

两个字段对排查最有价值:

  • next_record 是相对偏移。 它指向「下一条记录的头信息与真实数据之间」,所以从记录头向右读是真实数据、向左读是额外信息(这就是 §3.2 逆序的动机)。值为 0 表示本记录是链尾(Supremum)。
  • delete_mask 说明删除是逻辑删除。 被删记录的标记位被置 1,记录仍然留在页内、加入 PAGE_FREE 指向的垃圾链表。空间要等页内整理(reorganize)才回收,PAGE_GARBAGE 统计的正是这部分待回收字节。

n_owned 与 §2.6 的页目录直接挂钩:只有每组的最大记录才在 n_owned 里记下本组条数,组内其他记录的该字段为 0。record_type 用于区分普通记录与 B+ 树非叶子节点里的目录项记录,非叶子页的目录项也复用这套 5 字节记录头。

3.5 隐藏列 ​

真实数据区除了用户定义的列,还有 InnoDB 追加的隐藏列(来源:MySQL 8.0 Reference Manual, 17.10):

  • row_id(6 字节,可选)。 只有当表既没有主键、也没有非空唯一索引时才出现。此时 InnoDB 用这 6 字节内部行号充当聚簇索引键。
  • trx_id(6 字节,必需)。 最近一次修改该记录的事务 ID,MVCC 判断可见性时要用。
  • roll_pointer(7 字节,必需)。 指向 undo 日志中该记录的上一个版本,回滚与快照读沿它回溯旧版本。

三者中 trx_id 与 roll_pointer 每条聚簇索引记录都有,与表定义无关;row_id 是「表没有合适的聚簇索引键」时的兜底。三者加起来每条记录固定多出 13 到 19 字节。隐藏列是 MVCC 能在存储层实现的基础:不额外存版本,而是靠 trx_id + roll_pointer 顺着 undo 链把历史版本重新拼出来。

聚簇索引记录都带这三个隐藏列,二级索引记录不带全部:二级索引只存索引列与主键,因此同一行在二级索引里的记录通常比聚簇索引记录小得多。主键越长,二级索引里被复制的主键也越长,这是「主键要短」的存储层理由。

3.6 一行记录的完整字节布局 ​

把 §3.2 到 §3.5 拼起来,一条 COMPACT 记录的完整逻辑布局是:

text
   ┌──────────────────────┬──────────────┬──────────────────┬─────────────┐
   │ 变长字段长度列表      │ NULL 值列表   │ 记录头信息        │ 真实数据     │
   │ 逆序,每列 1~2 字节   │ 位图,逆序    │ 5 字节固定        │ 各列的值     │
   │ (无变长列则整段消失)│(无可空列则消失)│                  │             │
   └──────────────────────┴──────────────┴──────────────────┴─────────────┘
    真实数据区的内部顺序(左到右):
   ┌──────────────────────────────┬────────────┬──────────┬──────────────┐
   │ 用户定义的各列                │ row_id     │ trx_id   │ roll_pointer │
   │ (按建表声明顺序,NULL 列跳过)│ 6B,仅无主键│ 6B,必需 │ 7B,必需     │
   │                              │ 且无唯一键时│          │              │
   └──────────────────────────────┴────────────┴──────────┴──────────────┘
     └─ 变长列与 NULL 列越多,额外信息段越大;额外信息按逆序存放(§3.2)

用一张具体的表把各段字节数算出来。表 t (id INT NOT NULL PRIMARY KEY, name VARCHAR(20) NULL, age TINYINT NULL),字符集 ascii,行格式 COMPACT,插入一行 (id=7, name='abc', age=NULL):

text
   ① 变长字段长度列表:只有 name 是变长列
        name 最大 20 字节 ≤ 255 → 长度项 1 字节,值为 3
        列表 = [0x03],1 字节
   ② NULL 值列表:可空列 name、age 共 2 个 → CEILING(2/8) = 1 字节
        name 对应 bit0、age 对应 bit1;age 为 NULL → 0x02
   ③ 记录头:5 字节
   ④ 真实数据:id(4) + name('abc'=3) + age 的 NULL 不占 → 
        + row_id 不存在(有主键)+ trx_id(6) + roll_pointer(7)
        真实数据 = 4 + 3 + 6 + 7 = 20 字节
   ───────────────────────────────────────────────
   整条记录 ≈ 1 + 1 + 5 + 20 = 27 字节

DYNAMIC 与 COMPRESSED 的记录头结构与 COMPACT 相同,差异只体现在变长列溢出到页外的方式(§5.2)。REDUNDANT 不同:它用 6 字节记录头加一段「记录目录」(每列的指针数组,记录总长小于 128 字节时每个指针 1 字节,否则 2 字节),定长 CHAR 按定长存、VARCHAR 尾随空格不裁剪,可空列在记录目录里占 1 到 2 字节(来源:MySQL 8.0 Reference Manual, 17.10)。

一条记录各段是顺序拼接的字节流,没有分隔符,靠变长字段长度列表与记录头里的偏移来切分。这解释了一个常见现象:读取一行只需顺序解析这一段字节,不需要回表到别处找列位置,代价是列定义一旦变化(加列、改字符集)就得重建表来重排字节布局。

对同一张表,把行格式从 COMPACT 改成 DYNAMIC 不改变额外信息段的布局(记录头、变长长度列表、NULL 位图都相同),只改变大列的存放位置。读取一条记录时解析顺序与写入相反:先读 5 字节记录头确定 next_record,再按变长长度列表与 NULL 位图切分真实数据。next_record 是相对偏移,指向下一条记录的额外信息与真实数据之间,沿链表遍历时每步只做一次指针加法。

把上面那张表换成 DYNAMIC,同样的行字节数相同,因为额外信息段不因行格式而变,差异只在这种行里的 name 若被溢出时会整列移到溢出页(§5.2)。把主键从 INT 改成 BIGINT,真实数据多 4 字节,同时每个二级索引里复制的主键也多 4 字节——主键宽度以「聚簇索引 + 每个二级索引」的倍数放大。

4. varchar(n) 的 n 能取多大 ​

varchar(n) 的 n 受两层约束:MySQL 层 65535 字节的行上限,以及字符集决定的一字符几字节。本节先给出行上限,再导出单列与多列的上限算式,最后说明为什么同一张表加一列就可能建不出来。

4.1 行总长 65535 字节的前提 ​

MySQL 规定一张表一行的内部表示上限是 65535 字节,与存储引擎无关;BLOB 与 TEXT 列不按内容计入该上限,只各贡献 9 到 12 字节(内容存在页外)(来源:MySQL 8.0 Reference Manual, 10.4.7)。这 65535 字节是一行的总长,不是单列的上限。

还有第二层限制:InnoDB 单个页内可本地存放的行大小约为半页。对 4KB、8KB、16KB、32KB 页,行大小上限略小于该页的一半;16KB 页对应约 8126 字节(报错信息里的 > 8126)。64KB 页对应略小于 16KB(来源:MySQL 8.0 Reference Manual, 10.4.7)。这层限制是「行溢出」的触发线(§5.1),与 65535 那层是两回事。

两层约束管的事情不同:65535 是 MySQL 层对表定义的限制,在建表时按列的最大可能长度校验;半页是 InnoDB 层对单行实际大小的限制,在写入或建表时按行长校验。一张表可能通过 65535 的检查却触发行溢出,因为 65535 只看列声明的最大长度之和。

4.2 单变长列的上限导出 ​

先看最简情形:一张表只有一个 varchar(n) 列,字符集 ascii(每字符 1 字节),允许 NULL。n 能取到多少?把参与 65535 的量列出来:

text
   约束: 真实数据字节数 + 变长字段长度列表 + NULL 值列表 ≤ 65535

   ① 变长字段长度列表
        本列最大字节数 = n,当 n > 255 时长度项需 2 字节
        只有一个变长列 → 该列表 = 2 字节
   ② NULL 值列表
        只有一个可空列 → CEILING(1/8) = 1 字节
   ③ 真实数据
        本列真实数据上限 = n 字节

   代入: n + 2 + 1 ≤ 65535
          n ≤ 65532

官方文档用拉丁字符集给了同构的算例:VARCHAR(65535) NOT NULL 建表失败,报错信息是 Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535. This includes storage overhead,因为虽然列长度在 65535 以内,还要额外 2 字节记长度,总长超出;把列长度降到 65533 即可成功(来源:MySQL 8.0 Reference Manual, 10.4.7)。与上面的推导对上:非空列没有 NULL 列表的 1 字节,上限是 65535 − 2 = 65533;允许 NULL 再减 1 等于 65532。

字符集决定「每字符几字节」,因此把字节上限折算成字符数要除以字符的最大字节长度:

字符集每字符最大字节单列(允许 NULL)n 上限折算式
ascii16553265532 / 1
latin116553265532 / 1
utf8mb332184465532 / 3
utf8mb441638365532 / 4

MySQL 8.0 的默认字符集是 utf8mb4,所以 8.0 上单列 varchar 的常见上限是 16383,而不是 21844。把 utf8mb4 当成「每字符 3 字节」来算会得出偏大的 21844,建表时会被行大小限制挡回来。折算要向下取整或再留 1 字节余量,避免边界值恰好越限。

把算式写成通用形式:设表里有 V 个变长列、N 个可空列,并设各变长列最大长度都超过 255(长度项按 2 字节计),则各变长列真实数据之和最多为 65535 − 2V − CEILING(N/8) 字节。列数一多,扣减项会累积:100 个变长列、其中 100 个可空,长度项与位图合计就要扣掉约 200 + 13 = 213 字节。宽表的上限因此明显低于 65535。

4.3 多列的组合约束 ​

多列时,约束是对所有参与者求和:

text
    Σ 各列真实数据字节数
  + 变长字段长度列表总字节数(每个变长列 1 或 2 字节)
  + NULL 值列表总字节数(CEILING(可空列数 / 8))
  ≤ 65535

官方算例:(c1 VARCHAR(32765) NOT NULL, c2 VARCHAR(32766) NOT NULL) 拉丁字符集建表成功,因为两列各需 32765 + 2 与 32766 + 2 字节,合计 65535,正好落在上限内(来源:MySQL 8.0 Reference Manual, 10.4.7)。把这两列改成允许 NULL,非 InnoDB 引擎会因额外的 NULL 空间超出上限而失败——InnoDB 的 NULL 只用位图里的一个 bit,代价比「每列 1 字节」更省。

多列时还有一个易被忽略的项:定长列也占真实数据。char(10) 在非变长字符集下固定占 10 字节,int 占 4 字节,这些都要与变长列一起计入 65535。所以「多几个 int 列」也会把 varchar 的上限往下压,不只是变长列之间互相挤。

4.4 字符集与「加一列就建不出来」 ​

上面的约束是作用于整行的,因此会出现「单独能建、加一列就失败」的现象。给一张已经贴近上限的表再加一个 varchar 列,新列的长度项会多出 1 到 2 字节,可空列数跨过 8 的边界时 NULL 位图还会从 1 字节涨到 2 字节,这些都要从 65535 里扣。原来刚好 65532 的单列再补一列就溢出。

折算还有一处易踩:字符集按「最大字节长度」预留。utf8mb4 下 varchar(255) 的最坏情况是 255 × 4 = 1020 字节,但长度项只需 1 字节吗?长度项是否需要 2 字节看的是「列允许存储的最大字节数是否超过 255」以及实际长度,最大 1020 字节已超过 255,长度项按 2 字节预留。所以字符集从 latin1 改成 utf8mb4 时,同一个 varchar(255) 列的最坏字节数翻到四倍,行大小可能因此越限、建表失败或需要转 TEXT。这是「改字符集」在存储层的真实成本,不只是一处配置项。

官方文档还给了两组可对照的边界:VARCHAR(255) CHARACTER SET utf8mb3 每值最多 767 字节(255 × 3),长度项占 2 字节(来源:MySQL 8.0 Reference Manual, 10.4.7)。把这些数字记住,改字符集或加列前先算一遍总账,比建表失败再来回试更快。

5. 行溢出 ​

行溢出是行大小上限被触发后的处理。本节讲触发条件、COMPACT 与 DYNAMIC 在溢出时的差异、溢出页的结构,以及溢出为什么让查询变慢。

5.1 溢出的触发条件 ​

行溢出的触发线是 §4.1 的第二层限制:InnoDB 页内本地存放的行大小上限约半页(16KB 页约 8126 字节)。一行若超过这个上限,InnoDB 把最长的变长列挑出来移到页外的溢出页,直到页内剩下的部分落回上限以内(来源:MySQL 8.0 Reference Manual, 10.4.7、17.10)。

挑列的规则是「从最长的列开始」。一条记录里若既有短 varchar 又有长 TEXT,通常被移走的是 TEXT,短的变长列留在页内,以尽量少占溢出页(来源:MySQL 8.0 Reference Manual, 17.10)。这也意味着「哪一列被溢出」不是建表时固定的,而取决于这一行各列的实际长度。

触发点是半页边界,与「行能不能放进一个页」是两个判据。超过半页并不意味着这行放不进页,而是 InnoDB 主动把大列挪出去,避免单行占满整页、页内行数过少。溢出的判据是「行本地部分超过约半页」,与「行超过 16KB」是两回事。

5.2 COMPACT 与 DYNAMIC 的溢出差异 ​

两种行格式在溢出时的处理不同,这决定了页内保留多少:

text
   COMPACT(保留前缀)
   页内记录                                 溢出页
   ┌──────────────────────────────────┐
   │ 变长列的前 768 字节 + 20 字节指针  │───────▶ 剩余字节(768 之后的全部)
   └──────────────────────────────────┘
     └─ 页内仍塞着 768 字节的大列前缀,长 BLOB 多时页会被数据填满、行数变少

   DYNAMIC(整列外置)
   页内记录                                 溢出页
   ┌──────────────────────────────────┐
   │ 只有 20 字节指针                  │───────▶ 整个列的值
   └──────────────────────────────────┘
     └─ 页内只留指针,短列仍留在页内,给定行所需的溢出页数量更少

COMPACT 与 REDUNDANT 都是「页内存前 768 字节 + 20 字节指针 + 溢出页放其余」,官方对这两者的描述完全一致。DYNAMIC 把整个变长列放到溢出页,页内只留 20 字节指针;它支持最长 3072 字节的索引键前缀,而 COMPACT 与 REDUNDANT 只有 767 字节(来源:MySQL 8.0 Reference Manual, 17.10)。DYNAMIC 里小于等于 40 字节的 TEXT/BLOB 仍存在页内。

768 这个数是历史沿用的边界,与 InnoDB 早期的索引键前缀限制(767 字节)配套。DYNAMIC 的设计理由官方讲得很直接:一个长值只要有一部分被放到页外,通常把整个值放到页外效率更高;这样较短的列能留在 B+ 树节点里,给定行所需的溢出页数量更少。

COMPRESSED 的溢出方式与 DYNAMIC 相同,另加页级 zlib 压缩;它还有 KEY_BLOCK_SIZE 控制页内保留多少、溢出页放多少(来源:MySQL 8.0 Reference Manual, 17.10)。压缩页的物理页大小更小,代价是压缩与解压的 CPU 开销,以及压缩失败时触发页分裂。

行溢出说的是行数据怎么放,索引键前缀说的是索引键最多允许多少字节,二者不是一件事。COMPACT/REDUNDANT 的索引键前缀上限 767 字节,DYNAMIC/COMPRESSED 是 3072 字节;这两个数字不相等,因为键前缀限制来自页内目录项的可用空间,与行长无关(来源:MySQL 8.0 Reference Manual, 17.10)。把行格式从 COMPACT 改到 DYNAMIC 时两者一起变,复制环境里要留意。

DYNAMIC 成为默认(5.7.9 起)之后,新表的溢出行为默认是整列外置。要迁移的旧表若还是 COMPACT,重建时若未显式指定行格式,会按当前默认改成 DYNAMIC,从而改变溢出行为与索引键前缀上限(§3.1、§7.3)。

溢出页本身也按页分配,同样可能被读进 buffer pool 缓存。大 BLOB 列被频繁访问时,会把这些大页留在缓存里、挤出热点数据,这是另一类由溢出带来的隐性代价。

5.3 溢出页链表 ​

一个列的数据可能超过一个溢出页。溢出页是专门的 BLOB 类型页(FIL_PAGE_TYPE_BLOB,类型号 10),页头里同样有 FIL_PAGE_PREV 与 FIL_PAGE_NEXT,多个溢出页串成双向链表。页内的 20 字节指针指向第一个溢出页,指针里同时记录列的真实总长度,读取时顺着链表把整个值拼回来。

因为溢出页是额外的页,读一个被溢出的列至少要访问页内记录页之外的一个数据页;列越长、溢出页越多,访问的页数越多。20 字节指针的具体内容官方未逐步列出,已知其中包含溢出页的页号与列的真实长度(来源:MySQL 8.0 Reference Manual, 17.10)。

溢出页不计入用户直接可见的索引结构,但在表空间里实实在在占着区与页。约束一个列的最大溢出链长度,不能只看列声明类型,还要看实际写入的长度:TEXT 的声明上限是 65535 字节,而 LONGTEXT 可写到约 4GB,溢出页数量随实际值增长。这就是「同一列在不同行之间大小差异大」时表空间利用率难估的原因。

5.4 溢出为什么让查询变慢 ​

溢出影响性能的地方有三处:

  • 读放大。 只要查询涉及被溢出的列,就要额外读溢出页。SELECT * 会把所有溢出列都读进来,而只查主键或短列的查询可以完全不碰溢出页。
  • 无法走覆盖索引。 二级索引里只存索引列与主键,若查询要的列被溢出了,即使命中二级索引也不能只靠索引返回,必须回表把溢出列读出来。
  • 写放大与页分裂。 大列的更新要改写溢出页;若溢出列的存放方式(COMPACT 的前缀)让页内充满大列前缀,页能容纳的行数骤减,插入更频繁地触发页分裂(§6)。

一个可操作的判据:EXPLAIN 显示走了索引、但慢查询日志里响应时间仍偏高,且 Handler_read_* 与扫描行数不匹配时,先看表里有没有 TEXT/BLOB 或超长 varchar 列,再用 SELECT 只取需要的列验证是否变快。把 SELECT * 收窄到具体列,往往就是溢出列导致的差距。

6. 页分裂与页合并 ​

页的填充率由插入顺序与分裂、合并共同维持。本节讲分裂的过程与代价、主键选择对写放大的影响,以及页合并阈值与 innodb_fill_factor 的作用。

6.1 插入顺序与填充率 ​

InnoDB 在插入时尽量给页留出 1/16 的空闲,供后续更新和插入使用。填充率取决于插入顺序(来源:MySQL 8.0 Reference Manual, 17.6.2.2):

  • 顺序插入(键值单调递增或递减):页最终约 15/16 满。
  • 随机插入:页在 1/2 到 15/16 之间。

顺序插入之所以满,是因为新记录总是追加到最右侧页的末尾,不需要在页中间腾位置。随机插入会把记录插到已有页的中间,页满时必须分裂,分裂后左右两半都只装了一半左右,整体填充率下降。§2.3 提到的 PAGE_DIRECTION / PAGE_N_DIRECTION 正是 InnoDB 用来识别「当前是不是顺序插入」的计数器。

15/16 与 1/2 这两个数来自 InnoDB 主动保留的 1/16 空闲。顺序插入时只保留这 1/16,页最终约 15/16 满;随机插入时分裂让两页各自半满,之后新键继续插入才把填充率往上拉。观察页填充率可查 INFORMATION_SCHEMA 下的 InnoDB 统计表(具体视图字段待核官方文档)。

6.2 随机主键与自增主键的写放大 ​

页分裂的过程可以这样看:

text
   自增主键:永远插到最右页的末尾
   ┌────┬────┬────┐              ┌────┬────┬────┬────┐
   │ 1  │ 2  │ 3  │   ──▶ 插入 4   │ 1  │ 2  │ 3  │ 4  │
   └────┴────┴────┘              └────┴────┴────┴────┘
     └─ 顺序追加,页内约 15/16 满,几乎不分裂,写入集中在最右页

   随机主键(如 UUID):插到中间
   ┌────┬────┬────┐   目标页已满,插入键 2.5
   │ 1  │ 3  │ 5  │  ──▶
   └────┴────┴────┘      ┌────┬──────┐   ┌────┬────┐
                         │ 1  │ 2.5  │   │ 3  │ 5  │
                         └────┴──────┘   └────┴────┘
                            └─ 原页约一半数据被搬到新页,父节点还要插入一个目录项;
                               两个页各约半满,后续插入才各自重新填满

随机主键的代价在「搬一半数据 + 改父节点 + 让两个页重新填满」这三步上,而且新键越分散、触发的分裂越频繁。这会带来写放大:实际写入的页数远多于插入的记录数,redo 与 buffer pool 的压力随之上升。自增主键的写入集中在最右页,绝大多数时刻只改一个页,几乎没有搬迁。

量化一下写放大的量级。一个 16KB 页若放 80 行,页满分裂,新页只写进「被搬走的一半」(约 40 行)加一条新记录。若插入键完全随机,一个页从半满到再次分裂大约还要再插 40 行,期间每 40 行触发一次分裂,每次分裂多写约 40 行外加父节点目录项。粗算下来,随机插入的页写次数约为顺序插入的两倍量级,键值分布越均匀差异越明显。这是存储层的量级判断,精确倍数取决于行宽与树的层数。

要注意这不等于「自增主键永远更好」。自增主键在高并发插入时会集中在最右页上形成热点,也可能在分库分表、跨实例合并时冲突。取舍点在于:单实例内、写入以追加为主时用自增;需要全局唯一、跨节点生成时用有序的分布式 ID(如时间前缀 + 序列),避开完全随机的 UUID,让分裂可控。这块的写放大是存储层结论,单表行数的估算(B+ 树层数 × 单页行数)属于索引组织,见本专栏 03 篇。

页分裂还会向上传播:叶子页分裂后父节点要多一条目录项,父节点满则继续向上分裂,极端情况下树会加高一层。一次分裂涉及的页写次数取决于树高,这就是「随机主键写放大随数据量增长」的原因——不只是当下那个叶子页,整条到根的路径都可能被改。自增主键把分裂限制在树的最右边界,向上传播的频率低得多。

顺序写入还有第二个好处:page cleaner 刷脏集中在少数页上,日志与刷脏的局部性好。随机写入会同时触碰大量页,脏页分散,刷脏与 checkpoint 的压力更大。这里的「顺序」指插入键的顺序,与主键类型无关——用时间戳做主键、且时间单调递增时同样是顺序插入。

6.3 页合并与 MERGE_THRESHOLD ​

删除记录后,页的填充率会下降。当某个索引页的填充率跌到 MERGE_THRESHOLD 以下,InnoDB 会尝试收缩索引树、把该页合并到相邻页,从而释放整页(来源:MySQL 8.0 Reference Manual, 17.6.2.2)。MERGE_THRESHOLD 不显式指定时默认为 50%,对 B+ 树与 R 树索引都生效。

页合并是页分裂的逆过程:分裂在填充率高时把一页拆成两页,合并在填充率低时把两页并回一页。二者共同把页的填充率维持在一个区间内——太高则一插入就分裂,太低则浪费空间、范围扫描要读更多页。可以通过建表或建索引时的 MERGE_THRESHOLD 选项为单张表调整这个阈值。

MERGE_THRESHOLD 可以按表或按索引设置:调低它(如 30%)会让合并更激进、更省空间但更频繁地触发合并 I/O;调高(如 70%)则保守、占用更多页。默认 50% 是「空间」与「合并开销」的折中。它作用在运行期,与只作用于构建期的 innodb_fill_factor 分工不同。

6.4 innodb_fill_factor 的作用 ​

innodb_fill_factor 控制的是**有序索引构建(sorted index build)**时每页预留多少空间,不是运行期每次插入都生效(来源:MySQL 8.0 Reference Manual, 17.6.2.3)。取值 80 表示构建时每页预留 20% 空间给未来增长,该参数对 B+ 树的叶子与非叶子页都生效,但不作用于 TEXT/BLOB 的溢出页。官方明确它只是「提示而非硬上限」,实际预留量可能与配置值有偏差。默认值 100 表示不做额外预留,此时聚簇索引页仍会保留 1/16 的空间给后续增长(来源:MySQL 8.0 Reference Manual, 17.6.2.2)。详见 §7.4。

与运行期填充率相关的还有一类操作:重建表(ALTER TABLE ... ENGINE=InnoDB、OPTIMIZE TABLE)会走一次有序构建,因此会应用当时的 innodb_fill_factor;把表重建后填充率的变化可以解释一部分「重建后文件变大/变小」的现象。

7. 参数 ​

本节按「默认值 → 语义 → 什么时候该改 → 改大或改小的后果 → 与哪些参数联动 → 失败模式」六要素,逐条写出与存储结构直接相关的五个参数。

7.1 innodb_page_size ​

  • 默认值与取值:默认 16384(16KB),合法值 4096、8192、16384、32768、65536(来源:MySQL 8.0 Reference Manual, 17.14)。
  • 语义:表空间中页的字节大小,所有表空间共用同一页大小。
  • 什么时候该改:只能在初始化实例之前设定,实例创建后不可更改。要改必须先备份、重新初始化并用 mysqldump 一类逻辑方式重导数据,不能原地改。
  • 改大或改小的后果:小页(4KB/8KB)适合以大量小写为主的 OLTP,页内行数少、写冲突小,也贴合小块存储设备,把页大小贴近存储设备块大小能减少重写的未变数据;大页(32KB/64KB)适合扫描与批量更新。页为 32KB/64KB 时,行的最大长度约 16000 字节,ROW_FORMAT=COMPRESSED 不被支持;两者还必须把 innodb_log_buffer_size 保持在至少 16M(默认值)。
  • 联动:页大小决定区大小(32KB 页对应 2MB 区、64KB 页对应 4MB 区)、系统表空间首个数据文件的最小尺寸(16KB 及以下为 5MB、32KB 为 6MB、64KB 为 12MB),以及 AUTOEXTEND_SIZE 的最小值。它还与 innodb_log_buffer_size 联动。
  • 失败模式:使用不同页大小的实例之间,数据文件与日志文件不能互换使用,跨实例搬文件会直接打不开。把页大小当作一个「只读的实例属性」,排查时先用 SELECT @@GLOBAL.innodb_page_size 读出真实值。

7.2 innodb_file_per_table ​

  • 默认值:ON(来源:MySQL 8.0 Reference Manual, 17.6.3.2)。
  • 语义:ON 时每张新表建在自己的 .ibd 文件里;OFF 时建到共享表空间 ibdata1。
  • 什么时候该改:默认保持 ON 即可。只有在需要把大量小表集中管理、或历史遗留架构要求时才会考虑关闭;现代实践几乎都是 ON。
  • 改大或改小的后果:ON 的收益是删表/TRUNCATE 后空间归还操作系统、TRUNCATE 更快、单表单文件便于备份与搬迁、支持 DYNAMIC/COMPRESSED(系统表空间不支持这两个格式);代价是每表一个文件句柄、fsync 分散到多文件、可能增加碎片。OFF 的收益是文件句柄少、写入可合并;代价是文件只增不减、删表空间不回收。
  • 联动:与行格式联动(系统表空间不支持 DYNAMIC/COMPRESSED)、与 innodb_autoextend_increment 联动(该增量对独立表空间不适用,独立表空间初始小步扩、之后按 4MB 增)。
  • 失败模式:关闭后发现磁盘只涨不降——共享表空间删表后不会缩小。要回收只能导出重建整个实例。判断某张表落在哪种表空间,看数据目录下有没有对应的 .ibd。

7.3 innodb_default_row_format ​

  • 默认值:DYNAMIC(来源:MySQL 8.0 Reference Manual, 17.10)。
  • 语义:定义建表未显式指定 ROW_FORMAT(或写 ROW_FORMAT=DEFAULT)时采用的行格式。合法值只有 DYNAMIC、COMPACT、REDUNDANT,COMPRESSED 不允许。
  • 什么时候该改:一般保持 DYNAMIC。要与旧版本实例或旧表格式对齐、或要控制大列溢出行为时,才显式设置。
  • 改大或改小的后果:改这个值不会自动迁移已有表,但会改变此后重建类 DDL 的结果——需要重建的 ALTER TABLE、OPTIMIZE TABLE 会把表改成新的默认格式,可能在无感知的情况下改变存储布局。
  • 联动:与索引键前缀上限联动(DYNAMIC 支持 3072 字节前缀,COMPACT/REDUNDANT 只有 767 字节)、与复制环境的一致性联动。
  • 失败模式:主库与从库该参数不同时,一条隐式改行格式的 DDL 在主库成功、从库失败;跨实例导表时行格式不匹配报 schema 不一致。跨版本迁移前先把两端的值对齐。

7.4 innodb_fill_factor ​

  • 默认值与范围:默认 100,取值范围 10 到 100(来源:MySQL Worklog WL#7277;MySQL 8.0 Reference Manual, 17.6.2.2)。
  • 语义:有序索引构建时,每个 B+ 树页填充数据的百分比,剩余空间预留给未来增长。100 表示不额外预留(聚簇索引页仍保留 1/16)。
  • 什么时候该改:已知表后续会有大量插入、且插入键分散导致频繁分裂时,可下调到 70 到 90,为增长留出空间,减少分裂。
  • 改大或改小的后果:调小(如 80)预留更多空间、减少后续分裂,但索引当前占用磁盘更多、范围扫描要读更多页;保持 100 占用最省,但后续随机插入更容易分裂。官方明确它只作提示、不作硬保证,也不作用于 TEXT/BLOB 溢出页。
  • 联动:与 MERGE_THRESHOLD 一起决定页填充率的上下界;只在有序索引构建(CREATE INDEX、重建索引)时生效,运行期普通插入不受它控制。
  • 失败模式:把 innodb_fill_factor 当成「运行期控制页填充率」的手段会没有效果——运行期填充率由插入顺序(§6.1)与 MERGE_THRESHOLD 决定。低于 10 的取值会被拒绝。

7.5 innodb_page_cleaners ​

  • 默认值:4(来源:MySQL 8.0 Reference Manual, 17.8.3.5)。
  • 语义:刷新脏页的 page cleaner 线程数量。脏页指已修改但尚未落盘的页。
  • 什么时候该改:当磁盘 I/O 有余量、但刷脏跟不上写入、脏页比例长期逼近上限时,可以调大以加快刷脏。
  • 改大或改小的后果:调大增加并发刷脏、提升写入吞吐,代价是更多并发 I/O;调小会限制刷脏并发,写入密集时可能导致脏页堆积、触发激进刷脏甚至阶段性的吞吐下降。
  • 联动:与 innodb_buffer_pool_instances 联动——若该值超过 buffer pool 实例数,innodb_page_cleaners 会被自动设为与实例数相同的值;与 innodb_max_dirty_pages_pct(默认 90)、innodb_max_dirty_pages_pct_lwm(默认 10)联动决定刷脏时机。
  • 失败模式:该变量为只读,改不了运行期值,只能在启动配置里设。设得比 buffer pool 实例数大没有效果(会被自动下调)。

8. 常见故障与排查 ​

本节给出四类与存储结构相关的故障:表空间文件异常膨胀、页大小不兼容、行溢出导致查询变慢,以及 TEXT 与 VARCHAR 的选型判断。

8.1 表空间文件异常膨胀 ​

现象:t_order.ibd 或 ibdata1 持续变大,删掉大量数据后文件不缩小。定位按下面顺序:

text
   文件大但数据不多
        │
        ├─ ① 看是不是删了没回收
        │     DELETE 只打 delete_mask,空间要等页内整理与页回收;
        │     大量删除后填充率低,也会触发页合并。判据:SHOW TABLE STATUS
        │     的 Data_free(碎片字节数)很大。
        │
        ├─ ② 看是不是溢出页占着
        │     大 TEXT/BLOB 列的溢出页也是表空间的一部分。
        │
        ├─ ③ 看是不是 undo 没回收
        │     长事务/未提交事务会让 undo 长期保留,undo 表空间持续增长。
        │
        └─ ④ 看是不是共享表空间
              共享表空间删表不缩小,只能导出重建整个实例。

处理方式与诊断一一对应:碎片与已删除空间用 ALTER TABLE ... ENGINE=InnoDB(或 OPTIMIZE TABLE)重建表,把数据重新紧凑写入新表空间。溢出页占空间要靠减少大列或改用 DYNAMIC/COMPRESSED 控制。undo 膨胀要先查 INFORMATION_SCHEMA.INNODB_TRX 找长事务。共享表空间只能通过逻辑导出 + 重建实例回收。

判据小结:SHOW TABLE STATUS 的 Data_free 远大于 0 说明有可回收碎片;INNODB_TABLESPACES.FILE_SIZE 持续涨而 Data_length 不涨,则更可能是 undo 或溢出页。重建前一定要先确认磁盘剩余空间足够(重建期间新旧两份并存),并避开业务高峰。

还有一类膨胀来自 AUTOEXTEND_SIZE 与碎片预留:表空间按固定增量扩展,文件里会先留出一部分已分配但未使用的空间。判断顺序是先区分「文件里是真数据还是预备空间」,再决定要不要重建。

ALTER TABLE ... ENGINE=InnoDB 重建表时会把行格式改成当前默认(§7.3),若原表是 COMPACT,重建后可能变成 DYNAMIC;同时它会重排记录、回收页内碎片。重建是大事务,对大表应使用在线改表工具或按主键分批处理,避免长时间锁表。

8.2 innodb_page_size 的兼容问题 ​

现象:把一个实例的数据文件/日志文件搬到另一个实例,启动失败或报文件不兼容。原因几乎都是两端的 innodb_page_size 不一致——不同页大小的实例数据文件与日志文件不能互换(§7.1)。

排查:在两端分别执行 SELECT @@GLOBAL.innodb_page_size,值不同就是根因。这类问题无法通过改参数解决(页大小初始化后不可改),只能统一实例的页大小后重新初始化、逻辑重导数据。跨云或跨版本迁移前,把页大小当作必查项之一,与字符集、行格式、lower_case_table_names 一起列进前置检查。

另一个常见误判是「升级后就能改页大小」:页大小与文件格式绑定,升级不会改变它。跨版本迁移还有一处相关:innodb_default_row_format 的默认值在 5.7.9 处变过,从 5.7.8 升到 5.7.9+ 或升到 8.0 时,重建表会按新默认改成 DYNAMIC,与被迁移表的原格式可能不同(§7.3)。

8.3 行溢出导致查询变慢 ​

现象:走索引的查询仍然慢,且慢在读取阶段。确认溢出是原因的判断链:

  • 表里存在 TEXT/BLOB 或很长的 varchar 列,且这些列接近或超过半页大小(§5.1)。
  • 慢查询里 SELECT * 或取了这些大列;把它收窄成只取主键或短列后明显变快,说明差距来自溢出页读取。
  • 二级索引命中却仍要回表,因为需要的列不在索引里(无法覆盖)。

确认手段:用 SELECT 只取需要的列对比耗时,或用 SHOW TABLE STATUS / INNODB_TABLES 确认行格式(COMPACT 的页内容易被大列前缀填满,行数更少)。处理方向是收窄查询列、把不必要的大列拆到副表、或在允许时改用 DYNAMIC/COMPRESSED 让大列整列外置。

反向的判据也成立:把大列从查询里去掉后耗时不变,则慢的原因不在溢出,应转向索引与执行计划。溢出的排查价值在于它能解释「走了索引却仍慢」,对没走索引的查询它不是主因。

如果表里同时有多个大列,还要分清是哪一列在被溢出:COMPACT 下每列各保留 768 字节前缀,页内被这些前缀挤满,判定要结合具体行的长度分布。收窄查询列是最省事的验证手段,也常常是最省事的修复手段。

8.4 TEXT 还是 VARCHAR ​

选型判据集中在三处:

判据选 VARCHAR选 TEXT
长度有明确上限、通常较短(如 ≤ 几千字节)长度不可预期或经常很大
是否参与索引要建前缀索引、参与排序/比较一般不直接建索引
行内布局希望留在页内、减少溢出页内容大、本就该外置
行大小压力整行接近 65535 或半页上限时,转 TEXT 可把内容移出行外用来给行「减负」

关键事实是:TEXT/BLOB 对行大小上限只贡献 9 到 12 字节,把一个大 varchar 改成 TEXT 可以绕过 65535 与半页的限制(来源:MySQL 8.0 Reference Manual, 10.4.7)。VARCHAR 的好处是能参与索引、能留在页内;代价是长度上限受行大小约束。utf8mb4 下单列 varchar 上限 16383,超过就得用 TEXT。反过来,若列内容其实不大、又要建索引,用 VARCHAR 明显更合适——TEXT 建索引必须指定前缀长度,且前缀索引无法覆盖整个列。

最后一条经验判据:VARCHAR 的声明上限要按字符集折算后的最大字节数与行里其他列一起算(§4),需要能建索引就选 VARCHAR;长度不可控、又不建索引的列,转 TEXT 让内容移出行外更稳。

相关 ​

参考 ​

页的字节级布局(File Header 38 字节、Page Header 56 字节、Infimum+Supremum 26 字节、File Trailer 8 字节及字段偏移)取自 MySQL 8.0 源码的常量定义;官方文档只给段级描述与参数默认值,未逐字节列出布局。记录头 5 字节内的字段位宽分布来自 MySQL 源码的记录头宏,文中给出的是与源码常量一致的通用布局。

贡献者 ​

文件历史 ​