B+ Tree Engines: On-Disk Storage in C++
Learning Goal: Designing and implementing an on-disk B+ Tree storage engine for a custom relational database in C++. You will learn how memory layout, physical hardware constraints, raw serialization, binary page organization (like slotted pages), and active buffer pool management interact to build a high-performance, disk-backed transaction-ready index from scratch.
- Prerequisites: High-school algebra, basic programming concepts (variables, loops, arrays), and a passion for low-level systems programming. No prior database systems knowledge is required.
- Estimated Total Study Time: 65 Hours
Course Map
This map outlines the recommended learning order and structural dependencies, revised to introduce conceptual design patterns before physical hardware layout.
Module 1: C++ Programming & Memory Management Foundations
This module builds the base systems programming knowledge required to write robust C++ code. You will master physical memory representation, pointer mechanics, references, and the execution differences between stack-allocated objects and heap-allocated objects.
Recommended Videos
- Why this video: To build an on-disk database engine, you must understand exactly how data is laid out in virtual memory. This video breaks down pointers to their fundamental reality: raw integer memory addresses. It demystifies pointer syntax, double pointers, and address referencing, which are crucial for manipulating page frames in memory later.
- Knowledge checkpoint:
- What is a pointer at the hardware/CPU level?
- What does dereferencing a pointer using the
*operator actually do? - Why does a pointer type (e.g.,
char*vsint*) matter if all addresses are the same size?
- Why this video: Memory management is the heart of storage engine performance. This video compares stack and heap allocation from both a physical execution speed perspective and memory layout. Understanding how heap allocation utilizes OS-level requests is vital for designing high-performance buffer managers.
- Knowledge checkpoint:
- How does the CPU allocate stack memory so much faster than heap memory?
- What occurs in the OS background when the
newkeyword is executed in C++? - What are the lifetime differences of variables allocated on the stack vs those on the heap?
- Why this video: A deeper, highly visual dive into the four primary memory segments of an application (Text, Global, Stack, and Heap). It shows how pointer variables on the stack hold addresses to dynamically allocated objects on the heap, preventing segmentation faults and memory leaks during complex structural updates.
- Knowledge checkpoint:
- Draw the state of memory when an array is dynamically allocated using a pointer.
- How does a memory leak occur, and how do we prevent it during dynamic allocations?
- What are the dynamic allocation equivalents of C++'s
newanddeletein standard C?
- Why this video: While writing database components requires raw pointer manipulation, modern C++ patterns (smart pointers, RAII, dynamic vectors) are essential for building the host test harness and managing utility objects safely. This video covers modern C++ lifecycle management to prevent memory corruption.
- Knowledge checkpoint:
- How does
std::unique_ptrenforce exclusive ownership of a dynamic object? - What is Resource Acquisition Is Initialization (RAII), and why does it protect resource clean-up?
- How does
std::vectorallocate heap space dynamically compared to raw C-style arrays?
- How does
Module 2: Data Structures: BST to B+ Trees
Before dealing with on-disk byte formats, you must understand the mathematical and logical structures of hierarchical index algorithms. This module moves from simple Binary Search Trees to B-Trees and explains why B+ Trees are uniquely suited for persistent database indexes.
Recommended Videos
- Why this video: Provides a quick, clear conceptual primer on the binary search tree. Building a B+ Tree first requires deep comfort with pointers referencing sub-elements, tracking left-hand (lesser-than) and right-hand (greater-than) nodes, and tracing recursive searches.
- Knowledge checkpoint:
- What makes a tree structure a "Binary Search Tree" compared to a basic tree?
- What is the average-case vs. worst-case time complexity of searching a standard BST?
- How do node links (pointers) allow traversing child hierarchies?
- Why this video: This video explains the practical requirements of database storage. It explains why simple trees (like BSTs or AVL trees) fail under disk latency, how B-Trees improve access by matching disk block sizes, and why B+ Trees are the industry standard due to their range query speed and node design.
- Knowledge checkpoint:
- Why do AVL/BST search trees lead to high disk seek latency on physical hardware?
- What is the structural difference between a B-Tree and a B+ Tree?
- How do leaf-level sibling pointers in a B+ Tree optimize SQL range queries (e.g.,
SELECT * WHERE ID > 10)?
- Why this video: Provides a step-by-step visual demonstration of the B+ Tree insertion algorithm, including node splitting, leaf splitting, and parent propagation. You must understand this visual logic before writing C++ code for internal/leaf node structural splits.
- Knowledge checkpoint:
- Given a B+ Tree of order , what is the maximum number of keys and children a node can hold?
- When a leaf node splits, what key is copied to the parent, and where does the original key stay?
- When an internal node splits, what key is promoted, and how does it differ from a leaf split?
Module 3: Disk I/O & Low-Level Byte Representation
To write a real database, you cannot rely on high-level serialization libraries. You must handle byte-level representation, address hardware write constraints, serialize structs into raw binary buffers, and write those buffers directly to physical files.
Recommended Videos
- Why this video: Connects the physical physics of hard drives (spinning disks, seek arm latency) and solid-state drives (block erasability, write amplification) to software design choices. It details why databases partition index storage into fixed-size pages (commonly 4KB) to match physical sector read/write constraints.
- Knowledge checkpoint:
- Why is sequential physical disk access significantly faster than random access?
- How does the physical unit of disk block access (sectors/pages) dictate database storage architecture?
- What are the physical constraints of SSD flash blocks relative to rewriting small chunks of data?
- Why this video: Provides an introduction to handling raw, unformatted binary streams in C++ using
std::fstream. It teaches how to open files in binary modes (ios::binary), read and write streams without text-based conversion, and control file stream pointers. - Knowledge checkpoint:
- How does opening a file in binary mode in C++ differ from opening it in standard text mode?
- What C++ fstream methods write raw memory streams to disk, and what arguments do they take?
- How do you verify if a file stream successfully opened and bound to disk?
- Why this video: This is a vital tutorial on low-level serialization. It demonstrates how to serialize a C-style struct directly into a file, how to cast pointers to
char*for byte-level transport, and how to safely deserialize memory back to objects. It also reviews the dangers of trying to serialize raw pointers. - Knowledge checkpoint:
- Why can you not directly serialize a struct that contains raw pointer types (e.g.,
char* nameor pointers to sub-structures)? - How does
reinterpret_cast<char*>allow treating any typed C++ object as a raw block of bytes? - How does memory alignment affect structural sizes, and how do padding bytes end up on disk?
- Why can you not directly serialize a struct that contains raw pointer types (e.g.,
⚠️ Independent Gap Coverage: Memory Alignment & Struct Packing in C++
To successfully write a C++ struct to disk, you must prevent the C++ compiler from inserting arbitrary packing bytes between fields.
- Compiler Directives: Use
#pragma pack(push, 1)before your struct definition and#pragma pack(pop)after it. This forces the compiler to align all fields on 1-byte boundaries, guaranteeing that the file representation on disk matches the exact byte offset in memory. - Casting Example:
#pragma pack(push, 1) struct PageHeader { uint32_t page_id; uint16_t free_space_pointer; uint16_t slot_count; }; #pragma pack(pop) // Serializing raw struct to byte buffer char block[4096]; PageHeader header{1024, 4096, 0}; std::memcpy(block, &header, sizeof(PageHeader)); // Safe, dense copy
Module 4: B+ Tree Implementation and On-Disk Node Layout
This module bridges the gap between memory and physical storage. You will learn to design real binary page layouts (including Slotted Pages) and implement the algorithmic logic of leaf/internal node search, insertion, and split behaviors.
Recommended Videos
- Why this video: This video introduces binary structural design for leaf and internal nodes. It shows how keys, child pointers, and counts are packed into a single byte stream, laying the foundation for converting raw bytes into navigable tree nodes.
- Knowledge checkpoint:
- What physical fields must a binary leaf node pack in its header?
- How does the physical array layout of keys and values differ between leaf nodes and internal nodes?
- How is node type (leaf vs internal) determined during a disk read?
- Why this video: A premier conceptual video on Slotted Pages. This layout architecture is crucial for handling variable-length records (such as strings or variable size keys) inside a standard 4KB database page. It shows how the slot directory grows forward from the page header while actual records grow backward from the end of the page.
- Knowledge checkpoint:
- What are the two main pointers in a Slotted Page header, and what direction does each grow?
- What information is stored inside each entry of the slot directory?
- How is deletion handled in a Slotted Page to prevent fragmentation?
- Why this video: Walks through a complete custom database architecture. It details page-based abstraction layers, physical on-disk file splitting, block offsets, and building index files. This helps tie page layout design back into a cohesive, file-backed database system.
- Knowledge checkpoint:
- What role does a 4KB file-block offset system play in indexing?
- How do you convert a conceptual "Page ID" into a direct byte-level file offset?
- Why is on-disk serialization the core performance bottleneck in database systems?
⚠️ Independent Gap Coverage: Writing an On-Disk B+ Tree Node Layout in C++
To implement this in C++, you must avoid using high-level dynamic arrays (like std::vector) inside the code that maps to raw disk blocks. A disk page is a fixed-size byte array of 4096 bytes. You must use reinterpret_cast to cast raw page pointers to typed structures.
class BPlusNode { public: static constexpr size_t PAGE_SIZE = 4096;
// Explicit byte-level layout using a C++ class with a backing raw array
struct Header {
bool is_leaf;
uint16_t num_keys;
uint32_t next_page_id; // For leaf node linking
};
// Cast a raw 4KB memory frame directly to access the fields
static Header* GetHeader(char* page_data) {
return reinterpret_cast<Header*>(page_data);
}
static int32_t* GetKeys(char* page_data) {
// Keys start immediately after the Header
return reinterpret_cast<int32_t*>(page_data + sizeof(Header));
}
static uint32_t* GetChildPointers(char* page_data) {
// Child pointers (Page IDs) start after the maximum possible keys array
// (Assuming max keys is calculated based on remaining space in 4KB)
size_t offset = sizeof(Header) + (sizeof(int32_t) * 200); // e.g. Max 200 keys
return reinterpret_cast<uint32_t*>(page_data + offset);
}
};
Module 5: Buffer Pool Management and Storage Engine Integration
This final module ties everything together. You will build a Buffer Pool Manager (BPM) that caches disk pages in memory, handles page eviction via an LRU cache, maintains dirty flags, and orchestrates atomic reads and writes to form a complete database storage engine.
Recommended Videos
- Why this video: The gold-standard lecture on database buffer pools from CMU. It details why operating systems should not handle page caching (via virtual memory), how a system's Buffer Pool Manager coordinates page frames in memory, and how pinning, unpinning, and dirty flags manage active memory frames safely.
- Knowledge checkpoint:
- What is the difference between a database "Page ID" and a buffer pool "Frame ID"?
- What does the page directory/table track inside a running Buffer Pool Manager?
- What does it mean to "pin" a page in memory, and why can pinned pages not be evicted?
- What is the "dirty flag," and when must a page be written back to physical disk?
- Why this video: Provides a complete walkthrough for implementing an LRU (Least Recently Used) cache in C++ with lookup and eviction. Since the Buffer Pool Manager relies on an eviction algorithm, writing an efficient LRU Cache using a hash map combined with a doubly linked list is essential.
- Knowledge checkpoint:
- What are the memory access advantages of combining
std::unordered_mapwithstd::list? - When a page is read or updated, how is its position updated in the LRU eviction queue?
- How do you write the node eviction process in C++ without dangling pointers?
- What are the memory access advantages of combining
- Why this video: Explains database caching strategies and memory hierarchies. It traces how blocks move up and down between the storage layer and memory, helping you visualize how page read requests flow through the storage engine.
- Knowledge checkpoint:
- What physical data path does a database page trace when a query requests a record?
- Why is the buffer pool manager critical when a database is larger than the system's physical RAM?
- What performance metrics track the efficiency of a database cache?
⚠️ Independent Gap Coverage: Writing a Custom Buffer Pool Manager in C++
To implement your C++ Buffer Pool Manager, you must build a structure that manages a fixed number of in-memory page frames (a raw byte array pool) and a tracking table.
#include <unordered_map> #include <list> #include <vector> #include <iostream>
struct FrameMetadata { uint32_t page_id = 0; uint32_t pin_count = 0; bool is_dirty = false; };
class BufferPoolManager { private: static constexpr size_t PAGE_SIZE = 4096; size_t pool_size_;
// Physical memory array containing cached page frames
std::vector<char> memory_pool_;
// Map tracking which PageID is loaded into which physical FrameID
std::unordered_map<uint32_t, uint32_t> page_table_;
// Metadata array matching frames
std::vector<FrameMetadata> frame_table_;
// LRU list tracking eviction candidates (only frames with pin_count == 0)
std::list<uint32_t> lru_list_;
public: BufferPoolManager(size_t pool_size) : pool_size_(pool_size), memory_pool_(pool_size * PAGE_SIZE), frame_table_(pool_size) {}
char* FetchPage(uint32_t page_id) {
// 1. If page is in memory, increment pin count and return
if (page_table_.find(page_id) != page_table_.end()) {
uint32_t frame_id = page_table_[page_id];
frame_table_[frame_id].pin_count++;
// Remove from LRU list since it is currently pinned/active
lru_list_.remove(frame_id);
return &memory_pool_[frame_id * PAGE_SIZE];
}
// 2. If page is not in memory, we need to load it. Find eviction candidate from LRU list.
if (lru_list_.empty() && page_table_.size() >= pool_size_) {
std::cerr << "Out of memory! All pages are currently pinned." << std::endl;
return nullptr;
}
uint32_t victim_frame = 0;
if (page_table_.size() >= pool_size_) {
victim_frame = lru_list_.back();
lru_list_.pop_back();
// If dirty, write back to disk before evicting
if (frame_table_[victim_frame].is_dirty) {
WriteToDisk(frame_table_[victim_frame].page_id, &memory_pool_[victim_frame * PAGE_SIZE]);
}
page_table_.erase(frame_table_[victim_frame].page_id);
} else {
victim_frame = page_table_.size();
}
// 3. Load page from disk into the frame
ReadFromDisk(page_id, &memory_pool_[victim_frame * PAGE_SIZE]);
frame_table_[victim_frame].page_id = page_id;
frame_table_[victim_frame].pin_count = 1;
frame_table_[victim_frame].is_dirty = false;
page_table_[page_id] = victim_frame;
return &memory_pool_[victim_frame * PAGE_SIZE];
}
void UnpinPage(uint32_t page_id, bool is_dirty) {
if (page_table_.find(page_id) == page_table_.end()) return;
uint32_t frame_id = page_table_[page_id];
if (is_dirty) frame_table_[frame_id].is_dirty = true;
frame_table_[frame_id].pin_count--;
if (frame_table_[frame_id].pin_count == 0) {
// Safe to evict later
lru_list_.push_front(frame_id);
}
}
private: void ReadFromDisk(uint32_t page_id, char* dest) { // Physical disk read implementation using fstream goes here }
void WriteToDisk(uint32_t page_id, const char* source) {
// Physical disk write implementation using fstream goes here
}
};
Key People Index
- Andy Pavlo (Associate Professor of Database Systems, Carnegie Mellon University): Architect of the open-source CMU database curriculum and leading researcher in in-memory and autonomous database engines. His lectures (featured in Module 5) are standard material for modern database systems design.
- Cherno (Software Engineer & Educator): Former EA Frostbite Engine developer. His deep C++ tutorials are widely used for learning manual pointer manipulation, low-level optimization, and custom memory allocators.
- Michael Stonebraker (Pioneer of Relational Databases, Turing Award Winner): Lead designer of historical systems including INGRES, Postgres, and C-Store. His foundational work on OS-level interactions vs DBMS-level buffering informs why we build custom buffer managers instead of relying on the operating system.
Final Self-Assessment
Complete this comprehensive final checklist to verify that your custom on-disk B+ Tree engine matches industry standards:
- Pointers & Memory: You can confidently explain the difference between a memory address (pointer) and an object value, and can trace memory addresses using a debugger.
- Stack vs Heap: Your code uses stack allocations for fast local tasks, but dynamically manages page frames and buffer limits on the heap without leaks.
- Binary Serialization: You have implemented custom struct packing using compiler directives (
#pragma pack) to write raw binary structures to disk without padding bugs. - Page Conversion: Your engine can convert a
PageIDto a exact byte offset (e.g.,offset = PageID * 4096) and read/write those 4KB blocks using low-level C++ file streams. - Tree Visuals: You can visually trace B+ Tree nodes splitting, keys being promoted to parent nodes, and sibling links updating during insertions.
- Slotted Page Implementation: You can write a slotted page layout in C++ that manages variable-length records by growing the slot directory forward and the payload data backward.
- Buffer Pool Manager: Your engine includes a Buffer Pool Manager that acts as an in-memory cache, keeping the hot path of the tree in memory without exhausting physical RAM.
- LRU Eviction: Your buffer pool uses an LRU eviction algorithm that manages memory frame access order.
- Pinning Safety: Your code increments pin counts when a database component is modifying a page, preventing the eviction coordinator from discarding active nodes.
- Dirty Flag Persistence: Your buffer pool verifies the dirty flag on eviction, writing modified frames back to disk so that updates are safely saved.







![System Design First Princpal [5/15]: The Physics of Persistence (B-Trees vs LSM-Trees)](https://i.ytimg.com/vi/RAuIWs3ne9g/sddefault.jpg)







