05. B-Tree: The Practice (Part II)
Following the previous chapter on B-tree implementation.
5.1 The B-Tree Deletion
Step 1: Delete From Leaf Nodes
The code for deleting a key from a leaf node is just like other
nodeReplace*
functions.
// remove a key from a leaf node
func leafDelete(new BNode, old BNode, idx uint16) {
new.setHeader(BNODE_LEAF, old.nkeys()-1)
(new, old, 0, 0, idx)
nodeAppendRange(new, old, idx, idx+1, old.nkeys()-(idx+1))
nodeAppendRange}
Step 2: Recursive Deletion
The structure is similar to the insertion.
// delete a key from the tree
func treeDelete(tree *BTree, node BNode, key []byte) BNode {
// where to find the key?
:= nodeLookupLE(node, key)
idx // act depending on the node type
switch node.btype() {
case BNODE_LEAF:
if !bytes.Equal(key, node.getKey(idx)) {
return BNode{} // not found
}
// delete the key in the leaf
new := BNode{data: make([]byte, BTREE_PAGE_SIZE)}
(new, node, idx)
leafDeletereturn new
case BNODE_NODE:
return nodeDelete(tree, node, idx, key)
default:
panic("bad node!")
}
}
Step 3: Handle Internal Nodes
The difference is that we need to merge nodes instead of splitting
nodes. A node may be merged into one of its left or right siblings. The
nodeReplace*
functions are for updating links.
// part of the treeDelete()
func nodeDelete(tree *BTree, node BNode, idx uint16, key []byte) BNode {
// recurse into the kid
:= node.getPtr(idx)
kptr := treeDelete(tree, tree.get(kptr), key)
updated if len(updated.data) == 0 {
return BNode{} // not found
}
.del(kptr)
tree
new := BNode{data: make([]byte, BTREE_PAGE_SIZE)}
// check for merging
, sibling := shouldMerge(tree, node, idx, updated)
mergeDirswitch {
case mergeDir < 0: // left
:= BNode{data: make([]byte, BTREE_PAGE_SIZE)}
merged (merged, sibling, updated)
nodeMerge.del(node.getPtr(idx - 1))
tree(new, node, idx-1, tree.new(merged), merged.getKey(0))
nodeReplace2Kidcase mergeDir > 0: // right
:= BNode{data: make([]byte, BTREE_PAGE_SIZE)}
merged (merged, updated, sibling)
nodeMerge.del(node.getPtr(idx + 1))
tree(new, node, idx, tree.new(merged), merged.getKey(0))
nodeReplace2Kidcase mergeDir == 0:
if updated.nkeys() == 0 {
// kid is empty after deletion and has no sibling to merge with.
// this happens when its parent has only one kid.
// discard the empty kid and return the parent as an empty node.
(node.nkeys() == 1 && idx == 0)
assertnew.setHeader(BNODE_NODE, 0)
// the empty node will be eliminated before reaching root.
} else {
(tree, new, node, idx, updated)
nodeReplaceKidN}
}
return new
}
Extra care regarding empty nodes: If a node has no siblings, it cannot be merged, even if all its keys are deleted. In this case, we need to remove the empty node, this will also cause its parent to become an empty node, the empty node will propagate upwords until eventually merged.
// merge 2 nodes into 1
func nodeMerge(new BNode, left BNode, right BNode) {
new.setHeader(left.btype(), left.nkeys()+right.nkeys())
(new, left, 0, 0, left.nkeys())
nodeAppendRange(new, right, left.nkeys(), 0, right.nkeys())
nodeAppendRange}
Step 4: The Conditions for Merging
The conditions for merging are:
- The node is smaller than 1/4 of a page (this is arbitrary).
- Has a sibling and the merged result does not exceed one page.
// should the updated kid be merged with a sibling?
func shouldMerge(
*BTree, node BNode,
tree uint16, updated BNode,
idx ) (int, BNode) {
if updated.nbytes() > BTREE_PAGE_SIZE/4 {
return 0, BNode{}
}
if idx > 0 {
:= tree.get(node.getPtr(idx - 1))
sibling := sibling.nbytes() + updated.nbytes() - HEADER
merged if merged <= BTREE_PAGE_SIZE {
return -1, sibling
}
}
if idx+1 < node.nkeys() {
:= tree.get(node.getPtr(idx + 1))
sibling := sibling.nbytes() + updated.nbytes() - HEADER
merged if merged <= BTREE_PAGE_SIZE {
return +1, sibling
}
}
return 0, BNode{}
}
The deletion code is done.
5.2 The Root Node
We need to keep track of the root node as the tree grows and shrinks. Let’s start with deletion.
This is the final interface for B-tree deletion. The height of the tree will be reduced by one when:
- The root node is not a leaf.
- The root node has only one child.
func (tree *BTree) Delete(key []byte) bool {
(len(key) != 0)
assert(len(key) <= BTREE_MAX_KEY_SIZE)
assertif tree.root == 0 {
return false
}
:= treeDelete(tree, tree.get(tree.root), key)
updated if len(updated.data) == 0 {
return false // not found
}
.del(tree.root)
treeif updated.btype() == BNODE_NODE && updated.nkeys() == 1 {
// remove a level
.root = updated.getPtr(0)
tree} else {
.root = tree.new(updated)
tree}
return true
}
And below is the final interface for insertion:
// the interface
func (tree *BTree) Insert(key []byte, val []byte) {
(len(key) != 0)
assert(len(key) <= BTREE_MAX_KEY_SIZE)
assert(len(val) <= BTREE_MAX_VAL_SIZE)
assert
if tree.root == 0 {
// create the first node
:= BNode{data: make([]byte, BTREE_PAGE_SIZE)}
root .setHeader(BNODE_LEAF, 2)
root// a dummy key, this makes the tree cover the whole key space.
// thus a lookup can always find a containing node.
(root, 0, 0, nil, nil)
nodeAppendKV(root, 1, 0, key, val)
nodeAppendKV.root = tree.new(root)
treereturn
}
:= tree.get(tree.root)
node .del(tree.root)
tree
= treeInsert(tree, node, key, val)
node , splitted := nodeSplit3(node)
nsplitif nsplit > 1 {
// the root was split, add a new level.
:= BNode{data: make([]byte, BTREE_PAGE_SIZE)}
root .setHeader(BNODE_NODE, nsplit)
rootfor i, knode := range splitted[:nsplit] {
, key := tree.new(knode), knode.getKey(0)
ptr(root, uint16(i), ptr, key, nil)
nodeAppendKV}
.root = tree.new(root)
tree} else {
.root = tree.new(splitted[0])
tree}
}
It does two things:
- A new root node is created when the old root is split into multiple nodes.
- When inserting the first key, create the first leaf node as the root.
There is a little trick here. We insert an empty key into the tree
when we create the first node. The empty key is the lowest possible key
by sorting order, it makes the lookup function nodeLookupLE
always successful, eliminating the case of failing to find a node that
contains the input key.
5.3 Testing the B-Tree
Since our data structure code is pure data structure code (without IO), the page allocation code is isolated via 3 callbacks. Below is the container code for testing our B-tree, it keeps pages in an in-memory hashmap without persisting them to disk. In the next chapter, we’ll implement persistence without modifying the B-tree code.
type C struct {
tree BTreemap[string]string
ref map[uint64]BNode
pages }
func newC() *C {
:= map[uint64]BNode{}
pages return &C{
: BTree{
tree: func(ptr uint64) BNode {
get, ok := pages[ptr]
node(ok)
assertreturn node
},
new: func(node BNode) uint64 {
(node.nbytes() <= BTREE_PAGE_SIZE)
assert:= uint64(uintptr(unsafe.Pointer(&node.data[0])))
key (pages[key].data == nil)
assert[key] = node
pagesreturn key
},
: func(ptr uint64) {
del, ok := pages[ptr]
_(ok)
assertdelete(pages, ptr)
},
},
: map[string]string{},
ref: pages,
pages}
}
We use a reference map to record each B-tree update, so that we can verify the correctness of a B-tree later.
func (c *C) add(key string, val string) {
.tree.Insert([]byte(key), []byte(val))
c.ref[key] = val
c}
func (c *C) del(key string) bool {
delete(c.ref, key)
return c.tree.Delete([]byte(key))
}
Test cases are left to the reader as an exercise.
5.4 Closing Remarks
This B-tree implementation is pretty minimal, but minimal is good for the purpose of learning. Real-world implementations can be much more complicated and contain practical optimizations.
There are some easy improvements to our B-tree implementation:
- Use different formats for leaf nodes and internal nodes. Leaf nodes do not need pointers and internal nodes do not need values. This saves some space.
- One of the lengths of the key or value is redundant — the length of the KV pair can be inferred from the offset of the next key.
- The first key of a node is not needed because it’s inherited from a link of its parent.
- Add a checksum to detect data corruption.
The next step in building a KV store is to persist our B-tree to the disk, which is the topic of the next chapter.
codecrafters.io offers “Build Your Own X” courses in many programming languages.
Including Redis, Git, SQLite, Docker, and more.