单表多大该拆分:从 B+ 树层高到分库分表的决策
「单表超过 2000 万行就要分表」流传很广,推导过程通常是按 B+ 树三层估算出来的。但实测一张 2000 万行的窄表,B+ 树只有 3 层,理论上还能再装十几倍的数据;而一张每行约 2KB 的宽表,100 万行时就已经是 3 层。行数本身不是拆分的信号。
本文在 MySQL 8.4.11 上直接读取 .ibd 文件中根页的 PAGE_LEVEL 字段,测量了几张不同行宽、不同行数的表的 B+ 树层高,再用实测扇出推算容量上限。然后回到工程问题:表大了之后真正疼的是什么,以及从优化到分库分表应该按什么顺序推进。
一、先说结论
- B+ 树层高由行宽和主键长度决定,不由行数单独决定。 实测
BIGINT主键的中间页扇出约 910;每行约 38 字节的表 3 层可放约 3.7 亿行,每行约 2KB 的表只能放约 500 万行。 - 多一层,热数据查询几乎感受不到。 页都在 Buffer Pool 中时,实测 2 层与 3 层的主键点查只差不到 1 微秒;真正拉开差距的是叶子页是否在内存中,冷读是热读的约 8 倍。
- 大表真正的代价在运维和工作集。 实测 2000 万行表上,在线加索引 16 秒,改列类型要复制整表 52 秒;热数据超出 Buffer Pool 后,随机读会明显变慢。
- 拆分之前有一串更便宜的手段:索引与 SQL、归档冷数据、分区表、读写分离与缓存。分库分表带来的跨库事务、分页、扩容迁移,是长期成本。
- 需要拆分的信号是写入吞吐接近单机上限、热数据放不进内存、DDL 与备份恢复时间不可接受,而不是某个固定行数。
二、先测一下:B+ 树到底有几层
2.1 测量方法
每张独立表空间的表,主键索引根页的页号可以从 INNODB_INDEXES 查到(实测都是 4)。每个索引页的页头中有两个字段可以直接读出来:
PAGE_LEVEL:页在树中的层级,叶子页为 0,根页的值加 1 就是树高;PAGE_N_RECS:页中的用户记录数,根页的记录数就是它下一层的页数。
-- 先让脏页落盘
FLUSH TABLES narrow_20m FOR EXPORT; UNLOCK TABLES;
-- 查到主键索引的根页页号
SELECT i.PAGE_NO FROM information_schema.INNODB_INDEXES i
JOIN information_schema.INNODB_TABLES t USING (TABLE_ID)
WHERE t.NAME = 'demo/narrow_20m' AND i.NAME = 'PRIMARY'; -- 4# 页大小 16KB;页头从偏移 38 开始,PAGE_N_RECS 在其后 16 字节,PAGE_LEVEL 在其后 26 字节
f=/var/lib/mysql/demo/narrow_20m.ibd
dd if=$f bs=1 skip=$((4*16384+64)) count=2 2>/dev/null | od -An -tu1 # PAGE_LEVEL
dd if=$f bs=1 skip=$((4*16384+54)) count=2 2>/dev/null | od -An -tu1 # PAGE_N_RECS叶子页数量可以从统计表中读出:
SELECT stat_value FROM mysql.innodb_index_stats
WHERE database_name = 'demo' AND table_name = 'narrow_20m'
AND index_name = 'PRIMARY' AND stat_name = 'n_leaf_pages';2.2 实测结果
| 表 | 行数 | 平均行宽 | 树高 | 根页记录数 | 叶子页数 | 每页行数 |
|---|---|---|---|---|---|---|
orders | 10 万 | 68 字节 | 2 | 393 | 393 | 254 |
orders_big | 500 万 | 189 字节 | 3 | 61 | 55,563 | 89 |
narrow_20m | 2000 万 | 38 字节 | 3 | 50 | 45,356 | 440 |
wide_1m | 100 万 | 2,733 字节 | 3 | 155 | 142,874 | 6 |
平均行宽取自 information_schema.tables 的 AVG_ROW_LENGTH,包含行头、隐藏列与页内空闲空间;宽表每行的数据约 2KB,折合到页上是 2.7KB。三张 3 层的表,叶子页数除以根页记录数,得到每个中间页平均有 907—922 个子页,扇出非常稳定。这是因为中间页只存「主键 + 子页页号」,和行宽无关。
2.3 用实测扇出推算容量
3 层 B+ 树能容纳的行数约等于:
中间页扇出 × 中间页扇出 × 每个叶子页的行数
913 × 913 × 每页行数 ≈ 83 万 × 每页行数常见的「2000 万」是按每行 1KB、每页 16 行推出来的:1170 × 1170 × 16 ≈ 2190 万。这个算法本身没错,错在把「每行 1KB」当成了普遍情况。订单、流水这类表的行宽通常在几百字节,3 层能容纳的行数是它的几倍。
还有两个影响因素:
- 主键长度:用
VARCHAR(64)的 UUID 做主键,中间页每条记录变长,扇出会明显下降; - 页填充率:按主键顺序插入时页几乎填满,随机插入会频繁分裂,页通常只填一半多,相同行数需要更多页。
2.4 多一层到底慢多少
从 3 层变成 4 层,一次主键查询多读一个中间页。中间层的页数很少(实测 2000 万行的表只有 50 个),几乎总在 Buffer Pool 中,所以热数据查询几乎感受不到差别。实测在存储过程里对每张表执行 20,000 次随机主键查询,重启后先查一轮(冷),再用同一批主键查一轮(热):
| 表 | 树高 | 热:平均每次 | 冷:平均每次 | 冷的一轮中的物理读 |
|---|---|---|---|---|
orders(10 万行) | 2 | 7.15µs | 8.05µs | 395 |
narrow_20m(2000 万行) | 3 | 7.82µs | 62.05µs | 20,142 |
页都在内存时,多一层只多 0.67µs。拉开差距的是叶子页是否在内存中:2000 万行的表第一次查询时几乎每次都要读一个页,平均耗时是热查询的 7.9 倍。实验的数据文件在本机 SSD 上;换成云盘,一次随机读可能是毫秒级,差距会更大。
三、表大了真正疼在哪里
3.1 DDL 与维护操作
在 2000 万行的 narrow_20m 上实测:
| 操作 | 算法 | 耗时 |
|---|---|---|
| 末尾加一个可空列 | ALGORITHM=INSTANT | 5ms |
| 加一个二级索引 | ALGORITHM=INPLACE, LOCK=NONE | 15.9s |
把 INT 列改成 BIGINT | 复制整表(INPLACE 直接报错不支持) | 52.3s |
SELECT COUNT(*) | 扫描最小的索引 | 1.3s |
这张表只有约 730MB。换成几百 GB 的真实业务表,加索引可能要几小时,复制整表的 DDL 期间要额外占用一份磁盘空间,replica 上还会产生对应时长的复制延迟。备份、恢复、重建 replica 的时间也按数据量线性增长,见 MySQL 误删恢复。
MySQL 8.0.12 起支持 INSTANT 加列(8.0.29 起可以加在任意位置),加列不再是问题;但加索引、改类型、改字符集依然要花时间。大表通常要借助 gh-ost、pt-online-schema-change 这类工具做变更。
3.2 工作集超出内存
单表行数多不可怕,频繁访问的数据量超过 Buffer Pool 才可怕。一旦热数据页需要从磁盘读取,延迟会从微秒级跳到毫秒级,并且在流量高峰时剧烈抖动。
判断方法:观察 Buffer Pool 命中率和读盘次数。
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
-- Innodb_buffer_pool_reads:需要从磁盘读的次数
-- Innodb_buffer_pool_read_requests:总读取请求数
-- 前者占后者的比例持续上升,说明工作集放不下了3.3 写入吞吐
单个 MySQL 实例的写入能力受限于单机的 CPU、Redo 与 Binlog 刷盘、行锁竞争。当写入 TPS 接近单机上限,且无法通过批量写、异步化削峰时,拆分才是唯一出路。
四、从优化到拆分的演进路径
4.1 索引与 SQL
收益最大、成本最低。大部分「表太大所以慢」的问题,其实是索引没用上。见 SQL 调优实战。
4.2 归档冷数据
订单、流水、日志这类数据有明显的冷热分布:三个月前的订单很少被查询。把冷数据迁移到历史表或低成本存储,主表就能长期保持在一个较小的规模。清理大表数据的具体做法,见 千万级大表怎么清理数据。
4.3 分区表
按时间范围分区后:
- 查询带上分区键时,优化器只扫描相关分区(分区裁剪);
- 删除历史数据可以直接
DROP PARTITION,几乎瞬间完成。
限制也要清楚:
- 分区表的主键和唯一索引必须包含分区键;
- 不带分区键的查询要扫描所有分区,可能比不分区更慢;
- 所有分区仍在同一个实例上,解决不了写入吞吐和单机容量问题;
- MySQL 8.0 起只有 InnoDB 和 NDB 支持分区。
分区表适合「数据按时间增长、查询基本都带时间条件、需要定期清理」的场景。
4.4 读写分离与缓存
读多写少时,把读流量分到 replica(从库),热点数据放进缓存。要处理的是复制延迟带来的「刚写完读不到」,读策略见 MySQL 故障切换与读一致性,缓存一致性见 缓存一致性的四种写法。
4.5 分库分表或分布式数据库
前面的手段都不够用时,再考虑拆分:
| 方案 | 做法 | 主要代价 |
|---|---|---|
| 应用层分库分表 | ShardingSphere 等中间件,按分片键路由 | 跨分片查询、分页、排序;分布式事务;扩容要迁移数据 |
| 分布式数据库 | TiDB 等,自动分片与再平衡 | 运维体系变化;部分 SQL 行为与 MySQL 不同;延迟特征不同 |
选择分片键时要满足:绝大多数查询都带着它,数据在各分片上分布均匀,且不会随业务变化而需要修改。订单表常用用户 ID,但商家后台按商家查订单就需要额外的冗余或异构索引。跨库关联查询的几种做法,见 分布式与高并发 专题。
五、为什么 InnoDB 用 B+ 树
有两个常见的疑问可以顺带回答:
为什么不用 B 树? B 树的中间节点也存放数据,一个页能放的键更少,扇出更低,树更高;范围查询还要在不同层之间来回跳。B+ 树把数据全部放在叶子节点,中间页只放键和指针,扇出可以做到几百上千,叶子页之间有链表,范围扫描只需顺序读。
为什么 Redis 的有序集合用跳表? Redis 的数据完全在内存中,不需要为「一次读一个磁盘页」优化。跳表实现简单,范围查询方便,插入删除只改局部指针。InnoDB 面对的是磁盘,节点按页组织、减少随机读是第一目标,两者的约束不同。
六、常见误区
- 「超过 2000 万行必须分表」:实测行宽约 38 字节的表,3 层 B+ 树可以放约 3.7 亿行。
- 「树高多一层,查询就慢很多」:上层页几乎总在内存中,实测多一层只多不到 1 微秒。
- 「分区表能解决所有大表问题」:它能加速带分区键的查询和清理,不能提升写入吞吐和单机容量。
- 「先分库分表,以后就不用操心了」:分片后的跨库查询、分布式事务和扩容迁移,是持续的维护成本。
- 「用 UUID 做主键没区别」:主键越长扇出越低,随机插入还会导致页分裂和空间浪费。
小结
B+ 树的层高由行宽和主键长度决定,「2000 万行」只是在每行 1KB 的假设下算出来的一个值。判断一张表是否需要拆分,应该看 DDL 和备份恢复是否还在可接受时间内、热数据是否放得进内存、写入是否接近单机上限。在那之前,索引、归档、分区、读写分离都是更便宜的选择。
配套实验
- codesphere-labs/storage/mysql-table-size-sharding:直接读取四张表
.ibd根页的PAGE_LEVEL与PAGE_N_RECS、冷热两轮点查、2000 万行表上的三类 DDL(验证记录)
参考资料