B+树的“页分裂“机制

1. 简化示例(容量=3)

假设一个数据页最多能放3条记录(为了简化理解):

情况A:顺序插入(雪花ID)

复制代码
第1步:插入 ID=100
页1: [100]          ← 1/3

第2步:插入 ID=101  
页1: [100, 101]     ← 2/3

第3步:插入 ID=102
页1: [100, 101, 102] ← 3/3(满了)

第4步:插入 ID=103
创建新页2: [103]     ← 1/3

结果:页1利用率100%,页2才开始填充

情况B:随机插入(UUID)

复制代码
第1步:插入 UUID='mno...'
页1: ['mno']        ← 1/3

第2步:插入 UUID='abc...'(字典序在'mno'之前)
页1: ['abc', 'mno'] ← 需要重排,2/3

第3步:插入 UUID='def...'(在'abc'和'mno'之间)
页1: ['abc', 'def', 'mno'] ← 需要重排,3/3(满了)

第4步:插入 UUID='bcd...'(在'abc'和'def'之间)
❌ 问题:页1已满,但新记录必须插在'abc'和'def'之间

2. 关键的页分裂过程

复制代码
页1当前: ['abc', 'def', 'mno']  ← 已满
新记录: 'bcd'  (字典序:'abc' < 'bcd' < 'def')

无法直接插入!必须分裂:

分裂过程:
1. 创建新页2
2. 重新分配数据(一般从中间分裂):
   页1: ['abc', 'bcd']    ← 2/3(原来3条,移走1条,新增1条)
   页2: ['def', 'mno']    ← 2/3
3. 更新父节点指针

结果:两个页都只有2/3满

3. 扩展到80行容量的实际情况

为什么随机插入时页很难填满?

复制代码
# 模拟随机UUID插入到80容量的页
import random

def simulate_random_insertion():
    page_capacity = 80
    page = []  # 当前数据页
    splits = 0
    
    for i in range(1000):  # 尝试插入1000条记录
        # 生成随机字符串模拟UUID
        new_record = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz', k=10))
        
        # 找到插入位置
        insert_pos = 0
        for j, record in enumerate(page):
            if new_record < record:
                insert_pos = j
                break
            insert_pos = len(page)
        
        # 插入到对应位置
        page.insert(insert_pos, new_record)
        
        # 检查是否需要分裂
        if len(page) > page_capacity:
            splits += 1
            # 分裂:取中间点
            split_point = len(page) // 2
            # 保留前半部分,后半部分移到新页
            page = page[:split_point]
            # 新页从split_point开始
            # 这里简化,实际会创建新页
    
    return splits

print(f"插入1000条随机记录,发生分裂次数: {simulate_random_insertion()}")

关键洞察

复制代码
在随机插入下,新记录有:
- 1/81 的概率成为最大值(插入到最后)→ 不分裂
- 1/81 的概率成为最小值(插入到最前)→ 可能分裂  
- 79/81 的概率插入到中间某个位置 → 极可能导致分裂

当页接近满时(比如已有79条),任何新记录几乎都会导致分裂!

4. 数学概率分析

页分裂的概率计算

复制代码
设页容量为C,已有n条记录
插入新记录时:

情况1:新记录是最大值 → 追加到最后,不分裂
概率: 1/(n+1)

情况2:新记录是最小值 → 插入最前,可能需要分裂
概率: 1/(n+1)

情况3:新记录在中间 → 插入中间位置,极可能分裂
概率: (n-1)/(n+1) ≈ 1(当n很大时)

当页接近满时(n=79)

复制代码
插入第80条记录:
- 成为最大值概率: 1/80 = 1.25%
- 成为最小值概率: 1/80 = 1.25%
- 插入中间概率: 78/80 = 97.5%

也就是说,97.5%的概率会导致分裂!
分裂后每页大约40条记录,利用率只有50%。

5. 实际例子:80行容量为什么只能插3行

假设的实际插入序列

复制代码
页容量:80行
实际插入顺序(按字典序排序后):
1. 'a000...'  ← 插入页1
2. 'c000...'  ← 插入页1
3. 'e000...'  ← 插入页1
4. 'b000...'  ← 问题来了!它在'a'和'c'之间

此时页1: ['a000...', 'c000...', 'e000...']
插入'b000...'必须在'a'和'c'之间,但页已排序。

页分裂发生的情况

复制代码
插入前3条后,页还远没满(3/80),但:
第4条记录'b000...'必须插在'a000...'和'c000...'之间

由于B+树必须保持页内记录有序,数据库有两种选择:

选择1:尝试在当前位置插入
但这样会破坏顺序,需要移动'c000...'和'e000...'
这在B+树中代价很高,通常不会这样做

选择2:页分裂(更常见)
创建新页,重新分布数据,使每页保持有序

=========================================================================

1. 数据页的物理存储结构

数据页不是数组,而是"槽位"(Slot)结构

复制代码
一个数据页的物理布局:

偏移量   内容
0-37     Page Header (页头信息)
38-50    Infimum Record (虚拟最小记录)
51-63    Supremum Record (虚拟最大记录)
64-...   User Records (用户记录,按插入顺序存储)
...      空闲空间
...      Page Directory (页目录 - 槽位数组)
16376-   File Trailer (文件尾部)

关键:记录不是按顺序物理存储的!

复制代码
假设页1当前有3条记录:
逻辑顺序(按主键排序):['abc', 'def', 'mno']

但物理存储可能是:
物理位置1: 'def'  (最先插入)
物理位置2: 'mno'  (第二插入)  
物理位置3: 'abc'  (第三插入,但最小)

Page Directory (页目录) 维护逻辑顺序:
槽位0 → 指向'abc'的物理位置3
槽位1 → 指向'def'的物理位置1
槽位2 → 指向'mno'的物理位置2

2. 为什么可以插入但不能插在"中间"?

情况1:如果'bcd'是最大值

复制代码
当前:['abc', 'def', 'mno']
插入:'xyz' (比'mno'大)

可以插入!因为:
1. 物理上:追加到空闲空间末尾
2. 逻辑上:添加到Page Directory最后
3. 不需要移动已有记录

情况2:如果'bcd'要插入中间

复制代码
当前:['abc', 'def', 'mno']  ← 已排序
插入:'bcd' (在'abc'和'def'之间)

物理问题:
1. 'def'当前在物理位置1
2. 'bcd'需要插入到'def'之前
3. 但物理位置1已经被'def'占用!

逻辑问题:
Page Directory当前:
槽位0 → 'abc'
槽位1 → 'def'  
槽位2 → 'mno'

插入'bcd'后需要:
槽位0 → 'abc'
槽位1 → 'bcd'  ← 新记录
槽位2 → 'def'  ← 需要后移
槽位3 → 'mno'  ← 需要后移

3. 详细的不可能原因

原因1:物理空间连续性问题

复制代码
假设每行记录100字节,页大小16KB
物理布局:

位置0-99:     'def'记录
位置100-199:  'mno'记录  
位置200-299:  'abc'记录
位置300-...:  空闲空间

要插入'bcd'到'abc'和'def'之间:
需要在位置100插入,但那里已经有'def'了!

选项A:移动'def'和'mno'
把'def'从位置0移到位置100,'mno'从位置100移到位置200...
这需要复制大量数据,性能极差!

选项B:使用指针重定向
但InnoDB的行格式是紧凑的,记录连续存储

原因2:Page Directory的限制

复制代码
Page Directory就像书的目录页,有固定槽位:

初始:        插入'bcd'后需要:
[0] → 页100   [0] → 页100 ('abc')
[1] → 页0     [1] → 页300 ('bcd') ← 新
[2] → 页200   [2] → 页0   ('def') ← 需要后移
               [3] → 页200 ('mno') ← 需要后移

但Page Directory槽位数量是有限的!
如果每个槽位只能通过分裂增加...

4. 实际可以插入,但有性能问题

实际上InnoDB确实允许插入中间

复制代码
-- InnoDB确实可以在页中间插入记录
-- 但代价很高:

插入过程:
1. 在空闲空间创建新记录'bcd'
2. 更新Page Directory,插入新的槽位指向'bcd'
3. 调整后续槽位指针('def'从槽位1→2,'mno'从2→3)

问题在于:这会很快导致页溢出

复制代码
假设页容量80行,已存79行
Page Directory有79个槽位,接近满了

插入第80条记录(在中间):
需要:
1. 存储新记录(还有空间)
2. 添加第80个槽位(可能没空间了!)

Page Directory本身占用空间,当槽位太多时...

5. 真正的限制:Page Directory溢出

Page Directory的结构

复制代码
Page Directory是槽位数组,每个槽位2字节
指向记录在页内的相对位置(偏移量)

页大小16KB,最大槽位数 ≈ 8192个(理论上)
但实际InnoDB限制更严格

关键:槽位数组在页尾部向前增长
记录数据从页中部向尾部增长

当两者相遇时 → 页满了!

可视化冲突

复制代码
页开始                                          页结束
| 记录区 → 增长方向 → | ← 增长方向 ← 槽位数组 |

记录区从前往后存储记录
槽位数组从后往前存储指针

当需要在中间插入时:
记录区需要空间(可以)
槽位数组需要插入新指针(可能没空间!)
相关推荐
神仙别闹2 小时前
基于C语言实现B树存储的图书管理系统
c语言·前端·b树
福尔摩斯张3 小时前
C++核心特性精讲:从C语言痛点出发,掌握现代C++编程精髓(超详细)
java·linux·c语言·数据结构·c++·驱动开发·算法
历程里程碑4 小时前
C++ 9 stack_queue:数据结构的核心奥秘
java·开发语言·数据结构·c++·windows·笔记·算法
仰泳的熊猫4 小时前
1108 Finding Average
数据结构·c++·算法·pat考试
晨晖25 小时前
顺序栈的入栈函数
数据结构
hweiyu006 小时前
数据结构:后缀自动机
数据结构
小尧嵌入式6 小时前
C语言中的面向对象思想
c语言·开发语言·数据结构·c++·单片机·qt
花月C6 小时前
基于Redis的BitMap数据结构实现签到业务
数据结构·数据库·redis
一杯美式 no sugar6 小时前
数据结构——单向无头不循环链表
c语言·数据结构·链表