Databases at Scale¶
Access patterns pick the store. Sharding is a last-resort write-scale step after indexes, partitioning, and I/O — not the first answer to a slow primary.
Why This Exists¶
"Which database should we use?" is the most common bad question in system design. It invites a religious answer — Postgres vs Mongo vs Cassandra — when the real answer is a question back: what are the access patterns?
Databases are not general-purpose. Each one makes a bet about how you will read and write, and optimizes ruthlessly for that bet. Pick the store whose bet matches your workload and everything is easy. Pick against it and you will spend two years building workarounds for a decision you made in an afternoon.
The senior move is to describe the access pattern first, then let it select the store.
Mental Model¶
Scaling a database happens in a fixed order. Each step is cheap; each next step costs an order of magnitude more in complexity. Never skip ahead.
1. One database ← you are here; it is fine longer than you think
│ reads too slow
▼
2. Add indexes ← usually the whole problem
│ still too slow
▼
3. Add a cache ← read-heavy workloads stop here
│ reads still too slow
▼
4. Add read replicas ← now you own replication lag
│ WRITES too slow
▼
5. Shard ← last resort; you lose cross-shard joins,
transactions, and easy schema changes
The interview tell
Candidates who jump straight to "shard it" have not understood the cost. Steps 2–4 absorb read pressure. Write pressure is a different ladder: batching, contention, native partitioning, storage/WAL/fsync, vertical I/O, then sharding. Caches and replicas do not absorb primary writes. The diagnostic question is always "are we read-limited or write-limited?"
Typical interview-estimation anchors, not database limits: a well-tuned Postgres instance is often quoted around 5–15K TPS for simple transactions, and single-digit terabytes of working data. Real capacity changes by orders of magnitude depending on transaction shape, concurrency, indexes, storage, durability settings, contention, and hardware. Below those anchors, application-level sharding is usually premature. Above them, pre-shard write levers (batching, contention, native partitioning, storage layout) still come before sharding; caches and replicas are read-path tools, not write-scale next steps.
Access Patterns Choose the Store¶
| If your access pattern is… | You want… | Because |
|---|---|---|
| Fetch a row by primary key | Anything (KV store is fastest) | Every store does this well |
| Query flexibly across many columns, with joins | Relational (Postgres, MySQL) | Query planner + secondary indexes |
| Write enormous volume, read by a known key | Wide-column (Cassandra, DynamoDB) | LSM writes are append-only and fast |
| Full-text search / relevance ranking | Search engine (Elasticsearch) | Inverted index + scoring |
| Traverse relationships several hops deep | Graph (Neo4j) | Index-free adjacency beats recursive joins |
| Time-ordered metrics, roll-ups | Time-series (Timescale, Prometheus) | Time-partitioned, compression-friendly |
The critical insight underneath the table: B-trees optimize reads, LSM-trees optimize writes. Postgres uses a B-tree — reads are a handful of page fetches, but every write updates the tree in place. Cassandra uses an LSM-tree — writes are appended to a memory buffer and flushed sequentially, making them extremely fast, at the cost of reads that may check several files. That single structural difference explains most of the table, and it is covered in Indexing & storage engines.
Why One Bad Index Is Worth More Than Any Architecture¶
Before any distributed anything, understand what an index actually costs and saves. A missing index turns a lookup into a full scan — the difference is not 2×, it is thousands of times:
"""Why the shard key and the index must match your access pattern."""
import bisect
import random
def full_scan(rows: list[tuple[int, str]], target: int) -> int:
"""No index: touch every row. O(n)."""
return sum(1 for _ in rows) # comparisons performed
def indexed_lookup(sorted_keys: list[int], target: int) -> int:
"""B-tree index: binary search. O(log n)."""
bisect.bisect_left(sorted_keys, target)
return max(1, len(sorted_keys).bit_length()) # comparisons ≈ log2(n)
if __name__ == "__main__":
for n in (1_000, 1_000_000, 1_000_000_000):
rows = [(i, "x") for i in range(min(n, 1000))] # sample; math scales
scan = n
seek = max(1, n.bit_length())
print(f"n={n:>13,} full scan: {scan:>13,} comparisons "
f"indexed: {seek:>2} comparisons speedup: {scan // seek:>11,}×")
n= 1,000 full scan: 1,000 comparisons indexed: 10 comparisons speedup: 100×
n= 1,000,000 full scan: 1,000,000 comparisons indexed: 20 comparisons speedup: 50,000×
n=1,000,000,000 full scan: 1,000,000,000 comparisons indexed: 30 comparisons speedup: 33,333,333×
The lesson generalizes past indexes: a billion-row table costs 30 comparisons if you query along the indexed path, and a billion if you do not. The same logic governs shard keys — query along the shard key and you hit one node; query across it and you scatter to all of them.
Indexes are not free
Every index speeds reads and slows writes — each insert must update every index on the table. On a write-heavy table, six indexes can halve your write throughput. This is the read/write trade-off in its most concrete form.
The Sharding Cliff¶
Sharding is the step people underestimate, because the cost is not the sharding — it is everything you give up:
- Cross-shard joins stop working. You now join in application code, or denormalize.
- Transactions stop being easy. A transaction spanning shards needs two-phase commit or a saga.
- Unique constraints stop being global.
UNIQUE(email)cannot be enforced across shards without a lookup table. - Rebalancing is an operation, not a config change — unless you used consistent hashing, which is exactly why it exists.
- Hot shards ruin the average. One celebrity user can make one shard carry 70% of traffic while the rest idle.
The shard key choice is effectively permanent and determines all of the above. Choosing it badly is the single most expensive mistake in this section.
Pages in This Section¶
Foundational Concepts¶
| Page | Status |
|---|---|
| DDIA Concepts | Complete — Replication, partitioning, transactions, consistency, quorum, consensus, CRDTs |
| Indexing & storage engines | Complete |
| SQL vs NoSQL | Complete |
| Sharding | Complete + simulator |
| Consistent hashing | Complete + ring |
Database Deep-Dives¶
| Page | Status |
|---|---|
| PostgreSQL Deep Dive | Draft / needs review |
| MongoDB Deep Dive | Draft / needs review |
| Cassandra Deep Dive | Draft / needs review |
| DynamoDB Deep Dive | Draft / needs review |
| Redis Deep Dive | Draft / needs review |
| SQL Deep Dive | Draft / needs review |
Start with DDIA Concepts — replication, partitioning, and transactions are universal. Then indexing — B-tree vs LSM explains most of what the database pages assert. Then SQL vs NoSQL for choosing a store. Database deep-dives explain the specific trade-offs of each system. Sharding and consistent hashing cover distributed patterns shared across systems.
Key Takeaways¶
- Access patterns pick the store, not preference or popularity.
- Scale in order: index → cache → replicas → shard. Steps 2–4 fix reads; only sharding fixes writes.
- B-trees favor reads, LSM-trees favor writes. That one fact explains most store comparisons.
- Indexes trade write throughput for read speed. Nothing is free.
- Sharding costs you joins, transactions, and global constraints. Earn it with a number before you propose it.