“MySQL 三层 B+ 树能存多少数据?”这个问题,在 MySQL 面试题里出现频率非常高,基本属于 InnoDB 索引原理的“必考题”。但它不是一个靠背结论就能糊弄过去的问题——你哪怕背下来“大约两千万行”这个数字,面试官稍微变一下参数,或者追问一句“为什么是两千万”,答不上来一样露馅。更重要的是,这个问题的推导过程,直接决定了你日常建表时怎么选主键、怎么控制行大小、什么时候该考虑分库分表,这些全是实打实的工程判断,不是纯理论。
这篇文章我会从 InnoDB 的存储布局讲起,把三层 B+ 树的每个节点拆开来看,手把手把计算公式推一遍,再延伸到不同参数下的容量差异、单表两千万行说法的来龙去脉,以及我在实际表设计和面试候选人时积累的一些经验。内容适合正在准备 MySQL 面试的同学,也适合想真正理解索引结构、做表设计时心里有数的后端开发。
1. 别急着背答案:三层 B+ 树到底在讨论什么问题
1.1 从 InnoDB 的索引组织表说起
要算清楚这个问题,先得明白 InnoDB 里数据是怎么组织的。InnoDB 是索引组织表,意思是整张表的数据本身就是一棵以主键为序的 B+ 树,通常叫聚簇索引。B+ 树的叶子节点存放完整的用户数据行,非叶子节点只存放索引键和指向下一层节点的指针。也就是说,你往 InnoDB 表里插入的每一行数据,最后都落在某个叶子节点对应的数据页里。
举个例子,一张用户表以 id 为主键,那么这棵 B+ 树就以 id 排序。查 WHERE id = 100,从根节点出发,根据键值大小一层一层往下走,最终在叶子节点定位到那行数据。这里最关键的一点是:根节点、内部节点、叶子节点,它们本身都是一个 16KB 的数据页,所以“这一层能放多少条记录”这个问题,本质上就是“一个 16KB 的页能放多少个条目”的问题。
理解了这一点,你就能明白为什么问题叫“三层 B+ 树”——它就是一棵从根到叶子一共三层的索引树,也就是一次查询最多访问三次数据页:一次根节点,一次中间层节点,一次叶子节点。这个层数不是随便定的,它对应了磁盘 IO 的次数,直接关系查询性能。
1.2 为什么要拿“三层”当临界点
数据库里的数据页,如果不在内存缓冲池里,访问时就要做一次磁盘随机 IO。随机 IO 是什么概念?机械硬盘上大概 10 毫秒级别,即使是 NVMe SSD,也要几十微秒级别,而且这个开销在千万级并发查询下会被放大得非常明显。
三层 B+ 树意味着查询最多经过两次非叶子节点跳转,到达叶子节点。通常根节点是被频繁访问的,长时间驻留在 InnoDB 的 buffer pool 里,不太可能去磁盘读,所以实际磁盘 IO 往往只有一次到两次。但一旦 B+ 树变成四层,即使根节点还在内存里,每次查询也可能要多做一次磁盘随机读。在高并发场景下,这一层之差,可能就是性能从平稳到明显滑坡的分界线。这也是为什么大家常说“单表控制在千万级比较舒服”——说的就是三层 B+ 树基本够用的前提。
不过注意,这里说的是“主键聚簇索引”的三层。你如果建了几个二级索引,每个二级索引又是一棵独立的 B+ 树,它们的高度也要单独算,后面我会专门说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 算账之前,先把 B+ 树里每个节点的大小摸透
2.1 数据页:InnoDB 读写的最小单位
InnoDB 的数据页默认大小是 16KB,这个值可以通过 innodb_page_size 参数设置,但必须在初始化实例时指定,建库之后再改就来不及了。16KB 是一个典型的折中值:页太小,一个页能装的记录少,B+ 树容易变高;页太大,一次 IO 读取的数据多,但内存浪费和单页扫描开销也会增加。
一个 16KB 的页,并不是所有空间都能用来存索引条目或数据行。页头、页尾、页内的槽位信息、各种标记位都要占用空间。做精确计算时,能用的空间大概是 16384 字节减去 100 到 200 字节的系统开销,粗略估算时可以直接用 16KB 或者 15.5KB 来打底。这个细节在面试的时候提一嘴,会显得你是真看过 InnoDB 结构的,而不是背公式。
还要注意,页是 InnoDB 磁盘读写的最小单位,也是内存和磁盘交换的最小单位。无论你想读多少字节,最终都会把整个页加载进来。所以页内的记录条数直接影响一次 IO 能拿到多少有效数据——这就是为什么行大小会影响 B+ 树容量的根本原因。
2.2 非叶子节点:一个条目吃掉多少字节
非叶子节点里存的是“索引键 + 指向下一层节点的指针”。主键如果选 BIGINT,占 8 字节,指向子节点页的指针在 InnoDB 实现里通常算 4 到 6 字节。做估算时,业界普遍按 6 字节算,于是非叶子节点里一个条目就是 14 字节。
一个 16KB 页能放多少这样的条目?直接拿 16384 除以 14,约等于 1170。也就是说,一个非叶子节点大概能扇出 1170 个子节点。这个“扇出”数字是后面所有计算的地基,建议你把它刻在脑子里,面试时张嘴就能来。
这里顺便说一个面试加分点:B+ 树之所以比 B 树更适合做数据库索引,很重要的一点就是非叶子节点只存索引键和指针,不存数据,所以每个节点能放更多条目,扇出大,树就矮。树矮意味着磁盘 IO 次数少,查询更快。三层 B+ 树能覆盖千万行数据,本质上是这个结构设计带来的红利。
2.3 叶子节点:一行数据到底占多大
叶子节点里存的是完整的数据行。一行数据具体占多少字节,取决于表的字段定义和编码。比如一张表有 20 个字段,总长度加起来可能就超过 1KB 了;如果全是 varchar 且实际填充率不高,平均行大小可能只有几百字节。
这里要特别提醒一个误区:不能拿“所有字段长度之和”当平均行大小。InnoDB 的 Compact 行格式里,每行除了用户字段之外,还有变长字段长度列表、NULL 标志位、记录头信息、事务 ID 和回滚指针等额外开销。事务 ID 是 6 字节,回滚指针 7 字节,记录头一般 5 字节,再加上变长字段长度列表和 NULL 位图,一个短行的最小额外开销也在 18 字节上下。字段越多、变长字段越多,这部分开销越大。
所以估算行大小时,我会建议你在建表后实际查一下统计信息,或者用 information_schema 里的 AVG_ROW_LENGTH 做参考,而不是在纸上算完字段长度就觉得万事大吉。后面第 4 节我会专门讲怎么用 SQL 查看真实的行大小。
3. 手把手推演:三层 B+ 树能存多少行
3.1 核心公式:先算每一层能带多少节点
三层 B+ 树的结构是这样的:第一层是根节点,只有 1 个页;第二层是内部节点,数量等于根节点能容纳的指针数;第三层是叶子节点,数量等于第二层所有内部节点能容纳的指针数之和。
假设主键是 BIGINT,非叶子节点每个条目 14 字节,那么一个根节点大约能放 1170 个指针,第二层就有 1170 个页。第二层的每个页又能继续放 1170 个指针,所以第三层叶子节点总数就是 1170 × 1170,约 136.89 万个页。
这就是三层 B+ 树的“广度”上限。不管下面叶子节点每页能放多少行,树的容量上限首先是这个叶子页总数。实际工程里,叶子页数量通常达不到理论最大值,因为页填充率不是 100%,而且数据分布会导致页的空闲率波动,但这个理论值给了我们一个很好的估算上限。
3.2 每个叶子页能装多少行数据
叶子页能装的行数,就用 16KB 除以平均行大小来估算。我拿一个最常见的场景举例:一张表平均行大小约 1KB,那么一个 16KB 的叶子页大约能装 16 行数据。
注意,这和上一节说的“非叶子节点条目 14 字节”不是一回事。叶子节点里没有那么多“键 + 指针”的条目,而是有一行一行完整的记录,记录之间还要留出一些页内管理的开销,所以 16KB 页实际能放的行数会略小于 16384 除以行大小的商。按照 1KB 行大小粗略估 16 行,是一个工程上比较公认的近似值。
但如果你的表行大小只有 500 字节,一个叶子页就能放 32 行左右;如果一行数据大到 2KB,那就只有 8 行左右。所以你会发现,行大小对 B+ 树总容量的影响是线性的——行越小,同样的叶子页数量能装的行就越多。
3.3 完整算一遍:默认参数下的答案
把前面两步组合起来,完整的计算是这样:
- 非叶子节点每条目大小 = 8 字节主键 + 6 字节指针 = 14 字节
- 每个非叶子节点能放约 16384 / 14 ≈ 1170 个条目(这里省略页头页尾开销,精确计算略低)
- 三层 B+ 树的叶子节点总数 ≈ 1170 × 1170 ≈ 1,368,900 个页
- 假设行平均大小 1KB,每个叶子页约 16 行数据
- 总行数 ≈ 1,368,900 × 16 ≈ 21,902,400 行
换句话说,默认配置下,主键 BIGINT、平均行 1KB 的表,三层 B+ 树大概能存两千一百多万行。这就是网上“单表两千万行”说法的来源。但它从来不是一个绝对上限,而是一个特定参数组合下的估算结果。
如果主键换成 INT(4 字节),同样 3 层结构下,非叶子节点每个条目变成 10 字节,每个节点能放约 1638 个条目,总容量会涨到大约四千多万行。如果行平均大小变成 500 字节,BIGINT 主键下总容量翻倍到四千三百万行左右。所以结论是:“两千万行”只是参考值,不是真理。
3.4 关键参数的敏感性:主键大小、行大小、页大小
我整理了一个对比表,方便你直观感受这几个变量对结果的影响。假设叶子页能放的行数只随行大小变化,页大小固定 16KB:
| 主键类型 | 非叶条目大小 | 单节点指针数 | 叶子页总数 | 行大小 1KB | 行大小 500B |
|---|---|---|---|---|---|
| INT | 10 字节 | 约 1638 | 约 268 万 | 约 4290 万行 | 约 8580 万行 |
| BIGINT | 14 字节 | 约 1170 | 约 137 万 | 约 2190 万行 | 约 4380 万行 |
这个表能解释很多工程现象。为什么大家都劝你用自增 INT/BIGINT 做主键?因为主键越大,非叶子节点能容纳的条目越少,树的扇出越低,B+ 树就越容易长高。主键从 4 字节涨到 8 字节,容量接近腰斩,这个代价在数据量大的时候非常明显。
页大小的影响也可以顺带想想。InnoDB 的 innodb_page_size 支持配置为 8KB 或 16KB 等值,页越小,每个节点能放的条目越少,树越高;页越大,树越矮但单次 IO 浪费也越多,所以默认 16KB 是综合考量的结果。理解了这套逻辑,你面试时就不怕考官改参数了——无论他怎么改,你都能按“页大小除以条目大小”这个思路推出来。
4. 从“能存多少”到“该怎么设计”:这组数字的现实意义
4.1 主键选择与索引体积,不只是容量问题
很多人只记得“主键要自增”,但说不清为什么。从 B+ 树容量的角度就很好解释:自增主键值有序递增,新插入的行总是追加在 B+ 树最右侧的叶子页上,页面写满后产生新的页,顺序写入对页分裂影响小。而 UUID 那种随机主键,插入时可能落在树的任何位置,经常触发页分裂、页重排,页的填充率上不去,B+ 树实际能容纳的行数会比理论值低不少。
还有一个容易被忽略的点:二级索引的叶子节点存的是主键值。也就是说,你的主键越大,每一个二级索引的体积就越大,同样的二级索引 B+ 树能覆盖的行数就越少。一张表如果建了很多二级索引,主键选 BIGINT 和选 UUID 导致的额外存储和 IO 差距会非常明显。我在实际项目中见过一张商城的订单表,因为主键用了 36 字符的字符串,几个二级索引加起来比表数据本身还大,后来改造主键后,整个实例的磁盘占用和查询延迟都有明显改善。
所以,关于主键选择,我个人的建议很直接:默认用自增 BIGINT,除非你有非常明确的分布式 ID 需求,否则别为了“未来可能出现的场景”付出所有查询的代价。
4.2 “单表两千万行”说法的来龙去脉与局限
网上经常有人问“MySQL 单表超过两千万行就必须分库分表吗”,这个说法就是从前面的计算来的。默认情况下,主键 BIGINT、行平均 1KB,三层 B+ 树确实只能容纳两千多万行。所以两千万成了一个心理阈值。
但你要知道它的两个前提:一是主键是 8 字节的 BIGINT,二是行平均 1KB。如果你的表字段很少、行平均只有 300 字节,那三层 B+ 树能存到六七千万行;反过来,如果你的表有几十个字段、行平均 3KB,那可能几百万行就突破四层了。所以“单表两千万行”不是一个硬性指标,而是一个快速判断的起点。
我在做容量评估时,一般会先查这张表的实际平均行大小,再反推它现在几层、离变高还有多远。如果表已经快到四层了,就会开始考虑归档旧数据、拆分字段或者做分区,而不是盲目等到某个整数阈值才动手。
4.3 表量大之后:B+ 树变高的代价与应对
三层变四层,不只是容量数字变化,它意味着查询路径上多了一次磁盘 IO。假设根节点常驻内存,原来一次点查最多两次磁盘读,变四层后最多三次。看起来就多一次,但如果是几十万 QPS 的高频查询,多出来的这次随机 IO 会被放大成可感知的延迟增长,而且 buffer pool 的命中率也会受影响。
应对方式无非几条路:第一,压缩行大小,比如拆分宽表、减少冗余字段、合理选择数据类型,让每个叶子页能放更多行;第二,横向拆分,分表分库,降低单表数据量;第三,用归档把历史数据挪走,保持热表在千万级以内;第四,如果确实需要大容量,可以考虑把 innodb_page_size 调大,但这需要建实例时就规划好,后期改不了。我见过一个团队把订单流水表里一个 300 字节的 JSON 字段拆到附属表之后,单表容量直接翻了一倍多,这个方法在很多场景下立竿见影。
4.4 用一条 SQL 估算你当前表的样子
讲完理论,给一个可以直接上手的实操。想知道自己某张表现实的行大小,不需要去猜,直接查 information_schema:
sql复制SELECT
table_name,
table_rows,
data_length,
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
ROUND(data_length / table_rows, 2) AS avg_row_bytes
FROM information_schema.TABLES
WHERE table_schema = 'your_db'
AND table_name = 'your_table';
data_length 反映的是聚簇索引(也就是整张表数据)占用的字节数,table_rows 是采样估算的行数,两者相除能得出每个数据页里行的平均大小。注意 table_rows 不是精确值,它是 InnoDB 根据采样统计估算出来的,但对于评估行大小和容量已经够用了。
拿到平均行大小后,你就可以套用前面的公式:16384 除以平均行字节数,得到每个叶子页大概多少行,再乘以叶子页总数。在实际项目里,我会把这些统计信息收集成一个脚本,定期巡检重点大表,观察平均行大小有没有因为字段填充率变化而漂移,以及表数据是否接近某个 B+ 树层级的容量上限。这个方法比死记两千万行这个数可靠得多。
5. 面试追问与常见误区复盘
5.1 “三层 B+ 树”不是“三叉树”
一个很常见的误区,是把三层 B+ 树理解成每个节点只有三个子节点。三层指的是从根到叶子的高度是三层,每一层内部可以有成千上万个节点。一个非叶子节点能放 1170 个指针,根节点一下就能扇出一千多个子节点,这就是为什么三层能覆盖千万级数据。如果真是三叉树,三层最多存几千行,数据库早就没法用了。
面试时用一句话说清楚就很好:“B+ 树的层数取决于节点扇出,而节点扇出取决于页大小和条目大小,不是固定的三叉结构。”这句话能直接体现你对 B+ 树结构和 InnoDB 页机制的理解深度。
5.2 主键索引和二级索引要分开算
另一个常见误区,是把主键索引的容量当成整张表所有索引的容量。主键索引(聚簇索引)的叶子节点存的是完整数据行,计算容量时看行大小;二级索引的叶子节点存的是“索引列值 + 主键值”,计算容量时看的是索引键大小和主键大小之和。
举个例子,如果你在 user_name 字段上建了一个二级索引,user_name 是 varchar(50),实际平均 20 字节,那二级索引叶子节点每条目就是 20 字节索引值加 8 字节主键,约 28 字节,再叠加上页内开销,一个 16KB 页大概能放五百多条。这个二级索引要覆盖多少行、什么时候会涨到四层,和主键索引的算法一样,只是条目大小完全不同。这也是为什么主键越大,所有二级索引的占用都跟着膨胀。
5.3 面试官真正想从这个问题里听到什么
我面试候选人时,如果问“MySQL 三层 B+ 树能存多少数据”,我最想看到的不是那个数字,而是候选人能不能讲清楚:数据页多大、非叶子节点存什么、叶子节点存什么、为什么主键大小会影响容量、为什么行大小会影响容量。能把这四个逻辑点串起来的人,说明他对 InnoDB 结构有真实理解,而不是背了八股文。
如果候选人能进一步提到“单表两千万行只是特定参数下的估算”“实际容量要结合行大小和页填充率看”,我会非常加分。这意味着他做表设计、做容量规划时候是心里有数的,而不是等线上报警才开始慌。所以这篇文章虽然是以面试题为引子,但核心想传递的是:把 B+ 树容量模型弄明白,你日常建表、评估表是否需要拆分、判断为什么某条 SQL 慢的时候,都能多一个非常有力的判断依据。
最后分享一个我自己的习惯:每次新建核心业务表,我都会先按字段类型粗算一遍平均行大小,再套用三层 B+ 树的容量公式,如果不满足未来两三年的数据增长预期,就提前做好分表方案。这个习惯替我避免了好几次线上救火的场景。你可以把这个方法直接用到自己的项目里,比背任何结论都管用。
