问题背景
这是《MySQL 内核实战》系列的第一篇,后面讲索引、事务、Buffer Pool 的所有内容,都要建立在今天这篇的地基上:磁盘上的数据到底长什么样。很多线上问题绕来绕去,最后都会落到"页"这个单位上:为什么表只有三千万行却占了五十多 GB?为什么用 UUID 做主键后写入延迟周期性抖动?为什么 DELETE 掉一半数据,.ibd文件一点没小?为什么偶尔会冒出 “Row size too large (> 8126)” 这种看不懂的错误?答案全部藏在同一个事实里——InnoDB 的一切读写、加锁、刷盘、复制,都以页(page)为原子单位,而不是以行、也不是以表。本篇回答三个问题:一个表空间从磁盘文件到一行数据要穿过几层结构;一个 16KB 的页内部是怎么排布的;一行数据进了页又要付出多少"隐藏开销"。把这三层看清楚,后面关于主键设计、碎片率、SELECT *代价的讨论就都有了依据。
核心原理
第一层是表空间的层次结构。InnoDB 管理数据自下而上是:行(row)→ 页(page,默认 16KB)→ 区(extent,连续 64 页、正好 1MB)→ 段(segment,一棵 B+ 树就是一个段)→ 表空间(tablespace)。开启innodb_file_per_table(8.0 默认 ON)后,每张表一个独立.ibd文件即独立表空间,ibdata1只留系统表空间与 undo 等全局数据。独立文件这件事的工程价值远不止"整齐":DROP TABLE 能直接把空间还给操作系统,单表可以用OPTIMIZE TABLE/重建回收,误删表的恢复粒度也从"整个实例"缩小到"一个文件"。
第二层是页的内部结构:槽式页(slotted page)。一个数据页从头到尾依次是:38 字节的 FIL 文件头(页号、前后页指针、checksum)、36 字节的页头(可用空间水位、记录数、页类型)、文件段头部、infimum/supremum 两条哨兵记录,然后是用户记录区,页尾还有 8 字节 FILE trailer。关键在于用户区的排布方式:记录本体从页头向页中间写,页尾向中间生长一个"记录目录"(page directory),每条记录在目录里占一个 2 字节的槽(slot),记录按主键逻辑顺序通过槽号访问,物理位置完全不必有序。这个设计让"插入一条记录"不需要搬动页内任何已有记录——只找空位写入、目录里加一个槽即可。代价是空间被三头挤压:记录区、空闲区、目录区此消彼长,任何一处放不下新行,页就"满"了。
第三层是行在页内的真实开销。COMPACT/DYNAMIC 行格式下,一行数据的磁盘体积不是字段内容之和:5 字节记录头(下一记录偏移、删除标记、min-records 标志、记录类型)打头,跟着 NULL 位图(每 8 个可空列 1 字节,全表 NOT NULL 才能省掉这段),再跟变长列长度列表(每个 VARCHAR 至少 1 字节),之后才是字段内容。单字段超过 767 字节时,DYNAMIC 格式不再本地存储,而是整个挪进溢出页(overflow page),行内只留一个 20 字节的指针。同时 InnoDB 强制一条规则:一行本地部分不得超过约半个页——超了直接报错 1030 “Row size too large”。这意味着页可用空间除单行大小才是真实容量,而大字段表的 IO 放大藏在溢出页链里:多列拼一行,主键查询可能变成"页 + 溢出页"的两跳随机读。
第四层是页满之后发生什么。向一个写满的页插入,B+ 树要做页分裂(page split):新分配一个页,把一半左右的记录挪过去,再更新父节点的分隔键。分裂点决定了空间利用率——最理想的顺序追加(自增主键单调递增),每次分裂是"旧页写满、新页从零开始",填充率接近 100%;最坏的情况是随机主键(UUID),插入点随机落在中间页,半满的新页被反复从中间劈开,填充率掉到五到七成,同样三千万行,表体积可能翻近一倍,缓存命中率也跟着恶化。页是磁盘、Buffer Pool、redo 日志共同的最小单位,所以分裂还会放大 redo 量与崩溃恢复时间。
第一次代码实验及输出
下面把"槽式页"模拟出来:按真实常量留出 FIL 头、页头、哨兵记录、页尾的开销,逐行插入固定 schema 的订单记录,记录本体从前往后写、每条附一个 2 字节槽,看一个 16KB 页在放满之前到底被谁吃掉了空间,以及页内碎片是怎么产生的。
PAGE_SIZE=16*1024# 默认 16KB 页 (innodb_page_size)FIL_HEADER=38# 表空间文件头PAGE_HEADER=36# 页头: 页号/前后页指针/LSN/空间统计等FILE_TRAILER=8# 页尾: checksum + LSN, 用于崩溃恢复校验INF_SUP=16# infimum/supremum 两条哨兵记录SLOT_SIZE=2# 记录目录: 每条记录一个 2 字节槽usable=PAGE_SIZE-FIL_HEADER-PAGE_HEADER-FILE_TRAILER-INF_SUPprint("页总大小 %d 字节, 固定开销后真正可用 %d 字节"%(PAGE_SIZE,usable))classPage:def__init__(self,num):self.num,self.records,self.used=num,0,0deffree(self):returnusable-self.useddefinsert(self,rec_bytes):need=rec_bytes+SLOT_SIZEifself.free()<need:returnFalseself.used+=need self.records+=1returnTruerows=[]foriinrange(1,401):oid="order_2026_%06d"%i amount="%.2f"%(i*3.14)city="shanghai-city"rows.append((oid,amount,city))page=Page(1)splits=0fragments=[]fori,(oid,amount,city)inenumerate(rows):rec=len(oid.encode("utf-8"))+len(amount.encode("utf-8"))+len(city.encode("utf-8"))+5ifnotpage.insert(rec):print("页 %d 写入 %d 行后仅剩 %d 字节, 第 %d 行需要 %d 字节 -> 触发页分裂"%(page.num,page.records,page.free(),i+1,rec+SLOT_SIZE))fragments.append((page.num,page.free()))splits+=1page=Page(page.num+1)page.insert(rec)print("共插入 %d 行, 分裂 %d 次"%(len(rows),splits))print("当前页行数 %d, 剩余空闲 %d 字节"%(page.records,page.free()))fornum,fraginfragments:print("页 %d 分裂时剩余的 %d 字节放不下任何新行, 成为页内碎片"%(num,frag))运行输出:
页总大小 16384 字节, 固定开销后真正可用 16286 字节 页 1 写入 378 行后仅剩 6 字节, 第 379 行需要 44 字节 -> 触发页分裂 共插入 400 行, 分裂 1 次 当前页行数 22, 剩余空闲 15318 字节 页 1 分裂时剩余的 6 字节放不下任何新行, 成为页内碎片三个数字值得记住。其一,16384 字节的页扣掉固定开销只剩 16286,也就是说 98% 才是能装数据的净空间,任何"一页能放多少行"的估算都应从净空间出发。其二,本例单行是"33 字节内容 + 5 字节记录头 + 2 字节槽"约 40 字节,一页装了 378 行——把 378 乘以三千万行级别的表,就是七八万个页、一千多个区的量级,这解释了为什么回表几十万次就能把磁盘 IO 打满:行的隐藏开销逐行累加,页与区的数量比"数据看起来很小"的直觉大得多。其三,分裂时刻页里剩的 6 字节谁都放不下,这就是页内碎片的最小形态;真实 InnoDB 里 UPDATE 变长字段、删除留下的空洞、随机分裂产生的半满页,都是同一类"占着页的编制的空间",information_schema.INNODB_TABLESTATS与SHOW TABLE STATUS里的data_free就是它的账本。
工程化改进
把页的账本变成设计约束,分四步落地。
第一步,页面大小一次定死。innodb_page_size只能在实例初始化时设置,之后不可更改;绝大多数场景就用 16KB,改成 8K 只适用于行极短、点查极多的冷数据实例,而且整个库都改,不存在"热点表用 8K"的选项。选型阶段想清楚,比事后重建便宜一万倍。
第二步,主键选单调递增,而不是随机值。自增 BIGINT 或"时间前缀 + 随机后缀"的有序 ID(类似 Snowflake),保证追加写入落在最右页,分裂模式是"满页开新页";UUID 做主键则把每页劈成半满,表体积与分裂频率双高。需要对外暴露 ID 时,用业务编号字段承担可读性,聚簇主键仍是内部自增列——这是"页"教给我们的第一条设计律。
第三步,一张表一个文件,让空间可回收。保持innodb_file_per_table=ON;清理历史数据用分批 DELETE 加OPTIMIZE TABLE(在线 DDL 重建,注意需要约一倍磁盘水位)评估碎片收益,而不是留着半空页链让全表扫描陪着买单。
第四步,行格式按 DYNAMIC 管理大字段。8.0 默认 DYNAMIC 已经比 COMPACT 激进:超 767 字节的字段整体外置、行内只留 20 字节指针,本地行更短、每页容纳更多行。真正该做的是控制"进页"的内容——把 TEXT/BLOB/长 VARCHAR 拆到扩展表,主表行短到一条记录几十字节,让每页行数上一个台阶,同样的 Buffer Pool 能罩住更多数据。
第二次代码实验及输出
下面计算一行数据在 DYNAMIC 格式下的真实磁盘布局:记录头、NULL 位图、变长列长度表逐段累加,超过 767 字节的字段换成 20 字节溢出指针,最后换算"一页能放几行",并对比把大字段缩短后的差距。
MAX_LOCAL=767# COMPACT/DYNAMIC 单字段本地存储上限(字节), 超出转溢出页OVERFLOW_PTR=20# 本地保留的溢出指针: 20 字节指向溢出页defrecord_layout(cols,nullable_count):null_bitmap=(nullable_count+7)//8# NULL 位图: 可空列每 8 列 1 字节var_list=sum(1forcincolsifc["var"])# 变长列长度列表(短列 1 字节)header=5+null_bitmap+var_list# 5 字节记录头 + 两段附加信息local,overflow=0,[]forcincols:ifc["bytes"]<=MAX_LOCAL:local+=c["bytes"]else:local+=OVERFLOW_PTR# 只留指针, 内容进溢出页overflow.append(c["name"])returnheader,local,overflow cols=[{"name":"id","bytes":8,"var":False},# BIGINT{"name":"name","bytes":60,"var":True},# VARCHAR(20) utf8mb4{"name":"intro","bytes":3200,"var":True},# VARCHAR(800) utf8mb4]header,local,overflow=record_layout(cols,nullable_count=1)print("记录头 %d 字节 = 5(记录头) + 1(NULL位图) + 2(变长列表)"%header)print("本地存储 %d 字节, 转溢出页的字段: %s"%(local,overflow))per_row=header+local+2# 再加 2 字节页目录槽usable=16384-38-36-8-16print("单行占页 %d 字节, 一个数据页约容纳 %d 行"%(per_row,usable//per_row))print("行是否超过页可用空间的一半(建表即报错 1030)? %s"%(per_row>usable//2))intro_small=dict(cols[2]);intro_small["bytes"]=300header2,local2,overflow2=record_layout([cols[0],cols[1],intro_small],1)print("把 intro 缩到 300 字节后: 无溢出页, 单行 %d 字节, 每页约 %d 行"%(header2+local2+2,usable//(header2+local2+2)))运行输出:
记录头 8 字节 = 5(记录头) + 1(NULL位图) + 2(变长列表) 本地存储 88 字节, 转溢出页的字段: ['intro'] 单行占页 98 字节, 一个数据页约容纳 166 行 行是否超过页可用空间的一半(建表即报错 1030)? False 把 intro 缩到 300 字节后: 无溢出页, 单行 378 字节, 每页约 43 行输出里反直觉的一点:把intro从 3200 字节缩到 300 字节,每页容量反而从 166 行掉到 43 行——因为 3200 字节的字段整体外置后本地只占 20 字节指针,而 300 字节要全量存进页内。溢出页不是惩罚,本地硬塞才是。这直接推导出两条工程结论:第一,含大字段的表,"每页行数"这个缓存效率指标由本地部分决定,设计时盯着本地行长看;第二,外置不等于免费——SELECT *会把溢出页链上的内容也拉回来,本来一次页读能带 166 行短数据的查询,变成每行一次额外的随机 IO。列表页查主表字段、详情页再按 ID 取大字段,这个老建议的底层理由就在这。
常见陷阱
其一,UUID 随机主键引发分裂风暴:填充率跌到五成上下,表体积近乎翻倍,Buffer Pool 命中率同步跳水——典型症状是明明行数没涨,查询却越来越慢。其二,列数失控的宽表:几百列、每列 NULL 位图与变长列表虽小,逐行累加后单行轻松破千,每页行数骤减;ROW_FORMAT=DYNAMIC也救不了"每列都短但列太多"。其三,innodb_strict_mode=OFF把"Row size too large"降级成 warning:建表成功,插入时才随机失败,报错现场离真因十万八千里,测试环境一律开 strict。其四,以为 DELETE 会缩小.ibd:删除只把页标记为可复用,高水位不降,磁盘不归还;只有重建(OPTIMIZE/ALTER)才还空间,且重建本身需要近一倍磁盘余量,先查水位再动手。其五,用SHOW TABLE STATUS的Data_length当精确账本:它是聚簇索引 B+ 树的页估算值,统计信息有延迟,大流量写入后严重失真,容量规划要用information_schema.INNODB_TABLESPACES配合文件实际大小。
落地清单
- 实例初始化即锁定
innodb_page_size=16K,不做第二轮讨论;innodb_file_per_table=ON - 聚簇主键选单调递增(自增 BIGINT 或时间有序 ID),UUID 只做二级索引列
- 建表开
innodb_strict_mode=1,行本地部分控制在千字节内,长文本拆扩展表 - 定期看
data_free/INNODB_TABLESTATS的碎片水位,清理后评估 OPTIMIZE 的磁盘余量 - 列表查询禁止
SELECT *命中溢出页链,大字段单独按主键取
地基打好了:数据在磁盘上是一段段页,行按 DYNAMIC 格式挤进页,页满就分裂。但到目前为止页与页之间还是孤立的链表——为什么 WHERE 一个等值条件能跳过几千万行只读三四个页?索引如何把这些页组织成一棵永远平衡的树,LIKE 'abc%'为什么能走索引而LIKE '%abc'不行?下一篇《MySQL 内核实战(2):B+Tree 索引与最左前缀》把树的结构与查找路径讲透。
参考来源
- MySQL 8.0 Reference Manual:InnoDB Tablespaces:https://dev.mysql.com/doc/refman/8.0/en/innodb-tablespace.html
- MySQL 8.0 Reference Manual:InnoDB Row Formats:https://dev.mysql.com/doc/refman/8.0/en/innodb-row-format.html
- MySQL 8.0 Reference Manual:innodb_page_size 系统变量:https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_innodb_page_size
- MySQL 8.0 Reference Manual:Server Error 1030 (Row size too large):https://dev.mysql.com/doc/refman/8.0/en/server-error-reference.html
- Wikipedia:B+ tree:https://en.wikipedia.org/wiki/B%2B_tree
👍 觉得有用就点个赞 + 收藏,方便回头查阅;有疑问直接在评论区留言,我看到都会回。
🚀 本文属于《MySQL 内核实战》系列,持续更新,关注不迷路。
📌 文章里的代码都能直接跑。想要可直接 clone 的完整工程 + 配套部署脚本 / 踩坑清单?评论一声或发邮件到cj2664@qq.com,我免费发你。
如果你正好在做类似系统、或有工程化难题想找人做,也欢迎邮件聊一句——我按实际情况评估,能落地的就接单或出方案。评论和邮件都能直接找到我,不用跳别的平台。