我手写了一个 Java 内存数据库(二):B+ 树的插入与分裂

我手写了一个 Java 内存数据库(二):B+ 树的插入与分裂

上一篇搭好了节点和查询框架。这篇写 B+ 树最核心的部分------插入和节点分裂。这块我调了最久,分裂的边界条件特别多。

插入的整体思路

B+ 树插入分两步:

  1. 从根节点一路向下,找到应该插入的叶子节点
  2. 如果叶子节点满了,分裂成两个,把分裂向上传播给父节点

听起来简单,但分裂传播到根节点时树要长高,边界条件不少。

向下路由:找目标叶子

非叶子节点不存数据,只负责路由。我根据 key 的大小选择子树:

java 复制代码
if (isLeaf) {
    // 到叶子了,执行插入
} else {
    if (key.compareTo(entries.get(0).getKey()) <= 0) {
        children.get(0).insertOrUpdate(key, obj, tree, obligate);
    } else if (key.compareTo(entries.get(entries.size() - 1).getKey()) >= 0) {
        children.get(children.size() - 1).insertOrUpdate(key, obj, tree, obligate);
    } else {
        for (int i = 0; i < entries.size(); i++) {
            if (entries.get(i).getKey().compareTo(key) <= 0 
                && entries.get(i + 1).getKey().compareTo(key) > 0) {
                children.get(i).insertOrUpdate(key, obj, tree, obligate);
                break;
            }
        }
    }
}

三种情况:比最小还小走最左子树,比最大还大走最右子树,中间的线性扫描找。中间部分后来我意识到也可以用二分,但当时数据量不大,就没优化。

叶子节点插入:要不要分裂

到了叶子节点,先看还有没有位置:

java 复制代码
if (isLeaf) {
    float per = 0;
    if (obligate) {
        per = tree.getPer();  // 默认 0.1,预留 10%
    }
    int order = (int)(tree.getOrder() * (1 - per));
    
    if (contains(key) || entries.size() < order) {
        insertOrUpdate(key, obj);
        if (parent != null) parent.updateInsert(tree, obligate);
    } else {
        // 满了,分裂!
    }
}

预留空间的设计

obligate 这个参数是我后来加的。批量插入时发现,如果节点刚好满到 M,后续几乎每次插入都触发分裂,性能抖得厉害。于是加了一个 10% 的预留空间:

复制代码
实际可用容量 = M × (1 - 0.1) = M × 0.9

提前分裂,虽然多占一点空间,但减少了后续的分裂频率。后来查资料发现,MySQL InnoDB 的页分裂策略也有类似思路,算是歪打正着。

分裂:我调得最久的部分

当叶子节点满了,把它拆成两个:

复制代码
分裂前(M=4,已满,要插入 key=9):
┌─────────────────┐
│ 3 | 7 | 12 | 18 │   ← 插入 9 后变成 5 个 key
└─────────────────┘

分裂后:
左节点           右节点
┌───────┐     ┌───────────┐
│ 3 | 7 │     │ 9 | 12 | 18 │
└───────┘     └───────────┘
     ↑               ↑
     └─── 双向链表 ───┘

代码:

java 复制代码
Node left = new Node(true);
Node right = new Node(true);

// 维护双向链表------这块最容易出 bug
if (previous != null) {
    previous.setNext(left);
    left.setPrevious(previous);
}
if (next != null) {
    next.setPrevious(right);
    right.setNext(next);
}
if (previous == null) {
    tree.setHead(left);  // 原来是链表头,更新头指针
}

left.setNext(right);
right.setPrevious(left);
previous = null;
next = null;

// 先插入新 key,再按大小分配
insertOrUpdate(key, obj);
int leftSize = (order + 1) / 2 + (order + 1) % 2;
int rightSize = (order + 1) / 2;

for (int i = 0; i < leftSize; i++) {
    left.getEntries().add(entries.get(i));
}
for (int i = 0; i < rightSize; i++) {
    right.getEntries().add(entries.get(leftSize + i));
}

左右分配是 (order + 1) / 2 向上取整给左边。比如 order=4,插入后 5 个 key,左 3 右 2。

链表维护是最容易出 bug 的地方。 我当时调试了很久,主要问题是分裂后 previous/next 的指向容易搞乱,特别是链表头节点的处理。

分裂后:挂到父节点还是长出新的根

分裂完要把新节点挂到父节点上,分两种情况:

有父节点

java 复制代码
if (parent != null) {
    int index = parent.getChildren().indexOf(this);
    parent.getChildren().remove(this);
    left.setParent(parent);
    right.setParent(parent);
    parent.getChildren().add(index, left);
    parent.getChildren().add(index + 1, right);
    setEntries(null);
    setChildren(null);
    parent.updateInsert(tree, obligate);  // 父节点可能也要分裂
    setParent(null);
}

没有父节点------说明是根节点在分裂

java 复制代码
else {
    isRoot = false;
    Node parent = new Node(false, true);  // 新根,非叶子
    tree.setRoot(parent);
    left.setParent(parent);
    right.setParent(parent);
    parent.getChildren().add(left);
    parent.getChildren().add(right);
    setEntries(null);
    setChildren(null);
    parent.updateInsert(tree, obligate);
}

根节点分裂意味着树长高了一层。B+ 树就是这样一个节点一个节点地长高的。

父节点也可能分裂:级联传播

updateInsert 里判断父节点的子节点数是否超限:

java 复制代码
protected void updateInsert(BPTree tree, boolean obligate) {
    validate(this, tree);
    int order = (int)(tree.getOrder() * (1 - per));
    
    if (children.size() > order) {
        // 父节点也满了,继续分裂
        Node left = new Node(false);
        Node right = new Node(false);
        int leftSize = (order + 1) / 2 + (order + 1) % 2;
        int rightSize = (order + 1) / 2;
        
        for (int i = 0; i < leftSize; i++) {
            left.getChildren().add(children.get(i));
            left.getEntries().add(new SimpleEntry(
                children.get(i).getEntries().get(0).getKey(), null));
            children.get(i).setParent(left);
        }
        for (int i = 0; i < rightSize; i++) {
            right.getChildren().add(children.get(leftSize + i));
            right.getEntries().add(new SimpleEntry(
                children.get(leftSize + i).getEntries().get(0).getKey(), null));
            children.get(leftSize + i).setParent(right);
        }
        // 然后和叶子分裂一样,挂到祖父节点或建新根...
    }
}

分裂会从叶子向根传播,直到某个父节点能容纳为止。最坏情况下一直传播到根,树高 +1。

validate:关键字校准

这个函数我踩了一个大坑。分裂之后,非叶子节点的关键字必须反映子节点的最小 key,不然路由会出错。我一开始忘了同步,查了半天发现查询结果不对。

java 复制代码
protected static void validate(Node node, BPTree tree) {
    if (node.getEntries().size() == node.getChildren().size()) {
        for (int i = 0; i < node.getEntries().size(); i++) {
            Comparable key = node.getChildren().get(i).getEntries().get(0).getKey();
            if (node.getEntries().get(i).getKey().compareTo(key) != 0) {
                node.getEntries().remove(i);
                node.getEntries().add(i, new SimpleEntry(key, null));
                if (!node.isRoot()) {
                    validate(node.getParent(), tree);  // 递归向上校准
                }
            }
        }
    } else if (...) {
        // 关键字数量完全不对,重建
        node.getEntries().clear();
        for (int i = 0; i < node.getChildren().size(); i++) {
            Comparable key = node.getChildren().get(i).getEntries().get(0).getKey();
            node.getEntries().add(new SimpleEntry(key, null));
        }
        if (!node.isRoot()) validate(node.getParent(), tree);
    }
}

这篇的坑总结

  1. 链表维护------分裂时 previous/next 指向容易搞错,特别是头节点
  2. validate 忘调------非叶子节点关键字没同步,导致路由错误
  3. 分裂时先插入再分配------不是先分配再插入,顺序搞反会丢数据

上一篇

上一篇:[我手写了一个Java内存数据库(一):起因与架构]

下一篇

插入和分裂搞定了。下一篇写删除 (借节点、合并节点,比插入更复杂)和范围查询(B+ 树最大的优势所在)。

下一篇:[我手写了一个 Java 内存数据库(三):删除、合并与范围查询]


系列:我手写了一个 Java 内存数据库(共 4 篇)

相关推荐
zhouwy1131 小时前
Java 快速入门笔记:从基础语法到 Spring Boot 实战
java
大飞记Python1 小时前
【2026更新】Python基础学习指南(AI版)——04数据类型
开发语言·人工智能·python
极创信息2 小时前
信创产品认证怎么做?信创产品测试认证的主要流程
java·大数据·数据库·金融·软件工程
SamDeepThinking2 小时前
并发量就算只有2,该上锁还得上呀
java·后端·架构
Alice-YUE2 小时前
【js高频八股】防抖与节流
开发语言·前端·javascript·笔记·学习·ecmascript
Sam_Deep_Thinking2 小时前
如何让订单系统和营销系统解耦
java·架构·系统架构
云泽8082 小时前
C++11 核心特性全解:列表初始化、右值引用与移动语义实战
开发语言·c++
froginwe113 小时前
DOM 加载函数
开发语言
lzhdim3 小时前
SQL 入门 12:SQL 视图:创建、修改与可更新视图
java·大数据·服务器·数据库·sql