Concept 11: Databases Deep-Dive — SQL vs NoSQL, Indexing, Transactions & Storage Engines
System Design Bible — Bonus File 11 Difficulty: ★★★★☆ Why this file exists: Files 04 and 08 assumed you already understood what a database is doing internally — indexes, transactions, ACID, storage engines. This file fills that foundational gap. Almost every system design answer includes "…and we store it in a database"; this file makes you able to say which database and why with authority.
Table of Contents#
- Prerequisites — What You Must Understand First
- Architectural Definition & "The Why"
- The Recursive Sub-Concept Tree
- ACID vs BASE — The Transaction Guarantees
- Isolation Levels & Concurrency Anomalies
- Indexing — How Databases Find Data Fast
- Storage Engines: B-Tree vs LSM-Tree
- SQL vs NoSQL — The Real Decision
- Normalization vs Denormalization
- Interview Diagnostic Framework (Symptom → Solution)
- Real-World Engineering Scenarios
- Interview Gotchas & Failure Modes
- Whiteboard Cheat Sheet
0Prerequisites — What You Must Understand First#
Databases feel like magic until you understand the three physical realities they're fighting. Get these first.
0.1The disk vs. memory gulf (why database design is what it is)#
Everything a database does is shaped by one brutal fact from File 02's latency numbers: reading from RAM takes ~100 nanoseconds; reading from a spinning disk takes ~10 milliseconds — a 100,000× difference; even a fast SSD is ~16 microseconds, still ~150× slower than RAM. A database is fundamentally a program that stores more data than fits in RAM (that's the whole point — durable, large data lives on disk) and then tries desperately to avoid touching the disk, because every disk read is an eternity in CPU terms. This single tension explains indexes (data structures that find your row in 3–4 disk reads instead of scanning millions), the buffer pool / page cache (keep the hot subset of data in RAM), and the entire B-tree vs LSM-tree storage-engine debate (two different strategies for laying bytes on disk to minimize slow I/O). If you remember one thing entering this file: database engineering is the art of not touching the disk, and touching it sequentially when you must.
0.2Random I/O vs sequential I/O (the second physical fact)#
Not all disk access is equal. Reading bytes that are physically adjacent on disk (sequential I/O) is dramatically faster than jumping around to scattered locations (random I/O) — on a spinning disk because the read head must physically seek (~10 ms per seek), and even on SSDs because of how flash pages and controllers work. Sequential reads can be hundreds of times faster than random reads for the same volume of data. This is why appending to a log (always sequential) is a beloved pattern (Kafka in File 07, LSM-trees in §6, write-ahead logs in §3), and why a query that must fetch 10,000 scattered rows can be slower than one that scans 1,000,000 sequential ones. When you evaluate a storage design, always ask: is this access pattern sequential or random?
0.3A "page" / "block" is the unit of I/O#
Databases don't read one row at a time from disk — the disk hardware and OS work in fixed-size blocks (typically 4 KB) and databases in pages (often 8 KB or 16 KB). When you read a single 200-byte row, the database actually pulls the whole 8 KB page containing it into memory. This has a huge consequence: rows you access together should be stored together on the same page (locality), because then one page read serves many rows. Indexes, table layout, and clustering all revolve around packing related data into the fewest pages. Keep "the unit of I/O is a page, not a row" in mind — it explains why some queries are mysteriously cheap and others mysteriously expensive.
0.4What "a transaction" means before we formalize it#
A transaction is a group of one or more reads/writes that you want treated as a single indivisible unit: either all of them happen, or none of them do. The canonical example: transferring $100 from account A to account B is two writes — subtract 100 from A, add 100 to B. If the system crashes between them, you've destroyed $100 (A is debited, B never credited). A transaction wraps both writes so that a crash in the middle leaves the database as if neither happened. Everything in §3 (ACID) and §4 (isolation) is about making this guarantee real even under crashes and concurrent access. You don't need the formal properties yet — just hold the mental image of "all-or-nothing bundle of operations."
1Architectural Definition & "The Why"#
1.1The Plain Definition#
A database is a system for storing, retrieving, and managing data durably and efficiently, providing three things a plain file cannot: structured access (find/filter/aggregate data without reading all of it — via indexes and a query language), concurrency control (many clients reading and writing at once without corrupting each other's data — via transactions and locks), and durability + integrity (data survives crashes and stays internally consistent — via write-ahead logs and constraints).
A DBMS (Database Management System) — Postgres, MySQL, MongoDB, Cassandra — is the software that implements this. The two great families are relational (SQL) databases, which store data in tables of rows and columns with rigid schemas and rich querying, and non-relational (NoSQL) databases, which trade some of SQL's guarantees and query power for scale, flexibility, or specific data shapes.
1.2"The Why" — Why Not Just Files?#
Early programs stored data in flat files. This collapses the moment you have real requirements:
- Finding data without reading everything. A 100 GB file storing 500M users: to find one user by email, you'd read the entire 100 GB (minutes). A database with an index finds that user in ~4 disk reads (milliseconds). Indexes are the reason databases exist as a distinct thing (§5).
- Many writers at once. Two processes editing the same file simultaneously corrupt it — there's no coordination. Databases provide transactions and locking so thousands of concurrent clients don't clobber each other (§3, §4).
- Surviving a crash mid-write. If the power dies while you're rewriting a file, you get a half-written, corrupt file. Databases use a write-ahead log so an interrupted write leaves the data recoverable and consistent (§3.1).
- Enforcing rules. "An order must reference a real customer"; "an email must be unique." Databases enforce these constraints centrally; files enforce nothing.
- Answering questions. "Total revenue per region last quarter." A query language (SQL) expresses this declaratively and the database figures out the efficient execution; with files you'd hand-write and hand-optimize the traversal.
The why of the SQL/NoSQL split (§7) is the deeper story: for decades the relational model reigned because its guarantees (ACID, joins, flexible queries) are what applications need. But at internet scale (File 04), the very features that make relational databases powerful — strong consistency, joins, a single logical instance — became the things that don't scale horizontally. NoSQL databases were born (circa 2007–2010, Dynamo and Bigtable papers) by deliberately dropping some of those features (joins, multi-row ACID, rigid schema) in exchange for the horizontal scalability, availability, and write throughput that File 04 and File 08 are about. NoSQL is not "SQL but newer/better" — it's "SQL's guarantees, selectively sacrificed for scale." Understanding which guarantee each NoSQL type sacrifices is the whole game.
2The Recursive Sub-Concept Tree#
2.1Sub-Concept: The Relational Model (tables, rows, schema, keys)#
The relational model organizes data into tables (also called relations). A table has a fixed set of typed columns (e.g., users has id INT, email TEXT, created_at TIMESTAMP) and holds many rows (each row = one entity, one user). The column definitions are the schema — a rigid, enforced-in-advance contract: every row must have exactly these columns of these types, and the database rejects writes that violate it (schema-on-write). This rigidity is a feature — it guarantees data shape so the application never has to defend against a user row missing its email.
Two key concepts tie tables together:
- Primary key — a column (or set) that uniquely identifies each row (e.g.,
user.id). No two rows share it; it's how you address a specific row, and it's automatically indexed. - Foreign key — a column in one table that references the primary key of another, encoding a relationship (e.g.,
orders.user_idpoints tousers.id, meaning "this order belongs to that user"). The database can enforce referential integrity: it will refuse to insert an order referencing a non-existent user, and can refuse to delete a user who still has orders. This is how relational databases guarantee the data stays internally consistent.
2.2Sub-Concept: The JOIN (the relational superpower and scaling weakness)#
A join combines rows from two tables based on a related column, computed at query time. "Give me each order along with the name and email of the user who placed it" joins orders and users on orders.user_id = users.id. Joins are extraordinarily powerful: they let you store each fact once (users in one table, orders in another — no duplication) and assemble combinations on demand, so updating a user's email is a single-row write that instantly "updates" it everywhere it appears in query results.
But joins are exactly what breaks at scale (File 04). A join needs both tables reachable and, ideally, co-located; once you shard users across 10 machines and orders across 10 others, a join must gather rows from many machines (a cross-shard scatter-gather — File 04 §5.3), which is slow and doesn't scale. This is the crux of the SQL-vs-NoSQL trade: SQL lets you normalize and join (store once, assemble on read); NoSQL often forces you to denormalize (duplicate data so no join is needed) so reads hit one place (§8). Hold this tension — it recurs everywhere.
2.3Sub-Concept: Schema-on-Write vs Schema-on-Read#
- Schema-on-write (relational): the schema is enforced when you write. You must declare columns/types up front; bad-shaped data is rejected at insert. Pro: guaranteed structure, integrity, and query-ability; the data is always clean. Con: rigid — changing the schema (a migration) on a huge live table is a careful, sometimes painful operation, and it's awkward when different records genuinely have different shapes.
- Schema-on-read (many NoSQL): you write whatever shape you want (e.g., a JSON document); meaning is imposed when you read. Pro: flexibility — store heterogeneous or evolving data without migrations, great for rapidly-changing products or genuinely variable data. Con: no guarantees — your application must defend against missing/renamed/differently-typed fields, and inconsistency creeps in silently (the schema still exists, it's just now your app's problem, spread across your codebase, instead of the database's). The rigidity you "escaped" doesn't vanish; it moves into your application code.
2.4Sub-Concept: OLTP vs OLAP (two opposite workloads)#
A pervasive distinction that decides which database and design you pick:
- OLTP (Online Transaction Processing): many small, fast operations touching a few rows each, mostly by primary key — "fetch user 42," "insert this order," "decrement this inventory count." High volume, low latency, lots of writes, needs transactions. This is your application database (Postgres, MySQL, DynamoDB). Optimized for point access and writes.
- OLAP (Online Analytical Processing): few, huge, read-only queries that scan and aggregate millions of rows — "total revenue by region by month for the last 3 years." Low volume, high latency-tolerant, essentially no writes during query. This is your analytics database / data warehouse (Snowflake, BigQuery, Redshift, ClickHouse), and it typically uses columnar storage (§6.4). Optimized for scans and aggregation.
The critical rule (echoing File 04 §5.3): do not run heavy OLAP queries against your OLTP database — a report scanning your entire orders table will lock and choke the transactional workload your users depend on. You keep them separate and feed the OLAP system from the OLTP system asynchronously (via CDC/ETL streaming, File 07). Mixing them is one of the most common real-world scaling mistakes.
3ACID vs BASE — The Transaction Guarantees#
3.1ACID (the relational guarantee)#
ACID is four properties that together define a "reliable transaction." They're the reason you can trust a relational database with money.
A — Atomicity. A transaction is all-or-nothing: every operation in it commits, or (on any failure/crash) none do — the database rolls back to the pre-transaction state. This is the $100-transfer guarantee from §0.4: a crash between the debit and credit leaves neither, never a half-transfer. Atomicity is implemented with a write-ahead log (WAL):
Sub-Concept: Write-Ahead Log (WAL). Before the database modifies the actual data pages on disk, it first appends a record of the intended change to a sequential log (and flushes that log to disk). Only after the change is safely in the log does it modify the data. Why this ordering? Because the log write is a fast sequential append (§0.2), and it's durable: if the database crashes mid-transaction, on restart it replays the log to either finish committed transactions (redo) or undo incomplete ones (undo), reconstructing a consistent state. The WAL is the mechanism behind both Atomicity and Durability — the same "log-before-apply, replay-on-recovery" pattern you saw as the metadata master's operation log in File 09 §4.4 and as the replicated log in File 08. One idea, everywhere.
C — Consistency. A transaction moves the database from one valid state to another valid state, never violating declared rules (constraints, foreign keys, uniqueness, triggers). If a transaction would leave a dangling foreign key or a duplicate unique value, the database rejects the whole transaction. (Note: this "C" — application-level invariants — is confusingly different from the "C" in CAP theorem, File 08, which is about all replicas agreeing. Two different C's; interviewers love this trap.)
I — Isolation. Concurrent transactions must not step on each other; the result of running them simultaneously should be as if they ran one after another (serially). This is the hardest and most nuanced property — in practice databases offer a spectrum of isolation levels trading strictness for performance, which is important enough to get its own section (§4).
D — Durability. Once a transaction is committed, it survives permanently — even an immediate power loss. The committed data is on durable storage (the WAL, then the data files, plus replication to other nodes, File 08). "Committed means it's really, truly saved" is the promise.
3.2BASE (the NoSQL alternative)#
Many distributed NoSQL databases (especially the AP systems of File 08) can't provide full ACID across a cluster cheaply — recall CAP: strong consistency costs availability during partitions, and coordinating ACID across many nodes costs latency. So they adopt BASE, a deliberately looser model whose acronym is a chemistry pun on ACID:
- BA — Basically Available. The system prioritizes staying up and answering (the "A" of CAP) over always returning the freshest data. A request gets a response, even during failures, even if that response is slightly stale.
- S — Soft state. The state of the system may change over time even without new input, because replicas are converging in the background (anti-entropy, read repair — File 08). There isn't one instantaneous "true" value everywhere at once.
- E — Eventually consistent. If writes stop, all replicas converge to the same value eventually (File 08 §7), but at any given moment a read might be stale. You trade "always correct now" for "always available, correct soon."
ACID vs BASE is not good vs bad — it's a trade you match to the data. Money, inventory, bookings → ACID (a wrong answer is catastrophic; you'll accept lower availability/scale to be correct). Likes, feed items, view counts, session data, product catalogs → BASE (being briefly stale is harmless; being available and scalable matters more). Real systems use both: ACID Postgres for orders/payments, BASE Cassandra/DynamoDB for the activity feed — in the same product. The skill is knowing which data goes where.
4Isolation Levels & Concurrency Anomalies#
Isolation (the "I" in ACID) is a dial, not a switch, because perfect isolation is expensive (it requires serializing conflicting transactions, which limits concurrency and throughput). So databases offer levels, each permitting certain anomalies in exchange for more concurrency. You must know the anomalies to know what each level protects against.
4.1The Anomalies (what goes wrong without isolation)#
- Dirty read: transaction T1 reads data that T2 has written but not yet committed. If T2 then rolls back, T1 acted on data that never really existed. (Reading uncommitted garbage.)
- Non-repeatable read: T1 reads a row, then T2 modifies and commits that row, then T1 reads it again within the same transaction and gets a different value. T1's two reads of "the same" row disagree — the ground shifted under it mid-transaction.
- Phantom read: T1 runs a query returning a set of rows (e.g., "all orders over $100"), then T2 inserts a new row matching that condition and commits, then T1 re-runs the query and sees an extra ("phantom") row that wasn't there before. Like non-repeatable read but for newly appearing/disappearing rows rather than changed values.
- Lost update: T1 and T2 both read the same counter (value 10), both add 1, both write 11 — one increment is silently lost (should be 12). Classic with read-modify-write on the same row.
4.2The Levels (weakest to strongest)#
- Read Uncommitted — permits all the above, including dirty reads. Almost never used; barely any isolation.
- Read Committed — you only ever read committed data (no dirty reads), but non-repeatable and phantom reads are still possible. The default in Postgres and Oracle. Good enough for most workloads.
- Repeatable Read — a row you've read won't change if you read it again (no dirty or non-repeatable reads); phantoms may still occur (though MySQL's InnoDB largely prevents them too). The default in MySQL/InnoDB.
- Serializable — the strictest: transactions behave exactly as if they ran one at a time in some serial order. No anomalies at all. The safety you'd naively assume "a transaction" gives — but it costs the most concurrency (via heavy locking or aborting-and-retrying conflicting transactions), so you opt into it only where correctness demands (e.g., financial invariants).
4.3How Isolation Is Implemented: Locking vs MVCC#
- Pessimistic (locking): a transaction locks rows it reads/writes so others must wait. Safe but slow under contention, and risks deadlock (T1 holds lock A wanting B; T2 holds B wanting A — both stuck forever; the DB detects this and kills one).
- Optimistic (MVCC — Multi-Version Concurrency Control): the modern default (Postgres, MySQL, Oracle). Instead of locking readers, the database keeps multiple versions of each row; each transaction sees a consistent snapshot of the data as of when it started. Readers never block writers and writers never block readers (they read older versions), which massively improves concurrency. Conflicts between writers are detected at commit time (and one is retried/aborted). MVCC is why "your long-running read query sees a stable snapshot even as others write" — a hugely important property. The cost is storing old row versions and periodically garbage-collecting them (Postgres's
VACUUM).
5Indexing — How Databases Find Data Fast#
5.1The Problem an Index Solves#
Without an index, finding rows matching a condition (WHERE email = 'x@y.com') requires a full table scan — reading every row from disk and checking it. On 500M rows that's minutes and enormous I/O. An index is an auxiliary data structure that lets the database jump to matching rows in a handful of disk reads instead of scanning everything. It's the single most important tool for database performance, and "add an index" is the answer to a huge fraction of "the query is slow" problems.
The analogy: an index is the index at the back of a textbook. To find "mitochondria," you don't read all 900 pages (full scan); you flip to the alphabetical index (a sorted structure), find "mitochondria → p. 412," and jump straight there. The book's index is sorted and small; the content is large and unsorted. Same idea.
5.2What an Index Actually Is#
An index is a separate, sorted data structure (usually a B-tree, §6.1) mapping the indexed column's values → the locations of the matching rows (their primary keys or physical addresses). Because it's sorted, the database can binary-search it (or tree-walk it) in O(log N) — for 500M rows, ~30 comparisons / ~4 disk page reads instead of 500M. You create one explicitly: CREATE INDEX ON users(email). The primary key is automatically indexed.
5.3The Fundamental Trade-off: Reads vs Writes vs Space#
Indexes are not free — this trade is essential to state in interviews:
- They speed up reads matching the indexed column (dramatically).
- They slow down writes. Every
INSERT/UPDATE/DELETEmust now also update every index on that table (keeping each sorted structure correct). A table with 8 indexes pays 8× the index-maintenance cost per write. So you don't index everything — you index the columns you query by, and each extra index taxes your write throughput. - They consume storage. Each index is a whole additional data structure on disk (can be a significant fraction of the table's size).
So indexing is a workload-driven decision: index the columns in your WHERE/JOIN/ORDER BY clauses, resist indexing rarely-queried columns, and remember that a write-heavy table wants fewer indexes while a read-heavy one can afford more.
5.4Types of Index (the ones worth naming)#
- Clustered index: the table's rows are physically stored in the order of this index (usually the primary key). There can be only one (data can only be sorted one way physically). Range scans on the clustered key are extremely fast (rows are adjacent on disk — sequential I/O, §0.2). In MySQL/InnoDB the primary key is the clustered index.
- Non-clustered (secondary) index: a separate structure pointing back to the rows. You can have many. A lookup finds the pointer, then fetches the row (a possible extra read).
- Composite (multi-column) index: an index on
(last_name, first_name). Crucial rule — the leftmost-prefix: this index helps queries filtering onlast_name, or onlast_name AND first_name, but not onfirst_namealone (because it's sorted by last_name first — like a phone book sorted by last name is useless for finding all "James"). Column order in a composite index matters enormously. - Covering index: an index that contains all columns a query needs, so the database answers the query from the index alone without ever touching the table (an "index-only scan"). A powerful optimization.
- Hash index: maps values via a hash for
O(1)exact-match lookups, but useless for range queries (hashing destroys order — the same reason hash sharding kills range scans in File 04). Used for equality-only lookups.
6Storage Engines: B-Tree vs LSM-Tree#
The storage engine is the component that decides how bytes are physically laid out on disk and read back. The two dominant designs make opposite trade-offs, and this is a favorite deep-dive question because it reveals whether you understand the disk-I/O realities of §0.
6.1B-Tree (the read-optimized classic)#
Sub-Concept: B-Tree. A B-tree is a balanced tree structure kept sorted on disk, where each node is one disk page (§0.3) holding many keys and pointers to child pages. It's shallow and wide (a node has hundreds of children), so even a billion keys live in a tree only ~4 levels deep — meaning any key is found in ~4 disk reads by walking root → branch → branch → leaf. Crucially, B-trees update data in place: to change a value, you find its page and overwrite it where it sits.
- Reads: excellent and predictable —
O(log N), ~4 page reads for any key, and great for range scans (leaves are linked in sorted order). - Writes: slower and random — updating a key means seeking to its page (random I/O, §0.2) and overwriting it, plus WAL bookkeeping; a write can also trigger page splits (rebalancing). Each write is a scattered disk operation.
- Used by: Postgres, MySQL/InnoDB, Oracle — the relational default. Optimized for read-heavy and mixed workloads where predictable low-latency reads matter.
6.2LSM-Tree (the write-optimized challenger)#
Sub-Concept: LSM-Tree (Log-Structured Merge-Tree). An LSM-tree is built on a radically different idea: never do random writes; only ever append sequentially. Writes go first to an in-memory sorted structure called the memtable (fast, RAM). When the memtable fills, it's flushed to disk as an immutable sorted file (SSTable — Sorted String Table) in one big sequential write. Files are never modified after being written — updates and deletes are just new entries appended later (a delete writes a "tombstone" marker). A background process compaction periodically merges the many small SSTables into fewer larger ones, discarding superseded/deleted entries. A read may have to check the memtable plus several SSTables (newest first), accelerated by Bloom filters (File 02 §6.1 — "is this key possibly in this file?") so it skips files that definitely don't contain the key.
- Writes: excellent — every write is a fast sequential append to RAM then sequential flush to disk (§0.2). This gives LSM-trees far higher write throughput than B-trees. This is why the write-scaling NoSQL stores use them.
- Reads: can be slower — a key might live in the memtable or any of several SSTables, so a read may touch multiple places (mitigated by Bloom filters and caching, but still generally more variable than a B-tree's clean 4-read path). Range scans require merging across SSTables.
- Write amplification via compaction: the same data gets rewritten multiple times as it's merged into larger files — background compaction consumes I/O and CPU and can cause latency spikes.
- Used by: Cassandra, RocksDB, LevelDB, HBase, ScyllaDB, and as an option in many others — the write-heavy NoSQL default.
6.3The Decision#
| B-Tree | LSM-Tree | |
|---|---|---|
| Writes | Slower (random, in-place) | Fast (sequential appends) |
| Reads | Fast, predictable (~4 reads) | Variable (check multiple SSTables) |
| Space | In-place; can fragment | Compaction reclaims; temporary bloat |
| Best for | Read-heavy / mixed, low-latency reads | Write-heavy, high ingest volume |
| Homes | Postgres, MySQL, Oracle | Cassandra, RocksDB, HBase |
Interview line: "If the workload is write-heavy (time-series, event ingestion, high-volume logging), lean LSM (Cassandra/RocksDB); if it's read-heavy or needs predictable low-latency point/range reads, lean B-tree (Postgres/MySQL). The core reason is that LSM turns all writes into fast sequential appends while B-trees do slower random in-place updates."
6.4Bonus: Row-Oriented vs Column-Oriented Storage (OLTP vs OLAP again)#
Orthogonal to B-tree/LSM is how you group a row's columns on disk:
- Row storage (Postgres, MySQL) keeps all of a row's columns together. Great for OLTP — "fetch the whole user 42" reads one page. Bad for "average salary across 100M rows," which must read every full row just to extract one column.
- Column storage (Snowflake, BigQuery, Redshift, ClickHouse, Parquet) stores each column contiguously. "Average salary" reads only the salary column — 1 column of 100 instead of 100 — and compresses brilliantly (adjacent values are similar). This is why analytics/OLAP databases (§2.4) are columnar: aggregations over few columns across billions of rows become cheap. The trade: fetching a single whole row is expensive (gather one value from every column store), so columnar is wrong for OLTP. Match storage layout to workload.
7SQL vs NoSQL — The Real Decision#
7.1Why "SQL vs NoSQL" Is the Wrong Framing (and the right one)#
"NoSQL" is a terrible name — it lumps four completely different database types together whose only shared trait is "not a traditional relational table with SQL." The real decision isn't "SQL or NoSQL"; it's "which of these five tools fits my data shape and access pattern and scale?" Here they are, each with what it sacrifices and what it buys.
7.2Relational / SQL (Postgres, MySQL)#
- Data model: tables, rows, rigid schema, joins, foreign keys.
- Buys you: ACID transactions, joins (store-once/assemble-on-read), flexible ad-hoc queries, strong consistency, decades of tooling and reliability. The safest default for structured, related data with correctness needs.
- Sacrifices: horizontal write scaling is hard (File 04 sharding is bolted on, not native), and rigid schema means migrations. Vertical scaling + read replicas + caching take you very far (further than beginners think), and modern "NewSQL" (§7.7) even adds horizontal scale.
- Use for: anything transactional and relational — payments, orders, users, bookings, inventory. Default to this unless you have a concrete reason not to.
7.3Key-Value stores (Redis, DynamoDB, Riak)#
- Data model: a giant distributed hash map —
key → value(value opaque or richly structured). No joins, no queries beyond "get by key." - Buys you: blazing speed and effortless horizontal scale (keys shard perfectly via consistent hashing, File 06). Simplest possible model.
- Sacrifices: you can only look things up by key — no "find all users in Ohio" unless Ohio is baked into a key. All access patterns must be designed up front.
- Use for: caching (File 02), session stores, user profiles by ID, shopping carts, feature flags, real-time leaderboards (Redis).
7.4Document stores (MongoDB, Couchbase)#
- Data model: collections of documents — JSON, or BSON (MongoDB's Binary JSON: the same nested structure encoded compactly in binary with richer types like dates and integers, faster to parse and traverse than text JSON) — each a self-contained nested object. Schema-on-read (§2.3) — documents in a collection can differ.
- Buys you: flexibility (evolve shape without migrations), and locality — a document holds all of an entity's data together (a blog post with its comments nested inside), so one read fetches everything, no joins. Maps naturally to application objects.
- Sacrifices: relationships across documents are awkward (limited joins), and denormalized nesting means duplicated data and update fan-out. Weaker cross-document transactional guarantees (though MongoDB added multi-document ACID).
- Use for: content/catalogs, user profiles with varying fields, config, anything hierarchical and read-as-a-whole.
7.5Wide-column stores (Cassandra, HBase, ScyllaDB, Bigtable)#
- Data model: rows keyed by a partition key, each holding many columns grouped into families; think "a distributed, sparse, sorted map" (File 04 §4.3's partition + clustering key). LSM-tree backed (§6.2).
- Buys you: massive write throughput and linear horizontal scale with tunable consistency (File 08) — built for petabytes and millions of writes/sec across regions, leaderless (File 06/08).
- Sacrifices: you must model tables around your queries in advance (query-first design; no ad-hoc queries, limited/no joins), and you live with eventual consistency by default.
- Use for: time-series, event logging, messaging (Discord, File 04 §9.2), sensor/IoT data, any write-firehose at scale.
7.6Graph databases (Neo4j, Neptune)#
- Data model: nodes and edges (relationships) as first-class citizens.
- Buys you: traversing deep relationships cheaply — "friends of friends of friends who like X" is a fast graph walk, whereas in SQL it's a nightmare of recursive self-joins.
- Sacrifices: not built for high-volume simple CRUD (Create, Read, Update, Delete — the four basic record operations that make up the bread-and-butter workload of a normal application database) or aggregation; a specialist tool.
- Use for: social graphs, fraud rings, recommendation networks, knowledge graphs, dependency/permission graphs.
7.7A word on NewSQL (Spanner, CockroachDB, TiDB, Vitess)#
The newest category tries to have both: SQL's relational model + ACID transactions with NoSQL's horizontal scalability, by sharding automatically and using consensus (File 08's Raft/Paxos, Spanner's TrueTime) under the hood. This is increasingly the answer to "I need relational guarantees and to scale writes horizontally" — see File 08 §10.1 (Spanner). Worth naming; it dissolves the old "you must choose SQL or scale" dichotomy.
7.8The Decision Framework (say this in interviews)#
Don't recite "SQL good, NoSQL scalable." Reason from the data:
- Is the data highly relational with correctness/transaction needs (money, bookings)? → Relational (SQL). Default here.
- Is it simple lookups by a key at huge scale/speed? → Key-value.
- Is each entity a self-contained flexible document read as a whole? → Document.
- Is it a write-firehose at massive scale (time-series/events)? → Wide-column.
- **Is the value in the relationships/traversals themselves? → Graph**.
- **Do I need relational + ACID and horizontal scale? → NewSQL**.
And almost every real system is polyglot persistence — several of these together, each holding the data it fits best (Postgres for orders + Redis for cache/sessions + Cassandra for the event feed + Elasticsearch for search).
8Normalization vs Denormalization#
8.1Normalization (store each fact once)#
Normalization is organizing relational data so that each piece of information is stored in exactly one place, with relationships expressed via foreign keys. Instead of copying a user's name into every one of their 10,000 orders, you store the user once in users and put a user_id reference in orders; queries join to assemble the combination.
- Buys you: no duplication (saves space) and, crucially, no update anomalies — changing the user's name is one write to one row, and it's instantly correct everywhere, because it was never copied. Data integrity is structurally guaranteed.
- Costs you: reads must join (§2.2), which is more work per read and — the killer at scale — doesn't shard well (File 04). Many joins can make read queries slow.
8.2Denormalization (duplicate for read speed)#
Denormalization deliberately copies data so reads don't need joins — e.g., store the user's name inside each order row. Now "show this order with the buyer's name" is a single-row read, no join.
- Buys you: fast, simple reads that hit one place — essential for scale, for NoSQL stores that lack joins, and for read-heavy workloads. It's the only option in most NoSQL (§7.4/7.5): you model around your read queries and duplicate freely.
- Costs you: duplication (more storage) and, the real danger, update anomalies — if the user changes their name, you must now find and update it in all 10,000 order rows (a fan-out write), and if you miss any, the data is inconsistent. You've traded read speed for write complexity and consistency risk.
8.3The Rule of Thumb#
Normalize until it hurts (reads too slow / joins too costly), then denormalize until it works. Relational OLTP tends to start normalized (integrity first); scale and NoSQL push toward denormalization (read speed first). The trade is fundamentally read cost vs write/consistency cost, and it's the same tension as File 02's caching (a cache is a denormalized copy you must invalidate) and File 04's shard-key/locality decision. When you denormalize, you take on the job of keeping the copies in sync — often via the async event pipelines of File 07 (write once, fan out updates to all the denormalized copies).
9Interview Diagnostic Framework (Symptom → Solution)#
| System Symptom | Diagnosis | Solution |
|---|---|---|
| "A query filtering by a column is slow / does a full scan." | No index on that column. | Add an index (or composite index matching the query). |
| "Writes got slow after we added lots of indexes." | Index maintenance tax on every write. | Drop unused indexes; index only queried columns. |
| "Money/inventory must never be wrong, even under concurrency." | Needs strong transactional guarantees. | Relational DB with ACID; Serializable/Repeatable-Read isolation. |
| "Two concurrent updates to a counter lose increments." | Lost-update anomaly. | Higher isolation, atomic increment, or optimistic locking (version check). |
| "We ingest millions of events/sec; writes can't keep up." | B-tree random writes are the bottleneck. | LSM-tree store (Cassandra/RocksDB); write-optimized. |
| "Analytics queries scanning all rows choke the app DB." | OLAP on an OLTP system. | Separate columnar OLAP warehouse fed via CDC/ETL (File 07). |
| "Reports over billions of rows on few columns are slow." | Row storage reads whole rows. | Columnar store (Snowflake/BigQuery/ClickHouse). |
| "Data shape changes constantly; migrations are painful." | Rigid schema-on-write friction. | Document store (schema-on-read) for that data. |
| "We only ever look things up by an ID, but need huge scale/speed." | Over-engineered with relational. | Key-value store (DynamoDB/Redis). |
| "Deep 'friends-of-friends' relationship queries are brutal in SQL." | Recursive-join pain. | Graph database (Neo4j/Neptune). |
| "We need relational+ACID but also horizontal write scale." | Classic SQL can't scale writes natively. | NewSQL (Spanner/CockroachDB/Vitess). |
| "A user's name is wrong in some places after they changed it." | Denormalization update anomaly. | Fix the fan-out update (event-driven, File 07) or normalize that field. |
10Real-World Engineering Scenarios#
10.1Amazon — Polyglot Persistence & the Birth of DynamoDB#
- Amazon's shopping cart famously must be always writable (never lose an "add to cart" even during failures) — availability over strict consistency. Their relational DBs couldn't give that at scale, so they built Dynamo (File 06/08): a key-value, leaderless, eventually-consistent (BASE) store using consistent hashing + quorums.
- But orders and payments stay in ACID relational systems — you cannot be "eventually consistent" about whether a customer was charged.
- The result is polyglot persistence: cart in a KV/BASE store, orders in ACID SQL, catalog in a document/search store, analytics in a columnar warehouse — each data type in the database whose trade-offs fit it.
- Takeaway: the same company uses ACID and BASE side by side; maturity is matching each dataset to the right guarantee, not picking one database religion.
10.2Discord — Cassandra/ScyllaDB (LSM) for the Message Firehose#
- Discord stores trillions of messages with a relentless write firehose (every message is a write). A B-tree relational DB's random in-place writes couldn't keep up.
- They use Cassandra then ScyllaDB — LSM-tree (§6.2) wide-column stores whose sequential-append writes handle massive ingest, with partition + clustering keys (File 04 §9.2) modeling the exact query "recent messages in a channel, in order."
- They accept eventual consistency / query-first modeling (no ad-hoc queries, no joins) as the price of that write scale.
- Takeaway: write-heavy at scale → LSM wide-column; you design tables around queries and give up joins/ad-hoc flexibility to get the throughput.
10.3A Typical SaaS — Postgres + Read Replicas + Redis, then shard#
- Most successful products run on one Postgres (ACID, relational, B-tree) far longer than beginners expect — vertical scaling + read replicas (File 08) for read scale + Redis cache (File 02) for hot reads handles enormous load.
- Only when write volume or data size truly exceeds one machine do they add sharding (File 04) or migrate hot tables to a NoSQL/NewSQL store — and even then, often just the one table that needs it, keeping the rest relational.
- Heavy analytics get siphoned to a columnar warehouse (BigQuery/Snowflake) via CDC (File 07), never run against the OLTP Postgres.
- Takeaway: "boring" relational + replicas + cache is the correct default; reach for NoSQL/sharding for the specific data that outgrows it, not as a reflex. Premature NoSQL is as harmful as premature sharding.
11Interview Gotchas & Failure Modes#
11.1Classic Questions#
- "SQL vs NoSQL — when each?" — Reason from data shape + access pattern + scale + consistency needs (§7.8), not "NoSQL scales better." Default relational; reach for a specific NoSQL type for a specific reason. Mention NewSQL and polyglot persistence.
- "Explain ACID." — Atomicity (all-or-nothing, via WAL), Consistency (valid→valid, constraints; ≠ CAP's C), Isolation (concurrent = as-if-serial, a spectrum), Durability (committed survives crashes).
- "ACID vs BASE?" — ACID = strong guarantees for correctness-critical data; BASE = basically-available, soft-state, eventually-consistent for scale/availability. Match to data; use both.
- "How does an index work, and what does it cost?" — Sorted structure (B-tree) →
O(log N)lookup instead of full scan; costs write speed (every index updated per write) and storage. Index queried columns only. - "B-tree vs LSM-tree?" — B-tree: read-optimized, random in-place writes (Postgres/MySQL). LSM: write-optimized, sequential appends + compaction (Cassandra/RocksDB). Choose by read- vs write-heavy.
- "What isolation level and why?" — Know the anomalies (dirty/non-repeatable/phantom/lost-update) and that Read Committed (Postgres default) / Repeatable Read (MySQL default) trade strictness for concurrency; Serializable only where correctness demands.
- "Normalize or denormalize?" — Normalize for integrity (store once, join); denormalize for read speed/scale (duplicate, no joins) at the cost of update fan-out. "Normalize till it hurts, denormalize till it works."
- "Why not run analytics on the app database?" — OLAP scans choke OLTP; separate columnar warehouse fed async.
11.2Failure Modes & Mitigations#
- Missing index → full scans melt the DB under load. Mitigate: index queried columns; watch slow-query logs /
EXPLAINplans. - Too many indexes → write collapse. Mitigate: prune to actually-queried columns.
- Lost updates / race conditions on read-modify-write. Mitigate: atomic ops, optimistic locking (version columns), higher isolation.
- Deadlocks under pessimistic locking. Mitigate: consistent lock ordering, short transactions, retry on deadlock, prefer MVCC.
- Long-running transaction / VACUUM bloat (MVCC old versions pile up). Mitigate: keep transactions short; monitor/tune vacuum.
- Denormalization drift — copies get out of sync. Mitigate: event-driven fan-out updates (File 07), or normalize the volatile field.
- Hot row / hot partition (File 04 hot-key). Mitigate: shard/salt, cache, redesign key.
- OLAP-on-OLTP contention. Mitigate: replicas for reads, separate warehouse for analytics.
- Schemaless chaos (schema-on-read) — silent shape inconsistency. Mitigate: validation at the app boundary, schema registries.
- Choosing NoSQL prematurely — losing joins/ACID you actually needed, ending with worse problems. Mitigate: default relational; migrate specific datasets when they concretely outgrow it.
12Whiteboard Cheat Sheet#
PHYSICAL TRUTHS: RAM ~100ns vs disk ~10ms (100,000x) | sequential >> random I/O | I/O unit = 8KB page
GOAL OF ALL DB DESIGN: avoid touching disk; when you must, touch it SEQUENTIALLY.
ACID (relational): Atomic(all-or-nothing, WAL) Consistent(valid->valid, constraints; != CAP's C)
Isolated(concurrent = as-if-serial; a SPECTRUM) Durable(committed survives crash)
BASE (NoSQL/AP): Basically-Available, Soft-state, Eventually-consistent (File 08 CAP)
-> money/inventory=ACID ; feeds/counts/sessions=BASE ; real systems use BOTH.
ISOLATION (weak->strong): ReadUncommitted < ReadCommitted(PG default) < RepeatableRead(MySQL) < Serializable
anomalies: dirty read | non-repeatable read | phantom read | lost update
impl: pessimistic LOCKING (deadlock risk) vs optimistic MVCC (snapshots; readers don't block writers)
INDEX = sorted side structure -> O(log N) lookup vs full scan. Costs: slower writes + storage.
clustered(1, physical order) | secondary(many) | composite(leftmost-prefix!) | covering | hash(eq-only)
STORAGE ENGINE:
B-TREE -> read-optimized, random in-place writes, ~4 reads/key (Postgres,MySQL,Oracle)
LSM-TREE-> write-optimized: memtable -> flush immutable SSTables -> compaction; Bloom filters on read
(Cassandra,RocksDB,HBase). Writes = sequential appends = FAST.
ROW storage (OLTP: whole-row reads) vs COLUMN storage (OLAP: scan few cols over billions; Snowflake/BQ)
SQL vs NoSQL = pick the tool for data shape + access + scale + consistency:
Relational(ACID,joins; DEFAULT) | Key-Value(lookup by key, huge scale) | Document(flexible, read-whole)
Wide-Column(write firehose, LSM) | Graph(traverse relationships) | NewSQL(relational+ACID+horizontal)
-> most real systems = POLYGLOT PERSISTENCE (several together).
NORMALIZE (store once, join; integrity) <--> DENORMALIZE (duplicate, no join; read speed, update fan-out)
"normalize till it hurts, denormalize till it works."
OLTP (small fast point ops, your app DB) vs OLAP (huge scans/aggregates, warehouse) — NEVER mix them.One-sentence summary for an interviewer:
"A database is a program engineered around the fact that disk is ~100,000× slower than RAM, so it uses indexes (sorted B-trees →
O(log N)lookups instead of full scans, paid for in slower writes), transactions with ACID (atomic via a write-ahead log, isolated via MVCC/locking on a spectrum of levels) for correctness-critical data, or BASE/eventual consistency for scale; its storage engine is either a B-tree (read-optimized, random in-place writes — Postgres/MySQL) or an LSM-tree (write-optimized sequential appends + compaction — Cassandra/RocksDB); and 'SQL vs NoSQL' really means picking among relational, key-value, document, wide-column, graph, and NewSQL by data shape, access pattern, scale, and consistency needs — defaulting to relational and going polyglot, normalizing for integrity but denormalizing for read-speed at the cost of update fan-out."
End of Bonus File 11. See also File 12 (The System Design Interview Playbook) for how to apply* all of this under interview pressure.*