1. 当“庖丁解牛”遇上MySQL:先搞懂页是什么
第一次听到“MySQL页结构”这个词,大多数人会本能地往深处想:页,不就是磁盘上划出来的一个个固定大小Block嘛,16KB一个,装数据用的。这话没错,但只对了一半。真正做过调优、排查过慢查询或死锁的人,都会意识到一件事:页的内部远不是一个“16KB容器”那么简单。它有没有碎掉?碎在哪?满了之后怎么挤?删了之后空间去哪了?索引一深一浅为什么性能差距天差地别?这些问题背后全是页结构在暗中使力。
取“庖丁解牛”这个名字,就是想把InnoDB的页当成一头牛来拆。牛的骨架、筋膜、血管各有其位,庖丁眼中没有整牛,只有关节和缝隙。同理,InnoDB的每个数据页,内部也有明确的一层层“解剖结构”:文件头、页头、系统记录、用户记录、空闲空间、页目录、文件尾,环环相扣,缺一不可。真正吃透它,你能干三件事:看懂EXPLAIN里明明有索引却还是慢的怪事,设计表结构时提前避开页分裂和页碎片,调试线上死锁、锁等待、高版本MVCC的诡异现象时不靠猜。
这篇文章适合所有人:正在学MySQL底层原理的开发者、天天和慢查询缠斗的后端工程师、想搞清InnoDB行格式与页关系的DBA,以及所有被“为什么删了数据文件没变小”这种问题困扰过的人。看不懂没关系,我尽量用庖丁的方式,一段段切开给你看。但丑话说在前面,看完之后你大概率会顺手改几个表结构——因为这头“牛”一旦看清,你对存储的很多习惯性认知都会被推翻。
2. 页结构的整体解剖:先从外观和分区下手
2.1 一个数据页到底长什么样
先别管复杂的内部字段,把InnoDB数据页看成一张“写满字的A4纸”,纸是固定大小,16KB,这也就是InnoDB默认的page size。一个表中成万上亿的数据,最终都会被切成一段段,每段装进这样的页里。
页在磁盘上的物理位置是由表空间(tablespace)统一管理的,但页本身的结构是固定的。从头部到尾部大概可以分成七个区域:
- File Header(文件头,38字节):记录这个页的校验和、页号、上一页指针、下一页指针、页类型、表空间ID等信息。它是页与页之间串联的“关节”和“路标”。
- Page Header(页头,56字节):记录页内状态,比如页目录槽数量、已用记录数、第一条记录的偏移量、堆顶位置、碎片空间大小等。相当于这张“纸”的管理簿,页内有多少数据、从哪里找、哪里还空,都由它说了算。
- Infimum + Supremum(系统记录,26字节):每个页开头和结尾各有一条“虚拟记录”,分别表示页内最小值和最大值,叫“伪记录”。它们就像书架的左右挡板,让页内记录能组成一个逻辑上的有序链表。
- User Records(用户记录):真实存储在页里的数据行,按照主键顺序以单链表形式排列,注意这里我说的是“逻辑有序”,物理上未必连续。
- Free Space(空闲空间):页中还没用上的区域,新记录进来就往上堆。
- Page Directory(页目录):由一组槽(slot)组成,每个槽指向页中某条记录。目录的作用是配合二分查找,快速定位记录,而不是线性扫描。
- File Trailer(文件尾,8字节):存储页的校验和与LSN低32位,用于在崩溃恢复时判断页是否写入完整。
从文件头到文件尾,别看这只是十六KB的“小纸条”,每一块结构都各司其职,彼此依赖。真正理解页结构,你脑子里必须把这张“小纸条”在磁盘和内存之间的流转过程也串起来:读页、改页、刷盘、崩溃恢复,每一个环节都有对应的字段在做校验和仲裁。
2.2 为什么页是16KB,而不是1KB或者1MB
很多刚接触InnoDB的人都会问一个问题:页为什么是16KB?随便设不就行了吗?这里牵涉到性能和并发控制的平衡。
页太小,比如4KB,存储利用率会高一些,但一次磁盘I/O能读取的数据量就少,扫描大量数据时I/O次数大幅上升。页太大,比如64KB,虽然一次I/O能带更多数据,但内存中缓存同样多的页时,占用就是4倍,而且并发访问同一页的冲突概率也上升。
16KB这个值,是InnoDB在随机读写性能和顺序扫描效率之间反复权衡后的结果,也是mysql服务端历史上被验证过最稳妥的通用默认值。虽然MySQL也支持innodb_page_size参数设置为4KB、8KB、16KB,但官方并不推荐随意改动,尤其不能在建库后修改。你在实际项目里,也几乎不需要为页大小与OS块对齐之类的问题操心——你真正需要操心的,是页内部的“空间碎片化”和“页利用率”,这才是性能问题的温床。
2.3 每个区域的功能边界一图流
为了让你好记,我把页内七个区域按“功能角色”重新归一下类:
| 区域名称 | 核心作用 | 类比 |
|---|---|---|
| File Header + File Trailer | 页的身份证与安全锁,负责串联和校验 | 快递单号和防拆封条 |
| Page Header | 页内所有记录的管理台账 | 仓库的库存登记表 |
| Infimum + Supremum | 页内记录链的边界哨兵 | 书架的左右限位板 |
| User Records | 真正存放用户数据的地方 | 货架上的商品 |
| Free Space | 待分配的新空间 | 仓库里的空地 |
| Page Directory | 槽位化目录,支撑二分查找 | 图书馆的索书号索引 |
这张表是你整个“解牛”过程的骨架。后面聊行的存储格式,聊页分裂、页合并,聊如何定位“页内热点”,抽丝剥茧都离不开这张表。
3. 再往里一层:记录头、伪记录和行格式的秘密
3.1 记录头里的十六个二进制位
光看“页”这个维度还不够,真正的毫芒功夫在“记录头”里。每一条记录在页内的存储都由两部分组成:记录头信息和真实数据。
记录头信息占6字节,这里是InnoDB操作数据的最小单元,也是行锁、MVCC、回滚等机制能跑起来的基础数据结构。把这6个字节拆开,关键几个部分分别是:
- delete_mask(1bit):标记记录是否被删除。删数据并不会立刻清物理空间,只是置位这个标记位。
- min_rec_mask(1bit):标记是否为B+树每层最左侧的节点。在页分裂和层级维护中非常重要。
- n_owned(4bit):表示该记录拥有多少条记录,通常只有槽内“代表记录”才不为0。
- heap_no(13bit):记录在页堆中的位置号,系统记录的heap_no为0和1,用户记录从2开始。
- record_type(3bit):0是普通记录,1是B+树非叶节点目录项记录,2是Infimum,3是Supremum。
- next_record(16bit):从当前记录的真数据位置到下一记录真实数据的相对偏移量,这里存的是“负值”,是从当前记录头位置往前的偏移,方向相反,注意别搞混。
很多人在读源码或看内核文档时被这套位运算绕晕,实际上不用记每一位的精确位置,关键是理解两件事:第一,记录在页内是“单链表”结构,靠next_record串联;第二,头部信息极省空间,16个二进制位塞进6个字节,全部是拿比特抠出来的,这背后是为了在千万级数据下尽量压缩存储成本。
3.2 Infimum和Supremum为什么不能删
页内的Infimum和Supremum是两条固定的虚拟记录。它们不是用户数据,也永远不会被标记删除。存在意义只有一条:让页内的记录链表有一个“确定的起点”和“确定的终点”。
如果你做过链表插入操作,一定有体会:往链表中间插入节点很容易,但如果链表没有头尾哨兵,你在头部插入时需要特判“是不是第一个元素”,在尾部追加时要特判“是不是最后一个元素”,每一处特判都是bug的温床。Infimum和Supremum正是哨兵节点,它们让所有用户记录都能统一按“找到前驱节点、插入其后”的方式处理。
另外,Infimum和Supremum不是挂在Page Header区的普通数据,而是独立存在页的开头和结尾位置。最小记录(Infimum)永远指向页内主键最小的用户记录,最大记录(Supremum)永远是链尾。任何情况下,用户记录都不会越过这两个边界,这在B+树页内查找和页分裂的前驱寻找中会反复用到。
3.3 Compact和Dynamic行格式:行到底怎么放进页
页里最终囤的是行,行格式不同,塞进页里的效果天差地别。当前主流是Dynamic和Compact两种,理解两者的区别,是理解“页内空间怎么被消耗”的关键一步。
Compact行格式的核心设计是变长字段长度列表 + NULL值列表 + 记录头 + 真实数据域。变长字段的长度用1字节或2字节逆序存放,NULL值用bitmap处理,目的是尽量让每一行紧凑。Dynamic则是在Compact基础上进一步优化的产物:当某行的大字段(如TEXT、BLOB)超过一定阈值时,InnoDB会把字段的完整数据溢出存到“溢出页”(Off-page),而当前页只保留一个20字节的指针,指向溢出页地址。
这就意味着,同样一张表,如果字段设计成大VARCHAR存大文本,行数多起来后,一个16KB页里可能只能放几行甚至一行;但如果用Dynamic行格式让大数据溢出,主表页里就能放下更多行的瘦身版本。两个选择直接影响页的“空间利用率”和“扫描速度”,也是DB设计人员常忽略的隐藏性能杠杆。
注意,从MySQL的官方版本演进来看,新版本默认使用Dynamic,为什么?因为在老版本中Compact格式在特定边界条件下会频繁触发行溢出、页利用不稳定,而Dynamic借助溢出页机制,把主索引页的容量稳定性做到了更好。所谓“页内空间管理”,很多功夫其实早在建表语句里就定下了。
4. 页内的插入、查找与页分裂:结构如何决定行为
4.1 插入一条记录,数据到底经历了什么
在InnoDB中,把一个新行插入页内并不是“写进某个空位置”那么简单,它要遵循一套严格的步骤:
- 通过B+树索引定位到应该插入的页。如果页有足够Free Space,直接进入第4步。
- 如果页空间已满,触发页分裂逻辑(后面细说)。
- 在页内找到正确位置:从Infimum开始,沿next_record链表往后走,比较主键值,找到前驱记录。
- 从Free Space堆顶申请一条新记录空间,把变长字段长度表、NULL值列表、记录头信息、数据域依次填充。
- 按顺序将新记录挂到链表上,并更新Page Header的已用记录数、堆顶位置;如果必要,还要调整Page Directory槽位。
这里有个容易忽视的点:记录在页内的物理顺序不是连续的,而是靠链表逻辑排序。好比书架上的书,物理摆放位置是随便乱塞的,但每一本书上都贴了标签,按标签顺序形成一个逻辑序列。这样做的好处是,插入时不需要频繁搬动已有记录,代价则是访问时需要靠指针跳转。寻求平衡时,InnoDB引入了Page Directory,也就是页目录,来加速查找。
4.2 页目录的槽位和二分查找为什么快
页目录是页内加速结构里的核心。它不是为每条记录建一个槽,而是给大约4~8条记录分一个槽,通常n_owned控制在4~8之间。每个槽存放的是该组中“最大主键记录”的地址偏移。当你要在页内查找一条记录时,InnoDB会先在Page Directory里二分查找,定位记录属于哪个槽,然后在该槽所对应的小组内线性遍历。
这里做一道简单的计算题:假设一个16KB页塞满了约200条左右长度为80字节的记录(忽略页头、目录等开销),如果不采用页目录,最坏情况下要比较200次;采用目录后,假设有30个左右的槽,二分查找只需约5次定位,然后槽内线性扫描至多8次,总比较次数大幅下降到十几次。数据量越大,页目录带来的优势越明显。
“页目录 + 槽 + 链表”这套组合,是InnoDB能在B+树叶子节点快速定位行记录的重要基石。很多长期写SQL的人一直好奇“为什么B+树能这么快”,页内这层二分查找逻辑就是答案的重要组成部分。索引树帮你定位到页,页目录帮你定位到记录,两层接力快得吓人。
4.3 页分裂的触发条件和完整流程
页满了之后,插入新记录会触发页分裂。页分裂是InnoDB中最常见也最影响性能的底层层行为之一。它发生的时候,往往是某个索引的插入顺序和主键顺序不一致,比如UUID主键、随机字符串索引,或者对非聚簇索引做高频插入。
分裂的大致流程:
- 定位到当前已满页,找到待插入记录应处的位置。如果应插在中间,则需要从原页的后半段记录中选出约一半,搬入新页;如果应插在末尾,则直接从原页后半段搬一半到新页。
- 生成一个新页,分配新的页号,初始化File Header、Page Header、Infimum/Supremum等结构。
- 把原页中一半左右的记录迁移到新页,保持key顺序正确。
- 修改原页的next指针和新页的prev指针,以及它们的File Header里的前后页指针,同时通过上层节点更新B+树父子关系。
- 如果是非叶子节点发生分裂,还要新增目录项记录,并递归向父节点插入新记录,可能导致多层向上分裂,最终树的高度加1。
这不是小操作。一次分裂涉及页内记录搬移、指针更新、父节点更新、甚至可能触发多次写盘。所以你在生产环境看到“插入放大”这个词,很大程度上指的就是随机主键带来的高强度页分裂。要减少页分裂,首选思路就是主键尽量自增/顺序递增,而不是UUID或业务随机串。
4.4 页合并:与分裂相反的另一面
有分裂自然有合并。当页内记录删除量达到一定程度,相邻页的记录可以合并时,InnoDB会通过合并减少页数量,保持B+树页利用率不塌方。页合并的触发通常发生在删除场景中,如果一个页的已用空间降到很低,而且相邻页的数据合到一起也装得下,InnoDB会把两个页合并成一个,释放掉多余页。
页合并对查询性能的影响没有分裂那么明显,但对空间回收和文件膨胀控制有直接帮助。为什么一个表删了大量数据,磁盘文件却没有变小?因为页即使空了,在B+树的结构里依然存在,只是内容为空,表空间大小并不会自动释放。只有通过OPTIMIZE TABLE或在线重建表(ALGORITHM=INPLACE)把数据重新搬到新表空间,文件才会真正缩小。
页合并这个机制也解释了另一个现象:频繁删除+插入交织的业务场景下,页内的空闲空间可能被反复利用,但页与页之间的空间均衡性会变差,最终表出现明显“碎片”。在想明白碎片之前,先记住页合并是“修复”手段而非默认常态,绝大多数表空间膨胀问题,根因都在积压的空页和有大量空洞的页目录上。
5. 实操视角:三个最容易踩进去的“页级”坑
5.1 大字段导致的局部页空间耗尽
很多初学MySQL的人对多列大字段(比如把整段JSON、Markdown全文直接存进字段)没有概念,直到某张表的主键明明很规整,导数据时却异常慢、每分钟只能写几百条,才开始排查。这个坑十有八九就是行过大,一个页里只能装下一两行甚至半行。
以Dynamic行格式为例,如果一行数据本身超过了页大小的一半左右,InnoDB会使用溢出页存储。频繁读这大字段时,每次查询先命中主表页,再通过指针读溢出页,多一次磁盘I/O;当多行大字段并发访问时,磁盘随机I/O的次数会成倍上涨。
实操避坑建议:
- 把大文本/JSON/二进制内容拆到独立表,业务层按需读取,避免大字段常驻主表页。
- 若必须保留,尽量缩短前缀索引长度,避免对长文本建完整索引,防止辅助索引页内行数过少。
- 监控表统计信息里“平均行长度”,如果单行平均超过1KB就应警惕,超过4KB基本可以断定页利用率会很差。
5.2 二级索引页频繁分裂导致的写放大
二级索引是另一个页分裂重灾区。比如表里有一个业务订单号,虽然不是主键但是唯一索引,很多系统会用UUID/雪花ID来生成,插入顺序天然随机。随机插入时,B+树每层的页都可能触发分裂,且分裂出来的页离原页可能很远,导致索引页的物理布局和逻辑顺序不一致,随机读更明显。
这种情况下,直观表现是:系统TPS不低,但磁盘I/O非常高、日志里常见行锁等待与插入等待,而且后端慢查询监控经常出现“插入耗时陡增”。这个时候,光加缓存或加大Buffer Pool不能从根本上解决问题,你得从索引设计上把顺序性找回来。
两条建议:
- 如果业务允许,把随机全局ID改成“时间前缀 + 自增序列”,尽量让新插入的索引值落在最近访问页附近。
- 如果随机值无法改变,可以刻意选用自增主键,然后对二级索引的插入瓶颈做异步化或分片化,降低单页竞争概率。
5.3 页内热点记录与行锁的隐形放大
页结构还会在行锁场景里造成“隐性冲突”。InnoDB的行锁虽然锁的是索引记录,但同一页内的不同记录如果物理上挨得很近,某些版本和实现下,会对相邻记录或页内插入间隙造成额外影响。典型例子是“热点行”与它旁边的空位:多个事务同时向相近主键范围写入的时候,虽然锁的是不同行,但页级插入操作或间隙锁机制会让它们互相等待。
加上MySQL默认的锁检查是“锁next-key范围”,页内结构决定了范围判定受到物理相邻记录影响。你可能会遇到一个诡异问题:明明修改的不是同一行,偏偏出现锁等待,耗时飙到几百毫秒。这类问题排查起来经常让人抓头,但理解了页内记录的“主键相邻即物理邻居”特点,你就明白这不是玄学,而是页结构配合next-key锁的正常结果。
在不深入了解InnoDB内部的情况下,缓解方向是:让热点插入尽量顺序化;避免在热点范围内大范围更新(例如一次性UPDATE若干行);必要时调整隔离级别或对锁策略做更细分析。
6. 如何实际观察页结构:从工具到实验
6.1 用源码级视角验证:模拟一个页的布局
很多人问,看这些结构有什么用,又不能直接看到页。其实通过源码或系统表,很多信息是能观察到的。比如MySQL 8.0之后,information_schema.INNODB_SYS_TABLES、INNODB_TABLESPACES等表中就有SPACE、FILE_SIZE、FREE_EXTENTS等字段,能间接推算表空间的页数和空闲页情况。
如果你有实验环境,还可以用innodb_page_debug相关工具或自己写一个小程序模仿页结构。不用特别复杂,在内存里定义一个page结构体,按File Header、Page Header、Infimum、User Records、Free Space、Supremum、Page Directory、File Trailer的顺序去填充,模拟插入、删除、分裂三个场景。真跑一遍,你才会感受到页内部的“指针挪动”远比想象中复杂。
我也建议初学者在本地MySQL中做一次这样的观察实验:
-- 建一张简单表,连续插入一批数据 CREATE TABLE page_demo ( id INT PRIMARY KEY, col_a VARCHAR(20), col_b VARCHAR(50) ) ENGINE=InnoDB; -- 插入多行后查看表空间 SELECT SPACE, NAME, ROW_FORMAT, TABLE_ROWS, DATA_LENGTH FROM information_schema.TABLES WHERE TABLE_NAME = 'page_demo'; -- 查看表的物理文件大小 SHOW TABLE STATUS LIKE 'page_demo';连续插入自增主键数据时,页分裂次数会非常少;改为UUID主键重新建表插入相同行数,页分裂次数明显增多,文件大小也会因碎片而膨胀。对比两组的物理文件大小,你对“页利用率”的理解会一下具象化。
6.2 通过系统指标定位页级瓶颈
线上环境不能随便停库,但可以通过性能指标间接判断页级问题:
| 指标 | 含义 | 页级问题信号 |
|---|---|---|
| Innodb_row_lock_waits | 行锁等待次数 | 插入热点页或间隙锁冲突 |
| Innodb_data_writes | 数据页写入次数 | 页分裂频繁、脏页刷盘压力大 |
| Innodb_pages_read | 页读取次数 | 页利用率低导致扫描页数多 |
| Buffer pool命中率 | 缓存页命中情况 | 随机I/O高、页与逻辑顺序不匹配 |
| 磁盘I/O util | I/O利用率 | 页分裂+随机插入的典型连锁反应 |
通常我看到“页级问题”,会直接从这三样组合里判断:表平均行长过大、主键/索引随机性太强、Buffer Pool命中率正常但I/O依然高。命中率正常但I/O高,一级怀疑对象就是页内部结构导致的“物理读放大”——一个逻辑操作要额外读多个页、写多个页,这在普通业务监控里常常被误判为磁盘性能不足。
另外值得一提的排查技巧:慢查询日志里如果多次出现insert或update的等待事件,并且等待事件集中在waiting for handler insert或page cleaner,大概率就是页写入侧的瓶颈。此时先不看SQL长度,先看索引顺序和表行数,比盲目优化SQL更有用。
6.3 实验:一个模拟“页分裂”的SQL演示
为了帮助你直观感受页分裂对性能的影响,我建议在测试库执行一轮对照实验。
-- 实验准备:建两张结构完全相同的表 CREATE TABLE t_inc ( id BIGINT PRIMARY KEY AUTO_INCREMENT, payload VARCHAR(64) ) ENGINE=InnoDB; CREATE TABLE t_uuid ( id VARCHAR(36) PRIMARY KEY, payload VARCHAR(64) ) ENGINE=InnoDB;然后分别插入相同行数(如100万行)的随机数据,观察时长和物理文件大小。多次尝试后你基本会得到以下规律:
| 表 | 插入耗时 | 物理文件大小 | 页分裂估算次数 |
|---|---|---|---|
| t_inc | 明显更低 | 较小 | 极少 |
| t_uuid | 明显更高 | 更大(一般大10%~30%) | 频繁 |
用这个实验把“页分裂”从抽象概念转化为看得见、摸得着的数据,它比你背诵十遍定义都有用——那些又一次因为随机主键而撑爆磁盘的深夜,根源就在这张表里。
7. 各章节核心要点速查:庖丁的解牛刀法清单
| 章节 | 核心要点 | 一句话总结 |
|---|---|---|
| 整体结构 | 七个区域各有职责 | 读页先看File Header,页内管理看Page Header |
| 记录与行格式 | 记录头6字节、伪记录是哨兵 | 行太大就溢出到附加页,主表页保持瘦身 |
| 插入与查找 | 页目录加速二分查找,分裂与合并双向平衡 | 顺序主键能极大降低页分裂频率 |
| 实操陷阱 | 大字段、随机索引、热点页是三大坑 | 表结构设计决定90%的页级行为 |
| 观察与评估 | 系统表+性能指标结合判断 | 页级问题往往伪装成“磁盘慢” |
这份清单适合贴在工位,下次谁跟你抱怨“MySQL好卡”,先不要急着提SQL优化,先盘一遍表结构、行长、页利用率和索引插入顺序。
8. 我最后想分享的几个体会
页结构这头“牛”,我已经解剖了很多遍,每次重读InnoDB源码和物理存储算法,还是会发现一些新的细节。最有感触的一点是:页不是一个孤立的静态块,而是一整套动态交互机制的载体。它既要保证写的高效,又要在内存和磁盘间游刃有余,还要为事务的MVCC提供稳固的底层结构。你越了解它,越会觉得“调优MySQL”这件事不再靠猜。
在很多次真实排障中,我最后查出问题根因,都不是SQL写得有多烂,而是页结构在暗地里膨胀、破碎、争夺。比如曾有一个项目,某张业务表的数据量只有800万行,但物理文件却占了几十GB,查询慢、备份更慢。当时拿索引分析工具看,明明索引都命中了,但打开物理文件才发现页内碎片率高达四成。后来通过重建表并调整大字段存储方案,文件缩小了60%以上,查询性能也跟着上一个台阶。
所以,学页结构不只是为了应付面试,它是真正能改变你数据库设计思路的一个底层认知。最后送你一个实用小技巧:设计表结构时,心里默念三件事——主键是否顺序递增、单行是否过于臃肿、近热数据是否频繁随机写。把这三件事放在心上,页这头牛,多半不会在你最忙的时候反咬一口。