Overview
This page is the spaced-repetition companion to the Database Internals and Storage Engines topic:
five fixed drills that force active recall instead of passive re-reading. Work through them in
order -- short-answer recall first, then scenario judgment, then hands-on repetition, then a
checklist to confirm real automaticity, and finally why/why-not prompts that test whether you can
explain the reasoning, not just execute the syntax. Every answer is hidden in a <details> block;
try each item yourself before opening it. Together, the five drills below touch every one of this
topic's 28 concepts and cite specific examples spanning all 80 worked examples in the Beginner,
Intermediate, and Advanced tiers, plus the capstone.
Recall Q&A
Twenty-eight short-answer questions, one per concept (co-01 through co-28). Answer from memory,
then check.
Q1 (co-01 -- Fixed-Size Pages). Why do databases read, write, and cache in fixed-size pages instead of arbitrary byte ranges?
Answer
Fixed-size pages give I/O a well-defined unit -- the buffer pool, the B-tree's fanout math, and a checksum all assume the same fixed unit; without it, none of those mechanisms would have a stable size to reason about. Page size is engine-specific (Postgres 8 KB, InnoDB 16 KB), not universal (Example 1).
Q2 (co-02 -- Slotted-Page Layout). Describe a slotted page's three regions and which two grow toward each other.
Answer
A header, a slot array growing forward from right after the header, and tuple data growing backward from the end of the page, with free space between the slot array and the tuple data. The slot array and tuple data are the two regions that grow toward each other; the page is full exactly when they meet (Examples 2, 3, 4, 6).
Q3 (co-03 -- Variable-Length-Record Addressing). Why does addressing a record by slot index, not byte offset, matter for in-page compaction?
Answer
A byte offset changes whenever a compaction moves a record, which would invalidate any external reference that stored the old offset. A slot index stays stable across a compaction because only the slot array's own entry gets rewritten to point at the record's new location -- external references never need to change (Examples 5, 7, 8).
Q4 (co-04 -- Buffer Pool). What does a buffer pool's page table map, and what two pieces of bookkeeping does each frame carry?
Answer
The page table maps page-id to frame. Each frame carries a pin count (how many callers currently depend on this page staying resident) and a dirty bit (whether the page has been modified since it was loaded) (Examples 10, 13).
Q5 (co-05 -- Page Eviction Policies). How does CLOCK/second-chance differ from true LRU, and what does LRU-K specifically defeat that plain LRU cannot?
Answer
CLOCK approximates LRU cheaply via one reference bit per frame instead of maintaining a full recency ordering. LRU-K tracks the last K references (not just the most recent one), which is what lets it tell a genuinely hot page apart from a page touched only once during a sequential scan -- plain LRU cannot make that distinction and will evict hot pages during a scan. Whichever policy picks the victim, eviction itself must still write back a dirty page before the frame is reused, which is exactly what Example 14 verifies (Examples 14, 15, 16, 41).
Q6 (co-06 -- Read-Path Buffer Hit/Miss). Why is "pool first, disk only on a miss" the single invariant every later mechanism in this topic depends on?
Answer
Every metric (hit ratio) and every higher-level mechanism (the WAL's materialization path, an index's page fetch) assumes reads genuinely check memory before disk -- if that ordering were violated, a hit ratio would be meaningless and disk-read counts would not reflect real cache behavior (Examples 11, 12, 42).
Q7 (co-07 -- B-Tree vs B+-Tree). What's the structural difference between a B-tree and a B+-tree, and which do most SQL "btree" indexes actually implement?
Answer
A B-tree stores values at every node; a B+-tree keeps values only in leaves, with the leaves linked as siblings. Most SQL "btree" indexes (Postgres nbtree, InnoDB) are actually B+-trees, even though their public API calls them "btree" (Examples 17, 22).
Q8 (co-08 -- B-Tree Search and Fanout). Why does fanout, not key count, determine how many pages a lookup touches?
Answer
Tree height is approximately log_fanout(N) -- a higher fanout means each level can route among more
children, so the tree stays shallow even as N grows into the billions. A fanout in the hundreds keeps
a B-tree only 3-4 levels deep at realistic scale, which is why B-trees are the default on-disk index
structure. Example 18 confirms a lookup actually benefits from that shallow depth, and Example 43
shows bulk-loading sorted keys bottom-up builds the identical shape a one-by-one insert would (Examples
18, 19, 43).
Q9 (co-09 -- B-Tree Insert and Split). What happens when a leaf overflows, and how far can that effect propagate?
Answer
An overflowing leaf splits in two and promotes a separator key upward into its parent; if the parent also overflows from that promotion, the split propagates further up, potentially all the way to the root -- which is exactly how a B-tree grows one level taller while staying balanced. Deletion is the mirror image: Example 45 shows a leaf underflow triggering a merge or borrow instead of a split (Examples 20, 44, 45).
Q10 (co-10 -- B-Tree Range Scan). What do sibling links between leaves save a range scan from having to do?
Answer
Without sibling links, crossing from one leaf to the next during a range scan would require
re-descending from the root -- an O(log N) cost paid again for every leaf boundary. Sibling links
turn a multi-leaf range scan into one descent plus a cheap linear walk across linked leaves (Example
21).
Q11 (co-11 -- LSM Memtable and SSTable). Why does buffering writes in an in-memory memtable and flushing to an immutable SSTable turn random writes into sequential ones?
Answer
Many individual writes accumulate in the sorted memtable first; when it's flushed, the ENTIRE memtable becomes one large, already-sorted, sequential write to a new immutable SSTable -- the many small random writes never individually touch disk (Examples 23, 24).
Q12 (co-12 -- LSM Read Path). Why must an LSM read check the memtable, then SSTables newest to oldest, and stop at the first match?
Answer
Because SSTables are immutable, an update or delete cannot modify an old value in place -- it can only add a newer entry (or a tombstone) that shadows the older one. Checking newest-to-oldest and stopping at the first match is exactly what makes the newest write win (Examples 25, 26).
Q13 (co-13 -- LSM Compaction). Name the two compaction strategies this topic covers and one production system that defaults to each.
Answer
Leveled compaction (RocksDB's default) favors read cost and space at the expense of more write amplification; size-tiered compaction (Cassandra's default) favors write cost at the expense of more space and slower reads. Neither is universally better -- it's a workload bet (Examples 48, 49).
Q14 (co-14 -- Amplification and the RUM Conjecture). What does the RUM conjecture claim, and why does it make "B-tree or LSM" a genuine engineering decision rather than a solved problem?
Answer
The RUM conjecture claims no single design can simultaneously minimize read, write, and space amplification all at once -- improving one tends to cost one of the others. That's precisely why choosing between a B-tree and an LSM tree is a real trade-off decided by the actual workload, not a question with one universally correct answer. Examples 77, 78, and 79 measure that trade-off directly on real engines: the LSM sustains higher write throughput, the B-tree answers point reads in fewer page reads, and a workload-driven chooser picks the LSM for write-heavy load and the B-tree for read-heavy load (Examples 50, 51, 52, 77, 78, 79).
Q15 (co-15 -- Bloom Filter). Why is a Bloom filter's asymmetry (false positives possible, false negatives impossible) exactly the property an LSM read path needs?
Answer
A false negative would mean a read incorrectly skips an SSTable that actually contains the key -- that would silently produce a wrong answer, which is unacceptable. A false positive only causes an unnecessary (but safe) SSTable check. That asymmetry is what makes a Bloom filter safe to rely on for reducing read amplification (Examples 27, 28, 53).
Q16 (co-16 -- Write-Ahead-Log Rule). State the WAL rule precisely, and explain what could go wrong if a page could reach disk before its log record.
Answer
A log record must reach stable storage before the page it describes is written. If a page could reach disk first and the system crashed in between, there would be no log record to explain what that on-disk change even was, with no way to reconstruct or verify it during recovery (Examples 29, 30).
Q17 (co-17 -- LSN and pageLSN). What question does comparing a log record's LSN against a page's stored pageLSN answer during recovery?
Answer
Whether that specific log record's change is already reflected on the page. If the record's LSN is less than or equal to the page's pageLSN, the change is already applied and redoing it again would be wrong (or at least redundant) -- this comparison is what makes redo idempotent (Examples 31, 32).
Q18 (co-18 -- ARIES Recovery Phases). Name ARIES's three recovery phases in order, and what "repeats history" means for the redo phase specifically.
Answer
Analysis, then redo, then undo. "Repeats history" means redo replays EVERY logged change, whether its transaction eventually committed or not -- it does not try to distinguish winners from losers during redo. That deliberate choice avoids the much harder problem of deciding transaction outcomes before the log has even been fully processed; undo, which runs after redo, is what removes the losers' effects (Examples 64, 65, 66, 67).
Q19 (co-19 -- Redo vs Undo). Which buffer policy makes redo necessary, which makes undo necessary, and why does a real engine typically need both?
Answer
A no-force policy (a committed page need not reach disk at commit time) makes redo necessary, since a committed change might still only exist in the log after a crash. A steal policy (an uncommitted page CAN be written to disk before commit, e.g. under memory pressure) makes undo necessary, since an uncommitted change might already be on disk. Real engines use steal + no-force for buffer-pool flexibility, which is exactly why both redo and undo are needed together -- Example 67 combines both in one end-to-end recovery over a crashed log (Examples 33, 34, 67).
Q20 (co-20 -- Checkpointing). What does a checkpoint bound, and what would happen to recovery time without one?
Answer
A checkpoint bounds how far back in the log recovery must scan -- it records a known-consistent point, so recovery only replays log records written after it. Without checkpoints, every recovery would need to scan the log all the way back to the database's very first write, making recovery time grow unboundedly with the database's age (Example 35).
Q21 (co-21 -- MVCC Versions -- xmin/xmax). What does an update do under MVCC instead of overwriting a row in place, and what do xmin and xmax each record?
Answer
An update appends a brand-new row version rather than modifying the old one. xmin records the transaction id that created a version; xmax records the transaction id that deleted it (or stays unset if the version is still live) -- together they let the engine reconstruct exactly which versions existed at any point in time (Examples 36, 38).
Q22 (co-22 -- Snapshot-Read Non-Blocking). State the snapshot visibility rule, and explain why it means readers and writers never block each other.
Answer
A snapshot sees every version committed before it, and not yet deleted as of that point. Because this rule only reads facts already recorded on each version (who created it, who deleted it) -- it never needs to acquire a lock on a concurrently-running writer, and a writer never needs to wait for a reader either (Examples 37, 39).
Q23 (co-23 -- Version Bloat and Vacuum). Why does MVCC's "never overwrite" design create a cleanup problem, and what solves it?
Answer
Every update leaves the old version behind; once no active snapshot can still see a given dead version, it serves no purpose but still occupies space. VACUUM (or an equivalent garbage-collection pass) is what reclaims that space by removing versions no live snapshot can ever reference again (Example 40).
Q24 (co-24 -- Isolation Levels and Anomalies). Map each of the four standard isolation levels to the anomaly it stops permitting, and name the one anomaly snapshot isolation still allows.
Answer
Read-uncommitted permits dirty reads; read-committed stops dirty reads but permits non-repeatable
reads; repeatable-read stops non-repeatable reads but (in the classical definition) permits phantom
reads; serializable stops all of the above. PostgreSQL's REPEATABLE READ is actually snapshot
isolation, and snapshot isolation still permits write skew even though it blocks the classical
phantom-read pattern -- only true serializable (SSI) catches write skew. Example 76 runs that same
write-skew pair under serializable and confirms one transaction aborts instead (Examples 72, 73, 74,
75, 76).
Q25 (co-25 -- Concurrency Control -- 2PL and OCC). What bet does 2PL make about contention versus what bet OCC makes, and what does strict 2PL add on top of plain 2PL?
Answer
2PL pays a locking cost upfront on every access, betting that conflicts are common enough to be worth preventing early. OCC pays nothing until a commit-time validation check, betting that conflicts are rare. Strict 2PL adds the rule that write locks are held until commit (not released as soon as the growing phase's own last access happens), which is exactly what prevents cascading aborts that plain 2PL still permits (Examples 68, 69, 70, 71).
Q26 (co-26 -- Durability -- fsync and Torn Pages). Why is a page write not atomic across a crash, and name the two named mitigations this topic covers.
Answer
A page write can be physically interrupted mid-write by a crash, leaving a "torn" page that is neither the old version nor the fully-written new one -- Example 9's per-page checksum is what lets recovery even detect that tearing happened. PostgreSQL's full-page-writes and InnoDB's doublewrite buffer are both mitigations that let recovery repair a torn page rather than merely detect that it happened; fsync is what forces a write to genuinely reach stable storage in the first place, and group commit amortizes that fsync cost across many transactions (Examples 9, 60, 61, 62, 63).
Q27 (co-27 -- Clustered vs Heap). What does clustering a table on its primary key make cheap, and what does it make more expensive?
Answer
Clustering (InnoDB's PK, SQLite's rowid tables) makes a primary-key lookup essentially free -- the row itself lives right in the B+-tree leaf, no extra indirection needed. It makes secondary-index maintenance more expensive, since a page split can change where a row physically lives, and every secondary index's pointer would need updating. A heap (PostgreSQL) decouples the two by storing rows separately, with secondary indexes pointing via a stable row-id/ctid (Examples 46, 47).
Q28 (co-28 -- Column vs Row Store). Why does a column store's layout make dictionary, run-length, and delta encoding effective in a way row storage never would?
Answer
A column store lays out one column's values contiguously, all the same type, often with repeated or slowly-changing values (an OLAP pattern) -- that contiguity and homogeneity is exactly what dictionary encoding (mapping repeated values to small integer codes), run-length encoding (collapsing consecutive repeats), and delta encoding (storing differences between consecutive monotonic values) all exploit. Interleaved with other columns' unrelated values, as a row store does, none of those patterns would hold (Examples 54, 55, 56, 57, 58, 59).
Applied problems
Thirteen scenarios. Each describes a task without naming the mechanism or trade-off -- decide which one applies, then check.
AP1. A team is choosing a page size for a new storage engine and debating whether 4 KB is simply "the correct" size industry-wide.
Answer
Page size is engine-specific, not universal (co-01) -- Postgres and SQL Server use 8 KB, InnoDB uses 16 KB. The "correct" answer is a design trade-off (I/O granularity vs. internal fragmentation), not a fixed industry constant (Example 1).
AP2. A buffer pool serving an analytics workload runs a large one-time table scan every night, and the team notices genuinely hot, frequently-reused pages get evicted during that scan under the current eviction policy.
Answer
This is exactly what plain LRU mishandles and LRU-K is built for (co-05) -- a one-time sequential scan touches every page exactly once, which looks "recently used" to plain LRU and evicts genuinely hot pages in the process; LRU-K's multi-reference history correctly distinguishes the two access patterns (Example 41).
AP3. A new index type is being designed, and the team is deciding whether internal (non-leaf) nodes should store actual row values or only routing keys.
Answer
This is the B-tree-vs-B+-tree decision (co-07) -- keeping values only in leaves (a B+-tree) makes internal nodes smaller and more cacheable, and the leaf-sibling links this enables are what make range scans fast; most production SQL "btree" indexes make this choice (Examples 17, 22).
AP4. An application needs to fetch every row with a timestamp between two bounds, and the current index implementation re-descends from the root for every leaf boundary it crosses.
Answer
Sibling-linked leaves (co-10) fix exactly this -- once the scan reaches the first matching leaf, it walks forward via sibling links instead of re-descending, turning a multi-leaf range scan into one descent plus a cheap linear walk (Example 21).
AP5. A write-heavy time-series ingestion service is bottlenecked on random-write I/O to its current B-tree-backed store.
Answer
An LSM-tree (co-11) is the workload-appropriate alternative -- buffering writes in an in-memory memtable and flushing sorted, immutable SSTables converts many small random writes into large sequential ones, which is precisely what a write-heavy ingestion workload needs (Examples 23, 24).
AP6. An LSM-backed store's point-read latency is dominated by checking SSTables that turn out not to contain the requested key at all.
Answer
A per-SSTable Bloom filter (co-15) directly addresses this -- its no-false-negatives guarantee lets a read safely skip any SSTable the filter reports as definitely lacking the key, cutting read amplification without any risk of missing a real match (Examples 27, 28, 53).
AP7. A database must guarantee that a committed transaction's data survives a power loss the instant after the client receives a commit acknowledgment.
Answer
This is the WAL rule plus fsync as a durability barrier (co-16, co-26) -- the commit acknowledgment must not be sent until the transaction's log record has genuinely reached stable storage via fsync, which is the only guarantee that survives a crash occurring the instant after (Examples 29, 30, 60).
AP8. After a crash, a recovery process needs to decide which of a transaction's writes to keep and which to discard, without knowing in advance which transactions had committed.
Answer
ARIES's redo-then-undo phases (co-18, co-19) solve exactly this -- redo first repeats history, replaying every logged change regardless of commit status, deferring the committed/uncommitted question entirely; undo then rolls back only the uncommitted (loser) transactions' effects, using compensation log records (Examples 64, 65, 66, 67).
AP9. A reporting query needs a long-running read to see a perfectly consistent snapshot of the database, without blocking any concurrent writers for the query's whole duration.
Answer
MVCC's snapshot read (co-21, co-22) is designed for exactly this -- the snapshot visibility rule lets the reporting query see versions committed before its snapshot point without ever taking a lock a concurrent writer would have to wait on (Examples 36, 37, 38, 39).
AP10. Two concurrent transactions, under an isolation level the team assumed was "basically serializable," each individually read a value the other is about to update, and both commit -- producing a result no serial execution of the two transactions could have produced.
Answer
This is write skew under snapshot isolation (co-24) -- PostgreSQL's REPEATABLE READ is actually
snapshot isolation, which blocks the classical phantom-read pattern but does NOT block write skew;
only true serializable (SSI) detects the rw-antidependency cycle and aborts one of the two
transactions (Examples 75, 76).
AP11. A high-contention workload with many transactions competing for the same small set of hot rows is experiencing frequent transaction restarts under its current concurrency-control scheme.
Answer
This points toward 2PL over OCC (co-25) -- OCC's read/validate/write cycle bets that conflicts are rare and pays for that bet with restarts whenever a conflict IS detected at validation time; under genuinely high contention, that bet loses often, and 2PL's upfront locking (which simply makes a conflicting transaction wait, rather than restart) tends to perform better (Examples 68, 69, 70).
AP12. During a crash-injection test, a page is found to contain a mix of old and new bytes -- neither the fully-old nor the fully-new version.
Answer
This is a torn page (co-26) -- a page write is not atomic across a crash, and full-page-writes (or a doublewrite buffer) are the mitigation that lets recovery detect and repair exactly this mixed state, by restoring the page from a full pre-image logged before the write began (Examples 62, 63).
AP13. An OLAP system needs to run "average value of this one column across a billion rows" as fast as possible, and the team is deciding between a row-oriented and a column-oriented physical layout.
Answer
A column store (co-28) is the correct layout for this access pattern -- laying the target column's values contiguously means the aggregate query reads only the bytes for that one column, skipping every other column entirely, and the layout also enables dictionary/run-length/delta compression that a row store's interleaved layout cannot exploit (Examples 54, 55, 56).
Code katas
Eight hands-on repetition drills. Each is a before/after .py file colocated under drilling/code/.
Every "before" script is a real, runnable Python program that misapplies the concept being drilled --
run it yourself, diagnose the bug from the observed behavior, fix it from memory, then compare your
fix against the "after" script and the model solution before checking your work against the
actually-executed output shown.
Kata 1 -- Slotted-Page Layout: forgetting to write back pd_lower silently overwrites an earlier slot
relates to co-02, co-03, Examples 2-4
Task. insert_record() should give every inserted record its own, permanent slot. The version
below is broken: it never writes the advanced pd_lower back into the header, so the SAME slot
offset gets reused (and overwritten) by the next insert.
Before (drilling/code/kata-01-slotted-page-stale-pd-lower/before/kata.py)
"""Kata 1 (before): insert_record forgets to WRITE BACK the new pd_lower, so slot count never advances."""
from __future__ import annotations
import struct
PAGE_SIZE = 64
HEADER_FORMAT = ">HH"
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
def new_page() -> bytearray:
page = bytearray(PAGE_SIZE)
struct.pack_into(HEADER_FORMAT, page, 0, HEADER_SIZE, PAGE_SIZE)
return page
def insert_record(page: bytearray, record: bytes) -> int:
pd_lower, pd_upper = struct.unpack_from(HEADER_FORMAT, page, 0)
slot_count = (pd_lower - HEADER_SIZE) // 4
new_pd_upper = pd_upper - len(record)
# BUG: pd_lower's advance is never computed OR written back -- see the "after" fix below
page[new_pd_upper:pd_upper] = record
struct.pack_into(">HH", page, pd_lower, new_pd_upper, len(record))
# BUG: never packs (new_pd_lower, new_pd_upper) back into the header -- pd_lower stays STALE
return slot_count
def read_record(page: bytearray, slot: int) -> bytes:
slot_offset = HEADER_SIZE + slot * 4
record_offset, record_length = struct.unpack_from(">HH", page, slot_offset)
return bytes(page[record_offset : record_offset + record_length])
page = new_page()
slot_a = insert_record(page, b"first")
slot_b = insert_record(page, b"second") # pd_lower never advanced, so this OVERWRITES slot 0's slot entry
print(slot_a, slot_b)
print(read_record(page, slot_a)) # expected b"first" -- but slot 0's entry was overwritten by the 2nd insertObserved (buggy) output (captured by actually running the script above):
0 0
b'second'After (drilling/code/kata-01-slotted-page-stale-pd-lower/after/kata.py)
"""Kata 1 (after): insert_record writes the new pd_lower/pd_upper back into the header every time."""
from __future__ import annotations
import struct
PAGE_SIZE = 64
HEADER_FORMAT = ">HH"
HEADER_SIZE = struct.calcsize(HEADER_FORMAT)
def new_page() -> bytearray:
page = bytearray(PAGE_SIZE)
struct.pack_into(HEADER_FORMAT, page, 0, HEADER_SIZE, PAGE_SIZE)
return page
def insert_record(page: bytearray, record: bytes) -> int:
pd_lower, pd_upper = struct.unpack_from(HEADER_FORMAT, page, 0)
slot_count = (pd_lower - HEADER_SIZE) // 4
new_pd_upper = pd_upper - len(record)
new_pd_lower = pd_lower + 4
page[new_pd_upper:pd_upper] = record
struct.pack_into(">HH", page, pd_lower, new_pd_upper, len(record))
struct.pack_into(HEADER_FORMAT, page, 0, new_pd_lower, new_pd_upper) # => commits the new boundaries
return slot_count
def read_record(page: bytearray, slot: int) -> bytes:
slot_offset = HEADER_SIZE + slot * 4
record_offset, record_length = struct.unpack_from(">HH", page, slot_offset)
return bytes(page[record_offset : record_offset + record_length])
page = new_page()
slot_a = insert_record(page, b"first")
slot_b = insert_record(page, b"second")
print(slot_a, slot_b)
print(read_record(page, slot_a))Model solution
Root cause: the header's pd_lower field is the ONLY thing that tracks how many slots already
exist -- if it's never written back after an insert, the next insert recomputes the exact same
slot_count and reuses the exact same slot offset, silently clobbering the previous record's slot
entry. Packing (new_pd_lower, new_pd_upper) back into the header is what makes the slot array
genuinely grow.
Run: python3 kata.py
Output:
0 1
b'first'Kata 2 -- Buffer Pool: evicting the first frame found instead of an unpinned one corrupts a live page
relates to co-04, co-05, Examples 10, 13, 14
Task. BufferPool._evict() should never remove a frame a caller is still actively using. The
version below is broken: it evicts whatever frame happens to be first in the dict, completely
ignoring pin count.
Before (drilling/code/kata-02-buffer-pool-evicts-pinned-frame/before/kata.py)
"""Kata 2 (before): eviction picks the FIRST frame regardless of pin count, evicting one still in use."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Frame:
page: bytes
pin_count: int = 0
@dataclass
class BufferPool:
capacity: int
frames: dict[int, Frame] = field(default_factory=dict[int, Frame])
disk_reads: int = 0
def get_page(self, page_id: int) -> bytes:
frame = self.frames.get(page_id)
if frame is not None:
frame.pin_count += 1
return frame.page
self.disk_reads += 1
if len(self.frames) >= self.capacity:
self._evict()
self.frames[page_id] = Frame(page=f"page-{page_id}".encode(), pin_count=1)
return self.frames[page_id].page
def _evict(self) -> None:
victim = next(iter(self.frames)) # BUG: picks the first frame found, never checking pin_count
del self.frames[victim]
pool = BufferPool(capacity=1)
pool.get_page(1) # page 1 loaded and PINNED -- a caller still depends on it
pool.get_page(2) # forces an eviction; the buggy policy evicts page 1 even though it is still pinned
print(1 in pool.frames) # expected True -- page 1 was pinned and should never have been evictedObserved (buggy) output (captured by actually running the script above):
FalseAfter (drilling/code/kata-02-buffer-pool-evicts-pinned-frame/after/kata.py)
"""Kata 2 (after): eviction only ever considers a frame whose pin_count is genuinely zero."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Frame:
page: bytes
pin_count: int = 0
@dataclass
class BufferPool:
capacity: int
frames: dict[int, Frame] = field(default_factory=dict[int, Frame])
disk_reads: int = 0
def get_page(self, page_id: int) -> bytes:
frame = self.frames.get(page_id)
if frame is not None:
frame.pin_count += 1
return frame.page
self.disk_reads += 1
if len(self.frames) >= self.capacity:
self._evict()
self.frames[page_id] = Frame(page=f"page-{page_id}".encode(), pin_count=1)
return self.frames[page_id].page
def _evict(self) -> None:
for page_id, frame in list(self.frames.items()): # => scan for an UNPINNED frame specifically
if frame.pin_count == 0:
del self.frames[page_id]
return
raise RuntimeError("buffer pool full and every frame is pinned -- cannot evict")
pool = BufferPool(capacity=1)
pool.get_page(1) # page 1 loaded and pinned
try:
pool.get_page(2) # no unpinned victim exists -- fails LOUDLY instead of silently evicting a pinned page
except RuntimeError as error:
print(error)
print(1 in pool.frames)Model solution
Root cause: _evict() never consulted pin_count at all -- it just removed whatever frame
happened to be first in dict-iteration order, which can (and did) include a frame a caller is
actively depending on. Scanning specifically for an UNPINNED frame, and failing loudly with a
RuntimeError when none exists, is what makes the pin count a real safety guarantee rather than
decorative bookkeeping.
Run: python3 kata.py
Output:
buffer pool full and every frame is pinned -- cannot evict
TrueKata 3 -- B-Tree Leaf Insert: appending instead of sorting breaks a range scan's ordering guarantee
relates to co-07, co-10, Examples 17, 21
Task. A B-tree leaf's range_scan() should always return results in sorted key order. The
version below is broken: insert() always appends, so the leaf is never actually kept sorted.
Before (drilling/code/kata-03-btree-leaf-insert-not-sorted/before/kata.py)
"""Kata 3 (before): a B-tree leaf insert appends instead of sorting, breaking range_scan's ordering."""
from __future__ import annotations
def insert(leaf: list[tuple[int, int]], key: int, page_id: int) -> None:
leaf.append((key, page_id)) # BUG: always appends -- the leaf is never actually kept SORTED by key
def range_scan(leaf: list[tuple[int, int]], low: int, high: int) -> list[tuple[int, int]]:
return [(k, p) for k, p in leaf if low <= k <= high] # relies on `leaf` already being sorted
leaf: list[tuple[int, int]] = []
for key in [5, 1, 9, 3, 7]: # inserted out of order
insert(leaf, key, page_id=key * 10)
print(range_scan(leaf, 1, 7))
print(range_scan(leaf, 1, 7) == sorted(range_scan(leaf, 1, 7))) # expected True -- a range scan MUST be sortedObserved (buggy) output (captured by actually running the script above):
[(5, 50), (1, 10), (3, 30), (7, 70)]
FalseAfter (drilling/code/kata-03-btree-leaf-insert-not-sorted/after/kata.py)
"""Kata 3 (after): insert uses bisect.insort, so the leaf stays sorted by key after every insert."""
from __future__ import annotations
import bisect
def insert(leaf: list[tuple[int, int]], key: int, page_id: int) -> None:
bisect.insort(leaf, (key, page_id)) # => keeps `leaf` sorted by key on every single insert
def range_scan(leaf: list[tuple[int, int]], low: int, high: int) -> list[tuple[int, int]]:
return [(k, p) for k, p in leaf if low <= k <= high]
leaf: list[tuple[int, int]] = []
for key in [5, 1, 9, 3, 7]:
insert(leaf, key, page_id=key * 10)
print(range_scan(leaf, 1, 7))
print(range_scan(leaf, 1, 7) == sorted(range_scan(leaf, 1, 7)))Model solution
Root cause: range_scan()'s correctness depends entirely on leaf already being sorted -- it
never sorts anything itself, it just filters in existing order. An insert() that always appends
gives no such guarantee, so a range scan over out-of-order data returns results in whatever order
they happened to be inserted. bisect.insort is what actually maintains the sorted invariant that
range_scan silently relies on.
Run: python3 kata.py
Output:
[(1, 10), (3, 30), (5, 50), (7, 70)]
TrueKata 4 -- LSM Read Path: scanning SSTables oldest-first returns a stale value
relates to co-12, Examples 25, 26
Task. lsm_read() should always return the NEWEST value for a key across all SSTables. The
version below is broken: it scans SSTables oldest-to-newest and returns the first match, which is
the OLDEST value, not the newest.
Before (drilling/code/kata-04-lsm-read-path-oldest-first/before/kata.py)
"""Kata 4 (before): the LSM read path checks SSTables OLDEST-to-newest, returning a stale value."""
from __future__ import annotations
def lsm_read(sstables_oldest_to_newest: list[dict[str, str]], key: str) -> str | None:
for table in sstables_oldest_to_newest: # BUG: scans oldest-first, so an OLDER value wins over a newer one
if key in table:
return table[key]
return None
# sstables[0] is the OLDEST flush, sstables[-1] is the NEWEST -- "y" was updated in the newest table
sstables = [{"y": "original-value"}, {"y": "updated-value"}]
print(lsm_read(sstables, "y"))
print(lsm_read(sstables, "y") == "updated-value") # expected True -- the NEWEST write must winObserved (buggy) output (captured by actually running the script above):
original-value
FalseAfter (drilling/code/kata-04-lsm-read-path-oldest-first/after/kata.py)
"""Kata 4 (after): the LSM read path checks SSTables NEWEST-to-oldest, so the newest write wins."""
from __future__ import annotations
def lsm_read(sstables_oldest_to_newest: list[dict[str, str]], key: str) -> str | None:
for table in reversed(sstables_oldest_to_newest): # => co-12: newest-to-oldest -- the first match wins
if key in table:
return table[key]
return None
sstables = [{"y": "original-value"}, {"y": "updated-value"}]
print(lsm_read(sstables, "y"))
print(lsm_read(sstables, "y") == "updated-value")Model solution
Root cause: LSM SSTables are immutable, so a newer write to the same key lives in a NEWER
SSTable, never overwriting the old one in place -- scanning oldest-first means the read stops at the
stale value the very first time it finds any match at all, never reaching the newer, correct one.
Scanning reversed() (newest-first) is the one-line fix that makes "the newest write wins" actually
true.
Run: python3 kata.py
Output:
updated-value
TrueKata 5 -- Write-Ahead Log: commit() flips the flag but never materializes the write
relates to co-16, co-19, Examples 29, 33, 34
Task. A committed write should be immediately readable. The version below is broken: commit()
marks a log record as committed but never actually applies it into the readable table.
Before (drilling/code/kata-05-wal-commit-without-materialize/before/kata.py)
"""Kata 5 (before): commit() flips the committed flag but never materializes the write into storage."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class WalRecord:
txn_id: int
key: str
value: str
committed: bool = False
@dataclass
class Engine:
log: list[WalRecord] = field(default_factory=list[WalRecord])
table: dict[str, str] = field(default_factory=dict[str, str])
def append(self, txn_id: int, key: str, value: str) -> None:
self.log.append(WalRecord(txn_id=txn_id, key=key, value=value))
def commit(self, txn_id: int) -> None:
for record in self.log:
if record.txn_id == txn_id:
record.committed = True # BUG: marks committed but never writes into self.table
def read(self, key: str) -> str | None:
return self.table.get(key)
engine = Engine()
engine.append(txn_id=1, key="a", value="v1")
engine.commit(txn_id=1)
print(engine.read("a")) # expected "v1" -- a committed write should be immediately readableObserved (buggy) output (captured by actually running the script above):
NoneAfter (drilling/code/kata-05-wal-commit-without-materialize/after/kata.py)
"""Kata 5 (after): commit() flips the flag AND materializes the write into the table."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class WalRecord:
txn_id: int
key: str
value: str
committed: bool = False
@dataclass
class Engine:
log: list[WalRecord] = field(default_factory=list[WalRecord])
table: dict[str, str] = field(default_factory=dict[str, str])
def append(self, txn_id: int, key: str, value: str) -> None:
self.log.append(WalRecord(txn_id=txn_id, key=key, value=value))
def commit(self, txn_id: int) -> None:
for record in self.log:
if record.txn_id == txn_id:
record.committed = True
self.table[record.key] = record.value # => co-16: commit both durably logs AND materializes
def read(self, key: str) -> str | None:
return self.table.get(key)
engine = Engine()
engine.append(txn_id=1, key="a", value="v1")
engine.commit(txn_id=1)
print(engine.read("a"))Model solution
Root cause: durability (the log record persisting) and visibility (the write becoming readable) are two DIFFERENT guarantees that commit() must provide together -- the buggy version only provided durability, flipping a flag on the already-logged record, but never actually applied the value into the table readers query. A real commit path both marks the record committed AND materializes it.
Run: python3 kata.py
Output:
v1Kata 6 -- MVCC: deleting by popping the version list destroys history a snapshot still needs
relates to co-21, co-22, Examples 36, 37, 38
Task. A snapshot taken BEFORE a delete should still see the row as it existed at that time. The
version below is broken: delete() physically removes the version instead of tagging it with
xmax, so even an earlier snapshot loses access to it.
Before (drilling/code/kata-06-mvcc-delete-forgets-xmax/before/kata.py)
"""Kata 6 (before): delete() removes the version from the LIST instead of setting xmax, breaking history."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class RowVersion:
value: str
xmin: int
xmax: int | None = None
@dataclass
class Table:
versions: list[RowVersion] = field(default_factory=list[RowVersion])
def write(self, value: str, txn_id: int) -> None:
self.versions.append(RowVersion(value=value, xmin=txn_id))
def delete(self, txn_id: int) -> None:
self.versions.pop() # BUG: physically removes the version instead of tagging it with xmax
def snapshot_read(self, snapshot_at: int) -> str | None:
for version in reversed(self.versions):
if version.xmin < snapshot_at and (version.xmax is None or version.xmax >= snapshot_at):
return version.value
return None
table = Table()
table.write("row-exists", txn_id=1)
old_snapshot = 2 # a snapshot taken BEFORE the delete below
table.delete(txn_id=2)
print(table.snapshot_read(old_snapshot)) # expected "row-exists" -- a snapshot BEFORE the delete must still see itObserved (buggy) output (captured by actually running the script above):
NoneAfter (drilling/code/kata-06-mvcc-delete-forgets-xmax/after/kata.py)
"""Kata 6 (after): delete() sets xmax on the live version instead of physically removing it."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class RowVersion:
value: str
xmin: int
xmax: int | None = None
@dataclass
class Table:
versions: list[RowVersion] = field(default_factory=list[RowVersion])
def write(self, value: str, txn_id: int) -> None:
self.versions.append(RowVersion(value=value, xmin=txn_id))
def delete(self, txn_id: int) -> None:
self.versions[-1].xmax = txn_id # => co-21: tags the version deleted -- history is never destroyed
def snapshot_read(self, snapshot_at: int) -> str | None:
for version in reversed(self.versions):
if version.xmin < snapshot_at and (version.xmax is None or version.xmax >= snapshot_at):
return version.value
return None
table = Table()
table.write("row-exists", txn_id=1)
old_snapshot = 2
table.delete(txn_id=2)
print(table.snapshot_read(old_snapshot))Model solution
Root cause: MVCC's entire visibility model depends on old versions staying PRESENT, just tagged
with the transaction that deleted them (xmax) -- physically removing a version the moment it's
deleted destroys the exact history an earlier snapshot needs to still see. Setting xmax instead of
popping is what lets a snapshot correctly answer "was this row visible AS OF my snapshot point,"
regardless of what happened after.
Run: python3 kata.py
Output:
row-existsKata 7 -- Repeatable Read: re-querying live state instead of a real snapshot lets a phantom through
relates to co-24, Example 74
Task. Two reads inside the SAME repeatable-read transaction should see the identical row count, even if a concurrent transaction inserts a new matching row in between. The version below is broken: it holds a reference to the live, mutable table instead of taking an actual snapshot.
Before (drilling/code/kata-07-repeatable-read-misses-phantom/before/kata.py)
"""Kata 7 (before): a "repeatable read" that re-queries live state on every call is NOT actually snapshot-stable."""
from __future__ import annotations
class NaiveRepeatableRead:
def __init__(self, table: list[dict[str, str]]) -> None:
self.table = table # BUG: holds a reference to the LIVE table, never actually takes a snapshot
def count_rows(self, status: str) -> int:
return sum(1 for row in self.table if row["status"] == status) # re-reads live state every call
table: list[dict[str, str]] = [{"status": "open"}]
txn = NaiveRepeatableRead(table)
first_count = txn.count_rows("open")
table.append({"status": "open"}) # a concurrent transaction inserts a new matching row (a phantom)
second_count = txn.count_rows("open")
print(first_count, second_count)
print(first_count == second_count) # expected True under repeatable read -- the SAME transaction's two reads must agreeObserved (buggy) output (captured by actually running the script above):
1 2
FalseAfter (drilling/code/kata-07-repeatable-read-misses-phantom/after/kata.py)
"""Kata 7 (after): a real repeatable-read transaction takes an explicit COPY of the table at its start."""
from __future__ import annotations
class SnapshotRepeatableRead:
def __init__(self, table: list[dict[str, str]]) -> None:
self.snapshot = list(table) # => co-24: an explicit copy taken NOW -- later inserts cannot affect it
def count_rows(self, status: str) -> int:
return sum(1 for row in self.snapshot if row["status"] == status)
table: list[dict[str, str]] = [{"status": "open"}]
txn = SnapshotRepeatableRead(table)
first_count = txn.count_rows("open")
table.append({"status": "open"}) # the concurrent insert still happens -- but txn's snapshot never sees it
second_count = txn.count_rows("open")
print(first_count, second_count)
print(first_count == second_count)Model solution
Root cause: "repeatable read" is a promise about what a SNAPSHOT sees, not a promise that the underlying data never changes -- holding a live reference to mutable state means every query re-reads whatever the world looks like right now, which is exactly what a genuine snapshot is supposed to prevent. Taking an explicit copy (or, in a real engine, resolving reads through MVCC's snapshot visibility rule from co-22) is what makes the two reads inside one transaction agree.
Run: python3 kata.py
Output:
1 1
TrueKata 8 -- Strict 2PL: releasing a write lock right after the write, not at commit, permits a dirty read
relates to co-25, Examples 68, 69
Task. A second transaction should never be able to acquire a row's write lock while the first transaction that wrote it is still uncommitted. The version below is broken: it releases the lock immediately after the write completes, not at commit time.
Before (drilling/code/kata-08-2pl-releases-lock-early/before/kata.py)
"""Kata 8 (before): a transaction releases its write lock as soon as it finishes writing, not at commit."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class LockManager:
held_by: dict[str, int] = field(default_factory=dict[str, int])
def acquire(self, key: str, txn_id: int) -> bool:
if key in self.held_by and self.held_by[key] != txn_id:
return False
self.held_by[key] = txn_id
return True
def release(self, key: str) -> None:
self.held_by.pop(key, None)
locks = LockManager()
locks.acquire("row-1", txn_id=1) # txn 1 acquires the write lock and writes row-1
locks.release("row-1") # BUG: released immediately after the write, NOT held until commit (violates strict 2PL)
# txn 2 can now acquire the SAME row before txn 1 has committed -- a dirty read/write becomes possible
txn2_acquired_before_txn1_committed = locks.acquire("row-1", txn_id=2)
print(txn2_acquired_before_txn1_committed)
print(
txn2_acquired_before_txn1_committed is False
) # expected True (i.e. txn2 should NOT acquire it) under strict 2PLObserved (buggy) output (captured by actually running the script above):
True
FalseAfter (drilling/code/kata-08-2pl-releases-lock-early/after/kata.py)
"""Kata 8 (after): the write lock is held until an explicit commit() -- strict 2PL's defining rule."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class LockManager:
held_by: dict[str, int] = field(default_factory=dict[str, int])
def acquire(self, key: str, txn_id: int) -> bool:
if key in self.held_by and self.held_by[key] != txn_id:
return False
self.held_by[key] = txn_id
return True
def release(self, key: str) -> None:
self.held_by.pop(key, None)
class StrictTwoPhaseTxn:
def __init__(self, locks: LockManager, txn_id: int) -> None:
self.locks = locks
self.txn_id = txn_id
self.held_keys: list[str] = []
def write(self, key: str) -> bool:
if not self.locks.acquire(key, self.txn_id):
return False
self.held_keys.append(key) # => the lock is tracked, NOT released, until commit()
return True
def commit(self) -> None:
for key in self.held_keys: # => co-25: strict 2PL releases every lock only AT commit, all at once
self.locks.release(key)
self.held_keys.clear()
locks = LockManager()
txn1 = StrictTwoPhaseTxn(locks, txn_id=1)
txn1.write("row-1")
txn2_acquired_before_txn1_committed = locks.acquire("row-1", txn_id=2) # txn1 has NOT committed yet
print(txn2_acquired_before_txn1_committed)
print(txn2_acquired_before_txn1_committed is False)Model solution
Root cause: releasing a write lock as soon as the write itself is done (rather than holding it
until commit) is plain 2PL's shrinking phase starting too early -- it lets a second transaction
acquire and potentially read or overwrite data from a transaction that might still abort, which is
precisely the cascading-abort risk strict 2PL exists to close. Tracking every acquired lock and
releasing the whole set together, only inside commit(), is strict 2PL's defining rule.
Run: python3 kata.py
Output:
False
TrueSelf-check checklist
Work through this list without looking anything up. Every item should be something you can do from memory, not something you'd need to search for.
Pages and buffer pool (co-01 to co-06)
- I can explain why page size is engine-specific rather than a universal constant, and pack/unpack a slotted page's header from memory (co-01, co-02).
- I can explain why a slot index, not a byte offset, is what stays stable across an in-page compaction (co-03).
- I can describe what a buffer pool's page table maps and what pin count and dirty bit each track (co-04).
- I can explain how CLOCK approximates LRU, and what specific access pattern LRU-K handles that plain LRU does not (co-05).
- I can state the buffer-pool read-path invariant ("pool first, disk only on a miss") and why a hit ratio is only meaningful because of it (co-06).
B-tree indexing (co-07 to co-10)
- I can explain the structural difference between a B-tree and a B+-tree, and name which one most SQL "btree" indexes actually are (co-07).
- I can compute an approximate B-tree height from a given fanout and key count (co-08).
- I can describe what happens on a leaf overflow, including how a split can propagate to the root (co-09).
- I can explain what sibling links between leaves save a range scan from having to redo (co-10).
LSM-tree indexing (co-11 to co-15)
- I can explain how a memtable-plus-SSTable design turns random writes into sequential ones (co-11).
- I can state the LSM read-path rule (memtable, then SSTables newest-to-oldest) and why it must stop at the first match (co-12).
- I can name both compaction strategies covered here and one production system defaulting to each (co-13).
- I can state the RUM conjecture and explain why B-tree-vs-LSM is a genuine trade-off, not a solved problem (co-14).
- I can explain a Bloom filter's false-positive/false-negative asymmetry and why that asymmetry is what makes it safe to rely on (co-15).
Write-ahead logging and recovery (co-16 to co-20)
- I can state the WAL rule precisely and explain what breaks if a page reaches disk before its log record (co-16).
- I can explain what comparing a log record's LSN against a page's pageLSN answers during recovery (co-17).
- I can name ARIES's three phases in order and explain what "repeats history" means for redo (co-18).
- I can explain which buffer policy (steal vs. no-force) makes redo necessary and which makes undo necessary (co-19).
- I can explain what a checkpoint bounds and what happens to recovery time without one (co-20).
MVCC, isolation, and durability (co-21 to co-26)
- I can explain what an update does under MVCC instead of overwriting in place, and what xmin and xmax each record (co-21).
- I can state the snapshot visibility rule and explain why it makes readers and writers non-blocking (co-22).
- I can explain why MVCC needs VACUUM (or equivalent) and what specifically it reclaims (co-23).
- I can map each of the four standard isolation levels to the anomaly it stops permitting, and name the anomaly snapshot isolation still allows (co-24).
- I can explain the contention bet 2PL makes versus the bet OCC makes, and what strict 2PL adds on top of plain 2PL (co-25).
- I can explain why a page write is not atomic across a crash, and name two mitigations this topic covers (co-26).
Physical row/column layout (co-27 to co-28)
- I can explain what a clustered index makes cheap and what it makes more expensive, versus a heap with secondary indexes (co-27).
- I can explain why a column store's layout makes dictionary, run-length, and delta encoding effective in a way row storage cannot (co-28).
Elaborative interrogation & self-explanation
Eight why/why-not prompts. Answer in your own words before checking -- the goal is explaining the
reasoning, not reciting a definition. All eight trace back to this topic's cross-cutting big idea,
consistency-latency-throughput: nearly every mechanism here is a deliberate trade between a
durability or consistency guarantee and the latency or throughput it costs to provide it.
W1. Why does this topic insist the WAL rule be stated as "the log record reaches stable storage BEFORE the page," rather than just "every write gets logged eventually"?
Answer
The ORDER is the entire guarantee -- "eventually logged" says nothing about what happens if a crash lands between the page write and the (still-pending) log write, which is exactly the window where data could be lost with no way to reconstruct it. Requiring the log record first means that at any point a crash could occur, the log is always at least as up to date as any page, which is the precondition every later recovery mechanism (LSN comparison, ARIES's phases) depends on.
W2. Why does this topic treat "B-tree or LSM" as a genuine engineering decision rather than recommending one as generally superior?
Answer
Because the RUM conjecture names a real, unavoidable trade-off -- no single design minimizes read, write, and space amplification simultaneously. A B-tree tends to favor read latency and in-place update simplicity; an LSM tree tends to favor write throughput at the cost of read amplification (mitigated by Bloom filters) and compaction overhead. Recommending one universally would mean ignoring the actual workload, which is precisely the mistake the RUM conjecture warns against.
W3. Why does MVCC's "never overwrite, always append a new version" design necessarily create the version-bloat problem that VACUUM exists to solve?
Answer
The entire benefit of MVCC (non-blocking snapshot reads) depends on old versions staying present long enough for any snapshot that might still need them -- but that same design means dead versions accumulate by construction, since nothing automatically removes a version the moment it's superseded. VACUUM is the necessary other half of the trade: MVCC buys non-blocking concurrency at the cost of a cleanup pass that a simpler "update in place" design would never have needed in the first place.
W4. Why is PostgreSQL's REPEATABLE READ (which is actually snapshot isolation) able to block a
phantom read yet still permit write skew?
Answer
Snapshot isolation's guarantee is about what each transaction READS -- both transactions see a consistent snapshot, so neither observes a phantom row appearing mid-transaction. Write skew doesn't require either transaction to see anything inconsistent; it only requires that each transaction reads a value the OTHER is about to change and that neither's read technically conflicts with the other's write under snapshot isolation's conflict-detection rules. Catching that requires reasoning about the whole pair's write-read (rw-antidependency) relationship, which only true serializable (SSI) actually checks.
W5. Why does strict 2PL hold write locks until commit, rather than releasing each lock as soon as its own access is done (which would still technically satisfy plain 2PL's growing/shrinking-phase rule)?
Answer
Plain 2PL's rule (never acquire a new lock after releasing any lock) prevents SOME anomalies, but still allows a second transaction to acquire a just-released lock and read data from a first transaction that has not yet committed -- if the first transaction later aborts, that second transaction has now read (and possibly acted on) data that never should have existed, a cascading abort. Holding every write lock until commit closes exactly that window, at the cost of longer lock hold times.
W6. Why does a torn page specifically threaten a database's durability guarantee, when the page write itself was "just one disk operation" from the application's point of view?
Answer
"One disk operation" from the application's view is not atomic at the hardware level -- disk sectors are written incrementally, and a crash mid-write can leave a page that is neither the old, valid version nor the new, valid version, but a genuinely corrupted mix of both. Because a checksum alone can only DETECT this, not repair it, mitigations like full-page-writes exist specifically to give recovery something (a full pre-image) to restore from, closing the gap between "detected corruption" and "durable data."
W7. Why does this topic describe LRU-K as "a distinct algorithm," not merely "LRU with a bigger window"?
Answer
Plain LRU makes its eviction decision from a SINGLE piece of information -- the one most recent access. LRU-K deliberately tracks the last K references for each page and bases eviction on the K-th-most-recent reference specifically, which is what lets it distinguish a page touched once during a scan (whose K-th reference, if K > 1, doesn't exist or is very old) from a page touched repeatedly by a hot workload. That's a genuinely different decision rule, not just a parameterized version of the same one.
W8. Why does a clustered index make secondary-index maintenance more expensive, when the secondary index doesn't directly store the row's data at all?
Answer
A clustered index physically locates the row inside its own B+-tree leaf, which means a leaf split (or any operation that moves the row) changes the row's physical location -- and any secondary index pointing at that row by its old physical location would now be wrong unless it's updated too. A heap avoids this coupling by giving every row a stable, physical-location-independent identifier (row-id/ctid) that a secondary index can point to without needing to track every clustered-index reorganization.
← Previous: Capstone
Last updated July 26, 2026