3. B+树深度解析:页结构、分裂合并、高度与层数
聊到索引优化,B+树是绕不开的核心。很多同学觉得它就是个“平衡的多叉树”,但实际在磁盘上,它比你想的复杂得多。我个人习惯把B+树当成一种“存储的骨架”——它决定了数据怎么组织、怎么分裂、怎么合并。
今天咱们就把它拆开来看。嗯,从页结构开始。
3.1 页结构:B+树的最小存储单元
说白了,B+树的每个节点就是一个“页”(Page)。在InnoDB里,默认页大小是16KB。你每次插入一条记录,不会立马写盘,而是先攒在内存的页里。等页满了,再刷下去。
这个页的内部长什么样?我画了一张简图。
每个页内部由几部分构成:页头、记录区、页目录和尾部校验。页目录里放了一堆“槽”,每个槽指向一组数据。你想想看,查找一条记录是在槽里做二分查找,而不是挨个遍历。这就快了。
| 页内结构 | 占用空间 | 作用 |
|---|---|---|
| 页头 | 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万 | 8B | 16KB | 约1000 | 2(根+叶) |
| 1亿 | 8B | 16KB | 约1000 | 3 |
| 10亿 | 8B | 16KB | 约1000 | 4 |
| 100亿 | 8B | 16KB | 约1000 | 5 |
看到了吗?1亿条数据,树只有3层。如果主键更大,比如UUID(16B),扇出会减少,层数可能增加1层。但依然很浅。
注意:高度计算的公式大致是:高度 = 1 + log_{扇出}(行数/每页记录数)。但实际中因为有半分页、页合并、碎片等因素,高度可能会比理论值多0.5-1层。我个人习惯预估时多算一层做缓冲。
为什么会这样?说白了,就是因为页的大小(16KB)对比键值的大小(8B+指针)太大了。一个页能容纳上千个键值对。那么几十亿条数据,只需要4次IO就能定位到。你想想看,如果换成二叉树,那得几十层,IO次数多一个数量级。
嗯,这里要注意:高度不是固定的。如果数据分布不均匀,分裂次数多了,某些分支的层数可能比其他分支深。但B+树会通过平衡旋转和合并尽量保持所有叶子在同一层。
总结一下:B+树的高度决定了索引的查询效率。在InnoDB中,根节点常驻内存,所以一次实际查询可能只需要2-3次磁盘IO。这就是B+树成为数据库索引默认方案的根本原因。