Preface#
Basketly, Part 7's grocery-delivery app, is a year older. Its API is clean now. Its database isn't. (Basketly is illustrative: the company, times and numbers are made up. Each failure is a classic one, and the arithmetic matches this lesson's.)
- 10:00:00 — The sale opens.
ordershas a billion rows, a random UUIDv4 primary key and five secondary indexes. Insert latency climbs from 2 ms to 40 ms, and disk reads jump although nobody is reading old orders - 10:00:30 — One discounted tin of ghee is left. Two checkouts each read
stock = 1and each writestock = 0. Two customers have paid for one tin - 10:05 — The "first order, ₹100 off" coupon is used twice by one customer, from a phone and a laptop. Each checkout looked for an earlier use and found none
- 10:40 — Ops adds an index on
statusfor a dashboard. Inserts slow down again, and the dashboard doesn't even use it - 14:00 — An analyst's export, one transaction open since 06:00, is still running.
ordershas grown by a third, mostly old row versions that cleanup can't remove
All five happen on one server, from not knowing what it does inside. Part 6 gave you the vocabulary (ACID, isolation levels, WAL and LSM in a paragraph each). This part goes underneath it: the single-node knowledge interviewers probe when you write "PostgreSQL" and they ask "why?". Spreading data across machines is Week 1's job.
Basketly's illustrative Diwali sale breaks one server five ways. A commit is one WAL flush: at 1 ms a flush, ~1,000 commits a second alone, ~40,000 when 40 share each flush, and ~600 ms at risk with synchronous_commit off. A B-tree updates pages in place, depth 4 for a billion rows; an LSM-tree writes a memtable, flushes sorted files and compacts them into levels 10× bigger, trading write amplification (33 in RocksDB's example) for sequential writes. Indexes: what a secondary entry points to, HOT updates, equality columns first, covering indexes, a useless index on status, and EXPLAIN going from 8.4 s to 0.08 ms. The last tin of ghee is a lost update under both default isolation levels, fixed by a guarded UPDATE or FOR UPDATE; the reused coupon is write skew, fixed by a unique constraint. MVCC keeps old versions in PostgreSQL's heap (VACUUM) or InnoDB's undo log (purge), so long transactions hurt. UUIDv4 keys make the whole 40 GB index hot (~3,000 random reads a second); UUIDv7 or Snowflake IDs keep inserts at the right edge.
Part I: Storage Engines#
Chapter 1: What Happens to a Write#
1.1 The Simple Definition#
A storage engine puts rows on disk and finds them again. Three of its parts explain most of what follows:
- Pages. Disk is read and written in fixed blocks: 8 kB in PostgreSQL, 16 KB by default in InnoDB, MySQL's engine (PostgreSQL docs and MySQL 8.4 Reference Manual). Rows live on pages, and an index is a tree of pages.
- The buffer pool. Pages cached in RAM. A hit costs microseconds; a miss is a disk read.
- The write-ahead log (WAL). Every change is appended to a sequential log first. A commit is durable once its log record is on disk, even if the changed pages are only in memory.
1.2 The Buffer Pool and the Working Set#
For a dedicated server, PostgreSQL's docs suggest shared_buffers at 25% of RAM, and say more than 40% rarely helps "because PostgreSQL also relies on the operating system cache" (the page cache from Part 5). MySQL's docs say "on dedicated servers, up to 80% of physical memory is often assigned to the buffer pool": InnoDB does its own caching, inserting new pages at the midpoint of its LRU list so one big scan can't push out the hot pages.
The number that matters in a design is the working set, the pages your hot queries touch. While it fits in memory, the database is fast. When it stops fitting, latency jumps. Discord gave exactly this as its reason for moving messages off MongoDB: "the data and the index could no longer fit in RAM and latencies started to become unpredictable" (Discord Engineering, 2017).
1.3 The WAL: A Commit Is One Sequential Write#
PostgreSQL's rule: changes to data files "must be written only after those changes have been logged". Since the log can redo everything after a crash, "we do not need to flush data pages to disk on every transaction commit".
%% title: One commit in PostgreSQL: the log is flushed, the pages are not sequenceDiagram autonumber participant C as Checkout service participant BE as Backend process participant BP as Shared buffers participant W as WAL on disk participant D as Data files C->>BE: UPDATE stock, INSERT order, COMMIT BE->>BP: change pages in memory, now dirty BE->>W: append WAL records, then fsync W-->>BE: flushed BE-->>C: COMMIT OK Note over BP,D: later, background writer and checkpoints BP->>D: write dirty pages Note over W,D: after a crash, replay WAL from the last checkpoint
- Group commit. "When the server is processing many small concurrent transactions, one
fsyncof the WAL file may suffice to commit many transactions" (PostgreSQL docs). - Torn pages. With
full_page_writeson (the default), the first change to a page after a checkpoint logs the whole page, so a half-written page can be rebuilt. - Replicas apply this same log, which Week 1, Day 2 builds on.
Estimate first: how many commits a second?
Basketly's volume completes a WAL flush in about 1 ms (an assumption: measure yours with pg_test_fsync). If each commit waits for its own flush, what's the ceiling? What if 40 concurrent checkouts share each flush? And what do you buy, and risk, with synchronous_commit = off?
One strong answer
- One flush per commit: 1 s ÷ 1 ms = ~1,000 commits a second, whatever the CPU count.
- 40 per flush: ~40,000 a second from the same disk. Concurrency raises the ceiling; a single-threaded loader never gets it.
synchronous_commit = off: COMMIT returns before the flush, so a crash can lose up to 3 ×wal_writer_delay(200 ms default) ≈ 600 ms of commits. The docs stress "data loss, not data corruption". Fine for "recently viewed", never for payments.
Chapter 2: B-Trees#
2.1 How a B-Tree Finds a Row#
A B-tree is a sorted tree of pages: internal pages hold keys and child pointers, linked leaves hold every key in order. Hundreds of keys per page make it wide and shallow: a lookup reads one page per level, a range scan seeks once and walks the leaves.
Writes update pages in place. A full leaf splits into two half-full pages. Insert order decides how full pages stay: InnoDB's docs say sequential inserts leave pages "about 15/16 full", random ones "from 1/2 to 15/16 full".
Tables are organised differently, and Chapter 4 depends on it:
- InnoDB: the table is a B-tree. The clustered index on the primary key stores whole rows in its leaves. Secondary indexes store the primary key.
- PostgreSQL: the table is a heap. Rows go wherever there's room, and every index, the primary key included, points at a physical location (
ctid: page and slot).
2.2 The Worked Estimate: One Billion Orders#
| Value | |
|---|---|
| Rows | 1 billion orders |
| Page (PostgreSQL docs) | 8 kB, ~8,100 bytes usable |
| BIGINT key, entry with overhead (assumed) | ~20 bytes → ~360 a page at ~90% full |
| Leaf pages | 1B ÷ 360 ≈ 2.8 million → ~22 GB |
| Levels above | 2.8M ÷ 360 ≈ 7,700 → 22 → 1 root: depth 4 |
| Internal pages | ~7,700 × 8 kB ≈ 62 MB, always cached |
| UUID key (16 bytes), entry with overhead (assumed) | ~28 bytes; random inserts leave pages ~70% full (assumed, within InnoDB's 1/2–15/16) → ~200 a page |
| UUID leaf pages | 1B ÷ 200 ≈ 5 million → ~40 GB, still depth 4 |
| Pages an insert touches | BIGINT, sequential: the right-hand path, 4 pages, 32 kB. UUIDv4: a random leaf of 5 million |
| Buffer pool for this index (assumed) | 16 GB → ~40% of UUID leaves cached → ~60% of inserts read from disk first |
| At 5,000 inserts a second (assumed peak) | ~3,000 random reads a second for this one index |
Conclusion: depth is 4 for a billion rows whatever the key. The key changes the working set: sequential keys keep 32 kB hot, random keys need the whole 40 GB index in memory or most inserts pay a disk read. That's the Preface's 10:00:00, and why Chapter 9's ID choice is an index decision.
Chapter 3: LSM-Trees#
3.1 The Idea#
O'Neil, Cheng, Gawlick and O'Neil designed the log-structured merge-tree in 1996 for tables taking constant inserts, where a B-tree index "will effectively double the I/O cost of the transaction". Their algorithm "defers and batches index changes, cascading the changes from a memory-based component through one or more disk components in an efficient manner reminiscent of merge sort" (Acta Informatica, 1996). RocksDB, Cassandra and ScyllaDB descend from it. It never updates in place: it writes new sorted files and merges them later.
%% title: An LSM-tree (RocksDB style): sequential writes, merges in the background
flowchart TD
W["Put or Delete"]:::client --> WAL[("WAL<br/>sequential append")]:::store
W --> MT["Memtable<br/>sorted, in RAM"]:::cache
MT -->|"full: flush"| L0[("L0 SST files<br/>key ranges overlap")]:::store
L0 -->|"compaction"| L1[("L1<br/>one sorted run")]:::store
L1 -->|"compaction · next level 10× bigger"| L2[("L2 ... Ln")]:::store
R["Get key"]:::client -.->|"1 newest first"| MT
R -.->|"2 Bloom filter, then file"| L0
R -.->|"3 at most one file per level"| L1- Write: into the memtable (a skiplist by default) and optionally the WAL. A full memtable is flushed to a sorted SST file in L0 and its WAL can be deleted (RocksDB wiki).
- Compaction: in leveled compaction each level below L0 is one sorted run, 10× the one above by default. An overfull level merges a file into the next level, dropping overwritten values. A delete is a tombstone until compaction reaches it.
- Read: memtable, then L0's files, then at most one file per level. A per-file Bloom filter says "definitely not here": 10 bits per key gives about a 1% false-positive rate (RocksDB wiki).
3.2 Amplification: The Three Costs#
| Amplification | RocksDB's definition | B-tree | LSM (leveled) |
|---|---|---|---|
| Write | "bytes written to storage versus bytes written to the database" | A whole page for one changed row, plus WAL | Rewritten once per level: the tuning guide's example is 1 + 2 + 10 + 10 + 10 = 33 |
| Read | "the number of disk reads per query" | ~1 for a point read | L0 files + one per level; Bloom filters help point reads, not range scans |
| Space | "size of database files on disk to data size" | Half-empty pages, dead versions until vacuum | The guide's example: 1.14; tombstones linger until compacted |
33 doesn't make LSM worse at writes: its writes are large, sequential and in the background; a B-tree's are small, random and on the commit path.
B-tree (PostgreSQL, InnoDB)
- Updates pages in place
- Point and range reads in ~1 disk read, predictable
- Random writes on the commit path; slows as the working set outgrows RAM
- Best for read-heavy OLTP, range queries, transactions
LSM-tree (RocksDB, Cassandra, ScyllaDB)
- Appends, merges in the background
- Very high sequential ingest, compresses well
- Reads check several files; scans cost more
- Compaction competes for disk, can fall behind
- Best for write-heavy, append-mostly data: messages, events, metrics
Your call: B-tree or LSM for each?
Pick an engine family for three Basketly stores, and say what you'd watch: (a) the wallet ledger, read by account and date range, written on every payment, needing transactions; (b) a rider GPS stream, 200,000 points a second, read as "rider R's last 10 minutes"; (c) a search box matching part of a product name.
One strong answer
- (a) B-tree (PostgreSQL or InnoDB): transactions, range reads on
(account, time). Watch the indexes' working set. - (b) LSM (Cassandra, ScyllaDB, RocksDB): sequential ingest, partitioned by rider, sorted by time. Watch compaction backlog, and expire data with a TTL.
- (c) Neither alone.
LIKE '%ghee%'can't use key order. It needs an inverted index, a search engine fed from the primary store.
Part II: Indexes#
Chapter 4: What an Index Buys, and What It Costs#
4.1 Primary and Secondary#
What a secondary index entry points to differs:
- InnoDB: the primary key, so a lookup searches two B-trees. The docs: "If the primary key is long, the secondary indexes use more space, so it is advantageous to have a short primary key."
- PostgreSQL: a
ctid. One index search plus one heap read, but when an update writes the row at a new location, every index needs a new entry.
Uber gave that as a main reason for moving from PostgreSQL to MySQL in 2016: in PostgreSQL, even indexes on columns an update didn't change "still must be updated with the creation of a new row tuple", while "InnoDB secondary index records hold a pointer to the primary key value" (Uber Engineering, 2016). PostgreSQL's answer is HOT (heap-only tuple) updates: if no indexed column changes and the new version fits on the same page, no index entries are added. A lower fillfactor leaves room for them (PostgreSQL docs).
4.2 Composite Indexes: Equality First, Then Range#
Part 6 gave the leftmost-prefix rule. The ordering rule underneath: equality columns first, then the one range or sort column. (user_id, created_at) stores each user's orders together, sorted by time, so WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 reads 20 adjacent entries. For Part 7's cursor, add the tie-breaker: (user_id, created_at, id).
4.3 Covering Indexes, Selectivity and the Write Tax#
An index covers a query when it holds every column the query needs. In PostgreSQL that's an index-only scan, and INCLUDE adds payload columns: CREATE INDEX ... ON orders (user_id, created_at, id) INCLUDE (status, total). The MVCC catch: the index can't tell whether an entry is visible to your snapshot, so PostgreSQL checks the visibility map, and only pages marked all-visible (recently vacuumed) skip the heap. InnoDB likewise skips the shortcut when a secondary record is delete-marked or its page "is updated by a newer transaction".
Selectivity is the fraction of rows a condition matches. Small is good. When it's large, jumping from index to scattered rows is slower than reading the table straight through. PostgreSQL's EXPLAIN examples show the planner moving from an index scan (unique1 = 42) to a bitmap scan (< 100) to a sequential scan (< 7000 of 10,000 rows).
What breaks if you index status?
orders already has five secondary indexes. Ops adds one on status (five values: 90% of orders are delivered, under 1% payment_pending). What happens to each insert? Does the index help (a) WHERE status = 'delivered', (b) WHERE status = 'payment_pending'?
One strong answer
- Every insert maintains one more B-tree and writes more WAL. In PostgreSQL, an update that changes
statuscan't be HOT any more, so it adds entries to every index. - (a) No. 90% of rows: a sequential scan is cheaper, and the planner ignores the index.
- (b) Yes, but most of the index is wasted. A partial index,
... ON orders (created_at) WHERE status = 'payment_pending', is tiny and only touched by the rows it covers.
Every index is another tree to update and log on each write, and more pages competing for memory. Add one when a real query needs it and EXPLAIN shows it used.
4.4 Reading EXPLAIN: One Query, Three Plans#
Part 7's history page for the café owner:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, created_at, status, total
FROM orders
WHERE user_id = 4812
AND (created_at, id) < ('2026-03-02 18:04', 'ord_77')
ORDER BY created_at DESC, id DESC
LIMIT 21;ANALYZE runs the query (wrap writes in BEGIN … ROLLBACK, as the docs advise) and adds actual rows and times. BUFFERS (on by default with ANALYZE since PostgreSQL 18) shows pages found in cache (hit) and read from disk (read). The outputs are illustrative and trimmed: PostgreSQL 18's format and node names, made-up numbers.
Plan 1: only an index on created_at.
Limit (cost=0.57..504738.75 rows=21 width=48) (actual time=8412.317..8412.906 rows=21.00 loops=1)
-> Index Scan Backward using orders_created_at_idx on orders (cost=0.57..98544120.00 rows=4100 width=48) (actual time=8412.315..8412.901 rows=21.00 loops=1)
Filter: ((user_id = 4812) AND (ROW(created_at, id) < ROW(...)))
Rows Removed by Filter: 3186420
Buffers: shared hit=402113 read=1290874The index only supplies the order. PostgreSQL walks orders backwards in time and discards 3.2 million to find 21, reading 1.3 million pages from disk. The giveaway: Rows Removed by Filter far above rows returned.
Plan 2: CREATE INDEX orders_user_created_idx ON orders (user_id, created_at DESC, id DESC);
Limit (cost=0.70..85.30 rows=21 width=48) (actual time=0.912..1.384 rows=21.00 loops=1)
-> Index Scan using orders_user_created_idx on orders (cost=0.70..85.30 rows=21 width=48) (actual time=0.910..1.379 rows=21.00 loops=1)
Index Cond: ((user_id = 4812) AND (ROW(created_at, id) < ROW(...)))
Buffers: shared hit=6 read=19Now it's an Index Cond: one seek, 21 adjacent entries. The reads are mostly heap pages, one per order, for status and total.
Plan 3: the same keys plus INCLUDE (status, total).
Limit (cost=0.70..4.90 rows=21 width=48) (actual time=0.048..0.081 rows=21.00 loops=1)
-> Index Only Scan using orders_user_created_cov on orders (cost=0.70..4.90 rows=21 width=48) (actual time=0.047..0.077 rows=21.00 loops=1)
Index Cond: ((user_id = 4812) AND (ROW(created_at, id) < ROW(...)))
Heap Fetches: 0
Buffers: shared hit=5Heap Fetches: 0: the visibility map let it skip the table. Part 7 counted index entries; here's the cost in pages: five, all cached. If Heap Fetches climbs, vacuum is behind (Chapter 6). Also compare estimated and actual rows: a big gap means stale statistics.
Part III: Transactions, MVCC and Locking#
Chapter 5: The Anomalies, and What Each Level Prevents#
5.1 Five Anomalies#
Part 6 tabled the three ANSI phenomena. Berenson et al. showed those definitions "fail to characterize several popular isolation levels" and introduced new phenomena, among them lost update and write skew ("A Critique of ANSI SQL Isolation Levels", SIGMOD 1995), the two behind the Preface's ghee and coupon bugs. On Basketly:
| Anomaly | What happens | Basketly example |
|---|---|---|
| Dirty read | T1 reads T2's uncommitted write; T2 rolls back | The picker sees "paid"; the payment then rolls back |
| Non-repeatable read | T1 reads a row twice, T2 commits in between | An invoice reads ₹540, a refund commits, the tax line reads ₹490 |
| Phantom | T1's query returns new rows the second time | The 6–7 pm slot has 118 orders when riders are counted, 121 when routes are built |
| Lost update | Both read, both write a value computed from it | Stock 1: both read 1, both write 0. The paper's history: x=100, T2 writes 120, T1 writes 130, "T2's update will be lost" |
| Write skew | Both read the same data, check a rule, write different rows | One coupon per customer: both count earlier uses, see 0, insert a use |
Write skew is the hard one: no row is written twice, so row-level conflict checks never see it.
5.2 What PostgreSQL and InnoDB Actually Do#
Both use snapshots (Chapter 6), but they treat writes differently:
| Anomaly | PostgreSQL (default Read Committed) | MySQL InnoDB (default Repeatable Read) |
|---|---|---|
| Dirty read | Never: "Read Uncommitted mode behaves like Read Committed" | Only at Read Uncommitted |
| Non-repeatable read | Possible at Read Committed (snapshot per statement); prevented at Repeatable Read | Prevented at Repeatable Read: plain SELECTs "read the snapshot established by the first read" |
| Phantom | Prevented at Repeatable Read ("does not allow phantom reads") | Plain SELECTs: by the snapshot. Locking reads: by next-key locks on index gaps |
| Lost update | Allowed at Read Committed. At Repeatable Read the second writer gets could not serialize access due to concurrent update |
Allowed at Repeatable Read: the snapshot "applies to SELECT statements … not necessarily to DML statements", so the UPDATE silently overwrites. Serializable (autocommit off) makes SELECTs take shared locks |
| Write skew | Prevented automatically only at Serializable (SSI aborts one with SQLSTATE 40001); FOR UPDATE helps only if the rows read exist |
Serializable, or FOR UPDATE, whose next-key locks also cover the gaps read |
Read-then-write in application code loses updates on both defaults. Atomic statements avoid it: UPDATE products SET stock = stock - 1 WHERE id = 7 AND stock >= 1 is safe even at Read Committed. In PostgreSQL the second updater waits for the first to commit, then "the search condition of the command (the WHERE clause) is re-evaluated" against the updated row, so it updates 0 rows.
The last tin of ghee, sold twice
Basketly runs PostgreSQL's default level. Checkout does SELECT stock FROM products WHERE id = 7, checks stock >= 1 in Python, runs UPDATE products SET stock = 0 WHERE id = 7, and commits. Two run at once with stock at 1. What happens? Does Repeatable Read fix it? Give two fixes that don't depend on the level.
One strong answer
- Both succeed: both read 1 and both write 0. A lost update: one tin, two paid orders.
- Repeatable Read: the second UPDATE fails with "could not serialize access", so you also need a retry loop. MySQL's Repeatable Read would let both succeed.
- Fix 1:
UPDATE ... SET stock = stock - 1 WHERE id = 7 AND stock >= 1; 0 rows affected means sold out. - Fix 2:
SELECT ... FOR UPDATEfirst, so the second checkout waits and then reads 0.
For the coupon, the fix is a constraint: UNIQUE (customer_id, coupon_code) on the uses table makes the second insert fail at any level, because the rule now lives in one row the database can check. Where no constraint fits, use Serializable and retry on 40001. Week 5, Day 1 connects isolation to distributed consistency.
Chapter 6: MVCC: How Snapshots Are Built#
Multi-version concurrency control keeps old row versions so each transaction reads a consistent snapshot while others write: "reading never blocks writing and writing never blocks reading" (PostgreSQL docs). The two engines keep old versions in opposite places.
%% title: Where old versions live: PostgreSQL's heap versus InnoDB's undo log
flowchart TD
subgraph PG["PostgreSQL: new version in the heap"]
direction TB
IX1["Index entries<br/>point at a ctid"]:::service --> V1["Tuple v1 · stock=1<br/>xmin 900 · xmax 905"]:::bad
IX1 --> V2["Tuple v2 · stock=0<br/>xmin 905 · xmax 0"]:::ok
V1 -->|"t_ctid"| V2
VAC["VACUUM removes v1<br/>once no snapshot needs it"]:::core -.-> V1
end
subgraph INNO["InnoDB: update in place, old version in undo"]
direction TB
CI["Clustered index row · stock=0<br/>DB_TRX_ID 905"]:::ok -->|"DB_ROLL_PTR"| U1["Undo record<br/>stock=1 · trx 900"]:::bad
U1 --> U2["Older undo records"]:::bad
PUR["Purge discards undo<br/>once no read view needs it"]:::core -.-> U1
end6.1 PostgreSQL: Versions in the Table#
Each tuple header has t_xmin (the inserting transaction), t_xmax (the deleting one) and t_ctid (the newer version). An UPDATE writes a new tuple and sets xmax on the old one; a snapshot compares those IDs with what had committed when it was taken.
The old tuple stays until VACUUM: it "must not be deleted while it is still potentially visible to other transactions". VACUUM marks the space for reuse; VACUUM FULL shrinks the file but locks the table exclusively.
VACUUM also freezes old rows. Transaction IDs are 32 bits, so after about 4 billion transactions the counter wraps, and "transactions that were in the past appear to be in the future": their rows vanish. Autovacuum forces a VACUUM on any table whose oldest unfrozen XID is 200 million transactions old (the autovacuum_freeze_max_age default). Before sharding, Notion saw "the Postgres VACUUM process began to stall consistently", with "the prospect of transaction ID (TXID) wraparound" looming, when Postgres "would stop processing all writes" (Notion Engineering, 2021).
6.2 InnoDB: Versions in the Undo Log#
Each row carries a hidden 6-byte DB_TRX_ID (the last writer) and a 7-byte DB_ROLL_PTR to an undo log record that rebuilds the previous version. Rows are updated in place; an older snapshot follows the undo chain back.
Purge discards update undo once no snapshot could need it. The docs' advice applies to both engines: "commit transactions regularly, including transactions that issue only consistent reads", or "the rollback segment may grow too big".
What breaks if one transaction stays open for eight hours?
At 06:00 an analyst runs BEGIN; SELECT ... at Repeatable Read for an export and leaves it open. The sale makes millions of updates. What grows on PostgreSQL, what grows on InnoDB, and what do users notice by 14:00?
One strong answer
- PostgreSQL: VACUUM can't remove any version the 06:00 snapshot might see, so dead tuples pile up in tables and indexes. Scans read more pages, index-only scans fall back to the heap, freezing waits.
- InnoDB: purge can't discard undo newer than that snapshot, so undo grows and old reads walk longer chains.
- Users see everything slowly get slower. Fix: alert on transaction age, set
idle_in_transaction_session_timeoutin PostgreSQL, run exports on a replica.
Chapter 7: Locking: Pessimistic and Optimistic#
7.1 SELECT … FOR UPDATE#
A locking read makes other writers wait for the row:
BEGIN;
SELECT stock FROM products WHERE id = 7 FOR UPDATE; -- the second checkout waits here
-- check stock >= 1, compute the price, build the order
UPDATE products SET stock = stock - 1 WHERE id = 7;
INSERT INTO orders (...) VALUES (...);
COMMIT; -- lock releasedNOWAITerrors instead of waiting.SKIP LOCKEDskips locked rows: "an inconsistent view of the data", wrong for general work but right for "multiple consumers accessing a queue-like table" (PostgreSQL docs).- InnoDB locks ranges. At Repeatable Read,
WHERE slot BETWEEN 10 AND 20 FOR UPDATEtakes next-key locks that block inserts into the range. - Keep it short, with no network calls inside, and lock rows in ID order to avoid deadlocks.
7.2 Optimistic Concurrency With a Version Column#
Locks live inside one transaction. A manager who opens "edit product", thinks for two minutes and saves is outside any. Optimistic concurrency detects the conflict instead: each row has a version, and an update only succeeds if it hasn't changed. It's the database half of Part 7's ETag and If-Match.
class Conflict(Exception):
"""The row changed after we read it: reload and ask the user."""
def save_price(db, product_id: int, new_price: int, version_seen: int) -> int:
cur = db.execute(
"""UPDATE products
SET price = %s, version = version + 1
WHERE id = %s AND version = %s""",
(new_price, product_id, version_seen),
)
if cur.rowcount == 0: # version moved on, or the row is gone
raise Conflict(product_id) # API layer → 412 Precondition Failed
db.commit()
return version_seen + 1 # the new ETag%% title: Two managers edit one price: the version column catches the second save sequenceDiagram autonumber participant A as Manager A participant B as Manager B participant DB as products row A->>DB: read price 45, version 17 B->>DB: read price 45, version 17 A->>DB: UPDATE price 48 WHERE version = 17 DB-->>A: 1 row, now version 18 B->>DB: UPDATE price 42 WHERE version = 17 DB-->>B: 0 rows, conflict Note over B: reload, see 48, decide again
Pessimistic: FOR UPDATE
- Prevents the conflict: the second transaction waits
- For frequent conflicts (one hot stock row) and short transactions
- Costs lock waits, deadlocks, connections held
- Can't span a user's think time
Optimistic: version column
- Detects the conflict at write time; the loser retries
- For rare conflicts, and reads far from writes
- Costs retries, a storm if conflicts are common
- Works across requests and caches (ETags)
Part IV: Choosing a Database, and Its IDs#
Chapter 8: A Decision Framework#
8.1 Start From the Access Patterns#
Choose from the queries you must serve: by key, by range within a key, ad hoc, by text or by time? Which invariants span rows (stock ≥ 0, one coupon each) and need transactions in one place? Updates or appends? Does one node with replicas suffice, or is partitioning needed from day one (Week 1, Day 1)?
| Family | Strong at | Weak at | Examples |
|---|---|---|---|
| Relational | Transactions, invariants, ad hoc queries | Very high write volume on one node; manual resharding | PostgreSQL, MySQL |
| Document | Self-contained aggregates read whole | Relationships across documents | MongoDB |
| Wide-column | Huge append-heavy writes, ranges within a partition | Ad hoc queries, cross-partition transactions | Cassandra, ScyllaDB |
| Key-value | Key lookups at any scale | Anything else | DynamoDB, Redis |
| Search | Full text, relevance, facets | Being the source of truth | Elasticsearch |
| Time-series | Metric and event ingest, rollups, retention | Single-record updates | TimescaleDB, ClickHouse |
%% title: Picking the store behind an endpoint
flowchart TD
Q{"Invariants across rows,<br/>or ad hoc queries?"} -->|"yes"| PG@{ icon: "tech:postgresql", label: "Relational<br/>PostgreSQL · MySQL" }
Q -->|"no"| K{"Every read by a<br/>known key?"}
K -->|"yes, small values"| KV@{ icon: "tech:dynamodb", label: "Key-value<br/>DynamoDB · Redis" }
K -->|"key + range, huge writes"| WC@{ icon: "tech:cassandra", label: "Wide-column<br/>Cassandra · ScyllaDB" }
K -->|"no"| T{"Text relevance,<br/>or time rollups?"}
T -->|"text"| ES@{ icon: "tech:elasticsearch", label: "Search index<br/>fed from the primary store" }
T -->|"time"| TS@{ icon: "tech:clickhouse", label: "Time-series or columnar<br/>ClickHouse · TimescaleDB" }
T -->|"neither: whole aggregates"| DOC@{ icon: "tech:mongodb", label: "Document<br/>MongoDB" }
class PG,KV,WC,DOC store
class ES,TS serviceThe default is relational. Leave it for an access pattern you can name. A search index or cache is a copy, not the record.
8.2 What Real Companies Chose#
- Notion stayed on PostgreSQL and sharded it by workspace ID into 480 logical shards on 32 physical databases, since "each block belongs to exactly one workspace". It judged switching to DynamoDB "too risky for our use case" (Notion Engineering, 2021).
- Discord moved messages from MongoDB to Cassandra. Reads "were extremely random and our read/write ratio was about 50/50"; the key was
((channel_id, bucket), message_id)with Snowflake message IDs and ~10-day buckets (Discord Engineering, 2017). By 2022 that was 177 Cassandra nodes fighting hot partitions, GC pauses and compaction backlogs; on 72 ScyllaDB nodes, p99 historical reads fell from 40–125 ms to 15 ms (Discord Engineering, 2023). - Uber moved from PostgreSQL to MySQL in 2016, citing write amplification from index updates among other reasons (Uber Engineering, 2016).
- Key-value at scale: on Prime Day 2025, DynamoDB traffic from Amazon's own sites and systems (Alexa, Amazon.com, fulfilment centres) got "single-digit millisecond responses" while "peaking at 151 million requests per second" (AWS, August 2025).
- Search lags the source: Elasticsearch documents are searchable "within 1 second" by default (Elastic docs).
Chapter 9: Generating IDs#
9.1 Auto-Increment, and Where It Stops#
BIGSERIAL or AUTO_INCREMENT is 8 bytes, ordered and appends at the index's right edge. Its limits: one counter means one writer, so shards and regions need coordination. Values have gaps: in InnoDB, values used by rolled-back transactions are "lost", and "such lost values are not reused", and the default lock mode (2, "interleaved") doesn't promise consecutive values. IDs leak volume (order 1,000,412 tells a competitor your count), which is why Part 7 made them opaque. And the ID exists only after the insert.
9.2 The Alternatives#
UUIDv4 122 random bits, no coordination. RFC 9562 (May 2024) names the cost: non-time-ordered UUIDs "have poor database-index locality… they require inserts to be performed at random locations". That's the calc's 40 GB working set. In InnoDB it scatters the rows themselves and copies the key into every secondary entry.
UUIDv7 48-bit Unix milliseconds, 4-bit version, 12 bits rand_a, 2-bit variant, 62 bits rand_b (RFC 9562). It sorts by time, so inserts return to the right edge. The RFC says implementations "SHOULD utilize UUIDv7 instead of UUIDv1 and UUIDv6 if possible", and that UUIDs "MUST NOT be used as security capabilities". PostgreSQL 18 has uuidv7() built in.
import os, time, uuid
def uuid7() -> uuid.UUID:
"""RFC 9562: unix_ts_ms(48) | ver(4) | rand_a(12) | var(2) | rand_b(62)."""
ms = time.time_ns() // 1_000_000
rand = int.from_bytes(os.urandom(10), "big")
value = (ms & ((1 << 48) - 1)) << 80 # bits 127..80: ms since 1970
value |= 0x7 << 76 # version 7
value |= ((rand >> 62) & 0xFFF) << 64 # rand_a
value |= 0b10 << 62 # variant
value |= rand & ((1 << 62) - 1) # rand_b
return uuid.UUID(int=value)
# Same-millisecond IDs aren't ordered here; RFC 9562 §6.2 allows a counter in rand_a for that.Snowflake (Twitter, 2010) fits in 64 bits. Twitter built it moving from MySQL to Cassandra, for "minimum 10k ids per second per process" without coordination (Twitter's Snowflake README):
| Bits | Field | README |
|---|---|---|
| 41 | Milliseconds since a custom epoch | "gives us 69 years" |
| 10 | Configured machine ID | "up to 1024 machines" |
| 12 | Sequence | "rolls over every 4096 per machine" |
63 bits, so the sign bit stays 0 and IDs fit a signed BIGINT. The hard parts are unique machine IDs and clocks: the README says "you should use NTP", and if the clock moves back Snowflake "will refuse to generate ids" until it passes the last one used. The Unique ID Generator problem builds one end to end.
ULID (a community spec): 48-bit milliseconds plus 80 random bits as 26 Crockford base32 characters, sortable as text.
Ticket servers (Flickr, 2010): a one-row MySQL table bumped with REPLACE INTO Tickets64 (stub) VALUES ('a'); SELECT LAST_INSERT_ID();. Two servers with auto_increment_increment = 2 and offsets 1 and 2 hand out odd and even IDs. Flickr rejected GUIDs because "GUIDs are big, and they index badly in MySQL"; the scheme had run "since Friday the 13th, January 2006" (Flickr Engineering, 2010).
Estimate: what does the ID cost five secondary indexes?
orders is on InnoDB: a billion rows, five secondary indexes, each entry carrying the primary key. How many bytes does the key alone add across them for a BIGINT Snowflake ID, a BINARY(16) UUIDv7, and a UUIDv4 stored as CHAR(36)? Which also inserts at random places in the table itself?
One strong answer
- Snowflake BIGINT: 8 B × 5 × 1B = 40 GB.
- UUIDv7 BINARY(16): 16 B × 5 × 1B = 80 GB.
- UUIDv4 CHAR(36): at least 36 B × 5 × 1B = 180 GB, and it inserts rows at random leaves of the clustered index, so the table gets the calc's random working set too.
- So in InnoDB the primary key is a memory budget: short and time-ordered keeps every index small and every insert at the right edge.
Part V: Interview Preparation#
Chapter 10: Common Mistakes#
- "Add an index" for every slow query. Each one taxes every write. Read
EXPLAINfirst: the fix may be column order, a partial index or a covering one. - Trusting the default isolation level with read-then-write code. Neither default prevents lost updates. Use guarded updates,
FOR UPDATEor versions, and constraints for cross-row rules. - UUIDv4 primary keys on big write-heavy tables, especially InnoDB. Use UUIDv7 or Snowflake-style IDs.
- Long transactions on the primary. They block vacuum and purge. Alert on transaction age; run reports on a replica.
- NoSQL "for scale" without naming the access pattern. Notion sharded PostgreSQL instead.
Chapter 11: Interview Tips#
11.1 When to Bring It Up#
When you pick the database (one sentence of why), when you sketch the data model (primary key, indexes for the top queries), and whenever money, stock or uniqueness appears (how concurrent writers can't break the rule).
11.2 Key Phrases#
ENGINE
"Transactions and range reads, so a B-tree engine. The GPS stream is append-only at 200k a second, so it goes to an LSM store partitioned by rider."
INDEXES
"History filters by user and sorts by time, so
(user_id, created_at, id), covering if the payload is small."
CONCURRENCY
"Stock uses a guarded atomic update; zero rows means sold out. The coupon rule is a unique constraint, not an application check."
IDS
"Time-ordered 64-bit IDs, so inserts stay at the index's right edge and shards don't coordinate."
11.3 Common Follow-Up Questions#
| Question | Good answer |
|---|---|
| "Repeatable Read in PostgreSQL vs MySQL?" | "Both give a stable read snapshot. PostgreSQL aborts a concurrent update; InnoDB lets it overwrite. Neither stops write skew." |
| "How do you stop double-selling the last item?" | "SET stock = stock - 1 WHERE id = ? AND stock > 0 and check rows affected, or FOR UPDATE if logic sits between read and write." |
| "Why not UUIDv4 keys?" | "Random inserts make the whole index the working set. UUIDv7 keeps uniqueness and restores time order." |
Chapter 12: Practice Problems#
Practice · problem 1
Problem 1: A Seat-Booking Table#
Setup: A cinema chain sells seats for 2,000 screens, and two people often click the same seat within a second. Design the table, key and booking statement, and pick an isolation level.
Click for solution outline
- Table:
seats(show_id, seat_no, status, order_id, version), primary key(show_id, seat_no). - Book:
UPDATE seats SET status = 'held', order_id = ? WHERE show_id = ? AND seat_no = ? AND status = 'free'; one row affected means you got it. Read Committed is enough. - Several seats:
FOR UPDATEinseat_noorder, orUNIQUE (show_id, seat_no)on a bookings table.
Practice · problem 2
Problem 2: The Slow Admin Search#
Setup: WHERE store_id = ? AND status = 'packed' ORDER BY created_at LIMIT 50 takes 3 seconds. EXPLAIN shows Index Scan using orders_created_at_idx with Rows Removed by Filter: 2,400,000. Fix it.
Click for solution outline
- Diagnosis: it walks the time index and filters 2.4M rows; neither equality is an index condition.
- Index:
(store_id, status, created_at), equalities first. Sincepackedis rare, a partial(store_id, created_at) WHERE status = 'packed'is smaller. - Check: an
Index Condon the equalities, noSortnode, buffer reads in the tens.
Further Reading#
Documentation#
- Blog
PostgreSQL — Transaction Isolation: https://www.postgresql.org/docs/current/transaction-iso.html
- Blog
PostgreSQL — Routine Vacuuming: https://www.postgresql.org/docs/current/routine-vacuuming.html
- Blog
PostgreSQL — WAL Introduction: https://www.postgresql.org/docs/current/wal-intro.html
- Blog
PostgreSQL — Index-Only Scans and Covering Indexes: https://www.postgresql.org/docs/current/indexes-index-only-scans.html
- Blog
PostgreSQL — Using EXPLAIN: https://www.postgresql.org/docs/current/using-explain.html
- Docs
MySQL 8.4 — InnoDB Transaction Isolation Levels: https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html
- Docs
MySQL 8.4 — InnoDB Multi-Versioning: https://dev.mysql.com/doc/refman/8.4/en/innodb-multi-versioning.html
- Docs
MySQL 8.4 — InnoDB Locking: https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html
- Wiki
RocksDB — Tuning Guide: https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide
Standards and Papers#
- Docs
RFC 9562 — Universally Unique IDentifiers (May 2024): https://www.rfc-editor.org/rfc/rfc9562.html
- Paper
O'Neil et al. — The Log-Structured Merge-Tree, Acta Informatica, 1996: https://www.cs.umb.edu/~poneil/lsmtree.pdf
- Read
Berenson et al. — A Critique of ANSI SQL Isolation Levels, SIGMOD 1995: https://www.microsoft.com/en-us/research/publication/a-critique-of-ansi-sql-isolation-levels/
Engineering Posts#
- Read
Twitter — Snowflake README (2010): https://github.com/twitter-archive/snowflake/tree/snowflake-2010
- Read
Flickr — Ticket Servers (2010): https://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/
- Blog
Discord — Billions of Messages (2017) and Trillions of Messages (2023): https://discord.com/blog/how-discord-stores-billions-of-messages · https://discord.com/blog/how-discord-stores-trillions-of-messages
- Blog
Uber — Why Uber Switched from Postgres to MySQL (2016): https://www.uber.com/blog/postgres-to-mysql-migration/
- Blog
Notion — Sharding Postgres at Notion (2021): https://www.notion.com/blog/sharding-postgres-at-notion
Book#
- Read
Martin Kleppmann — Designing Data-Intensive Applications, chapters 3 and 7
Week 0 Complete! You now have a solid foundation in:
- Read
System design methodology (Part 1)
- Read
Infrastructure building blocks (Part 2)
- Read
Back-of-envelope estimation (Part 3)
- Read
Networking fundamentals (Part 4)
- Read
Operating system concepts (Part 5)
- Read
Theory and terminology (Part 6)
- Docs
API design (Part 7)
- Read
Databases in depth (Part 8)
Next: Week 1 — Data at Scale (the Week 1 preview). Everything here ran on one server. Week 1 spreads Basketly's billion orders across many: Day 1 partitions them (the shard key is often the column that leads your composite indexes), and Day 2 replicates them by shipping the WAL from Chapter 1.
this lesson in shorts · 4
play them all →One idea from this lesson in about a minute each, made for your phone.
Go deeper · reading trail
all reading →Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.
The Log-Structured Merge-Tree (LSM-Tree)
Patrick O'Neil, Edward Cheng et al. · 1996- What
- The 1996 paper that introduced the log-structured merge-tree, a disk index for tables that take a constant stream of inserts.
- Key idea
- A B-tree index can double a transaction's I/O cost; the LSM-tree defers and batches index changes from a memory component through disk components, like a merge sort.
- Why here
- It is the origin of the LSM-trees in this part's storage engines chapter, the design that RocksDB, Cassandra and ScyllaDB descend from.
A Critique of ANSI SQL Isolation Levels
Hal Berenson, Phil Bernstein et al. · 1995- What
- The SIGMOD 1995 paper showing that the ANSI SQL definitions of isolation levels fail to describe the levels real databases implement.
- Key idea
- Dirty reads, non-repeatable reads and phantoms are not enough; the paper adds phenomena such as lost update and write skew, and defines snapshot isolation.
- Why here
- It is the source of this part's five anomalies table, including the lost update behind the ghee bug and the write skew behind the coupon bug.
Ticket Servers: Distributed Unique Primary Keys on the Cheap
2010- What
- Flickr describes how it generates unique primary keys for its sharded MySQL databases with dedicated ticket servers.
- Key idea
- GUIDs are big and index badly in MySQL, so a one-row table bumped with REPLACE INTO hands out IDs, and two servers split odd and even values.
- Why here
- It is the ticket-server option in this part's ID chapter, next to auto-increment, UUIDv4, UUIDv7 and Snowflake, and why the key is an index decision.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.



