3. B+树深度解析:页结构、分裂合并、高度与层数

聊到索引优化,B+树是绕不开的核心。很多同学觉得它就是个“平衡的多叉树”,但实际在磁盘上,它比你想的复杂得多。我个人习惯把B+树当成一种“存储的骨架”——它决定了数据怎么组织、怎么分裂、怎么合并。

今天咱们就把它拆开来看。嗯,从页结构开始。

3.1 页结构:B+树的最小存储单元

说白了,B+树的每个节点就是一个“页”(Page)。在InnoDB里,默认页大小是16KB。你每次插入一条记录,不会立马写盘,而是先攒在内存的页里。等页满了,再刷下去。

这个页的内部长什么样?我画了一张简图。

B+树内部页结构示意 根节点(索引页) 中间节点页1 中间节点页2 中间节点页3 中间节点页4 叶子页1 叶子页2 叶子页3 叶子页4 叶子页5 页内物理结构 页头(38B) → 用户记录(行数据) → 页目录(槽) → 尾部校验 • 每个槽指向一组记录,用于二分查找 • 空闲空间从页尾向中间填充,记录从页头向后写 • 一页大约可存放 100~200 条 1KB 的普通行记录

每个页内部由几部分构成:页头、记录区、页目录和尾部校验。页目录里放了一堆“槽”,每个槽指向一组数据。你想想看,查找一条记录是在槽里做二分查找,而不是挨个遍历。这就快了。

页内结构占用空间作用
页头38B存储页号、上下页指针、校验信息
用户记录动态实际的行数据或索引键值
页目录动态槽数组,用于二分查找
尾部校验8B页完整性校验和

关键:一个15KB(扣除页头后)的页,如果主键是BigInt(8B),再加指针(4-6B),那一个非叶子节点可以存1000多个键值对。这就是为什么B+树层数很浅的原因。

3.2 分裂与合并:B+树的自平衡机制

数据不停地插,页总有一天会满。满了怎么办?分裂。

分裂不是随便切一半。InnoDB有一套策略:

  • 叶子节点分裂:当插入一条数据导致页满,取中间键值作为分割点。左边页保留到分割点之前,右边页放分割点及之后。分割点键值上升到父节点。
  • 非叶子节点分裂:类似,分裂后上升一个键值到祖父节点,导致父节点也可能递归分裂。
  • 合并:当删除数据导致页利用率低于某个阈值(通常50%左右),会尝试和相邻兄弟页合并,减少碎片。

避坑指南:我曾经遇到过自增主键插入导致的“热点页”问题。因为自增主键会导致所有新插入都集中在最后一页,分裂频繁,锁竞争剧烈。解决办法?我后来改成了哈希分区打散,或者用随机主键。效果立竿见影。

分裂合并的代价其实挺高的。因为它会触发磁盘写、页锁定、索引树结构调整。高并发下尤其要注意。

3.3 高度与层数:为什么B+树只有3-4层?

这个应该是很多人最关心的问题。B+树的高度就是树的层数,从根节点到叶子节点的路径长度。你想想看,如果一颗树有4层,那一次查询最多需要4次IO。4次磁盘IO在现代硬件下,基本在1ms以内。快不快?

为什么层数这么浅?因为每个节点(页)能存放的键值对很多。我们来算一下:

数据量(行数)主键大小页大小非叶子节点扇出树高度
100万8B16KB约10002(根+叶)
1亿8B16KB约10003
10亿8B16KB约10004
100亿8B16KB约10005

看到了吗?1亿条数据,树只有3层。如果主键更大,比如UUID(16B),扇出会减少,层数可能增加1层。但依然很浅。

注意:高度计算的公式大致是:高度 = 1 + log_{扇出}(行数/每页记录数)。但实际中因为有半分页、页合并、碎片等因素,高度可能会比理论值多0.5-1层。我个人习惯预估时多算一层做缓冲。

为什么会这样?说白了,就是因为页的大小(16KB)对比键值的大小(8B+指针)太大了。一个页能容纳上千个键值对。那么几十亿条数据,只需要4次IO就能定位到。你想想看,如果换成二叉树,那得几十层,IO次数多一个数量级。

嗯,这里要注意:高度不是固定的。如果数据分布不均匀,分裂次数多了,某些分支的层数可能比其他分支深。但B+树会通过平衡旋转和合并尽量保持所有叶子在同一层。

总结一下:B+树的高度决定了索引的查询效率。在InnoDB中,根节点常驻内存,所以一次实际查询可能只需要2-3次磁盘IO。这就是B+树成为数据库索引默认方案的根本原因。