2026-10-25 索引为什么是 B+ 树:自己实现一棵量了一下
数据库课讲索引必提 B+ 树,理由通常是"矮"和"适合范围查询"。我自己写了一棵 B+ 树,再写一棵普通二叉搜索树,用同一个指标比:一次查询要读几个页。
一、先把参数定死
比之前得先说清楚假设,不然后面的数没意义。
InnoDB 一个页是 16KB。主键是 bigint,占 8 字节;页内指向子节点的指针 6 字节。所以一个索引项 14 字节,一页能放 16384 / 14 = 1170 项。
这个 1170 就是扇出:一个内部节点最多有 1170 个孩子。
二、自己实现一棵
B+ 树和普通树的区别不在查找,在分裂。叶子节点满了,就劈成两半,把中间那个 key 提上去当分隔符;内部节点满了,同样劈开,再把中间 key 往上提。提到根还满,就长出新根。
// 叶子分裂:取中间位置劈开,右半部分的第一个 key 上提
int mid = leaf.n / 2;
int m = leaf.n - mid;
System.arraycopy(leaf.k, mid, right.k, 0, m);
right.n = m;
leaf.n = mid;
right.next = leaf.next; // 叶子之间用 next 串起来,这一步是范围查询的关键
leaf.next = right;
return new Object[]{right.k[0], right};
一共不到两百行。插 100 万条主键 1 到 100 万,模拟自增主键。
三、量出来的数
参数:页 16384 字节,索引项 14 字节,扇出 1170
数据量:1000000 条
=== B+ 树 ===
树高(层) = 3
占用页数 = 1712
平均每叶节点存 = 585 条
建树耗时 = 29 ms
点查 1000 次,平均读页 = 3.0
范围查 100 行,平均读页 = 3.16
=== 二叉搜索树(顺序插入 50000 条)===
树高(层) = 50000
建树耗时 = 1766 ms
=== 二叉搜索树(随机插入)===
树高(层) = 47
建树耗时 = 183 ms
四、这三个数各自说明什么
第一,100 万行只占 3 层。
树高 3,意味着任何一次点查最多读 3 个页。1712 页乘以 16KB 是 26.7 MB——这就是 100 万行的索引体积。
按扇出 1170 往上推,4 层能存 1170³ ≈ 16 亿 行。所以哪怕数据涨到十几亿,点查还是 4 次 I/O。这是"矮"的真正含义:它不是一个形容词,是一个上限。
第二,同一个问题,BST 要读 47 个页。
随机插入 100 万条,BST 树高 47。按 SSD 随机读 0.1ms 估,B+ 树 0.3ms,BST 4.7ms,差 16 倍。
而这还只是 SSD。要是机械盘,一次随机读 5-10ms,47 次就是半秒——这就是"树高等于 I/O 次数"这件事的分量。比较次数反而不是瓶颈,CPU 一秒能比几千万次。
第三,顺序插入的 BST 直接退化成链表。
我插了 5 万条自增主键,树高正好 50000——和条数一模一样,每个节点只有一个右孩子。
建树还花了 1766ms,因为顺序插入是 O(n²),每插一条都要从头走到尾。这也顺便解释了我为什么只敢测 5 万条:100 万条要跑几个小时。
真实场景里自增主键恰恰就是顺序插入。所以"用二叉搜索树做索引"在自增主键上不只是慢,是直接坏掉。
五、范围查询凭什么快
B+ 树的叶子节点之间用 next 指针串成一条链。查 WHERE id BETWEEN 1000 AND 1100,先走 3 层定位到 1000 所在的叶子,然后顺着链表读 100 条就完了——实测平均 3.16 个页。
普通 BST 没有这条链。找到下界之后,要回到父节点往上走、再往右下去,每取一条都要上下折腾。要用好几次 I/O 才能凑够 100 条连续记录。
这就是"叶子节点连成链表"这个设计的意义:它把范围查询从"多次点查"变成"一次顺序读"。
六、结论
索引选 B+ 树,不是因为它查询算法更聪明——点查的比较次数它并不比 BST 少。原因是三件事叠在一起:
扇出 1170 让树高被压到 3 层,点查的 I/O 次数有确定上限;叶子链表让范围查询变成顺序扫描;插入分裂是自平衡的,不会因为主键有序而退化。
反过来看,这三个优点分别对应着三种如果不用 B+ 树就会踩的坑:树太高、范围查询要回头、有序插入退化。自己写完一遍之后,这三个坑我都踩过了。
最后说清楚边界:这是我按 InnoDB 参数(16KB 页、14 字节索引项)自己实现的简化 B+ 树,不是 InnoDB 本身。真实实现还有页头页尾开销、填充因子、碎片整理,扇出会比 1170 略小。但量级和结论是一致的。
附:核心代码
public class BPlusTreeVsBst {
static final int PAGE_BYTES = 16384;
static final int ENTRY_BYTES = 8 + 6;
static final int ORDER = PAGE_BYTES / ENTRY_BYTES; // 1170
static final class Leaf extends Node {
final long[] k = new long[ORDER + 2];
int n;
Leaf next;
}
static final class Inner extends Node {
final long[] k = new long[ORDER + 2];
final Node[] c = new Node[ORDER + 3];
int n;
}
/** 返回 null 表示没分裂;否则返回 {上提的 key, 新右节点} */
static Object[] insert(Node node, long key) {
if (node instanceof Leaf leaf) {
int i = lowerBound(leaf.k, leaf.n, key);
System.arraycopy(leaf.k, i, leaf.k, i + 1, leaf.n - i);
leaf.k[i] = key;
leaf.n++;
if (leaf.n <= ORDER) return null;
Leaf right = new Leaf();
int mid = leaf.n / 2, m = leaf.n - mid;
System.arraycopy(leaf.k, mid, right.k, 0, m);
right.n = m;
leaf.n = mid;
right.next = leaf.next;
leaf.next = right;
return new Object[]{right.k[0], right};
}
Inner in = (Inner) node;
int i = upperBound(in.k, in.n, key);
Object[] split = insert(in.c[i], key);
if (split == null) return null;
long upKey = (Long) split[0];
Node rightChild = (Node) split[1];
System.arraycopy(in.k, i, in.k, i + 1, in.n - i);
in.k[i] = upKey;
System.arraycopy(in.c, i + 1, in.c, i + 2, in.n - i);
in.c[i + 1] = rightChild;
in.n++;
if (in.n <= ORDER) return null;
Inner rightNode = new Inner();
int mid = in.n / 2, m = in.n - mid - 1;
long up = in.k[mid];
System.arraycopy(in.k, mid + 1, rightNode.k, 0, m);
System.arraycopy(in.c, mid + 1, rightNode.c, 0, m + 1);
rightNode.n = m;
in.n = mid;
return new Object[]{up, rightNode};
}
}
浙公网安备 33010602011771号