:InnoDB 存储引擎与页结构)
问题背景这是《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_table8.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_SIZE16*1024# 默认 16KB 页 (innodb_page_size)FIL_HEADER38# 表空间文件头PAGE_HEADER36# 页头: 页号/前后页指针/LSN/空间统计等FILE_TRAILER8# 页尾: checksum LSN, 用于崩溃恢复校验INF_SUP16# infimum/supremum 两条哨兵记录SLOT_SIZE2# 记录目录: 每条记录一个 2 字节槽usablePAGE_SIZE-FIL_HEADER-PAGE_HEADER-FILE_TRAILER-INF_SUPprint(页总大小 %d 字节, 固定开销后真正可用 %d 字节%(PAGE_SIZE,usable))classPage:def__init__(self,num):self.num,self.records,self.usednum,0,0deffree(self):returnusable-self.useddefinsert(self,rec_bytes):needrec_bytesSLOT_SIZEifself.free()need:returnFalseself.usedneed self.records1returnTruerows[]foriinrange(1,401):oidorder_2026_%06d%i amount%.2f%(i*3.14)cityshanghai-cityrows.append((oid,amount,city))pagePage(1)splits0fragments[]fori,(oid,amount,city)inenumerate(rows):reclen(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(),i1,recSLOT_SIZE))fragments.append((page.num,page.free()))splits1pagePage(page.num1)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_tableON清理历史数据用分批 DELETE 加OPTIMIZE TABLE在线 DDL 重建注意需要约一倍磁盘水位评估碎片收益而不是留着半空页链让全表扫描陪着买单。第四步行格式按 DYNAMIC 管理大字段。8.0 默认 DYNAMIC 已经比 COMPACT 激进超 767 字节的字段整体外置、行内只留 20 字节指针本地行更短、每页容纳更多行。真正该做的是控制进页的内容——把 TEXT/BLOB/长 VARCHAR 拆到扩展表主表行短到一条记录几十字节让每页行数上一个台阶同样的 Buffer Pool 能罩住更多数据。第二次代码实验及输出下面计算一行数据在 DYNAMIC 格式下的真实磁盘布局记录头、NULL 位图、变长列长度表逐段累加超过 767 字节的字段换成 20 字节溢出指针最后换算一页能放几行并对比把大字段缩短后的差距。MAX_LOCAL767# COMPACT/DYNAMIC 单字段本地存储上限(字节), 超出转溢出页OVERFLOW_PTR20# 本地保留的溢出指针: 20 字节指向溢出页defrecord_layout(cols,nullable_count):null_bitmap(nullable_count7)//8# NULL 位图: 可空列每 8 列 1 字节var_listsum(1forcincolsifc[var])# 变长列长度列表(短列 1 字节)header5null_bitmapvar_list# 5 字节记录头 两段附加信息local,overflow0,[]forcincols:ifc[bytes]MAX_LOCAL:localc[bytes]else:localOVERFLOW_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,overflowrecord_layout(cols,nullable_count1)print(记录头 %d 字节 5(记录头) 1(NULL位图) 2(变长列表)%header)print(本地存储 %d 字节, 转溢出页的字段: %s%(local,overflow))per_rowheaderlocal2# 再加 2 字节页目录槽usable16384-38-36-8-16print(单行占页 %d 字节, 一个数据页约容纳 %d 行%(per_row,usable//per_row))print(行是否超过页可用空间的一半(建表即报错 1030)? %s%(per_rowusable//2))intro_smalldict(cols[2]);intro_small[bytes]300header2,local2,overflow2record_layout([cols[0],cols[1],intro_small],1)print(把 intro 缩到 300 字节后: 无溢出页, 单行 %d 字节, 每页约 %d 行%(header2local22,usable//(header2local22)))运行输出记录头 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_FORMATDYNAMIC也救不了每列都短但列太多。其三innodb_strict_modeOFF把Row size too large降级成 warning建表成功插入时才随机失败报错现场离真因十万八千里测试环境一律开 strict。其四以为 DELETE 会缩小.ibd删除只把页标记为可复用高水位不降磁盘不归还只有重建OPTIMIZE/ALTER才还空间且重建本身需要近一倍磁盘余量先查水位再动手。其五用SHOW TABLE STATUS的Data_length当精确账本它是聚簇索引 B 树的页估算值统计信息有延迟大流量写入后严重失真容量规划要用information_schema.INNODB_TABLESPACES配合文件实际大小。落地清单实例初始化即锁定innodb_page_size16K不做第二轮讨论innodb_file_per_tableON聚簇主键选单调递增自增 BIGINT 或时间有序 IDUUID 只做二级索引列建表开innodb_strict_mode1行本地部分控制在千字节内长文本拆扩展表定期看data_free/INNODB_TABLESTATS的碎片水位清理后评估 OPTIMIZE 的磁盘余量列表查询禁止SELECT *命中溢出页链大字段单独按主键取地基打好了数据在磁盘上是一段段页行按 DYNAMIC 格式挤进页页满就分裂。但到目前为止页与页之间还是孤立的链表——为什么 WHERE 一个等值条件能跳过几千万行只读三四个页索引如何把这些页组织成一棵永远平衡的树LIKE abc%为什么能走索引而LIKE %abc不行下一篇《MySQL 内核实战2BTree 索引与最左前缀》把树的结构与查找路径讲透。参考来源MySQL 8.0 Reference ManualInnoDB Tablespaceshttps://dev.mysql.com/doc/refman/8.0/en/innodb-tablespace.htmlMySQL 8.0 Reference ManualInnoDB Row Formatshttps://dev.mysql.com/doc/refman/8.0/en/innodb-row-format.htmlMySQL 8.0 Reference Manualinnodb_page_size 系统变量https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_innodb_page_sizeMySQL 8.0 Reference ManualServer Error 1030 (Row size too large)https://dev.mysql.com/doc/refman/8.0/en/server-error-reference.htmlWikipediaB treehttps://en.wikipedia.org/wiki/B%2B_tree 觉得有用就点个赞 收藏方便回头查阅有疑问直接在评论区留言我看到都会回。 本文属于《MySQL 内核实战》系列持续更新关注不迷路。 文章里的代码都能直接跑。想要可直接 clone 的完整工程 配套部署脚本 / 踩坑清单评论一声或发邮件到cj2664qq.com我免费发你。如果你正好在做类似系统、或有工程化难题想找人做也欢迎邮件聊一句——我按实际情况评估能落地的就接单或出方案。评论和邮件都能直接找到我不用跳别的平台。