all_lessons / system_design / 04 · data modeling & storage lesson 4 / 19

Data modeling & storage engines

This is the lesson the rest of the course quietly leans on. Partitioning (07), replication (08), messaging (11) and transactions (12) all assume you know what a write-ahead log is, why a B-tree and an LSM-tree make opposite bets, and what an index physically costs. We build all of that here, from one idea: you model the queries, not the entities, and the engine underneath is just a machine for making your chosen queries cheap and the rest expensive.

First principle

A database is a trade between write cost, read cost, and space — you cannot minimise all three. Every decision in this lesson (normalise vs denormalise, SQL vs NoSQL, B-tree vs LSM, which indexes to build) is choosing which queries get to be cheap and paying for it somewhere else. Name the cost every time.

1. Model the queries, not just the entities

Newcomers start by drawing entities — User, Order, Product — and wiring up relationships. That is necessary but not sufficient. The schema that actually survives contact with production is shaped by the access patterns: what reads and writes happen, how often, and how big. Those numbers come straight out of the estimate step from lesson 01 — QPS, read:write ratio, payload sizes, growth.

So before any tables, write the query list. For a social feed it might be:

Those three sentences decide more about your design than the entity diagram does. The hot read ("recent posts by followees") tells you what to index, what to denormalise, and eventually what to shard on (07). The rule: list the top ~5 queries by frequency and latency-sensitivity first; the schema is whatever makes those cheap.

Entities, keys, relationships — the vocabulary

2. Normalisation vs denormalisation — the core modelling trade

This is the first real fork, and it is the same write-vs-read trade as everything else, in miniature.

Normalise means: store each fact exactly once, in one place, and reference it by key. A user's display name lives only in the users table; an order row stores user_id, not the name. Benefits: one source of truth, so a name change is one write and nothing can drift. The cost: to show an order with the buyer's name, the read must join — go fetch the matching user row. Reads do more work.

Denormalise means: deliberately duplicate data so a single fetch answers the query. Store the buyer's name on the order row. Benefit: the read is one lookup, no join — fast. The cost: that name now lives in N places, so a change is N writes, and if any write is missed the copies drift (the dreaded update anomaly). You traded cheap writes and correctness-by-construction for cheap reads.

The maxim

"Normalise until it hurts, denormalise until it works." Start normalised — it is the safe default, correct by construction. Denormalise only the specific hot read that the normalised join can't serve fast enough, and only once you can name what keeps the copies in sync. A cache (06) is itself a denormalised read copy — same trade, same staleness risk, just in a faster tier. The capstone (19) walks a timeline where a feed starts normalised and selectively denormalises the fan-out read.

ApproachReadWriteBuysCost
NormalisedJoin (slower)One write, one placeOne source of truth; no update anomalies; flexible ad-hoc queriesReads pay join cost; can't always serve the hot read fast enough
DenormalisedSingle fetch (fast)Update every copyFast single-fetch reads; no joins; shard-friendlyDuplicate data drifts (update anomaly); writes fan out; more space

3. SQL vs NoSQL — a decision, not a religion

The framing that loses interviews is "NoSQL is web-scale, SQL is old." The framing that wins: relational and non-relational engines make different default trades, and you pick by your access patterns and consistency needs.

Relational (SQL) — Postgres, MySQL. Data in tables with a fixed schema; the engine does joins for you, supports ACID transactions (Atomic, Consistent, Isolated, Durable — see 12), and answers ad-hoc queries you didn't plan for (a query planner figures out how). You pay with a rigid schema and harder horizontal scaling (joins across machines are expensive — the reason sharding in 07 is painful).

Non-relational (NoSQL) is a family, not one thing:

The shared theme of NoSQL: flexible or access-pattern-shaped schema, horizontal scale, and no joins. No joins is the load-bearing constraint — it forces you to denormalise and model one table per query. You give up ad-hoc flexibility and (often) multi-key transactions to get scale and predictable single-key latency.

DimensionRelational (SQL)NoSQL (KV / doc / wide-col)
SchemaRigid, enforced up frontFlexible / per-access-pattern
JoinsYes (engine does them)No — you denormalise
Ad-hoc queriesStrong (query planner)Weak — only planned access paths
TransactionsFull ACID, multi-rowOften single-key only
Horizontal scaleHarder (cross-shard joins)Native, linear
Cost you payScaling pain past one boxModelling rigidity + duplication
Reach for it when…Relationships, ad-hoc reporting, strong invariantsKnown hot access pattern, huge write volume, scale-out
Polyglot persistence

Mature systems use several stores, each for what it's best at — Postgres for the transactional core, Redis for the hot cache, Cassandra for the high-volume event log, Elasticsearch for full-text search. This is polyglot persistence. The interview-grade instinct: don't pick one database for the whole system; pick the right store per access pattern, and accept the cost of keeping them in sync (often via the change log we meet next, see 11).

4. Storage-engine internals — the heart of the lesson

Now the part later lessons assume. SQL-vs-NoSQL is a packaging question; underneath, a database is a storage engine — code that turns "set key K to value V" and "get key K" into disk I/O. Two facts about disks drive everything:

  1. Sequential writes are ~100× cheaper than random writes (append to the end of a file vs seek to a random spot and overwrite). This gap was enormous on spinning disks (a seek costs milliseconds); it shrinks on SSDs but stays real, because random writes still trigger erase-block churn and write amplification. Re-use the latency ladder from 01: a disk seek dwarfs a sequential append.
  2. A crash can happen mid-write. Durability means a committed write survives a power loss — even if the box dies one instruction later.

The write-ahead log (WAL) — durability, cheaply

Here is the trick that makes durable databases fast. Before touching the actual data structure (which lives in random places on disk), the engine first appends the change to a sequential log — the write-ahead log — and flushes that. Only then does it update the data in place (or it does so lazily, in the background). The rule is in the name: write to the log ahead of writing the data.

Why this is brilliant: the append is sequential (cheap), so the latency the user waits for is one fast sequential write, not a slow random one. Durability is satisfied the instant the log entry is flushed. Think of a WAL like a kitchen's order-ticket rail: the server spikes the written ticket on the rail before anyone starts cooking, so if the kitchen is overwhelmed or "crashes" mid-service, the tickets are the durable record of what was promised, and you replay them to rebuild every in-flight order. And on crash recovery, the engine simply replays the log from the last checkpoint — every committed change is there, in order — to rebuild the correct on-disk state. You get durability at the price of a sequential append instead of a synchronous random write.

The WAL is everywhere downstream — say this in interviews

The same ordered log of changes is what these later lessons are built on:

One idea — an ordered, append-only log of changes — underlies durability, replication, CDC, and event streaming. Recognising it as the same thing is a senior signal.

B-tree — read-optimised, update-in-place

The default engine for relational databases (Postgres, MySQL/InnoDB). A B-tree keeps keys sorted on disk in a balanced tree of fixed-size pages (typically 4–16 KB). To find a key you walk from the root down: each node read narrows the search, so a point lookup is O(log n) — a handful of page reads even for billions of rows. Because keys are sorted and adjacent, range queries ("all orders between March and April") are also cheap: find the start, then scan sequentially.

Writes update in place: find the right page, modify it, write it back. The cost lives here:

Net: a B-tree makes reads (point and range) cheap and pays on writes. It is the right default for read-heavy and mixed workloads.

LSM-tree — write-optimised, append + compact

The log-structured merge-tree (Cassandra, RocksDB/LevelDB, ScyllaDB) makes the opposite bet: never update in place, only append. The flow:

WRITE PATH READ PATH (newest → oldest) ────────── ──────────────────────────── write ─► [ memtable ] in-RAM sorted map get(K): │ (also appended to WAL for 1. check memtable (RAM) │ durability — survives crash) 2. check Bloom filter, then ▼ SSTable L0, L1, L2 … (disk) flush when full └─ first hit wins (newest) │ ▼ [ SSTable L0 ] immutable sorted file on disk [ SSTable L0 ] [ SSTable L0 ] │ background COMPACTION ▼ merge + drop overwritten/deleted keys [ SSTable L1 ] fewer, larger, non-overlapping files │ ▼ [ SSTable L2 ] …

Walk it: a write lands in an in-memory sorted memtable (and is appended to a WAL for durability). When the memtable fills, it is flushed as an immutable sorted file — an SSTable (Sorted String Table). Writes are thus pure sequential appends — fast, the whole point. Over time SSTables pile up, so a background process called compaction merges them: combining sorted files, keeping only the newest value for each key and physically dropping deleted/overwritten ones.

Follow one key through an LSM-tree
  1. SET k=1 → appended to the WAL and written to the in-memory memtable (fast, sequential).
  2. The memtable fills → it is flushed as an immutable sorted file (an SSTable) at level 0.
  3. Later SET k=2 → a new append; the old k=1 is still sitting on disk in the L0 SSTable, now stale.
  4. Read k → check the memtable first, then the SSTables newest→oldest (Bloom filters skip files that can't contain it) → the newest value, 2, wins.
  5. Background compaction merges the overlapping SSTables, keeps only the newest k=2, and physically drops the stale k=1 — reclaiming the space and shrinking future reads.

This is why writes are cheap (always an append) but reads and space pay for it (multiple files plus compaction).

The costs, named:

Kafka's log is LSM-like — and that's WHY it's fast

A message log like Kafka (11) is essentially the append half of an LSM-tree: producers append records to the end of a partition's segment files; consumers read sequentially by offset. There is no in-place update, no random I/O — just sequential append and sequential read, which is precisely the cheap disk pattern from principle (1). That is the mechanical reason Kafka sustains millions of messages/sec on ordinary disks: it does only the operation disks are good at.

B-tree vs LSM-tree — the trade table

PropertyB-tree (Postgres, InnoDB)LSM-tree (Cassandra, RocksDB)
Write pathIn-place update + page splitAppend to memtable, flush, compact
Write amplificationHigher (page rewrites/splits per write)Lower per write, but compaction adds its own over time
Read amplification~1 (one tree walk)Higher (multiple levels; Bloom filters help)
Space amplificationLow–moderate (some page fragmentation)Higher transiently (old + new SSTables during compaction)
Range scansExcellent (sorted, adjacent on disk)Good but merges across levels
Best workloadRead-heavy / mixed, range queries, ad-hocWrite-heavy ingest, time-series, high throughput

One sentence to carry: read-heavy with range scans → B-tree; write-heavy ingest → LSM. The widget below makes that quantitative.

5. Indexes — turning O(n) into O(log n)

A query like WHERE email = 'x@y.com' on an unindexed table must read every row to find matches: a full table scan, O(n). On 100M rows that's seconds. An index fixes it.

Physically, an index is a second sorted data structure (usually its own B-tree, or built into the LSM) that maps a column value → the locations of rows with that value. Because it's sorted, a lookup is O(log n) — a few page reads. That's how a 1-second scan becomes a 1-millisecond lookup. The gain is biggest when the column is selective (few rows per value — like email); indexing a low-selectivity column (e.g. a boolean) barely helps, because you still read a huge fraction of rows.

The cost — and there is always a cost:

So: index the columns your hot reads filter/sort on, and don't index columns nothing queries — each one is a tax on every write.

Composite, covering, and the clustering key

6. Schema evolution — changing the model without downtime

Schemas change while the system is live and old code is still running. The discipline is the same as API versioning (03): make changes backward-compatible so old and new can coexist.

Expand–contract (the parallel-change pattern)

To rename namefull_name with zero downtime, never do it in one step:

  1. Expand: add full_name (additive, safe). Deploy code that writes both columns and reads full_name with fallback to name.
  2. Migrate: backfill full_name from name for existing rows, in batches.
  3. Contract: once all code reads full_name and the backfill is done, stop writing name and drop it.

At every step old and new code work against the same database. This is the data-layer twin of additive, versioned API changes — the cost is temporary duplication and a multi-deploy dance, bought in exchange for never taking the system down.

Try it: a B-tree vs LSM workload advisor

Pick a read:write mix, say how much range scans matter, and set write volume. The widget estimates a rough write-amplification and read-amplification for each engine and recommends one. The model is deliberately simple but directionally honest — see the hint for the formula.

B-tree vs LSM workload advisor
Read share r ∈ [0,1], write share w = 1−r. B-tree: read-amp ≈ 1, write-amp grows with write volume (page splits) → wamp_B ≈ 1 + k·writeVol. LSM: write-amp from compaction ≈ levels (rises with write volume), read-amp ≈ levels (a Bloom filter knocks it down, and range scans add a merge penalty). Recommendation weighs read share and range-scan importance against write volume.
B-tree write-amp
B-tree read-amp
LSM write-amp
LSM read-amp
Verdict

Interview prompts you should be ready for

  1. "What is a write-ahead log and why does it exist?" (senior answer: durability cheaply. The engine appends the change to a sequential log and flushes that before updating data in place; the append is sequential so it's ~100× cheaper than a random write, and on crash you replay the log to recover committed state. Bonus: that same log is what replication ships and what CDC tails — one structure, many uses.)
  2. "B-tree vs LSM-tree — when each?" (senior answer: B-tree updates in place, sorted on disk → read-amp ~1 and excellent range scans, at the cost of write amplification from page splits; pick it for read-heavy/mixed and ad-hoc queries — Postgres-style. LSM only appends (memtable → SSTable → compaction) → low per-write cost and huge ingest throughput, at the cost of read amplification (mitigated by Bloom filters) and compaction CPU/space; pick it for write-heavy/time-series — Cassandra/RocksDB.)
  3. "SQL or NoSQL for a product catalogue with ad-hoc filtering and reporting?" (senior answer: SQL. The decisive signals are relationships, ad-hoc queries you can't pre-plan, and strong invariants — exactly what a relational engine's joins, planner and ACID give you. Reach for NoSQL when the access pattern is known and fixed and write volume/scale-out dominates; even then, polyglot — keep the transactional core relational.)
  4. "What does adding an index cost?" (senior answer: it speeds the matching read from O(n) full scan to O(log n) lookup, biggest on selective columns — but it's a second sorted structure that every write must also update, so N indexes turn one insert into N+1 writes, plus disk/memory. Index the columns hot reads filter or sort on; don't index what nothing queries.)
  5. "This page joins users to orders on every request and it's slow — normalise or denormalise?" (senior answer: measure first, then denormalise the specific hot read — copy the few user fields the page shows onto the order — but only after deciding how the copies stay in sync (write-time fan-out, or a cache/CDC-fed read model). Name the cost: writes now update every copy and copies can drift. Keep the rest normalised; 'normalise until it hurts, denormalise until it works.')
  6. "Why are sequential writes so much cheaper than random ones, and how do databases exploit it?" (senior answer: a random write means a seek/erase-block cycle; an append doesn't — it's ~100× cheaper, per the latency ladder. WALs exploit it for durability (append the log, defer the in-place update), and LSM-trees/Kafka exploit it for throughput by making all writes appends — that's literally why Kafka is fast.)
  7. "How would you rename a column on a 500M-row live table with no downtime?" (senior answer: expand–contract. Add the new column (additive, safe), deploy code that writes both and reads new-with-fallback, backfill in batches, then once all reads use the new column stop writing and drop the old one. Old and new code coexist at every step — same discipline as backward-compatible API versioning.)
  8. "What is an index physically, and why doesn't a missing-key lookup hit disk in an LSM?" (senior answer: an index is a secondary sorted structure mapping column value → row locations, so lookups are O(log n). In an LSM, a per-SSTable Bloom filter — a probabilistic bitset with no false negatives — answers 'definitely not here,' letting a read skip files that can't contain the key, which is what keeps read amplification in check.)
Takeaway

A database is a machine for trading write cost, read cost, and space — and you decide which queries are cheap by modelling the access patterns first. Normalise for one source of truth, denormalise the hot read and pay to keep copies in sync. SQL gives joins/ACID/ad-hoc at the price of scale-out pain; NoSQL gives scale-out at the price of no-joins and per-query modelling. Underneath, the WAL buys durability with a cheap sequential append (and is what replication ships and CDC tails); a B-tree optimises reads/ranges and pays write amplification, while an LSM-tree optimises writes (append + compact, Bloom-filtered reads) and is why Kafka is fast. Indexes turn O(n) scans into O(log n) lookups but tax every write. With the data layer settled, the next question is how to put more boxes behind it — 05 · Horizontal scaling & load balancing.