很多做后端开发的兄弟,天天写 SQL,却很少认真想过一个底层问题:当 InnoDB 把一个带主键的行写进磁盘时,它到底是怎么一步步落到.ibd文件里的?网上常说“单表超过 2000W 行性能就会明显下降”,这个数字又到底怎么来的?
这两个问题,答案全藏在 InnoDB 的存储结构里,也就是常说的行、页、区、段四个层级。把这套东西吃透,面试能多聊十块钱的,更重要的是:你做容量规划、排查表空间膨胀、判断要不要分库分表的时候,终于有了一个可以计算的依据,而不是凭感觉拍脑袋。
这篇文章会用“从一行数据出发”的视角,把 InnoDB 物理存储结构完整拆一遍,然后把“单表 W 行”这个经验值从头推导一遍,最后聊几个我在线上环境踩过的真实大坑。
1. 先建立全局观:行、页、区、段到底是怎么套起来的
1.1 一条INSERT语句的物理足迹
先想一个最简单的场景:你执行了一行INSERT INTO user (id, name) VALUES (1, '张三'),这条数据在磁盘上经历了什么?
InnoDB 的处理路径大概是这样的:
- 这条记录先进入
Buffer Pool,也就是内存缓冲池,此时还不动磁盘; - 事务提交时,
redo log先落盘,保证崩溃可恢复; - 之后某个时间点,这条记录会被当作“脏页”刷到磁盘,真正写进表空间文件;
- 在磁盘上,它先被写进一个页(Page),页再属于某个区(Extent),区最终在段(Segment)下被管理。
这里最容易忽略的一点是:InnoDB 在磁盘上的最小单位从来不是“一行”,而是页。16KB 的页是 InnoDB 和磁盘、内存打交道的最小基本单位。也就是说,哪怕你只插入了一行只有几十字节的数据,InnoDB 也得先给这个行找到能容纳它的页;页满了,就申请新页。
所以“行→页→区→段”本质上是一条物理空间管理的链条:
- 行:你存储的业务数据,一行一条记录;
- 页:物理上固定 16KB 的容器,行就存在页里;
- 区:逻辑上连续的 64 个页,一共 1MB;
- 段:一组区的集合,InnoDB 把 B+ 树的叶子节点和非叶子节点分别放进不同的段。
这个层级关系,你完全可以把它类比成图书馆的管理方式:书(行)放在书架上,一排排书架(区)放在同一个阅览室(段),而每个书架只有固定那么多格子(页)。
1.2 为什么是“16KB页 + 1MB区”的组合
很多人会问:为什么页固定是 16KB?为什么区又要凑成 1MB?
先看页为什么是 16KB。这是 InnoDB 在“内存效率”和“磁盘 IO 效率”之间平衡出来的结果。传统的机械硬盘一次顺序读写的 IO 块也就 4KB~16KB,SSD 时代一次 IO 的瓶颈在于寻址而不是块大小,16KB 既能保证一次 IO 塞下足够多的行,又不至于因为页太大导致内存里只能缓存很少的页。
你可以算一笔账:假设一条业务记录平均 1KB,一个 16KB 的页去掉页头页尾、页目录这些开销,能放大约 15~16 行。Buffer Pool 如果是 8GB,理论能缓存 50 万个页,也就是约 750 万行数据——这个量级对绝大多数中小业务是足够的。
再看区为什么是 1MB。区的最小单位是 64 个连续页,所以 64 × 16KB = 1024KB = 1MB。连续页的意义在于:当你做全表扫描或者范围查询时,InnoDB 可以顺序预读这些连续页,一次磁盘 IO 就把好几个页读出来,不用频繁随机寻址。如果没有“区”这层结构,页和页之间东一块西一块,顺序扫描就退化成了随机 IO,性能会差出几个数量级。
1.3 链式存储:B+树叶子页之间的双向链表
这里必须插入一个有趣的结构:虽然 B+ 树在逻辑上是树,但在物理存储上,InnoDB 的页与页之间是靠双向链表串起来的。
每个数据页的文件头(File Header)里有两个字段:FIL_PAGE_PREV和FIL_PAGE_NEXT,分别指向前一页和后一页。这些指针所指向的正是磁盘上该页的偏移量,可以理解为“结构体的链式存储”——每个页结构体自带前驱和后继指针,树里同一层的页被串成了一个双向链表。
为什么要做双向链表?因为 B+ 树支持范围查询。比如SELECT * FROM user WHERE id > 100 AND id < 2000,InnoDB 先通过 B+ 树找到id=100所在的叶子页,接下来只要沿着叶子页之间的链表顺序往下读就行了,不需要每次都从根节点重新走一遍。
所以你记忆 InnoDB 存储结构时,不能只记一颗“树”,还要记住:树的分支是逻辑索引,页之间的链表是物理遍历路径。两者配合,才同时保证了单点查询和范围查询的效率。
2. 行格式深挖:一行记录在页里究竟占多少空间
2.1 Compact行格式:一行数据的“装箱单”
聊完宏观层级,我们把镜头拉到行本身。InnoDB 的行格式有好几种,COMPACT、DYNAMIC、COMPRESSED、REDUNDANT,但现在用得最多的是前两种。以最常见的COMPACT为例,一行数据在页内的真实布局并不是简单的“字段值接着字段值”,而是分成两大块:记录头信息 + 实际数据。
记录头信息里有几样东西你必须知道:
- 变长字段长度列表:如果有 VARCHAR、VARBINARY 这类变长字段,它们的长度会在这里先记录,而且是逆序存放;
- NULL 标志位:用 bit 位记录哪些列是 NULL;
- 记录头 5 字节:里面包含删除标记位
delete_mask、下一行记录的相对偏移量next_record、记录类型record_type等。
你可能觉得这些东西很琐碎,但它们直接影响一个关键问题:一行到底占多少字节。比如你在设计表时把很多字段都定义为VARCHAR(255)并且默认为 NULL,那么变长字段长度列表和 NULL 标志位会额外占用不小空间,最终算下来一行可能比你预想的大很多,直接降低单页能存的行数。
举个例子:假设表里有 4 个VARCHAR(100)字段,实际存的数据平均 50 字节。按照 Compact 行格式,每个变长字段的长度都需要 1~2 字节记录,4 个字段就是 4~8 字节;如果所有列都允许 NULL,恰好 4 列,NULL 标志位只需要 1 字节。再加上 5 字节记录头、6 字节ROW_ID(如果没有显式主键)、6 字节事务 ID、7 字节回滚指针,这些“隐藏开销”加起来就有 25 字节左右。如果一行业务数据本身才 200 字节,那这 25 字节的额外开销占了 12% 以上的空间。
2.2 行溢出:为什么VARCHAR(1024)不等于真的只存1KB
另一个行结构上的大坑是行溢出。很多人以为给VARCHAR定义了 1024 字节,就真的只存 1KB,没什么大不了。但 InnoDB 默认页是 16KB,页里还要留页头、页尾、页目录,真正给用户记录用的空间大约为 16KB 减去约 200 字节的固定开销。
如果一行数据太大了,一个页放不下,InnoDB 就会把那行里的长字段挪出去,存到单独的溢出页(Overflow Page)里,然后在原页只保留一个 20 字节的指针。在COMPACT和REDUNDANT格式下,阈值大约是页大小的一半,也就是 8KB 左右;而DYNAMIC行格式在 MySQL 8.0 已经是默认值,它会把长字段以完全溢出的方式存放,原页只留指针。
问题在于:一旦发生行溢出,你查询这一行时,InnoDB 可能需要额外读取溢出页,多一次随机 IO。如果你有一个表经常查询包含大文本的列,性能几乎一定受影响。
实操建议很朴素:能用TEXT就少用超大的VARCHAR;如果必须存储大字段,最好拆到独立表里,或者在频繁查询的视图/接口层做一个列的取舍,别让大字段跟核心查询搅在一起。
2.3 隐藏列与“索引即数据”
还有一个非常反直觉的点:你以为建表时定义了主键就有了主键,但 InnoDB 内部对主键的处理比你想的霸道得多。
如果表没有显式定义主键,InnoDB 会先找第一个非空的唯一索引作为主键;找不到的话,它干脆自己生成一个 6 字节的隐藏ROW_ID。也就是说,InnoDB 的表实际上默认就是索引组织表(Index-Organized Table),数据按照主键顺序物理排列,主键索引的叶子节点里存的是整行的所有字段,而不是指向行数据的指针。
这就是为什么你能在主键索引的叶子页里,直接通过一次 IO 拿到整行数据,这个过程叫“主键查询无需回表”。同时,这也解释了为什么主键最好用有序递增的整数:如果你用随机 UUID 做主键,新插入的行主键值毫无顺序,会导致频繁的页分裂和页重排,写入放大非常严重。
3. 页、区、段的空间管理与分配逻辑
3.1 16KB页内部:从头到尾的完整Layout
一个 16KB 的数据页,从文件头到文件尾,结构大致是这样的:
- File Header(38 字节):记录页的编号、上一页下一页指针、页类型、表空间 ID 等;
- Page Header(56 字节):记录页的状态信息,比如页里已有多少条记录、页目录槽位数、第一条记录的位置等;
- Infimum + Supremum(26 字节):两条虚拟的边界记录,分别代表“最小记录”和“最大记录”,所有真实记录都夹在它们中间;
- User Records:用户实际数据;
- Free Space:空闲空间,随着插入逐渐减少;
- Page Directory:页目录,由若干槽位组成;
- File Trailer(8 字节):页尾校验,用于在崩溃恢复时判断页是否完整。
这里有一个容易被忽视的地方:页目录在页的尾部,用户记录从页中间开始往尾部方向增长,两个方向相对增长。当 Free Space 耗尽时,页也就满了,再做插入就要走页分裂逻辑。
页内部还有一个非常高效的机制:所有用户记录在物理上并不是挨个连续存放的,而是通过记录头里的next_record指针串成一个单向链表。顺序按主键大小排列。所以页内查询不能直接靠数组下标定位,而是先通过页目录做粗略定位,再在槽内沿着链表找。
3.2 页目录:槽位与二分查找
页目录是 InnoDB 在页内实现高效查询的关键。它把页内的所有记录分成若干个组,每个组对应一个槽位,槽位里保存该组最大记录的相对偏移量。
查询时,InnoDB 先在页目录里做二分查找,找到目标记录可能所在的组,然后在这个组里通过next_record链表顺序查找。因为每组的记录条数通常控制在 4~8 条左右,所以即使在目录定位之后做顺序扫描,开销也非常小。
这个设计体现出 InnoDB 的一个核心思想:能用二分就不用全扫,能在页内解决的就不去跨页。设计表结构时,你可以反向利用这个特性:尽量让主键有序、查询条件走主键或索引,这样每次定位都从“根节点→中间节点→叶子页→页目录→链表记录”这条路径一路二分下去,整个过程最多也就经历几次二分和少量顺序扫描。
3.3 区的分配策略:碎片区与完整区
回到区这一层。InnoDB 并不是每次分配空间都直接给你一个完整的 1MB 区,这太浪费了。它有一个渐进策略:
- 刚开始创建表时,InnoDB 先分配一个碎片区(Fragmented Extent),里面每个页可以属于不同的段;
- 当碎片区里的页不够用了,再申请下一个碎片区;
- 直到碎片区数量达到阈值(通常是 32 个页),才一次性申请完整的区,且这个区内所有页都属于同一个段。
这样做的好处很明显:小表不会一上来就占用几十 MB 的磁盘空间。你建了 1000 张表但大部分是空表,它们在物理上大概率只各占了一个碎片区的少量页,而不是每张表都挥舞着 1MB。
但如果一张表的数据量持续增长,超过 32 个页之后,InnoDB 开始分配完整区,这时候你再去看.ibd文件大小,会发现它跳变式增长。记住这个特点,排查“表空间文件为什么突然变大”时会有用。
3.4 段的角色:叶子段和非叶子段
段是 InnoDB 里最容易被人忽略的一层。一张表的聚集索引(主键索引)在物理上会被分成两个段:叶子节点段和非叶子节点段。
为什么要把树的叶子和非叶子分开?因为两者的访问模式和生命周期不一样。叶子节点存的是数据,插入、更新、删除都会频繁改动;非叶子节点存的是索引目录项,相对稳定。分开管理后,InnoDB 可以对叶子段做更激进的预读和空间清理,而不用影响索引目录结构。
另外,每个二级索引在物理上也有自己独立的叶子段和非叶子段。所以你会发现,索引建得越多,表的物理空间占用就越大,写放大也越严重。这不是玄学,而是每一颗 B+ 树都需要自己的叶子页来存索引项。
4. 单表W行:把数字算明白
4.1 B+树扇出与层数估算
现在终于到了核心问题:单表到底能存多少行?“2000W”这个数字是怎么来的?
先说结论:它不是一个定值,而是由主键大小 + 每行平均大小联合算出来的结果,而且它对应的核心指标不是“能存多少行”,而是B+树的层数。
我们以最常见的场景推导:主键BIGINT(8 字节),每行数据平均 1KB。
非叶子节点页里存的是一条一条的“目录项”,每项大概包含:主键值 8 字节 + 指向子页的页号 4~6 字节 + 少量记录头信息,按 14 字节估算。一个 16KB 页能放:
16384 ÷ 14 ≈ 1170 条目录项
为了好记,业界通常取 1000~1200 作为扇出值。接着算叶子页:每行 1KB,去掉页固定开销后,一个 16KB 页大约能放:
15 行(留一点余量给页目录和碎片)
把这两层合起来,一颗三层 B+ 树能存的总行数大约是:
扇出 × 扇出 × 每页行数 = 1200 × 1200 × 15 = 2160 万行
看到没?2160 万,就是平时说的 2000W 的来历。在这个模型里,如果主键换成INT(4 字节),非叶子节点一条目录项压缩到约 10 字节,扇出能到 1600 左右,三层树能存约 3800 万行;如果每行数据平均只有 256 字节,每页能放 64 行,即使主键还是BIGINT,三层树也能存到 9000 万行以上。
所以别再相信“单表超过 2000W 行就废了”这句口头禅了。它是特定参数下的经验值,不是物理定律。
4.2 影响单表行数上限的变量
真正值得关心的是四个变量:
- 主键类型:
BIGINT比INT扇出低约 25%,VARCHAR主键更惨,目录项更肥; - 单行平均大小:这是最敏感变量,行越小,单页行数越多,总行数上限越高;
- 页填充率:InnoDB 为了给后续更新留空间,页并不会 100% 塞满,通常保留约 1/16 的空余,实际可用空间约 15/16;
- 碎片情况:频繁随机插入会导致页分裂和空页残留,实际容量低于理论值。
因此,在设计表的时候,对于核心大表,我建议你做一个“提前测算”:根据业务预估的单行大小、主键类型,用上面的公式反推三层 B+ 树能支撑的行数。如果预估未来三年会超过这个数,再考虑分区或分库分表。
4.3 实操:用SQL查看表的层数和占用
理论算完,还得落到实际。怎么确认某张表现在 B+ 树有几层?可以用information_schema.tables拿到表的数据长度,再除以 16KB 得到总页数。
SELECT TABLE_SCHEMA, TABLE_NAME, DATA_LENGTH / 1024 / 1024 AS data_mb, (DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024 AS total_mb, (DATA_LENGTH / 16384) AS approx_data_pages, TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME = 'your_table';这里的approx_data_pages是所有数据页的总数。如果数据页总数在 1200 以内,那 B+ 树大概率只有 2 层(根节点 + 叶子节点);如果超过 1200 但在 1200 × 1200 ≈ 144 万以内,那基本就是 3 层。依此类推。
需要提醒的是,information_schema.tables里的TABLE_ROWS是估算值,基于统计采样,不是精确的COUNT(*),别拿它做对账。
5. 当表真正跑到W行之后:性能与运维的双重挑战
5.1 树高增加带来的IO变化
单表突破 W 行级别后,你最先感知到的变化往往是:某些查询从“秒回”变成了“卡顿”。很多人归结为“数据量太大”,但从存储结构看,问题的本质是 B+ 树层数或缓存命中率发生了变化。
如果树是 3 层且根节点和中间层都在 Buffer Pool 里,那么一次主键查询只需要一次磁盘 IO 去读目标叶子页。但如果树长到 4 层,查询路径上就多了一次中间节点的读取。对于缓存命中率高的场景,这多出来的一次 IO 可能只是毫秒级;可一旦出现大量走二级索引回表、或者页不在 Buffer Pool 里的情况,多一次 IO 会被放大成明显的延迟抖动。
一个更隐蔽的问题是,数据量增长会让热数据在 Buffer Pool 中的占比下降。曾经 8GB 内存能覆盖的活跃数据,到了 W 行级别可能只覆盖了 30%,于是大量查询都要落盘,IO 延迟就成了短板。
5.2 二级索引、回表与随机IO
大表性能恶化的另一个帮凶是二级索引。
在 InnoDB 里,二级索引的叶子节点存的是“索引列的值 + 主键值”。比如你在user表的name字段建了二级索引,那么查询SELECT * FROM user WHERE name = '张三'时,InnoDB 会:
- 沿着二级索引 B+ 树找到目标记录的主键值;
- 再拿着主键值回聚集索引,定位到完整行。
这个过程叫回表。在小表上回表开销可忽略,但在 W 行量级的大表上,如果二级索引区分度不够高,或者一次性命中了大量行,回表就会变成随机 IO 洪水。比如 name 区分度低,一个 name 匹配出几千行,那就要回表几千次。
所以在大表上写 SQL 时,我会特别留意两个点:一是尽量用覆盖索引(把要查的字段全塞进索引),避免回表;二是在做分页查询时,别用LIMIT 100000, 10这种写法,让 MySQL 先扫 10 万行再丢掉,换成基于上次最大主键的“游标分页”,效率能差出几十倍。
5.3 大表运维:归档、分区与重建
单表数据量到了几千万之后,运维动作也会变得很重。最典型的是ALTER TABLE加字段或加索引,在早期 MySQL 版本里会重建整表,期间锁表且日志暴涨,堪比一次小型的“停机维护”。
我的做法通常分几步:
- 先评估是否真有必要保留这么多在线数据,能把历史数据归档到独立库的就归档;
- 第二考虑分区表,把数据按时间范围或哈希值拆分到不同区,但一定要清楚分区并不保证查询变快,它主要解决的是“按区清理数据”和“改善部分查询的 IO 局部性”;
- 最后才是分库分表,真到了这一步,意味着你要改代码里的路由逻辑,代价最大,尽量靠前面的手段延后。
另外,对大表做空间整理时,不要直接DELETE大量数据再指望空间回流。InnoDB 删除行只是打标记,空间不会自动还给操作系统。想让空间真正释放,通常要做一次OPTIMIZE TABLE或者ALTER TABLE ... ENGINE=InnoDB重建表,但这两者都会产生额外的 IO 和锁开销,务必在低峰期操作。
6. 常见问题与排查技巧实录
6.1 表空间文件大得离谱,数据却没多少
遇到.ibd文件几个 GB,但实际行数只有几百万,这种问题我排查过好几次。常见原因有三个:
- 曾有过大量插入后删除:页内留下大量标记删除的记录,空间没回收;
- 碎片页过多:随机主键或频繁 UPDATE 导致页分裂,逻辑上连续的数据散落在多个不连续的页;
- 大字段行溢出:
TEXT/BLOB溢出页占用大量空间。
排查时先看DATA_LENGTH和INDEX_LENGTH的比例,再看TABLE_ROWS是否和预期的量级匹配。如果差距大,再抽查行平均大小,基本能定位问题。能稳定复现的空间膨胀,大概率需要重建表来回收。
6.2 删除大量数据后空间不释放怎么办
这是很多同学踩过的坑:DELETE FROM big_table WHERE create_time < '2020-01-01'删掉了 90% 的数据,回应用 0.5 秒,但看磁盘空间,文件大小纹丝不动。
原因前面提过,InnoDB 的删除默认是“逻辑删除”,记录还在页里,只是标记为可复用。如果想真正释放空间,建议这样做:
- 先确认表没有长时间运行的事务,否则 undo 会阻碍空间复用;
- 低峰期执行
OPTIMIZE TABLE your_table;,或者ALTER TABLE your_table ENGINE=InnoDB;; - 如果表特别大,先用
pt-online-schema-change这类工具先在从库跑,切换后再回主库,避免直接锁表。
顺便说个个人习惯:如果要定期清理历史数据,尽量用分区表设计,按月分区,过期分区直接ALTER TABLE ... TRUNCATE PARTITION,秒级释放空间,比 DELETE 后重建表省心太多。
6.3 查询越来越慢,如何定位是不是存储结构的问题
查询变慢时,我一般按这个顺序排查:
- 先看
EXPLAIN,确认是否走了索引,有没有全表扫描; - 再看 Buffer Pool 命中率,可以用
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%',命中率长期低于 95% 就要考虑加内存或优化查询; - 然后看表的物理结构,用上面提到的
information_schema.tables估算 B+ 树层数和页数; - 最后检查是否存在大量碎片,用
SHOW TABLE STATUS LIKE 'your_table'\G看Data_free字段,这一项是页内可复用碎片空间的近似值,偏大就说明碎片不少。
大部分情况查到第二步就能定位,真正需要动到重建表的场景反而没那么常见。
6.4 经验速查表
| 现象 | 可能原因 | 建议动作 |
|---|---|---|
| 表文件暴涨但行数不多 | 碎片页、行溢出、历史删除未回收 | 重建表、优化行格式、排查大字段 |
| 主键随机写入慢 | 页分裂频繁、写放大严重 | 换成自增整数主键,或调整写入策略 |
| 单表几千万后查询变慢 | B+树层数增加、回表多、缓存命中下降 | 覆盖索引、游标分页、归档分区 |
| 删数据后空间不释放 | 标记删除未物理回收 | DELETE+重建 / 分区清理 |
| TABLE_ROWS 与实际行数差距大 | 统计采样误差 | 用 COUNT(*) 对账,或更新统计信息 |
最后再分享一个个人体会:每次遇到大表问题,我都会回到“行、页、区、段”这套底层模型去推演一遍。物理结构决定性能上限,SQL 写法决定你在这个上限下能发挥多少。只要把这四层结构装在脑子里,很多看似玄学的问题,算一算就通了。