Storage and data systems
Choosing a data model, sharding, replication, indexes, caching, consistent hashing, denormalization and write-ahead logs.
On this page
Compute is easy to scale because it forgets. Storage is hard because it must remember, and every trick in this article is a way of remembering more, faster, on more machines, without losing anything. The trade is always the same four things: read latency, write volume, schema flexibility and how strict the guarantees are.
The map
Read this first when short on time. Every branch is a section below.
Choosing a storage model
A storage model is a bet on how the data will be read and written. Each one is fast at the shape it was built for and slow or awkward at the rest, so the first question is never "which database is best" but "what are the access patterns".
| Model | Data structure | Scales by | Consistency | Built for | Examples |
|---|---|---|---|---|---|
| Relational | Tables with a fixed schema, joined by keys | Vertical, plus read replicas; sharding is manual | Strict ACID transactions | Anything transactional: orders, payments, inventory, accounts | PostgreSQL, MySQL, Aurora, Cloud SQL, SQL Server |
| Key-value | Opaque value looked up by key; a distributed hash map | Horizontal sharding by key | Eventual or strong, per request | Caches, sessions, shopping carts, feature flags, counters | Redis, Memcached, DynamoDB, etcd |
| Document | JSON-like documents with flexible fields, queried by content | Horizontal partitioning by a shard key | Tunable per operation | Catalogs, user profiles, content, anything that nests naturally | MongoDB, Firestore, Couchbase, DocumentDB |
| Wide-column | Rows with many sparse columns, grouped into partitions; a sorted multi-dimensional map | Distributed ring, add nodes freely | Eventual convergence, tunable quorums | Time-series, events, messages, anything append-heavy at very high write rates | Cassandra, ScyllaDB, Bigtable, HBase |
| Graph | Nodes and edges with properties, traversed by relationship | Partitioned clustering; hard to shard well | ACID within the graph engine | Social graphs, recommendations, fraud rings, knowledge graphs | Neo4j, Amazon Neptune, TigerGraph |
| Object | Immutable blobs addressed by key, with metadata | Effectively unlimited | Strong read-after-write (S3 since 2020) | Files, images, video, backups, data-lake tables | S3, GCS, Azure Blob |
| Search | Inverted index: term to list of documents | Sharded indexes with replicas | Eventual (near real-time refresh) | Full-text search, log analytics, faceted filtering | Elasticsearch, OpenSearch, Solr, Meilisearch |
flowchart TD
A{Transactions and joins?} -- yes --> R[Relational]
A -- no --> B{Shape of the data?}
B -- large binary files --> O[Object storage]
B -- relationships are the point --> G[Graph]
B -- text to search --> S[Search index]
B -- records --> C{Access pattern?}
C -- lookup by key, tiny latency --> K[Key-value]
C -- nested records, evolving fields --> D[Document]
C -- huge write rate, time-ordered --> W[Wide-column]
Two things the table hides. First, a relational database is the right default: it is the most flexible, the tooling is mature, and a single Postgres carries most products a long way. Second, real systems are polyglot: orders in Postgres, sessions in Redis, product search in Elasticsearch, images in S3, and an event log in Kafka, with the relational store as the source of truth and the rest derived from it.
- Instagram runs on sharded PostgreSQL for its core data, with Cassandra for feeds and Memcached in front of both.
- Amazon moved its retail catalog and order flows from Oracle to DynamoDB and Aurora; DynamoDB served tens of millions of requests per second on Prime Day.
- Discord stores trillions of messages in ScyllaDB (wide-column, partitioned by channel and time) after outgrowing Cassandra's garbage-collection pauses.
- Netflix keeps viewing history in Cassandra because the write rate is enormous and the reads are always by user.
- Uber built Schemaless, a document store on top of sharded MySQL, which shows how often the answer is "a simple model on a proven engine".
"NoSQL scales, SQL does not" is folklore. Wide-column and key-value stores scale writes easily because they refuse to do joins and cross-partition transactions; you pay for that by designing tables around each query up front. Pick them for the access pattern, never for the buzzword.
Sharding: splitting one table across machines
Sharding partitions a dataset horizontally: each shard holds a subset of the rows and runs on its own server, so both storage and write throughput grow with the number of shards. The one decision that matters is the shard key, because it decides which queries stay on one machine.
flowchart TD
Q["query: orders for user 8213"] --> R{"router: hash(user_id) mod 4"}
R -- 0 --> S0[(shard 0: users 0..)]
R -- 1 --> S1[(shard 1)]
R -- 2 --> S2[(shard 2)]
R -- 3 --> S3[(shard 3)]
Q2["query: all orders over $100 today"] -.-> SG[scatter to all shards, gather results]
SG -.-> S0 & S1 & S2 & S3
Choosing the shard key
| Strategy | How | Good | Bad |
|---|---|---|---|
| Hash | hash(key) decides the shard | Even spread, no hotspots from sequential keys | Range queries scatter to every shard; resharding moves most keys unless you use consistent hashing |
| Range | Contiguous key ranges per shard (A–F, G–M, …) | Range scans stay local; easy to split a hot range | Time-ordered or sequential keys pile onto the newest shard |
| Directory | A lookup table maps each key or tenant to a shard | Full control; move one tenant at a time | The lookup table is a dependency on every request |
The key should appear in almost every query (user id for a consumer app, tenant id for SaaS), spread load evenly, and rarely change. Hotspots come from keys that break the second rule: one celebrity account, one tenant that is a hundred times bigger than the rest, or a timestamp key where every write lands on "now". Fixes include adding a random suffix to hot keys, splitting hot shards, or caching the hot entity.
What sharding costs
- Cross-shard queries become scatter-gather: ask every shard, merge the results. Joins across shards are effectively gone; denormalize or keep a copy of what you need on the same shard.
- Cross-shard transactions need two-phase commit or sagas. Design so that a transaction's rows share a shard key.
- Resharding is the operation everyone fears: moving data while serving traffic. Plan for it on day one with more logical shards than physical servers (Instagram started with thousands of logical shards across a few machines) or a system that reshards online.
- Unique ids can no longer come from one auto-increment; use UUIDs, Snowflake-style ids, or per-shard ranges.
- DynamoDB shards by partition key automatically, splits partitions as they grow past about 10 GB or their throughput limits, and its adaptive capacity absorbs moderate hotspots.
- Vitess shards MySQL transparently, was built at YouTube and now runs Slack, GitHub and Shopify; Citus does the same for Postgres.
- MongoDB routes through mongos with a chosen shard key and rebalances chunks between shards in the background.
- Instagram sharded Postgres by user id into logical shards (schemas) that could be moved between physical servers without changing application code.
Shard late. A single well-indexed database on a large machine with read replicas usually lasts far longer than expected, and every feature that comes after sharding is harder to build. When you do shard, shard by the entity that queries revolve around, not by whatever is convenient to hash.
Replication: keeping copies in sync
Replication keeps the same data on several nodes so that reads can be spread out and a failed node does not lose anything. The design question is who accepts writes and how long a copy may lag behind.
sequenceDiagram autonumber participant C as Client participant L as Leader participant F1 as Follower (sync) participant F2 as Follower (async) C->>L: UPDATE balance L->>L: append to WAL, apply L->>F1: ship WAL record F1-->>L: acknowledged L-->>C: committed L->>F2: ship WAL record (later) Note over F2: lag: a read here may return the old balance
Three shapes
- Single leader. One node takes all writes and streams its log to followers, which serve reads. Simple and the default in Postgres, MySQL and most managed databases.
- Multi-leader. Several nodes accept writes, usually one per region, and exchange changes. Faster local writes, but concurrent writes to the same row must be reconciled: last-writer-wins, merge rules, or conflict-free data types.
- Leaderless. Any node accepts writes; a write is successful when W nodes confirm and a read asks R nodes, with W + R greater than N to guarantee overlap. This is the Dynamo model used by Cassandra and Riak; the reliability article covers quorums in depth.
Synchronous or asynchronous
A synchronous follower must acknowledge before the leader confirms the write: no data is lost on failover, at the cost of write latency and the risk that a slow follower stalls everything. An asynchronous follower is told about the write later: fast, but if the leader dies before shipping, those writes are gone. Most deployments use one synchronous follower (or a quorum of them) plus asynchronous read replicas.
Living with lag
An asynchronous replica is always slightly behind, from milliseconds to, under load, minutes. Three anomalies follow and each has a standard fix:
- Read your own writes. A user updates their profile, refreshes, and sees the old one. Fix: route that user's reads to the leader for a short window after a write, or track the write's log position and require a replica to have reached it.
- Monotonic reads. Two refreshes hit two replicas with different lag and time appears to go backwards. Fix: pin a user to one replica.
- Consistent prefix. An answer arrives before the question it replies to. Fix: write causally related data to the same partition.
Failover
When the leader dies, a follower is promoted and clients are repointed, either by the database's own election, an orchestrator (Patroni, Orchestrator) or the managed service. The dangers are losing unshipped asynchronous writes, and split brain: the old leader comes back and keeps accepting writes. Fencing (revoking the old leader's ability to write, for example with a lease or a fencing token) is what makes failover safe.
- PostgreSQL streaming replication ships WAL records to followers;
synchronous_standby_namesmakes one or more of them synchronous. MySQL ships the binlog with semi-synchronous mode as the middle ground. - Amazon Aurora replicates each write to six storage nodes across three availability zones and commits when four acknowledge, which is why failover loses nothing and completes in seconds.
- DynamoDB global tables are multi-leader across regions with last-writer-wins conflict resolution.
- Change data capture tools such as Debezium read the same replication log and turn each change into an event, which is how databases feed Kafka without dual writes.
- Replication lag
- How far a follower is behind the leader, in time or in log position. The number that turns into "I saved it but it is not there".
- Read replica
- An asynchronous follower used only for reads. Cheap read scaling as long as the application tolerates lag.
- Fencing
- Making sure a demoted leader cannot write. Without it, failover can produce two diverging histories.
Indexing: B-trees and LSM-trees
An index is a second data structure that trades write cost and space for fast lookups on a column. The two families behind every database differ in what they optimise: B-trees for reads, LSM-trees for writes.
flowchart TB
subgraph BT["B-tree: update in place"]
direction TB
W1[write] --> P[find the page, modify it, WAL it]
P --> Rd[read: walk root to leaf, 3 or 4 page reads]
end
subgraph LSM["LSM-tree: append, then merge"]
direction TB
W2[write] --> WAL[WAL] --> MT[memtable, sorted in memory]
MT -- full --> L0[flush: SSTable level 0]
L0 -- compaction --> L1[level 1, larger sorted files]
L1 -- compaction --> L2[level 2 ...]
Rd2[read] --> MT
Rd2 -. bloom filter says maybe .-> L0 & L1 & L2
end
BT ~~~ LSM
| B-tree | LSM-tree | |
|---|---|---|
| Writes | Random I/O: find and modify the page in place | Sequential: append to memtable and WAL, flush sorted files later |
| Reads | Predictable: root to leaf, a handful of pages | May check memtable plus several SSTables; bloom filters skip most |
| Space | Some fragmentation inside pages | Compression works well on sorted files; old versions linger until compaction |
| Background work | Little | Compaction: merging files costs CPU and disk bandwidth (write amplification) |
| Best for | Read-heavy and mixed workloads, range scans, transactional systems | Write-heavy workloads, time-series, logs, large key-value stores |
| Engines | PostgreSQL, InnoDB (MySQL), SQL Server, SQLite | RocksDB, LevelDB, Cassandra, HBase, Bigtable, CockroachDB, TiKV |
Using indexes well
- Every index is a write. A table with six indexes does seven writes per insert. Index what queries filter and sort on, and nothing else.
- Column order matters in a composite index.
(user_id, created_at)serves "this user's recent orders"; it does not serve "all orders on a date". - Covering indexes include the columns the query returns, so the table itself is never touched.
- Partial indexes cover only rows that match a condition (unshipped orders), which keeps them small and hot.
- Secondary indexes in distributed stores are either local to a partition (cheap, only useful with the partition key) or global (a separate sharded index, eventually consistent, costs extra writes). DynamoDB's LSI and GSI are the canonical pair.
-- Serves: WHERE user_id = ? ORDER BY created_at DESC LIMIT 20
CREATE INDEX orders_by_user_recent ON orders (user_id, created_at DESC);
-- Partial index: only the rows a worker actually polls for
CREATE INDEX orders_unshipped ON orders (created_at) WHERE shipped_at IS NULL;
-- Always confirm the plan uses it before shipping
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 8213 ORDER BY created_at DESC LIMIT 20;
- RocksDB (Facebook's fork of LevelDB) is the LSM engine inside CockroachDB, TiKV, Kafka Streams state stores and MyRocks; Facebook moved its user database from InnoDB to MyRocks to halve storage.
- Cassandra and Bigtable are LSM top to bottom, which is why they take writes so fast and why compaction tuning is a full-time topic.
- SQLite on Android is a B-tree engine; Room's default journal is its write-ahead log mode.
- Elasticsearch uses a different structure again, the inverted index, which is why it answers "documents containing these words" in milliseconds and simple key lookups comparatively slowly.
- SSTable
- Sorted string table. An immutable file of key-value pairs in key order, the unit an LSM-tree flushes and compacts.
- Bloom filter
- A tiny probabilistic set that answers "definitely not here" or "maybe here", letting a read skip files that cannot contain the key.
- Write amplification
- Bytes actually written to disk divided by bytes the application wrote. Compaction rewrites data several times, so LSM engines can reach 10× or more.
Caching: keeping hot data close
A cache keeps copies of frequently read data in a faster, smaller store so that most reads never reach the database. The patterns differ in who fills the cache and when writes go through it, and every one of them faces the same enemy: staleness.
flowchart TB
subgraph CA["Cache-aside"]
direction LR
A1[app] -- 1 GET key --> C1[(cache)]
C1 -- 2 miss --> A1
A1 -- 3 read --> D1[(database)]
A1 -- 4 SET key, TTL --> C1
end
subgraph WT["Write-through"]
direction LR
A2[app] -- write --> C2[(cache)] -- write synchronously --> D2[(database)]
end
subgraph WB["Write-back"]
direction LR
A3[app] -- write --> C3[(cache)] -. flush in batches .-> D3[(database)]
end
CA ~~~ WT ~~~ WB
| Pattern | Reads | Writes | Trade-off |
|---|---|---|---|
| Cache-aside (lazy loading) | App checks cache, on miss reads the database and fills the cache | App writes the database and deletes (or updates) the cache entry | Simple, only hot data is cached; first read is slow; a window of staleness after writes |
| Read-through | Cache itself loads from the database on a miss | Same as cache-aside | Cleaner application code; needs a cache that can call the database |
| Write-through | Always warm for written data | Written to cache and database synchronously | Consistent cache; slower writes; caches data that may never be read |
| Write-back (write-behind) | Same | Written to cache, flushed to the database asynchronously | Very fast writes, batching for free; data loss if the cache dies before the flush |
Expiry, eviction and invalidation
Every entry needs a way to leave. A TTL bounds staleness. Eviction (LRU or LFU when memory is full) bounds size. Invalidation on write removes an entry the moment it is wrong, and it is the hard part: the write path must know every key the change affects, and a delete that races with a concurrent read can leave the old value back in the cache. Deleting rather than updating on write, and keeping TTLs even when you invalidate, limits the damage.
Stampedes
When a popular key expires, every request that misses goes to the database at once. Three defences: a lock or single-flight so one request recomputes while the others wait or serve stale; early refresh, where an entry is refreshed in the background shortly before it expires; and jittered TTLs, so that a thousand keys written together do not expire together.
suspend fun product(id: Long): Product {
cache.get("product:$id")?.let { return it } // 1. hit
return loader.singleFlight("product:$id") { // 2. one loader per key
val p = db.findProduct(id) // 3. read the source
val ttl = 10.minutes + Random.nextLong(0, 60).seconds // 4. jitter the expiry
cache.set("product:$id", p, ttl)
p
}
}
suspend fun updateProduct(p: Product) {
db.save(p)
cache.delete("product:${p.id}") // invalidate by deleting, never by writing the new value
}
- Facebook's memcache paper describes leases: a miss returns a token, and only the holder may fill the key, which solved both stampedes and stale sets at billions of requests per second.
- Redis and Memcached are the shared caches nearly everyone uses; ElastiCache and Memorystore are the managed versions. Caffeine is the standard in-process cache on the JVM.
- Twitter caches entire home timelines in Redis and designs the pipeline so that they are always warm; rebuilding one from the database is the slow path, not the normal one.
- A database's own buffer pool is a cache too, and often the most important one: keep the working set in RAM and the database is fast on its own.
A cache that must be warm for the system to survive is not a cache, it is a tier. If the database cannot take the load of a cold cache, plan for it: warm caches before cutting traffic over, and rate limit the fill path.
Consistent hashing: adding a node without moving everything
Consistent hashing places both nodes and keys on the same circular hash space so that when a node is added or removed, only the keys next to it change owner. Plain hash(key) mod N remaps almost every key when N changes; consistent hashing remaps about 1/N of them.
The plain version has two flaws: with few nodes the arcs are uneven, and when a node leaves, its whole arc lands on one neighbour. Virtual nodes fix both by giving each physical server many positions. Replication falls out naturally: store each key on the next N distinct nodes clockwise, and a node's data is automatically spread across several successors.
- Amazon's Dynamo paper made the technique famous; DynamoDB and Cassandra (a token ring with virtual nodes) still partition this way.
- Memcached clients use ketama hashing so that adding a cache server does not flush the whole fleet.
- Envoy's ring hash and Google's Maglev use it for load balancing when the same client should keep hitting the same backend.
- Discord routes guilds to servers with consistent hashing so a deploy moves only a fraction of the live sessions.
Denormalization: copies instead of joins
Denormalization stores the same fact in more than one place so that reads need no join. Normalized data is easy to keep correct because each fact lives once; denormalized data is fast to read because each query finds everything it needs in one row, document or partition.
flowchart TB
subgraph N["Normalized"]
direction LR
O1[orders: id, user_id, total] --- U1[users: id, name, city]
O1 --- I1[order_items: order_id, product_id, qty] --- P1[products: id, name, price]
Q1["order page = 4 tables, 3 joins"]
end
subgraph DN["Denormalized"]
direction LR
O2["order document: id, user{name, city}, items[{product name, price, qty}], total"]
Q2["order page = 1 read"]
T2["timeline: precomputed list per user"]
end
N -- events or CDC keep the copies fresh --> DN
Forms it takes
- Embedding. A document holds the customer's name and the product names as they were at order time. Also correct historically: the order should not change when the product is renamed.
- Precomputed aggregates. A
like_countcolumn instead ofCOUNT(*)on every read. - Materialized views. A stored query result refreshed on a schedule or incrementally.
- Read models. A separate store shaped for one screen (a timeline, a search index, a dashboard) fed by events from the source of truth. This is the CQRS idea: one model for writes, others for reads.
- Single-table design. In DynamoDB, several entity types share one table with keys chosen so that each screen is one query.
Keeping copies honest
Every copy is a promise to update it. Dual writes from application code (write the order, then update the timeline) fail halfway and drift. Better: let the source of truth emit the change (an outbox table, change data capture) and have consumers maintain the copies, accepting that they lag by a moment. And keep a way to rebuild every derived store from the source, because one day you will need it.
- Twitter precomputes home timelines: a tweet is written into the cached timeline of every follower (fan-out on write), except for accounts with millions of followers, whose tweets are merged in at read time.
- DynamoDB single-table design is how Amazon itself models services on it: model the access patterns first, then choose keys that make each one a single query.
- Elasticsearch next to a relational database is the commonest read model: the database is truth, the index is a denormalized copy kept fresh by events.
- Postgres materialized views with
REFRESH MATERIALIZED VIEW CONCURRENTLYare the smallest useful version of the idea.
Denormalize for a measured read problem, not in anticipation of one. Each copy adds a write, a consistency question and a rebuild job. Start normalized, add indexes, add a cache, and denormalize when a specific screen is still too slow.
Write-ahead logging: durability before speed
A write-ahead log is an append-only file that a database writes every change to, and forces to disk, before it touches its real data structures. If the process dies at any point, replaying the log rebuilds what was in memory. It is how a database can be both durable and fast: the expensive random writes happen later, in batches.
sequenceDiagram autonumber participant C as Client participant D as Database participant W as WAL on disk participant P as Data pages C->>D: COMMIT (update row) D->>W: append record, fsync W-->>D: durable D-->>C: committed D->>D: modify the page in memory D-->>P: checkpoint later: write dirty pages, trim the log Note over D,P: crash before the checkpoint? replay the WAL from the last checkpoint
- Sequential wins. Appending to one file is the fastest thing a disk can do; updating pages scattered across a table is the slowest. The log takes the fast path on the critical path and defers the slow one.
- Group commit. Many transactions committing at once share a single
fsync, which is batching applied to durability. - Checkpoints. Periodically the dirty pages are written out and a checkpoint record marks how far the log can be truncated. Recovery replays only what came after.
- The same log powers replication. Followers are just processes replaying the leader's WAL, and change data capture reads it too.
- PostgreSQL's WAL and InnoDB's redo log are the reference implementations;
synchronous_commitin Postgres lets you trade durability for latency per transaction. - Kafka is a write-ahead log offered as a service: producers append, the broker fsyncs on its own schedule, consumers replay.
- Every LSM engine writes the WAL first, then the memtable, which is why a crash never loses acknowledged writes even though the memtable lives in RAM.
- SQLite's WAL mode, the default for Room on Android, lets readers keep going while a writer appends, which is why it replaced the older rollback journal.
- Raft and Paxos replicate a log across machines; the reliability article is largely about agreeing on its contents.
- fsync
- The system call that forces written data from the OS cache to the physical disk. Durability starts only when it returns.
- Checkpoint
- A point at which all changes before it are on disk in the data files, so the log before it can be discarded.
Transactions and isolation levels
A transaction groups several operations so that they succeed or fail as one and cannot see each other half done. ACID names the four promises, and the isolation level is the dial that trades how much concurrent transactions can see of each other against how much they block.
- Atomic
- All of the transaction's writes happen, or none do. Implemented with the log: uncommitted changes are rolled back on recovery.
- Consistent
- Constraints (unique keys, foreign keys, checks) hold before and after. This one is a promise about your rules, kept by the database.
- Isolated
- Concurrent transactions behave as if they ran one at a time, to the degree the isolation level says.
- Durable
- Once committed, the change survives crashes. The write-ahead log is what makes this true.
| Level | What you can see | Anomalies still possible | Default in |
|---|---|---|---|
| Read uncommitted | Other transactions' uncommitted writes | Dirty reads, everything below | Nobody sensible |
| Read committed | Only committed data, as of each statement | Non-repeatable reads, phantoms, write skew | PostgreSQL, Oracle, SQL Server |
| Repeatable read (snapshot) | A consistent snapshot taken at transaction start | Write skew, phantoms in some engines | MySQL InnoDB |
| Serializable | As if transactions ran one after another | None; conflicting transactions are aborted and must retry | CockroachDB, Spanner, FoundationDB |
Modern engines implement snapshot levels with MVCC: each write creates a new version of the row and readers see the version that was current when they started, so readers never block writers. The anomaly worth remembering is write skew: two transactions each read a condition (two doctors on call), each decide based on it (I can go off duty), and both commit, leaving nobody on call. Only serializable isolation, or an explicit lock, prevents it.
- PostgreSQL defaults to read committed and offers true serializable isolation (SSI) that detects conflicts instead of locking.
- Google Spanner provides serializable transactions across data centers, using synchronized clocks (TrueTime) to order them.
- DynamoDB added transactions in 2018 for up to 100 items, a sign that even key-value stores end up needing them.
Recap
- Choose storage by access pattern: relational by default, key-value for lookups, document for nested records, wide-column for huge write rates, graph for relationships, object for blobs, search for text. Real systems are polyglot with one source of truth.
- Sharding splits rows across machines by a shard key that should be in every query and spread evenly. Cross-shard queries scatter, cross-shard transactions need sagas, and resharding must be planned from the start. Shard late.
- Replication: single leader is the default; synchronous followers lose nothing, asynchronous ones lag. Handle read-your-writes and monotonic reads explicitly; fence the old leader on failover.
- B-trees update in place and read predictably; LSM-trees append and compact, taking writes far faster. Every index costs a write; order composite columns for the query; confirm with EXPLAIN.
- Cache-aside is the everyday pattern; write-through keeps the cache consistent; write-back buys speed with loss risk. Invalidate by deleting, jitter TTLs, and single-flight the refill to stop stampedes.
- Consistent hashing puts nodes and keys on one ring so membership changes move only a neighbouring arc; virtual nodes even out the load.
- Denormalize for measured read problems: embed, precompute, materialize, or keep read models fed by events or CDC. Never dual-write; keep a rebuild path.
- A write-ahead log makes commits durable with one sequential append and lets data pages catch up later; it also powers replication and CDC.
- ACID is atomic, consistent, isolated, durable. Read committed is the common default; snapshot isolation still allows write skew; serializable prevents it at the cost of retries.
Questions
Try answering each one out loud before opening it. Lead with the one-line answer, then the reasoning, then one detail from a real system.
How do you choose a database for a new feature?
List the access patterns first, then pick the model whose shape matches them; default to the relational database you already run unless a pattern clearly needs something else.
- Transactions across records, joins, ad hoc queries: relational.
- Key lookups at very low latency: key-value. Nested, evolving records: document. Huge time-ordered write rates: wide-column.
- Blobs go to object storage, text search to a search index, relationship traversals to a graph store.
Most products end up polyglot: Postgres as the source of truth with Redis, Elasticsearch and S3 derived from it.
When does a NoSQL store make sense, and what do you give up?
When the workload is dominated by one or two access patterns at a scale where a single relational node cannot keep up, especially write-heavy or key-lookup workloads; you give up joins, cross-partition transactions and query flexibility.
- Wide-column and key-value stores scale by refusing to do the expensive things.
- You design tables per query up front, and changing access patterns later is costly.
- Consistency is often tunable, which means the application must think about it.
Netflix keeps viewing history in Cassandra because writes are enormous and reads are always by user, which is exactly the shape wide-column stores are built for.
Explain sharding. How do you choose a shard key and handle hotspots?
Sharding splits a table's rows across servers by a shard key; choose a key that appears in nearly every query, spreads load evenly and rarely changes, and handle hotspots by salting hot keys, splitting hot shards or caching hot entities.
- Hash sharding spreads evenly but scatters range queries; range sharding keeps scans local but piles sequential keys on one shard.
- Queries without the key become scatter-gather; transactions across shards need sagas.
- Use more logical shards than servers so resharding is a move, not a re-hash.
DynamoDB splits partitions automatically past about 10 GB and uses adaptive capacity for moderate hotspots, but a single hot key still needs application-side salting.
How would you reshard a live database without downtime?
Add the new shards, copy data with a bulk snapshot plus ongoing change replication, dual-read to verify, then switch the routing for one key range at a time with a brief write pause per range.
- Logical shards mapped to physical servers make this a directory change rather than a data re-hash.
- Consistent hashing limits how much data moves when a node is added.
- Keep a rollback: the old shard stays authoritative until the cut-over is verified.
Vitess does this as an online operation (VReplication) and is why YouTube, Slack and GitHub can reshard MySQL while serving traffic.
Synchronous versus asynchronous replication: what is the trade-off, and what is replication lag?
Synchronous replication waits for a follower's acknowledgement before confirming a write, so nothing is lost on failover but writes are slower and a slow follower stalls the leader; asynchronous replication confirms immediately and the follower lags, so a failover can lose the last few writes.
- Lag is the gap between leader and follower, in time or log position; it grows under load.
- Common setup: one synchronous or quorum follower for durability plus asynchronous read replicas.
- Aurora writes to six storage nodes and commits on four, which is quorum replication built into storage.
Postgres exposes the choice per transaction with synchronous_commit, so a chat message can be fast and a payment can be safe.
A user saves a change and does not see it on refresh. What happened and how do you fix it?
The read went to an asynchronous replica that had not yet applied the write; fix it by routing that user's reads to the leader for a short window after they write, or by waiting until a replica has reached the write's log position.
- This is the read-your-writes anomaly; monotonic reads (time going backwards between refreshes) is the sibling, fixed by pinning a user to one replica.
- A simple version: after a write, set a cookie with the log position or a timestamp and route reads accordingly.
- Strongly consistent reads on DynamoDB are the managed equivalent, at double the cost.
The right design is to decide per read which anomalies are acceptable, rather than making every read strongly consistent.
What can go wrong during failover?
Two things: unshipped asynchronous writes are lost when a follower is promoted, and the old leader may come back and keep accepting writes, producing split brain.
- Fencing, through leases or fencing tokens, prevents the demoted leader from writing.
- Promotion must be agreed by a majority or an orchestrator so two followers are not promoted at once.
- Clients need to discover the new leader quickly, usually through DNS or a proxy.
Managed databases such as RDS Multi-AZ and Aurora hide this, but the same mechanisms run underneath.
B-tree or LSM-tree: which would you pick for a write-heavy workload, and why?
An LSM-tree, because it turns random writes into sequential appends to a memtable and log, flushing sorted files later; a B-tree must find and modify a page for every write.
- The cost is read amplification (several files may be checked) and compaction work in the background.
- Bloom filters and levelled compaction keep reads fast enough for most workloads.
- For read-heavy, range-scan and transactional workloads the B-tree's predictable reads win.
Facebook moved its user database to MyRocks (RocksDB under MySQL) mainly to cut storage in half through better compression.
Why not index every column?
Because every index is an extra write on every insert, update and delete, plus space and cache pressure; index only what queries filter or sort on, and order composite columns to match those queries.
- A table with six indexes does seven writes per row.
- Unused indexes still cost; check usage statistics and drop them.
- Covering and partial indexes get more from fewer indexes.
Always confirm with the query plan; an index the planner ignores is pure cost.
Compare cache-aside, write-through and write-back.
Cache-aside fills the cache lazily on read misses and invalidates on write; write-through writes to cache and database together so the cache is always consistent; write-back writes only to the cache and flushes later, fastest but lossy if the cache dies.
- Cache-aside caches only what is read and tolerates cache failure; it has a staleness window after writes.
- Write-through slows writes and caches data that may never be read.
- Write-back batches writes for free and suits counters and metrics where losing a few seconds is acceptable.
Nearly every application cache in production is cache-aside with TTLs; write-back shows up inside databases and in analytics counters.
What is a cache stampede and how do you prevent it?
A stampede is many requests missing the same expired key at once and all hitting the database; prevent it with single-flight locking so one request refills while others wait or serve stale, background refresh before expiry, and jittered TTLs so keys do not expire together.
- Facebook's memcache leases are the classic implementation: a miss returns a token and only the holder may set the key.
- Serving stale during refresh is usually acceptable and removes the latency spike.
- Rate limit the fill path so a cold cache cannot take the database down.
The same idea protects an origin behind a CDN, where it is called request collapsing.
What problem does consistent hashing solve, and why virtual nodes?
It solves the remapping problem: with hash mod N, changing N moves nearly every key, while consistent hashing moves only the keys on the arc next to the added or removed node; virtual nodes give each server many ring positions so load is even and a failed node's keys spread across all others.
- Keys and nodes hash onto the same ring; a key belongs to the first node clockwise.
- Replication is the next N distinct nodes clockwise.
- Used by Cassandra, DynamoDB, Memcached clients and ring-hash load balancers.
Cassandra gives each server many tokens on the ring for exactly this reason; the default was 256 and dropped to 16 in version 4.0 once the balancing algorithm improved.
When should you denormalize, and how do you keep the copies consistent?
Denormalize when a specific read path is measured to be too slow after indexing and caching, and keep copies consistent by deriving them from the source of truth through events or change data capture, never by dual writes from application code.
- Forms: embedded fields, precomputed counts, materialized views, read models, single-table designs.
- Accept a small lag in the copies; keep a job that can rebuild them from scratch.
- Embedding is also historically correct: an order should keep the price at purchase time.
Twitter's fan-out-on-write timelines are denormalization at planetary scale, with a read-time merge for accounts too popular to fan out.
What is a write-ahead log, and why is it faster than writing the data directly?
An append-only log every change is written and fsynced to before the data structures are modified; it is faster because one sequential append is the cheapest disk operation, while updating data pages in place is random I/O that can be deferred and batched.
- Recovery replays the log from the last checkpoint to rebuild memory state.
- Group commit shares one fsync across many transactions.
- The same log feeds replication and change data capture.
Kafka is the idea sold as a product: an append-only, replayable log that other systems treat as their write-ahead log.
Explain ACID and the isolation levels. What does serializable prevent that snapshot isolation does not?
ACID means atomic (all or nothing), consistent (constraints hold), isolated (concurrent transactions do not interfere) and durable (committed means on disk); isolation levels from read uncommitted to serializable allow fewer anomalies, and serializable is the only one that prevents write skew.
- Read committed sees only committed data per statement; snapshot sees one snapshot for the whole transaction.
- Write skew: two transactions read the same condition, both act on it, both commit, and the condition is violated.
- Serializable aborts conflicting transactions, so the application must retry.
Postgres defaults to read committed but its serializable mode detects conflicts without locks; Spanner and CockroachDB make serializable the default.
Design the storage for a social feed like a Twitter home timeline.
Store tweets in a sharded relational or wide-column store keyed by author, precompute each user's home timeline as a list of tweet ids in Redis by fanning out on write, and merge in celebrity accounts at read time.
- Fan-out on write turns a read-time join across followees into one cached list read.
- Accounts with millions of followers are excluded from fan-out and merged at read time to avoid write storms.
- Tweet bodies are fetched by id from a cache in front of the store; media lives in object storage behind a CDN.
This is exactly Twitter's published design, and it combines four topics from this article: sharding, caching, denormalization and a write-ahead style event pipeline to keep the timelines fresh.