AI EngineeringZero to ProductionHome·About·Contact
System Design · Chapter SD11

DBMS internals

SD1 and SD2 taught you to query and design schemas; this lesson opens the engine that runs them. ACID, isolation levels and the anomalies they prevent, locking and 2-phase locking, MVCC, B+ tree indexes, the query planner, and the write-ahead log — with runnable models of an index, an isolation anomaly, and WAL recovery.

⏱️ ~3.5 hours🧪 5 labs🎯 Beginner→Expert
🌱 Start here — from zeroDatabase internals, from scratch. You already write SQL (SD1) and design schemas (SD2). This chapter is the engine underneath: how the database keeps your data correct when many users hit it at once (ACID, isolation, locking, MVCC), how it finds rows fast (B+ tree indexes), how it decides how to run a query (the planner), and how it survives a crash (the write-ahead log). The index, anomaly and WAL labs are models of the mechanisms, not a real storage engine.

Learning objectives

  • State the ACID properties and what each one protects against.
  • Match each isolation level to the anomalies it does and does not prevent.
  • Explain two-phase locking and how MVCC lets readers avoid writers.
  • Explain why a B+ tree index makes lookups and range scans fast, and demonstrate one.
  • Explain the write-ahead log and trace crash recovery.
▶ Runnable companionThe labs are standard-library Python models: an ordered index you can search and range-scan, an interleaving that reproduces a real isolation anomaly, and a WAL you can replay after a simulated crash.
Cross-link — the surface you already knowEverything here powers the SQL of SD1 and the schemas of SD2. When SD1 said "add an index," this chapter explains the B+ tree that made the query fast; when SD2 talked about constraints, ACID is what enforces them under concurrency.

1 · ACID — the correctness contract

A transaction is a group of operations that must behave as one unit. ACID is the four-part guarantee a relational database makes about transactions: Atomicity (all-or-nothing — no half-applied transfer), Consistency (it moves the database from one valid state to another, honouring constraints), Isolation (concurrent transactions don't corrupt each other), and Durability (once committed, it survives a crash — the job of the WAL in §5).

PropertyGuaranteeWhat breaks without it
Atomicityall ops apply or none domoney debited but never credited
Consistencyconstraints always holdan order pointing at a deleted product
Isolationconcurrent txns don't interferethe anomalies in §2
Durabilitycommitted data survives crasha confirmed order lost on power failure
Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Step 1 · Atomicity: commit or rollback (model)
atomicity.py# MODEL: a transaction buffers changes and applies them ALL on commit, or
# discards them ALL on rollback. That all-or-nothing is atomicity.
class Txn:
    def __init__(self, db):
        self.db = db
        self.staged = {}
    def write(self, key, value):
        self.staged[key] = value        # not yet visible to the db
    def commit(self):
        self.db.update(self.staged)      # apply everything at once
        self.staged = {}
    def rollback(self):
        self.staged = {}                 # throw the batch away

db = {"alice": 100, "bob": 50}
t = Txn(db)
t.write("alice", 100 - 30)
t.write("bob", 50 + 30)
# crash BEFORE commit -> rollback: neither change applied
t.rollback()
print("after rollback:", db)
t2 = Txn(db); t2.write("alice", 70); t2.write("bob", 80); t2.commit()
print("after commit:  ", db)
after rollback: {'alice': 100, 'bob': 50}
after commit:   {'alice': 70, 'bob': 80}

2 · Isolation levels & the anomalies they prevent

Perfect isolation (every transaction runs as if alone) is expensive, so SQL defines isolation levels that trade correctness for concurrency. Each level permits or forbids three classic anomalies: a dirty read (reading another txn's uncommitted change), a non-repeatable read (re-reading a row and getting a different committed value), and a phantom (a re-run query returns new rows that another txn inserted).

Isolation levelDirty readNon-repeatable readPhantom
Read uncommittedpossiblepossiblepossible
Read committedpreventedpossiblepossible
Repeatable readpreventedpreventedpossible
Serializablepreventedpreventedprevented
Step 2 · Reproducing a non-repeatable read (model)
anomaly.py# MODEL: two transactions interleave. T1 reads a row twice; T2 commits a change
# in between. Under READ COMMITTED, T1 sees two different values -> non-repeatable.
committed = {"balance": 100}

def read_committed(key):        # READ COMMITTED always sees latest committed value
    return committed[key]

# --- interleaving ---
first = read_committed("balance")    # T1 first read
committed["balance"] = 150           # T2 commits an update in between
second = read_committed("balance")   # T1 second read

print("T1 first read: ", first)      # 100
print("T1 second read:", second)     # 150 -> non-repeatable!
print("non-repeatable read:", first != second)
T1 first read:  100
T1 second read: 150
non-repeatable read: True
Repeatable read would freeze T1's viewUnder REPEATABLE READ (or MVCC snapshot isolation), T1 would keep reading 100 for its whole lifetime — it sees a consistent snapshot taken when it started, so T2's commit is invisible to it. That is exactly the guarantee MVCC provides in §4.

3 · Locking & two-phase locking (2PL)

One way to get isolation is locking: a transaction takes a shared lock to read and an exclusive lock to write, blocking conflicting access. Two-phase locking (2PL) is the rule that makes locking produce serializable schedules: a transaction has a growing phase (it may only acquire locks) and then a shrinking phase (it may only release them). Once you release any lock you may never acquire another. This discipline is what guarantees the interleaving is equivalent to some serial order — at the cost of possible deadlock (SD9's Coffman conditions again).

Growing Lock point Shrinking
2PL can deadlockIf T1 holds A and wants B while T2 holds B and wants A, both block forever — a circular wait. Databases detect this (a wait-for graph) and abort one transaction as the victim, or prevent it with lock ordering, exactly as in SD9.

4 · MVCC — readers don't block writers

Pure locking makes readers and writers fight. MVCC (Multi-Version Concurrency Control) avoids that by keeping multiple versions of each row, each tagged with the transaction that created it. A reader sees a consistent snapshot — the versions that were committed when it started — so it never blocks a writer and never sees half-finished work. This is how PostgreSQL and others give you repeatable reads without read locks.

Step 3 · A tiny MVCC store (model)
mvcc.py# MODEL: each key keeps a list of (version_txn, value). A reader with snapshot S
# sees the newest version committed at or before S.
class MVCC:
    def __init__(self):
        self.versions = {}              # key -> list of (txn_id, value)
        self.clock = 0
    def begin(self):
        self.clock += 1
        return self.clock               # a transaction id / snapshot
    def write(self, txn, key, value):
        self.versions.setdefault(key, []).append((txn, value))
    def read(self, snapshot, key):
        visible = [(t, v) for (t, v) in self.versions.get(key, []) if t <= snapshot]
        return visible[-1][1] if visible else None

db = MVCC()
t1 = db.begin(); db.write(t1, "x", 10)          # version at txn 1
reader = db.begin()                              # snapshot = 2
t3 = db.begin(); db.write(t3, "x", 99)          # later version at txn 3
print("reader sees:", db.read(reader, "x"))      # 10 (its snapshot, not 99)
print("newest sees:", db.read(db.begin(), "x"))  # 99
reader sees: 10
newest sees: 99

5 · Indexing & B+ trees

Without an index, finding a row means scanning the whole table — O(n). An index is an auxiliary structure that turns that into a fast lookup. Most relational databases use a B+ tree: a balanced, high-fan-out tree that keeps keys sorted, so it supports both point lookups (O(log n)) and range scans (walk the sorted leaves). High fan-out means the tree is shallow, so even a billion rows are only a handful of levels — a handful of disk reads.

Root few keys, routes Internal nodes route by key range Sorted leaves all data, linked left→right
Step 4 · An ordered index: point lookup + range scan (model)
index.py# MODEL: a B+-tree behaves, for queries, like a sorted key list you binary-search
# for a point lookup and slice for a range scan. bisect models the sorted leaves.
import bisect

class OrderedIndex:
    def __init__(self):
        self.keys = []
        self.rows = {}
    def insert(self, key, row):
        i = bisect.bisect_left(self.keys, key)
        if i == len(self.keys) or self.keys[i] != key:
            self.keys.insert(i, key)     # keep keys sorted (like B+ leaves)
        self.rows[key] = row
    def lookup(self, key):               # point query: O(log n) search
        i = bisect.bisect_left(self.keys, key)
        if i < len(self.keys) and self.keys[i] == key:
            return self.rows[key]
        return None
    def range(self, lo, hi):             # range scan: slice the sorted keys
        i = bisect.bisect_left(self.keys, lo)
        j = bisect.bisect_right(self.keys, hi)
        return [self.rows[k] for k in self.keys[i:j]]

idx = OrderedIndex()
for k in [50, 20, 80, 10, 35, 65]:
    idx.insert(k, f"row-{k}")
print("sorted keys:", idx.keys)
print("lookup 35:", idx.lookup(35))
print("range 20..65:", idx.range(20, 65))
sorted keys: [10, 20, 35, 50, 65, 80]
lookup 35: row-35
range 20..65: ['row-20', 'row-35', 'row-50', 'row-65']
Why B+ trees, not hash indexes, are the defaultA hash index gives O(1) point lookups but cannot do range scans or ordered reads — WHERE age BETWEEN 20 AND 65 or ORDER BY would fall back to a full scan. The B+ tree's sorted leaves handle equality and ranges and ordering, which is why it is the default index for SQL.

6 · Query planning & the write-ahead log

The query planner turns your SQL into an execution plan. SQL is declarative — you say what, not how — so the planner chooses how: which index to use, join order, and join algorithm, picking the plan with the lowest estimated cost from table statistics. Choosing an index scan over a full table scan is the single most common plan decision, and it is why the index in §5 matters.

Durability comes from the write-ahead log (WAL): before changing a data page, the database first appends a record of the change to an append-only log and flushes it to disk. If the server crashes, recovery replays the log to redo committed transactions and discards uncommitted ones. "Log first, then data" is the rule that makes "committed means durable" true even across a power failure.

Change Append to WAL Flush log Apply to data
Step 5 · WAL crash recovery (model)
wal.py# MODEL: log-before-data. A crash loses in-memory data pages but NOT the flushed
# WAL, so recovery replays committed records to rebuild the correct state.
class Database:
    def __init__(self):
        self.data = {}                  # in-memory pages (lost on crash)
        self.wal = []                   # append-only log (survives crash)
    def txn(self, changes):             # changes: dict of key->value
        for k, v in changes.items():
            self.wal.append(("set", k, v))   # 1. write-ahead: log first
        self.wal.append(("commit",))         #    then a commit marker
        for k, v in changes.items():
            self.data[k] = v                 # 2. apply to data pages
    def crash(self):
        self.data = {}                  # volatile memory is gone; WAL remains
    def recover(self):
        pending, self.data = {}, {}
        for rec in self.wal:
            if rec[0] == "set":
                pending[rec[1]] = rec[2]
            elif rec[0] == "commit":
                self.data.update(pending); pending = {}   # redo committed
        # pending left over = uncommitted at crash -> discarded

db = Database()
db.txn({"alice": 70, "bob": 80})        # committed
db.wal.append(("set", "carol", 999))    # a write with NO commit (in-flight)
db.crash()
print("after crash, data:", db.data)    # {} -- memory lost
db.recover()
print("after recovery:  ", db.data)     # committed txn restored, carol dropped
after crash, data: {}
after recovery:   {'alice': 70, 'bob': 80}
This is why durability is cheap and safeThe WAL is a sequential append (fast) and the only thing that must be flushed before a commit is acknowledged. Data pages can be written lazily afterward, because recovery can always rebuild them from the log — the "D" in ACID, made concrete.

✓ Checkpoint — you can move on when you can…

  • State the four ACID properties and give a failure each one prevents.
  • Match an isolation level to the anomalies it permits and forbids.
  • Explain the growing/shrinking phases of 2PL and why they give serializability.
  • Explain how MVCC lets a reader avoid blocking a writer.
  • Explain why a B+ tree supports range scans and trace WAL recovery.

Knowledge check

check yourself
✓ Knowledge check

A report transaction reads a table, and midway another transaction inserts and commits a new matching row; the report's second scan now returns that extra row. Which anomaly is this, and which is the lowest isolation level that prevents it?

Show answer
This is a phantom read — a re-run query sees new rows, not just changed values. The lowest level that prevents phantoms is Serializable; Repeatable Read forbids non-repeatable reads (changed values) but still permits phantoms (new rows) in the standard model.
✓ Knowledge check

A team adds an index to speed up WHERE user_id = ? and it works, but WHERE created_at BETWEEN ? AND ? is still slow even with a hash index on created_at. Why, and what index type fixes it?

Show answer
A hash index supports only equality — it cannot answer a range query, so the planner falls back to a full scan for the BETWEEN. A B+ tree index keeps keys sorted, so it can seek to the low bound and walk the linked leaves to the high bound, making range scans (and ORDER BY) fast.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Atomic commit / rollbackBeginner

Context: Atomicity — all-or-nothing — is the property that stops a transfer from debiting one account without crediting the other.

Your task: Model a transaction that stages writes and either commits them all or rolls them all back, and show that a rollback leaves the database untouched.

Requirements:

  • Stage writes without touching the live database
  • commit() applies every staged change at once
  • rollback() discards the staged changes entirely
  • Show a rolled-back transaction leaves the database exactly as before
  • Show a committed transaction applies all changes together

💡 Hint: Buffer changes in a dict; commit applies the whole dict, rollback just clears it.

Show solution

Stage into a buffer; commit applies it, rollback drops it:

class Txn:
    def __init__(self, db): self.db, self.staged = db, {}
    def write(self, k, v): self.staged[k] = v
    def commit(self): self.db.update(self.staged); self.staged = {}
    def rollback(self): self.staged = {}

db = {"alice": 100, "bob": 50}
t = Txn(db); t.write("alice", 70); t.write("bob", 80)
t.rollback()
print("rolled back:", db)          # unchanged
t2 = Txn(db); t2.write("alice", 70); t2.write("bob", 80); t2.commit()
print("committed:  ", db)          # both applied

Because the writes are applied together on commit and never partially, a crash or rollback before commit leaves the database in its prior valid state — that is atomicity, the property that makes a transfer safe.

Exercise 2 · Reproduce a dirty vs non-repeatable readIntermediate

Context: Understanding isolation means being able to construct the interleaving that produces each anomaly — that is how you reason about which level you need.

Your task: Construct interleavings that produce (a) a non-repeatable read under read committed and (b) show it cannot happen under a frozen snapshot.

Requirements:

  • Model a committed value another transaction can change
  • Show T1 reading twice with a committed update in between differs (non-repeatable)
  • Model a snapshot read that captures the value at start
  • Show the snapshot read returns the same value both times
  • State which isolation level corresponds to each behaviour

💡 Hint: The only difference is whether the second read consults the live value or a snapshot captured at transaction start.

Show solution

The anomaly is entirely about which value the second read consults:

committed = {"balance": 100}

def read_committed(k):        # always the latest committed value
    return committed[k]

# Non-repeatable read under READ COMMITTED:
r1 = read_committed("balance")
committed["balance"] = 150    # another txn commits in between
r2 = read_committed("balance")
print("read committed:", r1, r2, "-> differs:", r1 != r2)

# A frozen SNAPSHOT (repeatable read) captures the value at start:
snapshot = dict(committed)    # taken when the txn began
committed["balance"] = 999    # later commit is invisible to the snapshot
s1 = snapshot["balance"]; s2 = snapshot["balance"]
print("snapshot:      ", s1, s2, "-> differs:", s1 != s2)

Read committed re-reads the live value, so an interleaved commit makes the two reads differ (non-repeatable). A snapshot captured at start freezes the view, so both reads agree — that is Repeatable Read / snapshot isolation.

Exercise 3 · An ordered index with point + range queriesAdvanced

Context: A B+ tree's value over a hash index is range and ordered access; an ordered index captures that behaviour for query purposes.

Your task: Implement a sorted index supporting insert, point lookup, and range scan, and show the range scan returning keys in order — something a hash index cannot do.

Requirements:

  • Keep keys sorted on insert (binary-search insertion point)
  • Point lookup via binary search: O(log n)
  • Range scan returns all rows with lo ≤ key ≤ hi, in key order
  • Demonstrate a lookup and a range query
  • State why a hash index cannot serve the range query

💡 Hint: The bisect module gives you sorted insertion and the slice bounds for the range.

Show solution

Sorted keys give both O(log n) lookups and ordered range slices:

import bisect

class OrderedIndex:
    def __init__(self): self.keys, self.rows = [], {}
    def insert(self, k, row):
        i = bisect.bisect_left(self.keys, k)
        if i == len(self.keys) or self.keys[i] != k: self.keys.insert(i, k)
        self.rows[k] = row
    def lookup(self, k):
        i = bisect.bisect_left(self.keys, k)
        return self.rows[k] if i < len(self.keys) and self.keys[i] == k else None
    def range(self, lo, hi):
        i, j = bisect.bisect_left(self.keys, lo), bisect.bisect_right(self.keys, hi)
        return [self.rows[k] for k in self.keys[i:j]]

idx = OrderedIndex()
for k in [50, 20, 80, 10, 35, 65]: idx.insert(k, f"row-{k}")
print(idx.lookup(35))          # row-35
print(idx.range(20, 65))       # ordered slice

The range query returns keys in sorted order by slicing the key array — the exact thing a B+ tree does by walking its linked leaves. A hash index, having no order, could answer lookup(35) but would have to full-scan for the range.

Exercise 4 · MVCC snapshot readsExpert

Context: MVCC is how modern databases let long-running readers see a consistent view without blocking writers; multiple row versions are the trick.

Your task: Implement a multi-version store where a reader with an older snapshot sees the old value even after a newer transaction commits a change.

Requirements:

  • Store each key as a list of (version_id, value)
  • A read with snapshot S returns the newest version with id ≤ S
  • Show a reader started before a write still sees the pre-write value
  • Show a reader started after the write sees the new value
  • Explain why this means readers never block writers

💡 Hint: Assign monotonically increasing ids at begin(); a read filters versions by version_id <= snapshot.

Show solution

Versions tagged by transaction id, filtered by snapshot:

class MVCC:
    def __init__(self): self.versions, self.clock = {}, 0
    def begin(self): self.clock += 1; return self.clock
    def write(self, txn, k, v): self.versions.setdefault(k, []).append((txn, v))
    def read(self, snap, k):
        vis = [(t, v) for t, v in self.versions.get(k, []) if t <= snap]
        return vis[-1][1] if vis else None

db = MVCC()
t1 = db.begin(); db.write(t1, "x", 10)
reader = db.begin()               # snapshot before the next write
t3 = db.begin(); db.write(t3, "x", 99)
print("old reader:", db.read(reader, "x"))   # 10
print("new reader:", db.read(db.begin(), "x"))  # 99

The old reader keeps seeing 10 because version 99 has an id beyond its snapshot; a reader that starts later sees 99. Since reads only ever look at existing versions, a writer appending a new version never has to wait for a reader — readers don't block writers and vice-versa.

Exercise 5 · A tiny cost-based plan chooserProfessional

Context: The planner's core job is choosing an index scan over a full scan based on estimated cost; modelling it demystifies why an index sometimes isn't used.

Your task: Model a planner that estimates the cost of a full scan versus an index scan for a predicate's selectivity and picks the cheaper, showing the crossover.

Requirements:

  • Full-scan cost ≈ number of rows
  • Index-scan cost ≈ log(rows) + rows × selectivity (rows returned)
  • Choose the cheaper plan for a given selectivity
  • Show a highly selective predicate picks the index and a non-selective one picks the full scan
  • Explain why an index on a low-selectivity column may be ignored

💡 Hint: When a predicate matches most rows, the index adds overhead without saving work — the full scan wins, which is why the planner sometimes ignores an index.

Show solution

Estimate both costs and take the cheaper:

import math

def choose_plan(rows, selectivity, random_penalty=4):
    """selectivity = fraction of rows matched. A full scan reads sequentially
    (1 unit/row); an index scan pays a seek plus RANDOM I/O per matched row,
    and random I/O is modelled as `random_penalty`x costlier than sequential."""
    full_scan = rows
    index_scan = math.log2(rows) + rows * selectivity * random_penalty
    plan = "index" if index_scan < full_scan else "full-scan"
    return plan, round(full_scan, 1), round(index_scan, 1)

rows = 1_000_000
for sel in (0.00001, 0.01, 0.1, 0.5, 0.9):
    plan, fs, ix = choose_plan(rows, sel)
    print(f"selectivity={sel:<8} -> {plan:9s} (full={fs}, index={ix})")

A highly selective predicate (matches few rows) makes the index scan far cheaper, so the planner uses the index. As selectivity rises toward "most rows," the index scan approaches the full-scan cost plus overhead, so the planner correctly ignores the index and scans — which is why an index on a low-selectivity column (e.g. a boolean) is often unused.

Exercise 6 · WAL recovery after a crashIndustry scenario

Context: Durability in practice is the write-ahead log: committed work must survive a crash, and in-flight work must not leak through.

Your task: Implement log-before-data with commit markers, simulate a crash that wipes the in-memory data, and show recovery replays committed transactions while discarding an uncommitted one.

Requirements:

  • Append change records to the WAL before applying them to data
  • Write a commit marker to delimit a completed transaction
  • On crash, clear the in-memory data but keep the WAL
  • On recovery, redo changes up to each commit marker; discard trailing uncommitted changes
  • Show a committed transaction is restored and an in-flight one is dropped

💡 Hint: Buffer records between commit markers; only fold them into the data when you hit the marker, so a trailing un-committed run is simply never applied.

Show solution

Log-before-data with commit markers makes recovery a replay:

class Database:
    def __init__(self): self.data, self.wal = {}, []
    def txn(self, changes):
        for k, v in changes.items(): self.wal.append(("set", k, v))  # log first
        self.wal.append(("commit",))
        for k, v in changes.items(): self.data[k] = v                # then apply
    def crash(self): self.data = {}          # volatile pages gone; WAL survives
    def recover(self):
        pending, self.data = {}, {}
        for rec in self.wal:
            if rec[0] == "set": pending[rec[1]] = rec[2]
            elif rec[0] == "commit": self.data.update(pending); pending = {}

db = Database()
db.txn({"alice": 70, "bob": 80})       # committed
db.wal.append(("set", "carol", 999))   # in-flight, never committed
db.crash()
print("post-crash:", db.data)          # {}
db.recover()
print("recovered: ", db.data)          # alice/bob restored, carol dropped

After the crash the in-memory data is empty, but the flushed WAL still holds the record of the committed transaction. Recovery folds each run of set records into the data only when it reaches a commit marker, so the committed transfer is restored and the trailing uncommitted carol write is discarded — committed-means-durable, in-flight-means-gone.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in