System design · 3 of 5

Storage and data systems

Choosing a data model, sharding, replication, indexes, caching, consistent hashing, denormalization and write-ahead logs.

Updated 2026-09-08
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.

Figure 1. The whole article on one page. Every branch is a section below; fold what you know, open what you do not.

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".

ModelData structureScales byConsistencyBuilt forExamples
RelationalTables with a fixed schema, joined by keysVertical, plus read replicas; sharding is manualStrict ACID transactionsAnything transactional: orders, payments, inventory, accountsPostgreSQL, MySQL, Aurora, Cloud SQL, SQL Server
Key-valueOpaque value looked up by key; a distributed hash mapHorizontal sharding by keyEventual or strong, per requestCaches, sessions, shopping carts, feature flags, countersRedis, Memcached, DynamoDB, etcd
DocumentJSON-like documents with flexible fields, queried by contentHorizontal partitioning by a shard keyTunable per operationCatalogs, user profiles, content, anything that nests naturallyMongoDB, Firestore, Couchbase, DocumentDB
Wide-columnRows with many sparse columns, grouped into partitions; a sorted multi-dimensional mapDistributed ring, add nodes freelyEventual convergence, tunable quorumsTime-series, events, messages, anything append-heavy at very high write ratesCassandra, ScyllaDB, Bigtable, HBase
GraphNodes and edges with properties, traversed by relationshipPartitioned clustering; hard to shard wellACID within the graph engineSocial graphs, recommendations, fraud rings, knowledge graphsNeo4j, Amazon Neptune, TigerGraph
ObjectImmutable blobs addressed by key, with metadataEffectively unlimitedStrong read-after-write (S3 since 2020)Files, images, video, backups, data-lake tablesS3, GCS, Azure Blob
SearchInverted index: term to list of documentsSharded indexes with replicasEventual (near real-time refresh)Full-text search, log analytics, faceted filteringElasticsearch, 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]
Figure 2. A starting point, not a rule. The first question is whether you need transactions across records, joins and ad hoc queries. Most products end up with a relational core plus two or three of the others.

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.

In the wild
  • 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".
Watch out

"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
Figure 3. A query that includes the shard key hits one shard. One that does not hits all of them.

Choosing the shard key

StrategyHowGoodBad
Hashhash(key) decides the shardEven spread, no hotspots from sequential keysRange queries scatter to every shard; resharding moves most keys unless you use consistent hashing
RangeContiguous key ranges per shard (A–F, G–M, …)Range scans stay local; easy to split a hot rangeTime-ordered or sequential keys pile onto the newest shard
DirectoryA lookup table maps each key or tenant to a shardFull control; move one tenant at a timeThe 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

In the wild
  • 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.
Watch out

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
Figure 4. Single-leader replication. The leader waits for the synchronous follower, not for the asynchronous one.

Three shapes

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:

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.

In the wild
  • PostgreSQL streaming replication ships WAL records to followers; synchronous_standby_names makes 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
Figure 5. A B-tree read touches a few pages. An LSM read may consult several files, so bloom filters and compaction exist to keep that number small.
B-treeLSM-tree
WritesRandom I/O: find and modify the page in placeSequential: append to memtable and WAL, flush sorted files later
ReadsPredictable: root to leaf, a handful of pagesMay check memtable plus several SSTables; bloom filters skip most
SpaceSome fragmentation inside pagesCompression works well on sorted files; old versions linger until compaction
Background workLittleCompaction: merging files costs CPU and disk bandwidth (write amplification)
Best forRead-heavy and mixed workloads, range scans, transactional systemsWrite-heavy workloads, time-series, logs, large key-value stores
EnginesPostgreSQL, InnoDB (MySQL), SQL Server, SQLiteRocksDB, LevelDB, Cassandra, HBase, Bigtable, CockroachDB, TiKV

Using indexes well

-- 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;
In the wild
  • 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
Figure 6. Cache-aside is the everyday pattern; write-through keeps the cache consistent; write-back buys write speed with the risk of losing what has not been flushed.
PatternReadsWritesTrade-off
Cache-aside (lazy loading)App checks cache, on miss reads the database and fills the cacheApp writes the database and deletes (or updates) the cache entrySimple, only hot data is cached; first read is slow; a window of staleness after writes
Read-throughCache itself loads from the database on a missSame as cache-asideCleaner application code; needs a cache that can call the database
Write-throughAlways warm for written dataWritten to cache and database synchronouslyConsistent cache; slower writes; caches data that may never be read
Write-back (write-behind)SameWritten to cache, flushed to the database asynchronouslyVery 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
}
In the wild
  • 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.
Watch out

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.

hash space 0 → 2³² → 0 A B C D k1 → B k2 → C k3 → D k4 → A E joins here clockwise How it works Hash every node and every key onto the same ring. A key belongs to the first node clockwise from it. Add node E: only k1 changes owner, from B to E. Remove node C: only k2 moves, on to D. Everything else stays where it was. Virtual nodes Each server takes 100 to 200 positions on the ring, so load evens out and a lost server's keys spread over all the others instead of landing on one neighbour.
Figure 7. Nodes and keys share the ring. Ownership is "next node clockwise", so membership changes only disturb one arc.

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.

In the wild
  • 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
Figure 8. The normalized side stays the source of truth; the denormalized side is rebuilt from it.

Forms it takes

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.

In the wild
  • 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 CONCURRENTLY are the smallest useful version of the idea.
Watch out

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
Figure 9. The commit is acknowledged as soon as the log is on disk. Data pages catch up in the background.
In the wild
  • PostgreSQL's WAL and InnoDB's redo log are the reference implementations; synchronous_commit in 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.
LevelWhat you can seeAnomalies still possibleDefault in
Read uncommittedOther transactions' uncommitted writesDirty reads, everything belowNobody sensible
Read committedOnly committed data, as of each statementNon-repeatable reads, phantoms, write skewPostgreSQL, Oracle, SQL Server
Repeatable read (snapshot)A consistent snapshot taken at transaction startWrite skew, phantoms in some enginesMySQL InnoDB
SerializableAs if transactions ran one after anotherNone; conflicting transactions are aborted and must retryCockroachDB, 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.

In the wild
  • 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.