经常会被问到同一个问题:“MySQL单表是不是超过2000万行就必须要分库分表?”尤其做后台开发的同学,很多都听过这个数字,但问起来由,大多数只能回答“网上说的”或者“感觉到了这个量级查询就慢了”。我之前也半信半疑,直到有一次排查线上的一张订单表,才真正意识到这个问题的本质根本不是“多少行”,而是InnoDB里那棵B+树到底有多高。
那次线上表有四千多万行,行宽不算大,主键是自增bigint,单次主键查询在业务低峰期只需要零点几毫秒。我顺手算了一下它的索引页数,发现B+树仍然只有三层。三层意味着随机主键查找从根节点到叶子节点最多三次页读取,如果根节点和中间层已经在buffer pool里,实际磁盘IO通常只有一次。所以“2000万”这个数字本身不是绝对红线,它背后真正值得搞清楚的,是InnoDB的B+树如何组织数据、每个节点能存多少条记录,以及我们手里的表和它有多匹配。这篇文章就把我自己梳理过的计算过程、底层结构和容易踩的坑一起整理出来。
1. 2000万这个经验值是怎么来的:问题本质是B+树高度,不是行数
1.1 一次让我改变看法的排查
那是一个典型的慢查询工单。业务方反馈某报表接口越来越慢,打开他们主表一看,行数已经将近五千万。当时第一反应也是“是不是该拆表了”。但看监控,磁盘IO并没有被打满,CPU也还好,单条主键查询的执行计划走的是PRIMARY,type是const,慢就慢在报表里一个全表扫描的分组统计。
我先没有直接谈分库分表,而是去查了这棵聚簇索引的高度。InnoDB里没有现成的SQL直接告诉你这棵树有几层,但可以通过页数倒推。后面我会给出具体的估算方法,这里先说结论:四千万行、单行平均约500字节、叶子页约60万页、非叶子节点扇出约1000左右,三层B+树依然扛得住。
从那以后,我再也不拿“多少行”当分表依据。行数只是一个表象,真正决定单表上限的是“B+树的层数”,以及“业务是否能接受对应的IO次数”。层数越低,主键查询路径越短,随机读写越稳定。所以很多人喜欢背“两千万”,其实是在一个特定的行宽和主键类型下面,三层B+树的容量边界恰好在两千万附近,但这个前提未必适用于你的表。
1.2 B+树高度决定随机IO次数
InnoDB的聚簇索引本身就是一张表,叶子节点存完整行数据,非叶子节点存“索引键 + 子页指针”。要定位一行数据,必须从根节点往下走,经过每一层节点,最终落到叶子页。这里每一层都可能发生一次逻辑IO,如果目标页不在buffer pool里,就是一次磁盘随机读。
- 两层B+树:根节点 -> 叶子页,最多2次页访问
- 三层B+树:根节点 -> 中间节点 -> 叶子页,最多3次页访问
- 四层B+树:根节点 -> 中间节点1 -> 中间节点2 -> 叶子页,最多4次页访问
看起来只是多一次,但磁盘随机IO的延迟是纳秒级内存访问的十万倍以上。单次随机读可能1-10ms,如果一条查询需要访问多个二级索引再回表,层数一旦增加,放大效果会非常明显。所以工程上真正关心的是“能不能把树的层数控制在三层”。
根节点几乎一定会常驻buffer pool,中间层节点也有大概率被缓存,所以实际查询经常少于理论最大层数。但缓存是概率性的,热数据命中率高,冷数据命中率低。用三层和四层作为容量规划的分界线,是比较保守也比较好用的经验。
1.3 三层B+树的容量边界
先给一个粗略的数量级推理,后面再精细展开。
默认InnoDB页大小是16KB。如果非叶子节点上每条“索引键 + 子页指针”占大约15字节,那么一个非叶子页大约可以容纳1000条指针。两层指针覆盖的叶子页数量就是1000 x 1000 = 100万个页。
每个叶子页能放多少行,取决于行宽。如果一行差不多800字节,16KB页减去页头、页尾、目录等开销,大约能放15KB/800约等于19行。那么:
- 三层B+树最大行数约 = 1000 x 1000 x 19 ≈ 1900万行
- 如果一行200字节,最大行数约 = 1000 x 1000 x 75 ≈ 7500万行
- 如果一行60字节,最大行数约 = 1000 x 1000 x 250 ≈ 2.5亿行
所以“2000万”这个数字,恰好是针对“行宽较大、每页只能放20行左右”的场景。而绝大多数线上订单、用户、交易类表,行宽并没有那么大,往往可以轻松撑到几千万甚至上亿行。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. InnoDB的B+树真实结构:页、记录、指针的协作方式
2.1 页是InnoDB读写的“快递包裹”
InnoDB表空间是由页组成的,默认每页16KB。不管是读取还是写入,InnoDB都是按“页”为单位操作的,不是按行。哪怕你只想查一行数据,也需要把包含这一整行记录的那个页加载到内存。
这就相当于快递员送包裹,他不是一件一件送,而是把一个中转箱整体装车运到网点,再在网点评分。一个16KB的页,就是那个中转箱。箱子里塞的包裹越多,同样一次IO能覆盖的数据越多;箱子在B+树里扮演的角色,决定了它能指向其他箱子,还是真正装着用户的订单数据。
页内部有自己的头部信息、页目录、用户记录区和空闲空间。计算时不能直接把16384字节当成可用空间,要扣除页头、页尾、Infimum/Supremum记录、Page Directory等固定开销。工程上常用的近似值是15000字节左右,精度要求高的场景按实际行格式和页统计来算。
2.2 聚簇索引叶子页里的一行是什么样
聚簇索引的叶子页里,每一行完整记录包括几个部分:
- 变长字段长度列表
- NULL标志位
- 记录头信息
- 隐藏列:DB_TRX_ID(事务ID,6字节)、DB_ROLL_PTR(回滚指针,7字节)
- 主键列
- 业务字段
如果表没有显式主键,InnoDB还会额外加一个6字节的DB_ROW_ID隐藏列。这会导致行记录变大,也是我强烈建议建表时都带上显式主键的原因之一。
用COMPACT行格式举例,一行记录的开销并不只是字段本身相加。一个varchar(64)的字段,因为utf8mb4下最大可能占256字节,变长字段长度列表里要为它预留2字节;记录头需要5字节;隐藏列加起来13字节。这些“看不见”的开销在估算单行大小时必须算进去,否则100行的表可能估成几十字节,实际每行却已经超了100字节。
2.3 非叶子页里存的到底是谁
非叶子节点不存业务数据,它只做“路由”。每一条记录由一个索引键和指向子页的指针组成。聚簇索引的键就是主键,指针是子页的页号。
假设主键是bigint,8字节;子页号4字节;再加上记录头5字节,一条非叶子记录大概占用15字节左右。如果主键是int,大概能省4字节,变成11字节左右;如果是16字节或36字节的UUID,那么一条非叶子记录会变成23-41字节,同样的页能容纳的指针数量直接下降。
这个关键参数叫“扇出”。扇出越高,一层节点能指向的叶子页越多,B+树越难长高;扇出越低,同样数据量下树的层数越高。这也是为什么我一直不建议在核心流水表上用随机字符串做主键,不是说不能做,而是你要为更低的扇出和更高的IO延迟买单。
2.4 主键选择才是扇出的第一决定因素
主键不仅影响非叶子页的扇出,还影响叶子页的行大小和写入顺序。
自增/有序主键的情况下,新数据基本追加在B+树右侧,页面分裂少,页填充率高,叶子页的空间利用率通常能到90%以上。随机主键的情况下,新数据会落在树的中部,导致页频繁分裂、页内残留大量空闲空间,实际页利用率可能掉到70%甚至更低。同样2000万行,随机主键可能比自增主键多占30%-40%的叶子页数量,B+树高度也可能因此从三层变成四层。
所以后面所有计算里,我都要加一个“页利用率”参数,它不是一个可以被忽略的细节,而是直接影响结论的关键因子。
3. 2000万行存储量的完整推导:从建表语句到树高
3.1 先定义一张能复算的订单表
为了把计算过程讲清楚,我在这里固定一张表结构。后面所有推导都以它为准:
sql复制CREATE TABLE `orders` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`customer_id` int unsigned NOT NULL,
`product_id` int unsigned NOT NULL,
`amount` decimal(10,2) NOT NULL,
`status` tinyint NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL,
`remark` varchar(64) DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这张表不复杂,但足够代表大部分订单类的核心场景:一个自增主键,几个索引字段,一个业务备注字段。
3.2 单行记录大小到底怎么估
在MySQL 8.0默认的DYNAMIC行格式下,估算这个表的一行记录占用,可以按下面的方式拆:
| 字段 | 字节数 | 说明 |
|---|---|---|
| id bigint | 8 | 主键 |
| customer_id int | 4 | 索引字段 |
| product_id int | 4 | 普通字段 |
| amount decimal(10,2) | 5 | 10位有效数字,约5字节 |
| status tinyint | 1 | 状态 |
| created_at datetime | 5 | 不含小数秒 |
| remark varchar(64) | 2 + 平均长度 | 变长列表2字节,数据长度按实际内容 |
| DB_TRX_ID + DB_ROLL_PTR | 13 | 隐藏列 |
| 记录头 | 5 | 行格式固定开销 |
假设remark平均16字节,这一行总占用大约是:
8 + 4 + 4 + 5 + 1 + 5 + 2 + 16 + 13 + 5 = 63字节
有同学可能会问:decimal(10,2)真的是5字节吗?大致是这样。DECIMAL每个9位数字用4字节,剩余1位数字需要1字节,整体按精度计算,10位有效数字就是5字节左右。虽然不同版本有细微差异,但估算到“几十字节”这个粒度已经够用。
如果remark是空字符串,那这一行能压到47字节左右;如果备注写到64字节,这一行会到111字节左右。所以“平均行大小”这个参数,受业务内容影响很大,不能简单看字段定义。
3.3 叶子页能塞多少行
默认页大小16KB,按可用空间15000字节算,把63字节作为平均行大小,那么一个叶子页最多能放:
15000 / 63 ≈ 238行
页目录和页内碎片再占掉一部分空间,按85%的页利用率,实际一个叶子页大概可容纳:
238 x 0.85 ≈ 202行
2000万行需要多少个叶子页?用20000000除以202:
20000000 / 202 ≈ 99010个叶子页
也就是大概10万个页。数据页本身占用的空间大约是:
99010 x 16KB ≈ 1.55GB
这还只是聚簇索引叶子节点的存储,不含二级索引。一张2000万行的订单表,主键+业务数据本身差不多就是1.5GB量级。
3.4 非叶子页的扇出与层数计算
非叶子页上的记录就短多了。聚簇索引的主键是bigint,8字节;子页号4字节;记录头5字节。精简一点算15字节一条。
一个非叶子页能放的指针数大约是:
15000 / 15 = 1000条
也就是说,一个非叶子节点最多能指向1000个叶子页。
那么两层B+树(根 + 叶子)最多覆盖:1000个叶子页。按每页202行,最多约20万行。
三层B+树(根 + 中间层 + 叶子)最多覆盖:1000 x 1000 = 100万个叶子页。按每页202行,最多约2亿行。
所以前面那张2000万行的orders表,需要约10万个叶子页。三层B+树的容量大概是100万个叶子页,远远没有达到三层上限,树的高度依然稳定在3层。
这个结果直接说明一个问题:行宽只有60多字节的表,2000万行离三层B+树的极限差得远,根本不需要因为“到了2000万”就拆表。
3.5 用公式反向验证2000万
把上面的过程整理成一个通用公式,方便你迁移到自己的表上。
- P:页大小,默认16384字节
- R:平均行大小,可以用information_schema里的AVG_ROW_LENGTH估算
- U:页利用率,建议自增主键取0.9,随机主键取0.7-0.8
- F:非叶子页扇出,约等于15000 / (主键大小 + 9)
- N:目标行数
- 每页可容纳行数 L = floor(P * U / R)
- 需要的叶子页数 L_pages = ceil(N / L)
- 需要的层数 H:需要满足 F^(H-1) >= L_pages
举个反向例子,假设你的表平均行宽是800字节,页利用率0.85,每页大约能放:
16384 x 0.85 / 800 ≈ 17行
三层B+树按扇出1000算,最多覆盖100万个叶子页,最大容量:
1000000 x 17 = 1700万行
这时候你刚好逼近2000万,再涨就可能进入四层。所以“2000万”这个经验值,本质上是在“行宽较大”的情况下触顶的临界值。如果你的行宽是200字节,三层能容纳约7000万行;行宽是60字节,三层能容纳超过2亿行。这就是为什么有些表5000万行还飞快,有些表1500万行已经开始出现性能告警。
4. 估算里的隐藏变量:溢出页、碎片、二级索引和压缩怎么影响结论
4.1 行溢出:大字段会让“行大小”变成伪命题
如果表里有一个比较大的TEXT、BLOB字段,或者一个很长的VARCHAR,单定义一个字段的字节数很容易让人掉坑。InnoDB不是把大字段全部塞进叶子页的。
在DYNAMIC行格式下,当一行数据过大、超过页大小的一定比例时,InnoDB会把变长大字段放到单独的溢出页里。叶子页里只留下一个指向溢出页的指针,大概20字节。这种情况下,你在叶子页里看到的一行很小,但读取这一行时可能需要额外IO去读溢出页。
所以包含大字段的表,直接套“平均行大小”的估算方法会失真。更准确的做法是:区分“索引行大小”和“物理存储总大小”。前者影响B+树高度,后者影响表空间占用。一个存了文章正文的表,可能业务数据总大小很大,但聚簇索引本身的叶子页并不宽,树高也不会因为一个1MB的TEXT字段就显著增加。然而每次访问该行时额外的溢出页IO,依然会拖慢查询。
4.2 页利用率和碎片:理想计算和真实世界的差距
我在前面的计算里给了利用率0.85,这已经是比较乐观的值。真实的页利用率取决于写入模式。
自增主键顺序插入时,每次插入基本都在当前最大页追加,页满了就新建页,很少把已有页劈成两半,页内空闲空间少,利用率能到90%以上。随机主键或频繁删除修改时,页会反复分裂,旧页中间出现空洞,新页开头写不满,利用率可能只有60%-75%。
页目录也会占用空间。每个页目录槽位是2字节,大约每6-8条记录分配一个槽位。行越小、行数越多,页目录的绝对字节数越大。虽然占比通常不高,但精确估算时不能完全忽略。
我们项目里有一张大日志表,主键是UUID字符串,删除任务又很频繁。跑了一段时间后,我统计它的avg_row_length比理论行大小高了接近40%。这40%里主要是页碎片和半满页。后来把主键改成自增bigint并定期归档,同样行数占用的存储居然减少了将近三分之一,B+树高度也稳定在三层。
4.3 二级索引:存储翻倍、写入放大、回表
之前算的都是聚簇索引,也就是主键那一棵B+树。但表里还有二级索引。每一棵二级索引在InnoDB里也是一棵独立的B+树,只是叶子节点存的不是完整行,而是“索引键 + 主键”。
以前面的orders表为例,有个idx_customer_id,索引键是customer_id,4字节;主键id是bigint,8字节;加上记录头、变长字段长度列表等,二级索引叶子记录大约20字节。二级索引非叶子节点的记录大约是“customer_id + 子页号 + 记录头”,约13字节。
当二级索引覆盖的数据范围很大时,它可能拥有很多页,树也可能比聚簇索引更高。二级索引也带来写入放大,插入一行订单,要同时维护主键索引和所有二级索引。为了控制成本和复杂度,我不会在一张表上无脑加索引。每个索引除了加快查询,还会牺牲写性能和存储空间。
查询绕过二级索引直接走主键,效率最高;走二级索引查一棵树,再回表按主键查另一棵树,路径更长。这也是为什么覆盖索引能大幅优化查询,因为它把二级索引叶子页变成了“迷你结果集”,省掉了回表。
4.4 压缩和页大小:默认16KB之外的选项
InnoDB也支持页压缩、透明页压缩,以及建表时指定KEY_BLOCK_SIZE的压缩表。压缩会让计算更复杂:逻辑页大小是16KB,但物理压缩后占用更少磁盘;然而buffer pool里加载的是压缩页还是解压页,取决于配置。
也有一些业务场景会把页大小改成8KB或32KB?InnoDB的page_size在初始化实例时固定,8KB页会让每页能放的行数和扇出都减半,想清楚再改。保持默认16KB仍然是最稳妥、最容易对齐社区经验的选择。
如果需要更准确评估,直接看information_schema会更实际,不必在自己脑子里精确到每个字节。
5. 落地检验:用系统表复核线上表,决定拆不拆
5.1 一次查出目标表的真实页数和平均行宽
与其在文档里算一万遍,不如直接看线上表自己给出的统计信息。information_schema里已经有很多有用的数字:
sql复制SELECT
TABLE_NAME,
TABLE_ROWS,
AVG_ROW_LENGTH,
DATA_LENGTH,
INDEX_LENGTH,
(DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024 AS total_mb,
ROUND(DATA_LENGTH / 16384) AS approx_leaf_pages
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_db'
AND TABLE_NAME = 'orders';
- TABLE_ROWS:来自统计采样的估算值,不是精确行数
- AVG_ROW_LENGTH:索引大小除以行数,可以理解为统计意义上的平均行宽
- DATA_LENGTH:聚簇索引叶子节点等数据页的总字节数
- INDEX_LENGTH:二级索引页的总字节数
如果表是自增主键、没有大字段,AVG_ROW_LENGTH和理论单行大小会比较接近。如果偏差很大,说明存在行碎片、大字段溢出或新建表尚未ANALYZE。可以先执行ANALYZE TABLE,让统计信息更接近当前状态。
还可以查mysql.innodb_table_stats,直接看聚簇索引和二级索引的页数:
sql复制SELECT
database_name,
table_name,
clustered_index_size,
sum_of_other_index_sizes
FROM mysql.innodb_table_stats
WHERE database_name = 'your_db'
AND table_name = 'orders';
5.2 估计当前表的B+树层数
有了页数,就可以结合扇出估算层数。
假设clustered_index_size显示聚簇索引一共12万个页,非叶子页扇出按1000算。那么如果树高是2层,最多1000个页;树高是3层,最多100万个页;当前12万个页明显在3层范围内。
也可以反过来验证:如果一棵聚簇索引的叶子页数量已经大于100万,而扇出还是1000,那大概率已经进入四层;如果达到10亿页数量级,那就得五层了。
还有一种更硬核的办法:直接去读表空间文件的页面偏移,分析PAGE_LEVEL字段。这个适合做内核调试,业务排查通常不用做到这个粒度。对普通工程分析来说,用页数反向推算已经足够,因为扇出误差在几百字节范围内很难改变“三层还是四层”的定性结论。
5.3 什么情况下“2000万”真的会失效
即使前面说了一堆“2000万不是绝对阈值”,但确实有一些情况下,行数不到2000万就会遇到明显的性能问题:
- 行宽很大,单回行数据超过800字节,每页只能塞不到20行
- 主键是UUID或随机字符串,页利用率低、非叶子扇出低
- 二级索引非常多,写放大严重,或者每个查询都需要回表
- 一个大字段被高频读取,虽然B+树不高,但每次都要额外访问溢出页
- buffer pool很小,根节点/中间层经常被淘汰,导致每次都要完整走四层IO
如果这些场景出现,拆表确实是合理的,但拆表不能只按“行数”拆,而要按“数据访问模式”拆。比如把活跃订单和历史订单分开,把大字段拆到附属表,把UUID主键改成自增id或有序雪花id,有时候比单纯分库分表更有效。
5.4 我现在的建表和分表决策清单
经过这些折腾,我现在做存储规划时会按下面的顺序走一遍:
- 先用information_schema看目标表当前的真实avg_row_length和页数
- 按公式估算当前B+树层数,确认是在三层还是四层
- 如果已经四层,再判断是行宽过高、主键设计不合理还是数据量真的太大
- 优先优化行宽和主键,再考虑归档,最后才考虑分库分表
- 新表设计时预留一个目标行数,比如未来三年预估3000万行,按平均行大小350字节算一下需要多少个叶子页,能不能保持三层
- 二级索引每加一个都反问自己:这个索引是否真的常用?能不能用联合索引替代?能不能用覆盖索引消除回表?
这套流程看起来没有“直接拆表”那么快,但它能逼着我把问题定位准确。以前我也迷信“两千万拆表”,现在只会先看树高。实际上,只要保持三层B+树,再配合合理索引和buffer pool配置,单表几千万行在绝大多数业务下都能跑得不错。
最后再分享一个我个人觉得很有用的小习惯:在日常巡检时记录核心表的TABLE_ROWS、AVG_ROW_LENGTH、DATA_LENGTH和估算出的树高。这些数字本身就是最好的监控指标,比单纯盯着“有没有超过2000万”靠谱得多。等某一天树高真的从三层跳到四层,你也能提前看到叶子页数量的变化趋势,提前决定是归档还是拆分,而不是等性能报警了再熬夜排查。
