Build Your Own Database From Scratch Subscribe to get notified of new chapters and the book's release.
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:
(updated.nkeys() > 0)
assert(tree, new, node, idx, updated)
nodeReplaceKidN}
return new
}
// 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.
- 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
TBA
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.