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.
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.
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).
| Property | Guarantee | What breaks without it |
|---|---|---|
| Atomicity | all ops apply or none do | money debited but never credited |
| Consistency | constraints always hold | an order pointing at a deleted product |
| Isolation | concurrent txns don't interfere | the anomalies in §2 |
| Durability | committed data survives crash | a confirmed order lost on power failure |
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 level | Dirty read | Non-repeatable read | Phantom |
|---|---|---|---|
| Read uncommitted | possible | possible | possible |
| Read committed | prevented | possible | possible |
| Repeatable read | prevented | prevented | possible |
| Serializable | prevented | prevented | prevented |
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
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).
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.
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.
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']
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.
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}
✓ 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 yourselfA 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
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
ORDER BY) fast.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
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 appliedBecause 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.
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.
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 sliceThe 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.
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")) # 99The 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.
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.
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 droppedAfter 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.