“MySQL 三层 B+ 树能存多少数据”这个问题,我最早是在一次技术评审会上听到的。当时架构师抛出来,让我估算一张核心业务表的容量上限,我脱口而出“两千万行”,然后就被追问了一句:“这个数怎么来的?你算过吗?”那一瞬间我意识到,很多人(包括当时的我)都背过“2000万”这个结论,但根本没搞懂背后的推导链路。后来我自己把 InnoDB 的页结构、行格式、索引组织方式完整捋了一遍,又用真实表反复验证过,才敢说真正理解了这组数字。
这篇文章不打算只给结论。我会从 InnoDB 的存储单元讲起,把“三层 B+ 树能存多少数据”这个公式一步步拆开,再结合实际表结构演示怎么算、怎么验证,最后聊几个我在实践中踩过的坑。无论你是在准备面试,还是要做容量评估,或者纯粹想搞懂 MySQL 底层原理,这篇都能给你一个清晰的框架。
1. 从 InnoDB 的“页”说起:一切计算的基础
在算 B+ 树能存多少数据之前,得先搞懂 InnoDB 到底是怎么把数据放在磁盘上的。MySQL 的存储引擎中,InnoDB 是默认且最常用的,它所有的数据最终都以“页”(Page)为基本单位存放在磁盘上。你可以把页想象成一本很厚的书里的固定页码纸张,无论书的内容怎么变,每一页的物理大小是固定的。
1.1 页大小:16KB 这个数字怎么来的
InnoDB 默认的页大小是 16KB,这一点绝大多数情况下不需要改。你可以在 MySQL 配置文件(my.cnf 或 my.ini)里通过 innodb_page_size 参数调整,可选值有 4KB、8KB、16KB、32KB、64KB,但官方强烈不建议在生产环境随意改动,因为一旦建库完成,页大小就不能再变了。
为什么是 16KB?这是一个“读写效率”和“内存利用”的折中。如果页太小,比如 4KB,那么一次磁盘 I/O 能读到的数据就少,查询大量数据时 I/O 次数会变多;如果页太大,比如 64KB,那么一次 I/O 读取的数据量大,但内存中能缓存的页数就变少,而且对于小规模查询来说,读一个 64KB 的页可能只用到了其中很小一部分,造成浪费。16KB 在绝大多数场景下是平衡点。
页的物理结构大致分为几块:页头(38 字节,记录页的校验和、页号、上下页指针等信息)、页尾(8 字节,主要用于校验)、以及中间的用户记录区域。用户记录并不是从头到尾挨着放的,而是通过一个叫“页目录”(Page Directory)的结构来加速查找。这个概念后面计算时会用到,但先有个印象就行。
1.2 行格式:数据行是怎么塞进页里的
知道了页是 16KB,那么一个页里能放多少行数据,取决于每一行数据占多少字节。InnoDB 有几种行格式,从早期版本到现在,主要包括 COMPACT、REDUNDANT、DYNAMIC 和 COMPRESSED。MySQL 5.7 及以后默认是 DYNAMIC 行格式。
以 COMPACT 和 DYNAMIC 为例,一行记录在物理存储上,至少包含以下部分:
- 变长字段长度列表:记录 VARCHAR、VARBINARY 等变长字段的真实长度,如果所有字段都是定长的,这一部分可以为空。
- NULL 值列表:记录哪些字段是 NULL。即使你没有 NULL 字段,这个列表也会占 1 个字节(用一个标志位表示),所以别再以为“没有 NULL 字段就不占空间了”。
- 记录头信息:固定 5 字节,包含 delete_mask(是否删除)、record_type(普通记录还是索引记录)、n_owned(槽位引用数)、heap_no(记录在堆中的位置)、next_record(下一条记录的相对位置)等。
- 隐藏列:InnoDB 每一行还有额外的隐藏字段,包括 6 字节的 DB_ROW_ID(行 ID,如果你没有定义主键才会用到)、6 字节的 DB_TRX_ID(事务 ID)、7 字节的 DB_ROLL_PTR(回滚指针)。如果你自己定义了主键,那么 DB_ROW_ID 不会存储;但另外两个 6+7=13 字节是无论如何都省不掉的。
这几项加起来,一行记录光“固定开销”就有:5 字节记录头 + 13 字节隐藏列 + 1 字节 NULL 值列表 = 19 字节(约等于 20 字节)。也就是说,哪怕你的表里只有一个 INT 字段,一行也不可能低于 20 字节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. B+ 树索引是如何组织数据的
搞清楚了页和行,接下来要看 InnoDB 的索引到底长什么样。InnoDB 使用的是 B+ 树结构,这个树有几个关键特点,决定了它能存多少数据。
2.1 聚簇索引:数据就是索引
InnoDB 的表是索引组织表,也叫聚簇索引表。什么意思?就是整张表的数据行本身,就是一棵 B+ 树的叶子节点。主键决定了数据行在物理上的排列顺序,叶子节点上存的是完整的行记录。所以,InnoDB 表的“主键索引”就是数据本身,不需要单独的“数据文件”和“索引文件”分开。
这意味着,每张 InnoDB 表默认就有一棵以主键为排序键的 B+ 树。如果你建表时没有指定主键,InnoDB 会先找有没有非空的唯一索引,如果有就拿来当主键;如果还没有,它就会自动生成一个隐藏的 6 字节 DB_ROW_ID 作为主键。这就是为什么我前面说,如果没有显式主键,每行会额外多出 6 字节的隐藏列。
2.2 非叶子节点与叶子节点的差异
B+ 树的非叶子节点(也叫内节点、索引节点)不存完整行记录,只存索引键值和指向下一层节点的指针。叶子节点才存完整的行数据。这种“索引和数据分离到不同层”的设计,正是 B+ 树能支撑海量数据的关键。
非叶子节点上,一条索引项大致由两部分组成:索引键值(比如主键值,如果是 BIGINT 就是 8 字节)和指向子节点的指针(InnoDB 中通常是 6 字节)。另外还有一点点额外的记录头信息,我习惯按总共 14 字节来估算。
叶子节点上,一条数据行的字节数 = 各个字段的实际内容长度 + 前面说到的约 20 字节固定开销(记录头、隐藏列、NULL 标志等)。这个值根据表结构差异很大,可能几十字节,也可能好几 KB。
2.3 三层树意味着什么
B+ 树的“层数”,就是从根节点到叶子节点经过几层。查询一个数据时,InnoDB 从根节点开始,一层一层往下找,直到叶子节点拿到完整行记录。树的高度直接决定了查询需要多少次 I/O(不考虑缓冲池命中):
- 1 层:只有根节点,根节点就是叶子节点,直接存放数据。这种表总共也就能存一个页的数据。
- 2 层:根节点是索引页,叶子节点是数据页。理论上可以存 “根节点能存的索引项数 × 每个叶子页能存的行数” 条记录。
- 3 层:根节点 + 一层中间索引节点 + 叶子数据节点。这就是我们这篇文章的主角。
对于大多数业务表,3 层是一个很理想的状态:根节点常驻内存(这部分内存占用很小,一般几百字节到几 KB),查询时最多只需要 2 次磁盘 I/O(从根到二级节点一次,从二级节点到叶子一次),响应非常快。一旦树变成 4 层,理论上多一次 I/O,但实际操作中如果不命中缓冲池,延迟会有明显上升。所以“三层能存多少数据”这个问题,本质是在问:在保持三星访问性能的前提下,一张表能承载多少行。
3. 核心推导:三层 B+ 树的容量计算
现在来到全文最关键的部分——把公式一步步推出来。这里先给出一个典型的计算过程,后面再讲不同场景下怎么调整。
3.1 假设条件
为了把数字算清楚,我们先做几个常见假设:
- 页大小:16KB(16384 字节),InnoDB 默认值。
- 主键类型:BIGINT,占 8 字节。
- 非叶子节点的索引项:键值 8 字节 + 指针 6 字节 = 14 字节,这里忽略记录头的小额开销(实际会占用一点点,但 14 字节已经是工程上常用的近似值)。
- 每个叶子页能存的数据行数:这里先假设每行数据平均占用 1KB(1024 字节)。实际表结构不同,这个数字变动很大,后面我会演示怎么根据具体表来算。
3.2 计算根节点能放多少索引项
一个非叶子节点有 16384 字节可用空间,每一条索引项占 14 字节,那么一个页能存的索引项数量为:
16384 ÷ 14 ≈ 1170.28,向下取整是 1170 条。
这里的计算还忽略了一个细节:页本身有页头和页尾,用户记录实际可用空间大约是 16384 - 38(页头)- 8(页尾)= 16338 字节。但因为我们用 14 字节/条做近似,且索引项的实际开销会略大于 14(还有记录头),所以直接按 16384 ÷ 14 算出来的 1170 是业界的通用估算值,误差可以接受。工程上不是做绝密精度实验,我们要的是一个可信的区间。
3.3 计算第二层能指向多少数据页
三层 B+ 树的结构是:
- 第 1 层(根节点):1 个索引页,能存 1170 条索引项。
- 第 2 层:根节点每条索引项指向一个子页,所以最多有 1170 个索引页(如果第二层还是索引页)或数据页(如果第二层已经是叶子)。
- 第 3 层:第 2 层每个页也最多有 1170 条索引项,这些项指向第三层的叶子数据页。
在“三层”的结构下,第 2 层是索引页,第 3 层是数据页。所以数据页的总数量为:
1170 × 1170 = 1,368,900 个数据页。
3.4 计算总共能存多少行数据
每个数据页假设存 16 行(因为 16KB ÷ 1KB = 16),那么总行数为:
1170 × 1170 × 16 = 21,902,400,约 2190 万行。
这就是“MySQL 三层 B+ 树能存约 2000 万行”这个结论的由来。
3.5 一组表格展示不同行大小下的容量
很多人算到这里就停了,但我觉得不够。因为每行 1KB 只是假设,真实表结构千差万别。我整理了一个常用的参考表,你以后可以直接对照:
| 平均行大小 | 每个叶子页存的行数 | 三层 B+ 树可存总行数 |
|---|---|---|
| 512 字节 | 32 行 | 约 4380 万行 |
| 1KB | 16 行 | 约 2190 万行 |
| 2KB | 8 行 | 约 1095 万行 |
| 4KB | 4 行 | 约 547 万行 |
| 8KB | 2 行 | 约 273 万行 |
注意,如果平均行大小超过 8KB,那么一个 16KB 的页可能只能存 1 行,此时单表容量会被急剧压缩。这时候你就得考虑对大字段做垂直拆分、行压缩或者改用更大的页大小(如果业务允许)。
4. 实操验证:用真实表把计算跑一遍
理论算完以后,我强烈建议你亲手验证一次。因为“纸上得来终觉浅”,而且验证过程能帮你发现很多理论公式没有覆盖到的细节。
4.1 建一张测试表
我建了一张比较接近真实业务的表,包含常见的字段类型:
sql复制CREATE TABLE `user_profile` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(64) NOT NULL,
`email` VARCHAR(128) DEFAULT NULL,
`age` INT DEFAULT NULL,
`city` VARCHAR(64) DEFAULT NULL,
`bio` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这张表里,VARCHAR 字段比较多,而且 utf8mb4 每个字符最多 4 字节,所以一行数据的平均大小要按实际内容来评估,而不是按字段定义长度。
4.2 如何估算表的平均行大小
最准的办法不是靠猜,而是用 MySQL 自己的统计信息。你可以在导入一批样本数据后执行:
sql复制SHOW TABLE STATUS LIKE 'user_profile';
输出里的 Avg_row_length 列,就是 InnoDB 估算的平均行字节数。这个值是“数据文件总大小 ÷ 总行数”,虽然不完全精确(因为它包含了碎片和页结构开销),但用于容量规划完全够用。
比如我插入 50 万行样本数据后,Avg_row_length 显示大约 210 字节(因为 username 和 bio 平均没那么长),那么:
每个叶子页能存:16384 ÷ 210 ≈ 78 行。
三层 B+ 树能存:1170 × 1170 × 78 ≈ 1.07 亿行。
这个结果和“每行 1KB”算出来的 2190 万差别非常大。所以我说,要看真实表结构,不能拿一个固定数值套到底。
4.3 通过统计信息验证索引层级
验证树到底是几层,可以这样看:如果一张表的主键索引根节点能存 1170 条索引项,那么当数据行数超过 1170 × 78 ≈ 9 万行时,树就从 2 层变成 3 层。实际上,当表里只有几万行时,你可能已经处在 2 层到 3 层的过渡区,因为根节点和数据页分散在不同 extent(区)中,难以精确判断具体边界。
如果你想更直接地观察层级,可以通过 information_schema.INNODB_SYS_TABLESPACES 结合 innodb_ruby 之类的工具来分析,但对大多数人来说,用 SHOW TABLE STATUS 加上 EXPLAIN 观察索引访问成本,已经足够验证层级和容量了。
注意:
SHOW TABLE STATUS中显示的Data_length是主键索引(聚簇索引)占用的总字节数。如果你看到Data_length = 256 MB,行数 50 万,那么Avg_row_length = 256 * 1024 * 1024 / 500000 ≈ 537字节。这个估算非常实用。
4.4 用数据量倒推树的层级
还有一种反向验证法:如果你的表数据量是 1500 万行,平均行大小 500 字节,那么数据页数量 = 1500万 × 500 ÷ 16384 ≈ 457,763 个数据页。而这棵树如果有 3 层,数据页最大数 = 1170 × 1170 = 1,368,900 个,457,763 明显小于这个上限,所以树应该还是 3 层。如果数据量涨到 3000 万行,平均行大小 700 字节,数据页数 = 3000万 × 700 ÷ 16384 ≈ 128 万个数据页,仍然没超过 136 万,还是 3 层。一旦超过这个上限,树就会变成 4 层。
我拿这个逻辑去验证过实际生产环境里一张 2600 万行的订单表,平均行大小约 550 字节,算出来数据页大约 87 万个,确实还在 3 层之内。所以“三层能存多少数据”不只是面试题,也是估算容量和预判性能的一个实用工具。
5. 哪些因素会改变这个数字
理论和公式说完了,但实际工程中还有几个变量,会显著影响最终的容量。
5.1 主键类型:INT 还是 BIGINT
非叶子节点上,索引项 = 键值 + 指针。如果主键用 INT(4 字节),那么一条索引项大约是 4 + 6 = 10 字节,一个非叶子页能存 16384 ÷ 10 ≈ 1638 条索引项。三层树能指向的叶子数据页数量就变成:
1638 × 1638 = 2,683,044 个数据页。
假设每行 1KB,能存的总行数约为 2683万 × 16 ≈ 4.29 亿行。注意,这不意味着用 INT 就一定比 BIGINT 好。因为 INT 的数值范围只有 21.47 亿,对一张可能达到几亿行的表来说,存在溢出的风险。用 BIGINT 换的是未来的扩展空间。
5.2 页大小:改大能提升容量
如果修改 innodb_page_size 为 32KB,那么一个页能存 32768 ÷ 14 = 2340 条索引项,第二层索引项数量也会翻倍,数据页总数变成:
2340 × 2340 ≈ 547.6 万个数据页。
每行 1KB 时,每个数据页能存 32 行,三层总容量约 1.75 亿行。但如前所述,页大小调整会影响内存和 I/O 行为,改前要压测,别只看容量加分。
5.3 行大小:VARCHAR 和字段长度是最大变量
很多人容易忽略 VARCHAR 不是固定空间的。VARCHAR(500) 只是上限,不是实际占用。在 utf8mb4 下,一个中文字符最多占 4 字节,所以“500 个字符的最坏情况”是 2000 字节。但实际业务数据往往达不到这个上限。因此,估算容量前一定要了解业务字段的平均长度,而不是拿 VARCHAR 的定义长度去算。这也是为什么我建议直接看 Avg_row_length,而不是手工对数。
5.4 溢出页:大字段的隐藏开销
对于 VARCHAR、TEXT、BLOB 这类大字段,如果单行数据加上固定开销后超过页大小的一半(约 8126 字节),InnoDB 会把部分内容存到“溢出页”(Off-Page)中,叶子节点上只保留 20 字节的指针。这在 DYNAMIC 行格式下表现尤其明显。所以,如果一张表有一个很大的 TEXT 字段,比如文章正文有 10KB,那么叶子节点上可能压根不存正文,只存指向正文存储位置的指针,行记录本身仍然比较小。这种情况下,Avg_row_length 会低估实际的存储开销,但它对查询性能的影响才是更需要关注的。
5.5 缓冲区命中率 vs 树的层数
容量和树的层数相关,但真正影响线上体验的,是每次查询要从磁盘读多少页。三层树在根节点常驻内存的情况下,查询一条记录通常只需要 2 次磁盘 I/O(读第二层页 + 读叶子页)。如果树涨到 4 层,就可能变成 3 次磁盘 I/O。在机械硬盘时代差距很明显,上了 SSD 以后,多一跳 I/O 的延迟差异会缩小,但并不会消失。所以在容量规划时,我会习惯把“不要超过三层”作为单表容量的默认上限。
6. 常见问题与排查技巧
围绕“三层 B+ 树能存多少数据”这个话题,我在社区和技术群里看到过不少疑问和误解,这里挑几个典型的说一下。
6.1 为什么网上有人算出“2000万”,有人算出“4000万”?
因为假设条件不同。有人按每行 1KB 算,有人按每行 512 字节算,还有人把索引项字节数按 13 或 15 来估。本质都是同一个公式,差别只在取值。这不算错误,但你要清楚自己用的假设是什么。如果你跟别人讨论“2000 万”,最好先对齐假设条件,否则会鸡同鸭讲。
6.2 数据量超过“三层容量”就一定会慢吗?
不一定。三层容量是一个理论阈值,表示在这个范围内,主键查询通常只需 2 次磁盘 I/O。超过之后,树变成 4 层,查询多一次 I/O,但如果有足够大的缓冲池把热点索引页都缓存住,实际延迟可能变化不大。另一个关键点是,大部分查询未必走聚簇索引主键查询,如果走二级索引,可能还要多一次回表。所以“超过 2000 万就必须分库分表”是一个过于简单化的断言。很多表三四千万行、甚至上亿行,在合理索引和缓存配置下依然运行良好。
我得强调一个观点:单表行数不是性能瓶颈的唯一因素。真正要关注的是“每行大小”、“访问模式”、“缓冲池大小”和“写入并发”。一张 5000 万行但每行只有 100 字节的表,和一张 1000 万行但每行有 5KB 的表,它的性能表现可能完全反过来。
6.3 如何提前判断一张表会变成 4 层
实践里,我会把公式做成一个监控指标。例如:
- 定期查询
SHOW TABLE STATUS,记录Data_length和Rows。 - 计算出当前数据页数量:
Data_length ÷ 16384。 - 如果这个值接近 1170 × 1170 = 1,368,900,说明离“三层变四层”的临界点不远了,需要提前规划只读副本、归档历史数据或者做垂直拆分。
这套方法不复杂,但非常有效。我就是在生产环境出现了一条慢查询之后,才去复盘发现那张日志表已经悄悄长到快 2 亿行了。用这个方法提前盯住,完全可以避免这种被动局面。
6.4 表里有二级索引,会影响这个计算吗?
聚簇索引(主键索引)的容量计算不直接受二级索引影响,因为二级索引是另一棵 B+ 树,它消耗的是额外的磁盘空间。但二级索引的叶子节点不存完整行记录,只存“索引列 + 主键值”,所以单条记录通常较小,每个页能存更多记录,对应的树层级会比聚簇索引更浅。实际中,如果一张表有多个二级索引,总磁盘占用会明显上升,但在评估“主键查询性能受层级影响”时,主要看聚簇索引的层数。
6.5 主键乱序写入会不会影响容量?
主键乱序写入不会改变 B+ 树的层数和理论容量上限,但会造成页分裂和碎片,使实际可用空间变小。比如一张表理论上能存 2000 万行,但如果主键是随机 UUID,频繁的页分裂可能让实际容量打八折,甚至更低。所以业务上能用自增主键或有序 ID 就用有序 ID,这不只是写入性能问题,也会直接影响空间利用率和容量估算的准确性。如果你非要使用 UUID 作为主键,至少考虑用 UUID 的排序版本(如 UUIDv7),以缓解随机写入带来的页分裂。
6.6 有没有办法强制让树保持三层?
不能直接“强制”,但可以通过控制单表数据量或行大小来实现。具体手段包括:
- 控制主键大小,避免超长主键,比如用 BIGINT 而不是 VARCHAR
- 控制字段数量和大字段的使用,减少每行平均字节数
- 对历史数据做归档,把热表和冷表分离
- 必要时用分区表,但要注意分区表本质上还是同一棵 B+ 树,只是物理存储分散到不同区,不能降低索引层级
7. 扩展:从三层算到四层,以及容量规划的思路
了解了三层,顺手也能推出四层的容量。四层就是在三层基础上,再乘一个非叶子页能存的索引项数。假设 BIGINT 主键、每行 1KB:
- 非叶子页:1170 条索引项/页
- 四层容量 = 1170 × 1170 × 1170 × 16 ≈ 256 亿行
看到这个数字,有人可能会说:“既然四层能存 256 亿行,那单表随便长长不就行了?”但请记住,四层意味着最多要 3 次磁盘 I/O 才能拿到数据。当系统并发很高时,这多出来的 1 次 I/O 会被放大很多倍。所以容量规划不能只看“塞不塞得下”,还要看“查询等不等得起”。
一个比较稳妥的思路是:
- 先统计表结构,算出平均行大小。
- 根据业务增长预期,估算多久会达到三层临界点。
- 在临界点还差 30% 左右时,提前启动优化预案,比如归档、读写分离或者按业务键拆分。
- 监控
Data_length和Avg_row_length这两个指标,用公式做趋势预测,而不是等慢查询出现了再救火。
用一个小例子收尾吧。我之前维护过一张风控事件表,表结构固定,平均行 480 字节,三层容量约 4300 万行。上线时只有 200 万行,看上去很安全。但我按每天新增 50 万行估算,不到 3 个月就会触及三层临界点。于是提前做了按月分区和归档策略,后来这张表稳定运行到 8000 万行以上,查询响应依然保持在毫秒级。这就是“三层 B+ 树能存多少数据”这个问题的真正价值——它不是让你背一个数字,而是让你掌握一套评估容量、预判风险的方法。
如果你以后再去面试或评审,被问到“MySQL 三层 B+ 树能存多少数据”,希望你能像我一样,先问清楚“主键多大、每行多大、页多大”,然后当面心算出结果,顺便把公式推导讲给面试官听。这一套连招打下来,对方大概率会对你刮目相看。
