Storage Internals — Pages, Buffers & B+ Trees

Storage internals — page, buffer, B+ tree

Read: ~40 min Hard 10 practice problems Live SQLite runner

1. Below SQL — Where Does the Data Actually Live?

Until now we have treated the database as a black box: we throw SELECT at it, rows come back. But behind that one line of SQL there is a small operating system in itself — a storage manager that fights physics every day. Disks are slow. Memory is fast but small. CPU caches are tiny. Every database in the world — SQLite, PostgreSQL, MySQL, Oracle, MongoDB — is in the end a clever stack of data structures wrapped around this one painful fact: moving bytes from disk costs a million times more than moving them inside RAM.

এতদিন আমরা database-কে black box হিসেবে দেখেছি — SELECT দিলে সাথে সাথেই row চলে আসে। কিন্তু সেই এক লাইন SQL-এর পেছনে আসলে একটা ছোটখাটো operating system লুকিয়ে আছে — storage manager। এই layer-এর কাজই হচ্ছে disk-এর ধীরগতি, RAM-এর সীমিত আকার এবং CPU cache-এর ছোট size — এই তিনটির সাথে রোজ যুদ্ধ করা। মূল সত্য একটাই: disk থেকে এক byte আনা মানে RAM-এর তুলনায় প্রায় দশ লাখ গুণ বেশি সময়।

In this module you will learn the four pillars of every classical disk-based DBMS: (1) how rows are packed into pages; (2) how the buffer pool caches those pages in RAM; (3) how B+ trees let us find a single row among billions in milliseconds; (4) why a newer family of structures — the LSM-tree — has taken over the write-heavy workloads at companies like Pathao, Daraz and Cassandra-shops worldwide.

2. Pages and Tuples — The Atom of Storage

A disk does not understand the concept of a "row". It understands blocks — fixed-size chunks (typically 4 KB or 8 KB on modern SSDs). To match this reality, every DBMS organizes its data as a sequence of equally-sized pages. SQLite's default page size is 4096 bytes; PostgreSQL uses 8192; Oracle traditionally 8 KB. Inside one page, the DBMS packs as many tuples (rows) as will fit and keeps a small directory at the top called the slot array (sometimes "line pointers").

Disk আসলে "row" বোঝে না, বোঝে block — সাধারণত 4 KB বা 8 KB-এর fixed-size টুকরা। তাই সব DBMS তার ডেটাকে সমান-আকারের page-এ ভাগ করে রাখে। SQLite-এ default page size 4096 byte, PostgreSQL-এ 8192। প্রতিটি page-এর শুরুতে একটি ছোট directory থাকে — slot array, যা প্রতিটি tuple কোথায় বসেছে তা track করে।
Slotted Page Layout (size = 4096 bytes) Page header slot 0 slot 1 slot 2 slot 3 free space (grows ↓ as new tuples come, ↑ as slots come) tuple 3 (Rahim, 23, Dhaka) tuple 2 (Karim, 31, Khulna) tuple 1 (Sumi, 28, Sylhet) tuple 0 (Tanvir, 19, Chattogram, NSU, BBA) Figure 36.1 — Slotted page: header + slot directory bottom-up, tuples top-down, free space in the middle.

Each tuple inside a page is identified by its RID — Row Identifier — typically the pair (page_number, slot_number). When an index says "row 7 lives in page 412 slot 3", the engine can jump directly to that tuple in O(1).

pages.sql
-- SQLite exposes its page size and total page count.
-- Run this and notice: even an "empty" database has 1 or 2 pages.
PRAGMA page_size;
PRAGMA page_count;

CREATE TABLE student(
    id      INTEGER PRIMARY KEY,
    name    TEXT,
    city    TEXT
);
INSERT INTO student VALUES
    (1,'Rahim','Dhaka'),
    (2,'Karim','Khulna'),
    (3,'Sumi','Sylhet');

PRAGMA page_count;          -- now larger
SELECT rowid, name FROM student;

3. Row-Store vs Column-Store — Two Ways to Slice the Same Table

The traditional layout we just saw is called a row-store: tuple n sits as a single contiguous blob inside its page. That is wonderful for OLTP queries like SELECT * FROM student WHERE id = 7 — one disk read brings the entire row. But it is terrible for analytics: SELECT AVG(age) FROM customer drags every column of every row into memory just to read one column.

A column-store turns the layout 90 degrees. Each column is stored in its own file (or page set), so AVG(age) reads only the age column — sometimes 50× less data. ClickHouse, Apache Parquet, BigQuery, Snowflake, Amazon Redshift and MonetDB all use column layouts. Bangladesh examples: any analytics dashboard at bKash, Grameenphone or Pathao that crunches billions of rows is almost certainly running on a column-store.

Row-store মানে — এক row-এর সব column পাশাপাশি বসে থাকে। OLTP-এ (যেমন bKash-এ একটি transaction খোঁজা) এটি দ্রুত। কিন্তু analytics-এ (Daraz-এর গত বছরের বিক্রয় গড় বের করা) row-store ভয়ংকর slow। তখন কাজে আসে column-store — প্রতিটি column আলাদা file-এ। ClickHouse, BigQuery, Parquet এই layout ব্যবহার করে।
Row-store (good for OLTP) [1, Rahim, 23, Dhaka] [2, Karim, 31, Khulna] [3, Sumi, 28, Sylhet] [4, Tanvir, 19, Chattogram] Column-store (good for OLAP) id 12 34 name RahimKarim SumiTanvir age 2331 2819 city DhakaKhulna SylhetChattogram SUM(age) on a column-store reads only the orange column → 4× less I/O on this tiny table, 50–100× on real ones. Figure 36.2 — Same table, two physical layouts. Column-stores also compress better: the city column has only ~64 distinct values nationwide.
PropertyRow-storeColumn-store
Best forOLTP — point lookups, single-row updatesOLAP — aggregations across millions of rows
CompressionModest — values mixedExcellent — same dtype together (10–30× typical)
Update costCheap (one row in one place)Expensive — touches every column file
Real systemsSQLite, MySQL InnoDB, PostgreSQL heapClickHouse, Parquet, Druid, Vertica, BigQuery
Bangladesh usebKash core ledger, eCab booking tableDaraz analytics warehouse, Grameenphone CDR vault

4. The Buffer Pool — RAM as a Cache for the Disk

The DBMS never operates on disk pages directly. It reserves a slab of RAM called the buffer pool (or "page cache" or "shared buffers" in PostgreSQL) and treats it like a giant in-memory cache of disk pages. When a query needs page 412, the buffer manager checks: is page 412 already in the pool? If yes — a cache hit, microseconds. If no — a cache miss: evict some other page (using LRU, CLOCK, or LRU-K policy), read 412 from disk (milliseconds), pin it in RAM, hand it over.

DBMS কখনোই সরাসরি disk-এ কাজ করে না। RAM-এ একটি বড় cache রাখে — buffer pool। কোনো query যদি page 412 চায়, প্রথমে দেখা হয় সেটা pool-এ আছে কিনা — থাকলে cache hit (microsecond)। না থাকলে cache miss: কোনো একটা page বের করে দিতে হয় (LRU বা CLOCK policy দিয়ে), নতুন page disk থেকে এনে বসানো হয়, তারপর হাতে দেওয়া হয়।
Buffer pool (RAM, ~limited) page 412 page 88 page 9 free page 1024 page 17 LRU list — least recently used at the bottom, evicted first Disk file (huge, slow) 1 2 3 … 412 … 2048 … millions of pages, only a fraction fit in RAM at any moment read on miss write on flush Figure 36.3 — Buffer pool sits between SQL queries and the disk. Hit rate > 99% is the goal in production.

Pinning is the rule that keeps a page in memory while a query is reading or writing it — you cannot evict a page that someone is currently using. Once all references release it, the page is unpinned and becomes a candidate for eviction. Pages that have been modified are marked dirty and must be written back to disk before they can be evicted, to satisfy durability.

Why hit rate matters in numbers
A modern NVMe SSD does ~100 µs per random read. RAM does ~100 ns. That is a 1000× ratio. If 1000 queries hit RAM and only 1 hits disk, total time is dominated by that one query — but if hit rate falls from 99% to 90%, throughput drops by an order of magnitude.

একটা NVMe SSD-তে random read ~100 microsecond, RAM-এ ~100 nanosecond — তফাৎ 1000 গুণ। তাই buffer pool-এর hit rate 99%+ রাখতে পারলেই production database "fast" থাকে।

5. Heap Files vs Clustered (Index-Organized) Tables

How are pages of a table connected? Two big traditions exist.

  • Heap-organized (PostgreSQL, MySQL MyISAM, Oracle default): The table is a pile (heap) of pages in arbitrary insertion order. Indexes are separate structures whose leaves carry RIDs pointing into the heap. To read row 7 you first descend the index, then jump into the heap — two structures, two reads.
  • Index-organized / Clustered (SQLite, MySQL InnoDB, Oracle IOT, MS SQL clustered index): The table itself is a B+ tree on the primary key. Leaves of the tree hold the actual rows. Reading by primary key means a single descent — no second hop.
একটি table-এর page-গুলো কীভাবে সাজানো? দুটি ঘরানা আছে। Heap: row-গুলো arbitrary order-এ pile-up — index আলাদা structure, যার leaf থেকে heap-এ jump করতে হয়। Index-organized / Clustered: পুরো table-ই primary key-এর উপর একটি B+ tree, leaf-এ row সরাসরি বসে — এক descent-এই row পেয়ে যাই। SQLite, MySQL InnoDB এই model ব্যবহার করে।
clustered.sql
-- In SQLite, a table is index-organized by default. The "INTEGER PRIMARY KEY"
-- column is an alias for ROWID and IS the clustering key — rows live in
-- ROWID order on disk. WITHOUT ROWID changes that.

CREATE TABLE heap_like(id INTEGER, val TEXT);
CREATE TABLE clustered(id INTEGER PRIMARY KEY, val TEXT);

INSERT INTO clustered VALUES
    (300,'three'),(100,'one'),(200,'two');

-- rows come back in id order — they are physically sorted on disk
SELECT rowid, id, val FROM clustered;

-- EXPLAIN QUERY PLAN proves it: PK lookup uses the clustering tree directly,
-- not a secondary index.
EXPLAIN QUERY PLAN
SELECT * FROM clustered WHERE id = 200;

6. The B+ Tree — How a Database Finds 1 Row Among 10 Billion

A B+ tree is a balanced multi-way search tree designed specifically for disk. The differences from a binary search tree are crucial: each node holds hundreds of keys, not one — that way the tree depth stays at 3 or 4 even for tables with billions of rows. And a B+ tree comes in two node flavours:

  • Internal nodes — only carry keys + child pointers; route searches downward.
  • Leaf nodes — hold the actual data (clustered) or RIDs (non-clustered) and are linked left-to-right in a doubly linked list, so range scans are sequential and brutally fast.
B+ tree হচ্ছে disk-এর জন্য বিশেষভাবে designed balanced multi-way search tree। প্রতিটি node-এ শয়ে শয়ে key থাকে, তাই কোটি row-এর table-এও tree মাত্র 3-4 level গভীর হয়। দুই ধরনের node — internal node শুধু key ও child pointer রাখে, leaf node-এ আসল data বসে এবং leaf-গুলো বাঁ থেকে ডানে linked থাকে — ফলে range scan ভয়ংকর দ্রুত।
[ 50 | 200 ] root [ 10 | 25 | 40 ] [ 80 | 120 | 160 ] [ 250 | 320 ] internal 5,8 11,18 28,33 45,55 90,110 140,180 220,260 leaf (sibling-linked) Search (33): root → middle internal? No, < 50 → left internal → leaf [28,33] — 3 page reads. Range scan (28..160): walk leaves left-to-right via the green links — fully sequential. Figure 36.4 — A small B+ tree. In real systems each node fits an entire 4 KB page → fanout ≈ 200, so depth 4 covers ≈ 1.6 billion rows.

6.1 Insert — split when a leaf overflows

Inserting a key walks the tree to the correct leaf, then places the new key there. If the leaf is full, the engine splits it into two halves and pushes the median key up to the parent. If the parent is full, that propagates upward — and if it reaches the root, the root itself splits and the tree grows taller by exactly one level. This is how a B+ tree stays perfectly balanced through millions of inserts without any explicit rebalancing pass.

6.2 Delete — borrow or merge

When a deletion empties a leaf below the minimum fill ratio, the engine first tries to borrow a key from a sibling. If the sibling is also at the minimum, the two siblings merge into one and the corresponding key is removed from the parent. This too can propagate upward and may shrink the tree.

btree_proof.sql
-- Watch SQLite use a B+ tree index. EXPLAIN QUERY PLAN tells us
-- whether the engine SCANs (full table) or SEARCHes (B+ tree descent).
CREATE TABLE customer(
    id     INTEGER PRIMARY KEY,
    phone  TEXT,
    name   TEXT
);
CREATE INDEX idx_phone ON customer(phone);

WITH RECURSIVE g(i) AS (
    SELECT 1 UNION ALL SELECT i+1 FROM g WHERE i < 5000
)
INSERT INTO customer(id,phone,name)
SELECT i, '+8801'||printf('%09d',i), 'User-'||i FROM g;

EXPLAIN QUERY PLAN
SELECT name FROM customer WHERE phone = '+880100000247';
-- → SEARCH customer USING INDEX idx_phone   (a B+ tree descent)

EXPLAIN QUERY PLAN
SELECT name FROM customer WHERE name = 'User-247';
-- → SCAN customer   (no index → full B+ tree leaf scan)

7. LSM-Trees — The Modern Alternative for Write-Heavy Workloads

B+ trees are excellent for read-mostly OLTP. But what about a system that ingests millions of writes per second — a logging pipeline at Pathao, an IoT sensor stream from a smart-meter rollout in Sylhet, a chat backend? Every B+ tree insert may dirty a random page; with billions of writes you saturate disk with random I/O. The Log-Structured Merge-tree (LSM-tree) flips the strategy.

  1. All writes first go to an in-memory sorted structure called a memtable (often a skiplist).
  2. When the memtable fills, it is flushed to disk as an immutable, sorted file — an SSTable (Sorted String Table).
  3. Background threads compact small SSTables into bigger ones, merging duplicates and discarding tombstones.
  4. Reads check the memtable first, then SSTables newest-to-oldest, helped by Bloom filters to skip files that definitely do not contain the key.
যেখানে প্রতি সেকেন্ডে লক্ষ লক্ষ write হয় (Pathao-এর log pipeline, smart-meter telemetry), সেখানে B+ tree-এর random I/O চাপ হয়ে যায়। এই সমস্যার আধুনিক সমাধান LSM-tree। সব write আগে যায় in-memory memtable-এ; ভর্তি হলে disk-এ flush হয়ে immutable SSTable হয়; পেছনে background thread পুরনো SSTable-গুলো merge করতে থাকে। RocksDB, Cassandra, ScyllaDB, LevelDB এই strategy ব্যবহার করে।
RAM (memtable) memtable (skiplist) L0 — recent flushes SST 5 SST 6 SST 7 L1 — compacted SST 1.0 SST 1.1 L2 — bigger, older SST 2.0 (huge, sorted) flush compaction (merge) major compaction Figure 36.5 — LSM levels. Writes are sequential; reads consult Bloom filters and may touch multiple levels.
Trade-offB+ treeLSM-tree
Write amplification~1× (one page rewrite per insert)10–30× (every key is rewritten during compactions)
Read amplification1 descent (~3-4 reads)Possibly multiple SSTables + Bloom filter
Space amplification~33% wasted (half-empty pages)Variable, depends on compaction
Sequential writes?No — randomYes — append-only
Used bySQLite, PostgreSQL, MySQL InnoDB, OracleRocksDB, Cassandra, LevelDB, ScyllaDB, HBase

Note: SQLite4 (an experimental successor that never shipped) explored an LSM-style storage engine, and RocksDB — the LSM library extracted from Facebook's modified LevelDB — is now embedded inside MySQL's MyRocks engine, MongoDB-with-RocksDB, and many startups' homegrown databases.

8. Putting It All Together — Tracing One SELECT

Let us trace a single query — SELECT name FROM customer WHERE id = 247 — from SQL to the disk and back, on a clustered B+ tree table.

  1. SQL parser → logical plan: scan customer for id=247.
  2. Optimizer notices id is the primary key (clustering key) → chooses an index seek.
  3. Executor asks the access method: "find leaf containing key 247".
  4. B+ tree descent: read root page (probably already pinned in RAM), choose the right child, read internal page, choose leaf page.
  5. Buffer manager satisfies each request: hit if cached, miss → 4 KB read from SSD.
  6. Leaf is decoded into tuples; the slot for id=247 is located in O(log k) using a binary search inside the page.
  7. Tuple is returned up the operator tree, projected (only the name column), and shipped back to the client.

The whole thing — for a row found in a warm buffer pool — finishes in under 10 microseconds. The same query against a cold disk costs ~3 reads × 100 µs ≈ 300 µs, still very fast. That gap is exactly why DBAs obsess over buffer-pool hit rate.

একটা query — SELECT name FROM customer WHERE id = 247 — কিভাবে disk-এ পৌঁছে আবার ফেরত আসে: parser → optimizer → access method → B+ tree descent (root → internal → leaf) → buffer pool hit/miss → tuple decode → projection। গরম buffer pool-এ এটা ~10 microsecond, ঠান্ডা disk-এ ~300 microsecond — তাই DBA-রা hit rate নিয়ে এত মাথা ঘামান।

9. Practice Problems

Each problem comes with a Show Answer button containing the explanation and, where useful, runnable SQLite code. Try them yourself first.

  1. What is the default page size of an SQLite database, and how would you find it for a given file?
    SQLite-এ default page size কত? কোনো নির্দিষ্ট database file-এর page size কীভাবে দেখবেন?
    ✨ Show Answer (উত্তর দেখুন)

    Answer: Default is 4096 bytes since SQLite 3.12 (2016). Use PRAGMA page_size;.

    ans1.sql
    PRAGMA page_size;
    PRAGMA page_count;
  2. A query SELECT SUM(amount) FROM transactions runs over 100 million rows. Which storage layout — row or column — will be faster, and why?
    ১০ কোটি row-এর উপর SELECT SUM(amount) চালালে row-store নাকি column-store দ্রুত হবে এবং কেন?
    ✨ Show Answer

    Answer: A column-store. It reads only the amount column from disk, while a row-store must drag every column of every row through I/O and CPU cache. With ~10 columns per row that is roughly a 10× I/O reduction — and column-stores compress that one column extremely well, often another 5–10× saving.

  3. Use EXPLAIN QUERY PLAN to prove that a primary-key lookup uses the clustered tree, while a non-indexed column lookup does a full scan.
    EXPLAIN QUERY PLAN দিয়ে দেখান যে PK lookup index ব্যবহার করে কিন্তু non-indexed column-এ full scan হয়।
    ✨ Show Answer
    ans3.sql
    EXPLAIN QUERY PLAN
    SELECT * FROM u WHERE id = 2;
    
    EXPLAIN QUERY PLAN
    SELECT * FROM u WHERE email = 'b@x';

    The first plan shows SEARCH … USING INTEGER PRIMARY KEY — clustered tree. The second shows SCAN — full table walk.

  4. Suppose a B+ tree node holds 200 keys. How tall does the tree need to be to index 1 billion rows?
    একটি B+ tree node-এ যদি 200 key থাকে, ১০০ কোটি row index করতে গাছ কতটা গভীর হবে?
    ✨ Show Answer

    Answer: log200(109) ≈ 4 levels. So even a billion-row table takes 4 page reads at most for any point lookup — that is the magic of high-fanout trees.

  5. Why are leaf nodes in a B+ tree linked left-to-right? Give a concrete query example.
    B+ tree-এর leaf node-গুলো বাঁ থেকে ডানে linked কেন থাকে? একটা concrete query উদাহরণ দিন।
    ✨ Show Answer

    Answer: So that range scans become sequential. Example: SELECT * FROM order_log WHERE created_at BETWEEN '2025-05-01' AND '2025-05-31' — the engine descends to the first leaf, then walks right via the sibling pointers without ever returning to the root. That is O(K + log N) instead of O(K log N).

  6. Define dirty page and explain why the buffer manager must not evict a dirty page without writing it out first.
    Dirty page কী? buffer manager কেন একটি dirty page disk-এ না লিখে evict করতে পারে না?
    ✨ Show Answer

    Answer: A dirty page is one whose in-memory copy has been modified by a transaction but whose on-disk copy is still the old version. If the buffer manager evicted it without flushing, the change would be silently lost when the page is later re-read from disk — directly violating durability, the D in ACID.

  7. Insert these keys in order into an empty B+ tree of order 3 (max 3 keys per node): 10, 20, 5, 6, 12, 30, 7, 17. Sketch the final tree.
    খালি B+ tree (order 3)-এ ক্রমে এই key-গুলো insert করুন: 10, 20, 5, 6, 12, 30, 7, 17। চূড়ান্ত tree-টি draw করুন।
    ✨ Show Answer

    Answer (one valid shape — splits depend on tie-breaking):

                                     [ 10 | 20 ]
                                   /     |     \
                              [5,6,7]  [10,12,17]  [20,30]
                            

    Three leaves, sibling-linked. Internal root holds the separator keys 10 and 20. Note that the separator 10 is duplicated in the leaf because in B+ trees all real data lives in the leaves.

  8. A workload writes 200,000 events per second to a single table. Would you choose a B+ tree-based storage engine or an LSM-tree-based one? Justify in 3 sentences.
    এক table-এ প্রতি সেকেন্ডে ২ লাখ event write হয়। B+ tree না LSM-tree বেছে নেবেন? তিন বাক্যে যুক্তি দিন।
    ✨ Show Answer

    Answer: Choose LSM. (1) LSM turns those 200k random writes into sequential append-only writes via the memtable + SSTable flow, which an SSD can sustain easily. (2) B+ trees would dirty hundreds of random pages per second, saturating buffer-pool flushes and causing write stalls. (3) The trade-off is read amplification, but a Bloom filter per SSTable keeps point reads at ~1 SSTable seek on average.

  9. What is a slot array, and why does putting it at the top of the page (with tuples growing from the bottom) make variable-length tuples easier to manage?
    Slot array কী? এটিকে page-এর উপরে রেখে tuple-গুলোকে নিচ থেকে বাড়তে দেওয়া হয় কেন?
    ✨ Show Answer

    Answer: The slot array is a small directory of (offset, length) pointers — one per tuple. Putting it at the top growing downward, while tuples grow upward from the bottom, means the free space is one contiguous gap in the middle. Adding a new tuple is O(1) (consume from both ends), and variable-length tuples never need to be packed because their location is found via the slot pointer, not arithmetic on a fixed row width.

  10. Name three production databases that use B+ trees and three that use LSM-trees, and one example each of when you would pick each in Bangladesh.
    তিনটি B+ tree-ভিত্তিক ও তিনটি LSM-ভিত্তিক production database-এর নাম বলুন; বাংলাদেশের context-এ কোনটিতে কোনটি লাগবে — একটি করে উদাহরণ দিন।
    ✨ Show Answer

    Answer:

    • B+ tree: SQLite, PostgreSQL, MySQL InnoDB. Use case: bKash transaction ledger — needs strong consistency and read-mostly point lookups by transaction-id.
    • LSM-tree: Cassandra, RocksDB, ScyllaDB. Use case: Pathao ride-event stream — millions of events per minute, mostly time-ordered writes, range scans by driver-id over a recent window.

Summary — Module 36

Beneath every SQL query lies a stack of physical structures. Tables live as fixed-size pages; tuples are placed using a slot directory; pages are cached in a buffer pool whose hit rate dictates throughput. Tables are organized either as heaps with side indexes, or clustered as a single B+ tree. The B+ tree — high fanout, balanced, leaf-linked — is the default index of every classical RDBMS, while the LSM-tree dominates write-heavy and distributed systems. Knowing this layer lets you read execution plans, predict performance, and explain why two seemingly identical queries can differ by a thousand-fold.

প্রতিটি SQL query-এর নিচে রয়েছে একটি physical stack — fixed-size page, slot directory, RAM-এ buffer pool, heap বা clustered B+ tree, এবং write-heavy workload-এর জন্য LSM-tree। এই layer বুঝলেই execution plan পড়া যায়, performance ভবিষ্যদ্বাণী করা যায়, এবং কেন দুইটা একই দেখা query হাজার গুণ আলাদা সময় নেয় — সেটাও ব্যাখ্যা করা যায়।

Next Module → Distributed Databases & the CAP Theorem — replicate, partition, and accept the trade-off.