1. 三层B+树这个问题,到底在问什么
前段时间有个同事跑来问我:订单表已经1800万行,是不是该分表了?我没直接给建议,先反问他:你知道 InnoDB 一棵三层 B+ 树大概能承载多少数据量吗?他想了半天,报了个“2000万”的答案,但再往下问就说不清这个数字是怎么来的了。
其实“MySQL InnoDB引擎,三层B+树可以存储多少数据量”这句话,几乎每个数据库面试官都问过,看起来像一道八股题,实际上背后藏着三层逻辑:一是你对 InnoDB 索引结构是否真的理解,二是你能不能从物理存储的基本参数出发做估算,三是你在真实业务里是否知道“单表多少行该紧张”的边界。这篇文章就是把这套东西从页结构讲到计算公式,再从公式讲到生产实践,给准备面试的朋友和已经在业务里纠结分表不分的同学,都提供一个可以落地的判断方法。
先说结论,让大家有个锚点:在默认 16KB 数据页、主键用 BIGINT、单行数据平均 1KB 左右的典型场景下,一棵三层 B+ 树大约能存储 2000 万行左右的用户数据。注意“大约”两个字,下面展开的所有内容,都是在解释这个大约是怎么来的,以及为什么它在不同行大小、不同主键长度下,可以从几百万行差到几千万行。
1.1 一个“面试八股”背后的底层逻辑
面试官之所以爱问这个题,不是想让你背一个数字,而是想看看你对下面三件事有没有感觉:
第一,B+树的非叶子节点到底存了什么。很多人知道 InnoDB 的索引是 B+树,但不知道非叶子节点里存的是“键值 + 指向子节点的指针”,而不是完整的数据行。正因为内部节点只存这些轻量级的“导航信息”,一个 16KB 的页才能塞下一千多个键值,树才能长得又矮又胖。
第二,InnoDB 读写数据的最小单位是页。不管你在 SQL 里是查一行还是扫一百行,存储引擎真正干活的单位都是 16KB 的一个数据页。所以讨论“多少数据量”,本质是在讨论“多少个数据页”和“每个数据页里平均塞了多少行”。
第三,聚簇索引的叶子节点存的是整行数据。InnoDB 默认的主键索引就是聚簇索引,表数据本身就是按主键顺序存在 B+树的叶子页里。因此“一棵树能存多少数据”,约等于“这张表能存多少行”。二级索引则是另一回事,它的叶子节点存的是索引列和主键值,这会导致主键越大,二级索引占用的空间也越大。
明白了这三件事,你就不需要死记硬背任何结论。只要手里有 16KB、主键大小、行大小这三个参数,随时能自己算出来。
1.2 为什么容量的讨论总是锚定“三层”
原因很简单:三层 B+树对应的是“根节点 + 一层中间节点 + 一层叶子节点”的路径。InnoDB 做一次主键查找时,最多要读取三个数据页。如果这三个页都在缓冲池里,速度极快;如果不在,就最多可能产生三次磁盘随机 IO。
三层是一个性能上很舒服的高度。到了四层,意味着每次查询可能多一次磁盘随机 IO,在传统机械硬盘时代,这往往是性能恶化的临界点。所以大家讨论“单表多少数据量还算健康”时,习惯用三层 B+树作为参考上限。这也解释了为什么网上流行的说法是“两千万行”而不是“一亿行”:一亿行往往已经让这棵树长到了四层以上,或者至少是三层里的每个叶子页都塞得过满,系统余量非常小。
但要注意,这里是“参考上限”,不是“硬性限制”。如果数据全被缓冲池覆盖,或者访问模式非常集中,几千万行甚至上亿行也未必出问题。反过来,如果一行数据特别大,或者主键是乱七八糟的 UUID,可能几百万行时树就已经很高了。这些都得靠完整推导才能看清。
2. 从16KB数据页开始:先搞清楚一个页能放多少东西
2.1 数据页不是全用来放数据的
InnoDB 默认的innodb_page_size是 16384 字节,也就是 16KB。可以简单把它理解成一张“日报纸”,一整版虽然看起来有 16KB 空间,但真正留给正文的并不是全部:报纸还要有报头、栏线、目录、页脚。
一个标准的 InnoDB 数据页包含 File Header(文件头)、Page Header(页头)、Infimum + Supremum 记录(用来限定页内记录的最小值和最大值)、User Records(真正的用户数据区)、Free Space(空闲区)、Page Directory(页目录)和 Fil Trailer(页尾)。这些固定结构会占用一部分空间,所以严格意义上,一页能装下的用户数据不是 16384 字节,而是比它略小,大概在 15KB 到 16KB 之间。
不过,在做容量估算时,大多数人会先用 16384 作为分母,省去这些开销。这样算出来的结果会有少量上浮,但数量级是完全正确的。真要精确到“能存多少条”,反而没有必要,因为行数据本身还有记录头、变长字段长度列表、NULL 值列表等额外信息,精确计算非常复杂,收益却很低。
2.2 非叶子节点一个页能塞多少“导航项”
现在我们看 B+树的内部节点,也就是非叶子节点。它不像叶子节点那样存整行数据,只存两部分:一个键值(通常是主键值),加一个指向子页的 6 字节指针。
为什么是 6 字节指针?因为 InnoDB 的页号按表空间空间大小设计,6 字节足够表达很大的地址空间。这个值在官方文档和主流技术书籍里经常被直接拿来做计算。
如果主键是 BIGINT,长度为 8 字节,那么一个索引项大约是 8 + 6 = 14 字节。那么一个 16KB 的页能放多少个索引项?
$$16384 / 14 \approx 1170$$
也就是说,一个非叶子页大约可以保存 1170 个“导航项”。这意味着这一层的节点最多能扇出约 1170 个子页。
如果主键是 INT,长度 4 字节,索引项就是 4 + 6 = 10 字节:
$$16384 / 10 \approx 1638$$
一个非叶子页可以保存约 1638 个导航项。这就已经能看到主键长度对容量的影响了:同样的三层树,INT 主键的内层扇出比 BIGINT 主键多了将近一倍,后面的总容量差距会非常明显。
如果把真实页面的固定开销也纳入考虑,比如用 15KB 作为可用空间,BIGINT 主键时:
$$15360 / 14 \approx 1097$$
数量和 1170 差别不大,所以很多教材干脆直接用 16KB 算。我在实际给别人解释时也经常用 1170,因为更好记,而且容量估算本身就应该留有余地。
3. 三层完整推导:1170×1170×每页行数
3.1 先说清楚三层树的结构
一棵三层 B+树,从顶到底分别是:
- 第 1 层:根节点,它是一个内部节点,存主键和指向第 2 层节点的指针;
- 第 2 层:中间内部节点,存主键和指向第 3 层叶子节点的指针;
- 第 3 层:叶子节点,存完整的用户数据行。
从根节点出发,经过根节点页、中间节点页、叶子节点页,一共三次指针跳转就能定位到任意一行数据。
因此,计算整棵树能存多少行,要分两步:先算中间层能扇出多少个叶子页,再算每个叶子页平均能放多少行。
根节点能指向多少个中间节点?大约 1170 个(BIGINT 主键场景)。每个中间节点又能指向多少个叶子页?同样大约 1170 个。所以叶子页的总数是:
$$1170 \times 1170 \approx 1,368,900$$
这个数就是整棵三层树最多能拥有的叶子页数量。接下来,只要知道每个叶子页能放多少行,就能算出总行数。
3.2 叶子页能放多少行:取决于行大小
叶子页里放的是完整行数据,所以“一个叶子页能放多少行”完全取决于你的表平均每行有多大。这和表结构、字段类型、编码方式、有没有大字段都有关系。
在实际估算时,我一般按下面几个常见档位来看:
| 平均行大小 | 单页可容纳行数(按 16384 字节估算) | 三层层数容量 |
|---|---|---|
| 500 字节 | 约 32 行 | 约 4380 万行 |
| 1KB | 约 16 行 | 约 2190 万行 |
| 2KB | 约 8 行 | 约 1095 万行 |
| 4KB | 约 4 行 | 约 548 万行 |
| 8KB | 约 2 行 | 约 273 万行 |
这里的单页行数直接用 16384 除以行大小向下取整。比如 1KB 的行,一页 16 行;如果考虑真实页可用空间是 15KB,那大概是 15 行,计算结果是:
$$1170 \times 1170 \times 15 \approx 2053 万$$
你会发现还是落到了“2000万”附近。这就是全网流行说法的来源:默认 BIGINT 主键、默认 1KB 行大小,三层 B+树能装大约 2000 万行。但不要把它当成万能答案,因为行大小一变,结果立刻就变了。
3.3 完整公式和手算示例
把上面所有东西汇总成一个可复用的公式:
$$容量上限 \approx \left\lfloor \frac{页大小}{主键长度 + 6} \right\rfloor^2 \times \left\lfloor \frac{页大小}{平均行大小} \right\rfloor$$
用 BIGINT 主键、平均行大小 1KB 代入:
$$容量上限 \approx \left\lfloor \frac{16384}{8+6} \right\rfloor^2 \times \left\lfloor \frac{16384}{1024} \right\rfloor$$
$$= 1170^2 \times 16 \approx 2190万$$
如果平均行大小变成 500 字节:
$$1170^2 \times 32 \approx 4380万$$
如果平均行大小变成 4KB:
$$1170^2 \times 4 \approx 548万$$
可以看到,行大小对整个容量的影响是线性的,而行大小的差异在真实业务里非常普遍。比如一张订单表,如果字段很多、带大量备注信息,平均行很容易超过 2KB;而一张精简的流水表,可能每行只有几百字节。同样是三层 B+树,容量可以相差四倍以上。
所以下次再有人问“三层B+树能存多少数据”,最稳的回答不是一个固定数字,而是“在 BIGINT 主键、平均 1KB 行的默认情况下,大约是 2000 万,但具体要看主键大小和行大小”。能说出这句话,说明你真的理解了模型,而不是只会背结论。
4. 主键选型如何左右三层容量:INT、BIGINT、UUID的对比
4.1 主键长度如何左右“容量”
上一章推导时,非叶子节点的扇出是由“主键长度 + 6 字节指针”决定的。主键越短,一个内部节点能存的导航项越多,三层树能挂载的叶子页就越多。这个影响在数据量大了以后会被平方放大。
我列一个对比表,假设平均行大小都是 1KB:
| 主键类型 | 索引项大小 | 单页扇出 | 三层树叶子页总数 | 三层容量(1KB行) |
|---|---|---|---|---|
| INT (4B) | 10 字节 | 约 1638 | 约 268 万个 | 约 4290 万行 |
| BIGINT (8B) | 14 字节 | 约 1170 | 约 137 万个 | 约 2190 万行 |
| CHAR(16) 十六进制字符串 | 22 字节 | 约 744 | 约 55 万个 | 约 880 万行 |
| VARCHAR(32) UUID字符串 | 38 字节 | 约 431 | 约 18 万个 | 约 290 万行 |
从 INT 到 BIGINT,三层容量从约 4300 万降到约 2200 万,接近一半;如果继续用 32 位字符串当主键,三层容量甚至不到 300 万。这还只是聚簇索引层面的影响,二级索引在存储时也要附带主键值,主键越大,二级索引的每个页能存下的索引项也越少,整体空间占用和查询成本都会跟着涨。
所以我一直建议,MySQL 表的主键优先选自增 INT 或 BIGINT,不是为了教条,而是它在 B+树容量模型里的表现确实最好。
4.2 UUID主键会让三层容量大幅缩水
UUID 主键的问题不只是“长度长”。很多团队选 UUID 是看中它全局唯一、好做分布式生成,但从 InnoDB 存储引擎的视角看,它有两个明显的缺点。
第一,UUID 作为字符串存储时,通常是一个 32 位或 36 位的字符串,按上面的计算,三层容量会缩水到几百万行量级。如果你的业务规模不大,问题不明显;一旦数据量上来,树的高度可能很早就从三层变成四层,查询性能就会比 INT/BIGINT 主键的表更早遇到瓶颈。
第二,UUID 随机性很强,插入时主键顺序基本是随机的。InnoDB 的聚簇索引在插入随机主键时,会不断触发页面分裂和页重排,导致大量随机写和索引碎片。这个影响甚至比容量更致命,因为它会让“每个叶子页的实际可用空间”和“缓冲区命中率”都变得更差。页分裂还会在表空间里留下碎片,进一步压缩有效容量。
如果确实因为业务需要必须使用非自增主键,可以考虑有序雪花 ID 之类的方案。它比 UUID 更适合 InnoDB,因为具备一定的趋势递增性,能减少页分裂。无论如何,要明白主键选型不只是一个逻辑设计问题,它会直接作用到 B+树挂载叶子页的能力上。
5. 行大小、溢出列与统计表:别让估算偏离现实
5.1 行记录结构和行溢出的影响
在真实表里,“平均行大小”往往不是一个固定值,而且也很少等于建表时所有字段长度之和。因为 InnoDB 的行格式会对变长字段做一些处理,例如 VARCHAR 只存实际长度,NULL 值列表也会集中标记,不会为每个 NULL 字段浪费一整个字段的空间。
更需要注意的是行溢出。InnoDB 默认的行格式在 MySQL 5.7 以后是 DYNAMIC,在这种格式下,如果某个字段特别大,例如大 TEXT 或 BLOB,InnoDB 不会把整个大字段都塞进叶子页,而是会把一部分数据放到溢出页(Off-Page),在叶子页里只保留 20 字节左右的指针。换句话说,一个大字段行占用的总空间可能远超 16KB,但它在叶子页里的“常驻体积”并没有那么大,额外数据占用的是新的溢出页。
这会对容量估算产生两个方向的影响:一方面,叶子页里的行数可能会比“按总行大小直接 16384 除”更多一些,因为大字段被挪走了;另一方面,表空间整体占用的页数会变大,因为溢出页也要算空间。因此,如果你的表里主要存的是普通结构化字段,用平均行大小估算 B+树容量是靠谱的;如果表里挂了大文本、JSON、图片二进制等字段,更准确的说法是“聚簇索引叶子页里存储的压缩后元数据量”,而不是全表数据总量。
5.2 从统计表里粗略估算平均行长
在实际业务中,怎么才能快速得到一张表的平均行大小?不需要去一行行量,MySQL 的信息模式里已经给了大概数据。
你可以执行下面这条 SQL:
SELECT TABLE_ROWS, DATA_LENGTH, AVG_ROW_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'your_database' AND TABLE_NAME = 'your_table';其中DATA_LENGTH的单位是字节,表示聚簇索引叶子部分占用的数据页总数乘以页大小的近似值,TABLE_ROWS是优化器估算的行数。AVG_ROW_LENGTH就是 MySQL 自己算出来的平均行长,它大致等于DATA_LENGTH / TABLE_ROWS。注意,TABLE_ROWS是估算值,不是精确值,但对于判断数量级已经够了。
拿到平均行长度后,你可以自己算一下这个表目前在几层。先估算叶子页数量:
$$叶子页数量 \approx DATA_LENGTH / 16384$$
然后不断除以内部节点扇出,看几次能降到 1。这就是“倒推树高”的简易方法。比如一张表有 2200 万行,平均行长 1KB,叶子页大约 137 万个;除以扇出 1170,得到中间层页约 1170 个;再除以 1170,得到根节点 1 个。正好三层。如果表数据量达到 5000 万行,同样的行大小下,叶子页约 312 万个,除以 1170 是 2667,再除以 1170 是 2.28,说明树已经接近四层。到了这个阶段,你就该认真评估分表、归档或冷热分离了。
6. 三层容量在生产里的正确用法:它决定不了分表,但能修正预期
6.1 2000万行不是“必须分表”的信号
很多团队一看到某张表过了 2000 万行,就开始焦虑,急着拆库拆表。但三层容量只是一个基于页大小的理论上限,它不直接等于“超过这个值性能就崩”。
分表决策要看的其实是几个更现实的问题:这张表的写入并发是不是已经打到了单点瓶颈?主键写入顺序是否稳定?常见查询是不是都是主键点查或者高效走索引?表体积是否已经大到备份、DDL 需要数小时甚至数天?如果这些问题都没有困扰你,哪怕单表 4000 万行,配合合理的索引和缓冲池配置,依然能跑得不错。反过来,如果一张表只有 500 万行,但每天的全表扫描把缓冲池刷得干干净净,那它比 1 亿行的点查表更值得改造。
所以我更愿意把“三层容量两千万行”当成一个预期管理工具,而不是一个阀门。它提醒你在千万量级后开始关注索引层级和页密度,但真正的优化动作还是要回到慢查询、IO 吞吐和写入模式里去度量。
6.2 结合缓冲池和访问热度的实际容量观
一个很容易被忽略的事实是:三层 B+树的“三次页访问”只有在内层和根节点都命中缓冲池时,才能做到快速。实际上,根节点和上层中间节点通常都很小,很容易常驻在 InnoDB Buffer Pool 里,真正可能产生磁盘 IO 的往往是你想查的叶子页。
如果一张表有 2000 万行、平均行长 1KB,那么叶子页就有 137 万个。假设 Buffer Pool 是 16GB,理论上可以放下很多页,但业务不可能只访问这一张表。如果热点数据只有最近一周,而这 2000 万行里只有 200 万行是热点,那么缓冲池命中率会很高,表再大也无所谓。如果业务是随机访问全量数据,那么缓冲池再大也可能经常未命中,三层树的容量支撑反而显得不够用。
所以,容量估算应该和访问特征放一起看。三层 B+树能存多少数据是一个物理上限,而业务能承受多少数据是一个系统上限,两者之间隔着的实际是缓存命中率、磁盘随机 IOPS、查询复杂度,以及你为这张表设计的索引是否合理。
7. 最后分享我的实操习惯与压测心得
我在自己负责的项目里,会把容量估算作为建表评审的一部分。新表上线前,我会先按预估的字段长度粗算一下平均行长,再根据业务规划的年增长量估算两三年后的数据量,反向确认树高会不会很快突破三层、二级索引大概要占多少空间。这套动作不需要很精确,但能让我提前避开很多坑。
另一个习惯是定期看information_schema.TABLES里的AVG_ROW_LENGTH和DATA_LENGTH。有一次表数据量看起来涨得很快,仓库同事问要不要扩容,我查了一下发现平均行长从 1.2KB 涨到了 3.5KB,原因是最近半年新增了两个大文本字段。真实原因不是行数暴涨,而是行宽涨了。如果不看这两个字段,很容易误判成“单表容量到了瓶颈”,进而做出不必要的分表方案。
还有一个体会:压测比估算更可靠。估算告诉我们预期,但最终性能要以实际压测为准。我会在数据量达到预估容量的 80% 时,做一些主键点查、范围查询、分页查询和批量写入的压测,观察 P99 延迟和 IO 队列长度。如果在这个阶段仍然稳,就说明这条容量线有余量;如果已经出现明显劣化,再启动分表或归档方案也来得及。
最后提醒一句:不要把这三层容量估算当成一个死数字到处套用。只要主键用得更短、行控制得更紧凑、热点访问更集中,你完全可以让单表在几千行时稳步运行,也能让表在几亿行时继续服务。真正值得你花时间的,永远是理解它背后的存储模型,然后用实际数据去验证和调整。