我手写了一个 Java 内存数据库(二):B+ 树的插入与分裂
上一篇搭好了节点和查询框架。这篇写 B+ 树最核心的部分------插入和节点分裂。这块我调了最久,分裂的边界条件特别多。
插入的整体思路
B+ 树插入分两步:
- 从根节点一路向下,找到应该插入的叶子节点
- 如果叶子节点满了,分裂成两个,把分裂向上传播给父节点
听起来简单,但分裂传播到根节点时树要长高,边界条件不少。
向下路由:找目标叶子
非叶子节点不存数据,只负责路由。我根据 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);
}
}
这篇的坑总结
- 链表维护------分裂时 previous/next 指向容易搞错,特别是头节点
- validate 忘调------非叶子节点关键字没同步,导致路由错误
- 分裂时先插入再分配------不是先分配再插入,顺序搞反会丢数据
上一篇
下一篇
插入和分裂搞定了。下一篇写删除 (借节点、合并节点,比插入更复杂)和范围查询(B+ 树最大的优势所在)。
系列:我手写了一个 Java 内存数据库(共 4 篇)