news 2026/10/19 5:19:50

MySQL InnoDB原理:段、区、页、行四层结构解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MySQL InnoDB原理:段、区、页、行四层结构解析

前阵子内部某张核心业务流水表到了千万行量级之后,写入延迟肉眼可见地变差,查了监控发现每次批量插入都会触发不少“等待”事件。当时同事随口甩了一句“多半是页分裂搞的”,然后丢给我一份日志就走了。坦白说,如果没把 MySQL 里段、区、页、行这套存储体系啃明白,这句话是没法真正理解透的。误打误撞的是,借着排查这轮性能抖动,我把 InnoDB 存储引擎一整套物理组织方式系统梳理了一遍,越到后面越觉得平时很多“玄学调优”,本质上都能在这套结构里找到答案。

这篇我就把整理后的内容完整交代一遍:从记录长什么样的“行”,到 IO 最小单位的“页”,再到物理连续分配的“区”,最后到管理索引空间的“段”,每一层都对应真实的磁盘和树形结构问题。看完之后,页分裂为什么会拖慢写入、delete 之后表文件为什么不见小、为什么单表别盲目上超大 varchar,这类问题应该都能自己推理出来。

1. 一条 SQL 的背后:MySQL 为什么要把存储拆成四层

很多人对 MySQL 存储结构的理解停留在“表就是一堆文件”的层面。从使用者角度,这没错;但从引擎实现角度,不把数据组织成层次结构,就没有今天的性能和可靠性。

1.1 磁盘读写的最小代价,决定了不能“按行”存

机械磁盘时代留下的一个核心事实是:一次顺序读 1MB 和随机读 16KB 的耗时差距可能不止一个量级。即便现在很多服务器换成了 NVMe SSD,随机小 IO 延迟大幅下降,但 IO 的“最小开销单位”依然存在——一次 IO 总要付出寻址、传输、校验的固定成本。InnoDB 的策略是:与其让引擎每次按“一行”去碰磁盘,不如把很多行打包进一个固定大小的页,读写都按页进行。这个固定大小的页就是 InnoDB 的“逻辑扇区”。

页默认大小 16KB,对应到系统层可以看作一次预读的基本单位。当执行SELECT * FROM t WHERE id = 5时,引擎不会只把 id=5 那一条记录从磁盘抠出来,而是把包含这条记录的那一整页加载进缓冲池。同一页里的其他行大概率承载着相近的业务特征,这种“顺带加载”就是局部性原理。如果每行都单独组织,一次查询就要触发多次随机读,索引树的路径查找也会被无限放大,任何索引结构都救不回来。

1.2 B+树天然需要“页”这个包袱

InnoDB 的索引是 B+ 树。B+ 树有一个特征:所有数据行都挂在叶子节点上,非叶子节点只存索引键和指向下一层节点的指针。既然数据要挂在树上,树的每个节点就必须对应一块“可以独立读写、大小固定”的载体,否则树的分裂、合并、搜索都没法用统一逻辑实现。这个载体就是页。一个非叶子页能装几百到上千个索引键,一个叶子页能装几十到几百条完整行记录,具体数量取决于行宽和页大小。

在这样一棵树上找一个主键值,过程大致是:从根页开始,读入页,二分定位到应该去的子页指针,继续读页,一直下沉到叶子页,然后在页内部做顺序扫描或二分查找。这里可以数一下:假设非叶子页能放 1200 个键,叶子页能放 160 行记录,三层 B+ 树大约就能扛住 160×1200×1200,接近两亿行。能支撑这个数据量的核心,恰恰是“页”这个稳定单位。

1.3 四层结构与业务逻辑的对应关系

一句话版:行是最小的记录单位,页是 IO 和读写的基本单位,区是物理连续分配的单位,段则是索引和数据的逻辑组织单位。四个层级像套娃一样层层包含:

  • 用户数据按“行”插入页内;
  • 页们按“区”在表空间里连续铺开;
  • 同一个索引的全部页又归属于若干“段”;
  • 段最终挂在表空间上,一个独立表空间文件承载一张表的所有段。

这层对应关系决定了建表、查数据、删数据时,引擎和文件系统各自干了什么。把层级弄乱,后面所有调优都会变成盲人摸象。

2. 行:数据落盘的底层单位,远不止“一行记录”

行是用户能感知的最小存储单元。但 InnoDB 在磁盘上落一条行时,旁边还塞了一堆用户看不见的元数据。

2.1 COMPACT 行格式:变长列和 NULL 是怎么存进一行里的

InnoDB 的 COMPACT 行格式,是理解行结构的最佳入口。“行”在磁盘上的物理布局大致分几块:

  • 变长字段长度列表
  • NULL 值列表
  • 记录头信息
  • 隐藏列(row_id、trx_id、roll_pointer)
  • 真实数据列

变长字段长度列表逆序存储各变长列的长度字节,目的是让引擎在解析记录时能快速算出每个字段的偏移位置。NULL 值列表是位图结构,每行最多占 1 字节左右,比如一张表有 9 个可空字段,NULL 列表就按一个字节+一个高位占用来标记,哪个位是 1,就代表哪个字段为 NULL。记录头信息固定 5 字节,里面有 deleted_flag、n_owned、next_record 等标记,其中 next_record 是这行记录在页内逻辑链中的“下一个”偏移量,注意是逻辑相邻,不是物理相邻。

隐藏列里最值得注意三个:

  • DB_ROW_ID:6 字节,没有显式主键时用它生成内部自增 id;
  • DB_TRX_ID:6 字节,记录最后修改这行的事务 id;
  • DB_ROLL_PTR:7 字节,回滚指针,指向 undo 日志中该行旧版本的记录位置,是 MVCC 实现的关键。

所以哪怕你的表只有两个 int 字段,磁盘上每行也背负着额外约 20~25 字节的“过路费”。

2.2 行溢出:varchar 不是你能无限长用下去的理由

B+ 树叶子页只有 16KB,而一行记录的总大小却可能逼近甚至超过这个值。MySQL 行内所有列长度总和有上限,经典问题是 varchar(N) 中 N 到底能多大:65535 字节。但这个限制的前提是字符集、是否可空、是否有变长前缀等因素全部折算进去之后的总和。比如 utf8mb4 下一个 varchar 字段最多约 16383 个字符,这是最最基本的约束。

真正容易被忽略的是行溢出机制:当一个字段值特别大(典型如 text/blob 或超长 varchar),InnoDB 不会把整条数据都塞进叶子页。它会尝试在记录中保留前 768 字节(旧版行为,8.0 之后实现有调整,但“部分行内存储”思路仍在),剩余内容溢出到单独的“溢出页”中存储。如果一条表记录里有多个大字段同时溢出,叶子页内会被掏空出很多“指针”。这时候的查询如果没覆盖索引,动不动就要顺着溢出页指针再读一个页,性能损失往往比看起来大得多。因此实际设计表结构时,把大字段拆到附属表、或者改成 JSON 并控制单行数据量,通常比一味依赖 InnoDB 的溢出机制更划算。

2.3 删除行并不会立刻消失:deleted_flag 与碎片

在 InnoDB 页内部,执行 DELETE 时默认不会物理移除记录,而是先把这条记录标记为 deleted。这个行为有两层意义:一方面支撑事务隔离,已删除但未提交事务的数据依然可以被旧版本读取;另一方面也意味着空间不会立即还给操作系统,它只是留在页内,等待后续新记录复用。

一行“逻辑删除”后占用的空间依然挂在碎片的池子里。日积月累,页内哈希得很厉害时,新插入的行未必能填进它理想的位置,这时树结构还会发生页分裂,最终在表文件内部留下大量“半空页”。很多 DBA 会建议周期性执行OPTIMIZE TABLE,本质就是把表重建一遍,把半空页里的记录压实、把碎片页回收掉。理解了行标记删除,你才算理解碎片从哪来。

3. 页:16KB 是怎么承载查找、插入和并发写入的

页是 InnoDB 里存在感最强的一层。默认 16KB,所有真实 IO 都在“页”这个粒度上展开。

3.1 一页的结构,像一本会议记录册

每个数据页内部并不是“从头到尾塞数据”这么简单,而是有固定的磁盘布局。一个 16KB 页大约从偏移量 0 到 16384 分布如下:

  • File Header:38 字节,页的校验和、页号、上一页/下一页指针等;
  • Page Header:56 字节,记录页内记录数量、页目录槽数、分配的空间起始位置等;
  • Infimum + Supremum:系统预置两条虚拟记录,标记页内记录的“最小下界”和“最大上界”,引擎遍历页内记录时以它们为起点和终点;
  • User Records:真正存放用户数据行的区域;
  • Free Space:剩余可用空间;
  • Page Directory:页目录,记录每 4~8 行记录位置的槽位数组;
  • File Trailer:8 字节,页尾校验,配合 File Header 做“页是否完整写入”的校验。

其中最容易让人迷惑的是 Page Directory。页内的行并非靠挨个遍历找到的,而是先通过页目录的槽位定位到“大概区间”,再在区间内做少量线性扫描。这相当于在页内部又做了一层“小索引”,牺牲一点空间换取了页内查找效率。

3.2 页与页之间靠什么建立关系

B+ 树的叶子节点在逻辑层面通过 File Header 里的 PREV 和 NEXT 指针串联成双向链表。这个设计很重要:范围查询(比如WHERE id BETWEEN 100 AND 200)只要定位到第一页,然后沿叶子页链表顺序读后面的页即可,不需要每次都从根节点重新搜索。InnoDB 会利用这个特性做预读,一次范围扫描往往连续读入很多相邻叶子页。

非叶子页中的目录项记录类似关键字+子页指针的结构,但不存完整行数据。正因为非叶子节点体积可以做得更小,单页内能容纳的“指针数”会远多于叶子页能容纳的“行数”,这让 B+ 树的“胖而矮”优势被充分发挥。

3.3 页分裂:写入变慢的常见元凶

当一条新记录按主键顺序插入时,应落入的叶子页刚好满了,InnoDB 只能申请新页,并把原页一半左右的记录搬过去,然后在两个页之间调整链表指针。这个动作就是页分裂。页分裂本身并不复杂,但它发生在每次插入时,叠加锁竞争和 redo log 写入压力,就会成为高并发写入场景下的明显瓶颈。

自增主键插入永远在树的最右侧追加,通常只会在最右叶子页发生分裂,操作路径很短。但如果主键是 UUID 或随机的字符串,新记录落点几乎随机,可能导致 B+ 树中部不断分裂、合并、再分裂,频繁更新父节点的目录项,甚至波及多个层级的索引页。这也是为什么很多经历过 MySQL 写入抖动的人,都坚决建议在 InnoDB 上使用整型自增主键。

页分裂之后还会产生一个问题:即使一个页只剩几行,它依然占满 16KB 空间。DELETE + 乱序 INSERT 越多,表和索引的“空间利用率”越难看,这又回到了前面说的碎片问题。

4. 区:为什么物理上连续,对性能这么重要

页是 IO 基本单位,但如果一个表的数据零零散散分布在整个表空间文件里,引擎每拿到一批页就需要做大量随机 IO。区的设计就是为解决这个问题而生的。

4.1 1MB 一区:一个区恰好是 64 个 16KB 页

InnoDB 中一个区的大小固定为 1MB,默认页大小 16KB 下正好对应 64 个页。这意味着分配给某个索引的磁盘空间,不是一页一页零散给出去的,而是以区为单位一次性申请一整块物理连续空间。这样在表空间文件中,索引的相邻页在磁盘上大概率也是相邻的。

这个表面“小设计”的价值非常大:当 InnoDB 需要预读时,它可以把一个区内的 64 个页连续读到缓冲池,顺序 IO 的效率远高于随机 IO。比如你执行全表扫描,引擎一看数据在磁盘上是连续的区,就能触发顺序预读,把耗时压到很低;反过来如果区被拆得七零八落,顺序预读的效果就大打折扣。

4.2 区分了三种状态:空闲、有碎片、已满

InnoDB 用一种基于位图的方式管理区状态:

  • 空闲区:完全没有分配出去,可以随时给任何段使用;
  • 有碎片的区:部分页已经分配,但区内还有空闲页;
  • 已满区:所有页都已分配,无法继续从该区申请页。

这种设计的实际意义是,小表可以“共用有碎片的区”,不必为了一张只有几十行的表就直接占用几十个完整的区。只有表慢慢长大、数据量达到一定阈值后,才会从“碎片区模式”切换成“整区分配模式”。如果你观察过新表刚插入少量数据时表空间文件增长模式——为了一个 10 行的表,文件一样要按区向上增长,这正是 InnoDB 分配策略的体现。

4.3 区的引入让“预读”从理论走向现实

MySQL 有一个特性叫线性预读,每次顺序扫描完一个区内的页之后,会对下一个区触发预读。注意“下一个区”意味着空间上连续,而不是逻辑上连续。如果表空间碎片化程度高,连续区被各种数据交织占据,线性预读的命中率迅速下降,大量 IO 退化为随机读,查询变慢也就不奇怪了。

我遇到过一种情况:某张线上表频繁 UPDATE 大字段之后,叶子页反复溢出,表空间文件中间布满了半满的页和溢出页,相邻区已经被“凿空”,全表扫描耗时几乎翻倍。后来重建表之后,连续区重新排布,耗时立刻回到正常水平。这就是区层面碎片对性能最直接的证明。

5. 段:树上的“空间管家”,索引的每一个分支都有自己的段

段是四层中最抽象、最容易被忽略的一层,也是理解“表为什么越删越大”的关键。

5.1 每个索引至少两个段:叶子段和非叶子段

InnoDB 中段并不是“一张表一个段”,而是“一棵索引树会分成多个段”。更准确地说,B+ 树的叶子节点组成一个数据段,非叶子节点组成一个索引段。为什么分这么细?两类节点访问模式不一样——叶子节点做最终的范围扫描和数据读取,数据量大、和行记录强相关;非叶子节点更多是快速定位路径,数据量小、访问频率高。分成两个段以后,引擎可以针对性地做不同的页分配策略和预读模式。

一张表通常有一个聚集索引和若干二级索引,所以它的所有段加起来往往是“索引数×2”以上。加上回收站性质的回滚段等内部段,一张普通业务表对应的段数量远比直觉上多。

5.2 区、页、段三者如何协作

段由若干区和碎片区组成。当段需要扩展时,InnoDB 会先尝试从碎片区分配少量页,避免浪费;只有当段扩展超过一定规模(具体逻辑对应数据量阈值)后,才直接申请完整的区。这是“小表省空间、大表保性能”的关键策略。反过来也可以理解:一张只有百来行的表,文件可能只有几 MB,但它内部一样有段、有区、有页,只是分配时没有真正占满页和区,大量空间留白。

这套协作机制还解释了另一个常见问题:为什么DROP TABLE之后磁盘空间立刻释放,而DELETE FROM 大表之后表空间文件大小纹丝不动。前者直接把段占用的区整体标记为可复用,后者只是把行标记为 deleted,段并没有收缩。文件系统看到的还是那么多页,除非触发OPTIMIZE TABLE之类的重建操作,否则空间的“假性占用”会持续存在。

5.3 回滚段与 MVCC:段里住着旧版本数据

除了用户表和索引对应的段,InnoDB 内部还有重要的回滚段。每次 UPDATE 或 DELETE 都会在回滚段里记录旧版本信息,也就是 undo log 所在的位置。MVCC 的快照读需要根据 roll_pointer 沿着 undo 链找到更早版本的数据。如果事务长时间不提交、或者有长查询长期打开快照,回滚段里的旧版本就无法被 purge 线程回收,对应的物理空间也就一直挂在段上。

所以经常能看到的现象是:大表做了一轮批量 UPDATE,提交完事务后,表文件体积不减反增,然后迟迟不下降。这不是 MySQL“舍不得吐空间”,是回滚段里还压着旧版本。理解段之后,再去定位这种问题,思路会清晰很多。

6. 把四层结构倒过来用:重建、估大小和表设计

前面把四层结构从头到尾拆了一遍,但理论如果不能反向解释真实问题,价值就要打折扣。最后我用这套结构回答几个高频问题,每一个都来自线上实际踩坑场景。

6.1 表文件越删越大,OPTIMIZE 到底做了什么

DELETE只做逻辑标记,页内空间还在;行标记后留下的空隙虽然可供后续插入复用,但如果连续删除发生在表的各个位置,新插入的行又未必会落到被删除行的物理位置,最终结果是页利用率逐步下降、碎片分布散乱。OPTIMIZE TABLE的做法是重建整张表:新建临时表空间、把原表数据按主键顺序重新插入、重建所有二级索引、完成后切换回原表名。这会让页重新“压实”,行记录在连续区内顺序排列,文件体积随之缩小。

执行优化操作之前要注意两点:一是重建过程中需要消耗几乎等于原表体积的临时磁盘空间,磁盘不足时很容易中断;二是在线 DDL 虽然能减少锁阻塞,但大表优化的时间成本仍然不可忽略,建议放在业务低峰期执行。

6.2 怎么估算一张表现在和未来会占多大

知道行、页、区、段之后,物理大小的估算就变得直白:

假设一张订单表:

  • 每行记录约 200 字节;
  • 一个 16KB 叶子页中可以存放约 80 行(扣掉页头页尾、页目录、碎片后粗略估算);
  • 10 亿行数据大约需要 1250 万个叶子页;
  • 1250 万 × 16KB,约等于 200GB 的用户数据;再加上非叶子索引、二级索引、回滚段和碎片空间,实际的表文件通常会在此数字上膨胀 20%~50%。

这种估算方式在容量规划和分库分表决策时特别有用。很多团队在做数据量上限评估时只看行数,忽略页内预留和索引开销,导致初期容量评估偏差很大。

6.3 表结构设计中的“结构后遗症”

有几个常见表设计问题,用四层结构看其实都是“物理结构后遗症”:

  • 每行字段过多、行均宽度很大,叶子页能容纳的行数就会骤降,同样的数据量占用更多页,扫描成本直线上升;
  • 大字段与主查询字段混在同一张表,宽行记录在页内互相挤压,主键索引的叶子页容易提前分裂,此时拆分大字段到附属表反而更快;
  • 二级索引过多,每个索引都有一组独立段,写入时每个索引都要维护自己的页分裂和页分配,写放大明显,新增一个二级索引绝不只多占一点空间那么简单。

做表结构设计时,把“页内空间”当作稀缺资源来规划,很多决定就自然而然地合理了。比如主键尽量短,自增或雪花 id 优于随机 UUID;大字段独立存放;避免在一个表上堆太多无用的二级索引,这些都是四层结构落到底层后的直接推论。

6.4 故障排查时,先在心里画一张“层级地图”

遇到 MySQL 性能问题,我现在的习惯是先按四个层级逐一排查:

  • 行层:某条记录是否存在行溢出、超长字段是否导致单页记录数骤降;
  • 页层:缓冲池命中率是否下降、页分裂是否剧烈、页利用率是否异常;
  • 区层:表空间是否碎片化、分配是否连续、预读是否形同虚设;
  • 段层:是否存在长事务压住回滚段、段空间是否迟迟不回收。

这个顺序看起来“偏底层”,但在排查慢查询时往往比盯着执行计划更接近真相。执行计划和索引选型帮助你判断 SQL 写得对不对,四层结构帮助你判断表本身在物理层面是否已经“病入膏肓”。很多问题在 SQL 优化的前一步就注定好了——表结构在物理层不健康,再好的执行计划也只是让病马跑得快一点。

7. 我的一点实际体会

把段、区、页、行整套结构吃透之后,再看 MySQL 的很多“怪现象”,心态会稳很多。以前遇到大表 delete 后空间不释放,第一反应是重启实例或者干脆重建表;现在会先去看回滚段和 purge 线程状态,很多时候是长事务压着旧版本不让回收,只要找到那个拖了半天的“老事务”并让它结束,空间就自己回来了。以前遇到写入突慢,第一反应是加内存、扩缓冲池;现在会优先查页分裂频率和叶子页的空闲空间分布,因为很多时候根本问题在表结构太“宽”或者主键设计不理想,加内存只是花钱延后症状。

这套知识最难的地方不是记住“一区 64 页、一页 16KB”这种常数,而是把每一层背后的“为什么”串起来:为什么选页做 IO 单位,为什么区必须连续,为什么段要分叶子和非叶子,为什么行内要留元数据支撑 MVCC。串起来之后,无论是建表、调索引、做迁移还是排故障,你都有了一个判断的“底层坐标系”。希望这篇整理对你能有同样的帮助。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 5:19:50

YOLOv5+DeepSORT车辆检测跟踪:数据集与工程避坑实践

简介:这是一份面向计算机视觉学习者与智能交通开发者的车辆检测与多目标跟踪项目资源,以YOLOv5作为检测器、DeepSORT作为跟踪器,解决实时场景中车辆识别、遮挡与重叠情况下的稳定追踪问题,并附带了经过整理的数据集,降…

作者头像 李华
网站建设 2026/10/19 5:19:21

ICMP隧道实战:突破防火墙策略的内网渗透与检测思路

Windows 出口防火墙只留了一个 ping 的放行策略,TCP 全部拒绝;DMZ 到核心生产网段之间的 ACL 也只开放了 ICMP;目标机器上连 445、3389、53 都试过了,全部不通——这是我在授权红蓝对抗中遇到过的真实卡点。如果你的第一反应是&qu…

作者头像 李华
网站建设 2026/10/19 5:19:09

基于医学图像处理的肺结节检测:从预处理到两阶段流水线实战

简介:这份资源面向医学图像处理与深度学习方向的初学者及研究者,聚焦肺部CT影像中肺结节的自动检测问题,提供了一套可复现的完整项目代码。包内共26个文件,以Python脚本、Jupyter Notebook、PNG图示和说明文档为主,压缩…

作者头像 李华
网站建设 2026/10/19 5:18:18

基于深度学习的滚动轴承故障诊断毕设实战:CWRU数据预处理与CNN/DNN模型全链路解析

简介:这份资源是面向高校学生与深度学习入门者的滚动轴承故障诊断毕业设计完整项目包,围绕振动信号采集、预处理、模型构建、训练调优、故障诊断预测及结果可视化六个环节展开,适合作为课程设计、毕业设计或故障诊断方向入门实践的参考方案。…

作者头像 李华
网站建设 2026/10/19 5:18:18

WinGet完整指南:从下载安装到批量部署与自动化更新

Windows 自带的应用商店确实解决了一部分问题,但说实话,对于每天要装十几个软件、或者是运维一堆电脑的朋友来说,一个个去官网下载安装包,再点“下一步”的旧流程还是太耗费时间了。装上 WinGet 之后,我现在的习惯变成…

作者头像 李华
网站建设 2026/10/19 5:18:09

Vibe-Coding实战:五端协同开发的关键流程与踩坑复盘

今年圈子里聊开发,几乎绕不开 Vibe-Coding 这个词。给它一个不那么玄的解释:你把需求用自然语言讲清楚,AI 负责把代码生成出来,开发者负责拆需求、验结果、修边界,整个过程像顺着节奏走一样。真正让我改观的是手头一个…

作者头像 李华