深蓝黑背景封面:左侧大标题「千万行数据,索引只要三层」,副标题「页大小、扇出与树容量的定量估算」;右侧原创示意图——一棵三层的 B+ 树:顶部一个小根节点,中间一层三个节点向下展开,底层一排叶子节点用横线串成链;左上标注「面试经典 · 数据库」,右上标注「Day 07 · 10-25」 图 1:本文封面,三层是常态。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。面试官常问的是一句大白话:一张千万行的表,走索引查一行要读几次磁盘?这个问题背后是一道定量估算题——B+ 树到底有几层,每一层能放大多少倍。

先把结论立住:**以 InnoDB 默认 16KB 页为前提,常见键型下三层树就能装下两千万行量级的数据,四层更是百亿量级,所以「索引一般三四层」是算出来的,不是背出来的。**下面是推导过程,全部数字都是数量级估算,用来建立直觉,不是精确测量。

**第一步,明确三种输入。**InnoDB 的默认页大小是 16KB(建实例时确定,之后不变)。B+ 树里只有叶子页存整行数据,非叶子页只存「键 + 指向子页的指针」。于是容量由三个数决定:页大小、每条「键加指针」在非叶页里占多少字节、每行数据在叶页里占多少字节。

**第二步,算扇出。**扇出就是一个非叶页能放多少个指针,等于 16KB 除以「键长加指针长」。以 8 字节整型主键为例,键加指针按十几字节估算,16384 除以 14,约 1170——一个页挂上千个孩子。这就是为什么 B+ 树矮:每一次分叉不是翻两倍,而是翻上千倍。

**第三步,算叶页行数。**假设一行数据按 1KB 估算,一个 16KB 叶页大约放 16 行。

**第四步,相乘。**两层树(根加叶子)能装扇出乘每叶行数,约 1.9 万行;三层树再乘一次扇出,约 2200 万行;四层约 256 亿行。换句话说,一张几百万到几千万行的表,索引三层就够,读一行最多四次页访问——而且实际磁盘 IO 往往更少,因为根页和上层页是热数据,长期驻留在缓冲池里,真正落到磁盘的一般只有叶子那一两次。

推导链路图:左侧三个输入参数卡片——一页 16KB、非叶页只存键加指针、叶页存整行数据;中间四步推导——第一步算非叶页扇出,16KB 页除以「键长加指针」的每条占用,常见键型下单页可放下千级指针;第二步算叶页行数,16KB 除以单行长度得到每页几十到上百行;第三步相乘,两层树约为扇出乘每叶行数达十万行量级,三层树再乘一次扇出达到千万行量级;第四步得出结论:三层是常态,四次磁盘 IO 已覆盖绝大多数表,表再翻十倍也才需要第四层。右侧一棵三层树示意对应标出根、中间层、叶子链。底部提示:高度由扇出决定,扇出由页大小与键长决定——这就是「键越短树越矮」的定量原因 图 2:高度由扇出决定,扇出由页大小与键长决定。

**用代码把估算跑一遍。**与其手算,不如把公式写死,顺便看看键变长会发生什么。Node.js 22.15.0 直接运行(node btree-capacity.js):

'use strict';

// B+ 树容量估算:页大小、键长、行长 → 各层高的装行量级(数量级估算,忽略页内冗余开销)
function estimate(name, { pageBytes, keyBytes, pointerBytes, rowBytes }) {
  const fanout = Math.floor(pageBytes / (keyBytes + pointerBytes)); // 非叶页每条「键+指针」的占用
  const rowsPerLeaf = Math.floor(pageBytes / rowBytes);             // 叶页存整行
  console.log(`【${name}】页 ${pageBytes}B,键 ${keyBytes}B+指针 ${pointerBytes}B → 扇出约 ${fanout},每叶页约 ${rowsPerLeaf} 行`);
  for (let h = 2; h <= 4; h++) {
    const rows = rowsPerLeaf * Math.pow(fanout, h - 1); // h 层(含叶):扇出^(h-1) × 每叶行数
    console.log(`  ${h} 层约装 ${rows.toExponential(2)} 行`);
  }
}

estimate('8 字节整型主键', { pageBytes: 16384, keyBytes: 8, pointerBytes: 6, rowBytes: 1024 });
estimate('50 字节字符串键', { pageBytes: 16384, keyBytes: 50, pointerBytes: 6, rowBytes: 1024 });

实测输出:

【8 字节整型主键】页 16384B,键 8B+指针 6B → 扇出约 1170,每叶页约 16 行
  2 层约装 1.87e+4 行
  3 层约装 2.19e+7 行
  4 层约装 2.56e+10 行
【50 字节字符串键】页 16384B,键 50B+指针 6B → 扇出约 292,每叶页约 16 行
  2 层约装 4.67e+3 行
  3 层约装 1.36e+6 行
  4 层约装 3.98e+8 行

对照两组数字最有说服力:键从 8 字节涨到 50 字节,扇出从约 1170 掉到约 292,同样三层树,容量从两千万行量级缩到一百来万行——差了一个数量级还多。这就是「键越短树越矮」的定量原因,也是长字符串不适合直接当索引键的根本原因。

**常见误区。**一是把「三层」当固定常数背,忽略了它由页大小和键长决定,换个键型结论就变了;二是只盯着非叶层算扇出,忘了叶页装的是整行,行宽直接决定容量公式里那个乘数,宽表的同层容量会小得多;三是以为树有几层就要做几次磁盘 IO——上层页常驻内存,三层树的典型磁盘读只有一两次;四是把估算当精确值,真实页里还有页头、页目录、记录头等开销,扇出会比理想值低一些,但数量级结论不变。

落到实践,这张推导图给了两条可执行建议:主键和索引键尽量短,扇出是容量的杠杆;行尽量窄,叶页的行数同样进公式。下次评估一张表「还能长多大才要多一层」,先算扇出再下结论。你估算过自己线上核心表的索引层高吗?欢迎分享你的数字。