Design a Distributed Key-Value Store
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | A KV store for one product team | It becomes the company-wide platform | It goes global |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | 10M DAU, ~10K QPS peak | 200M DAU, 250K QPS peak | 1B MAU, ~1M QPS peak across regions |
| Data | ~1 TB raw, values up to 100 KB | 23 TB raw, 103.5 TB provisioned, values up to 1 MB | ~69 TB of unique data, ~810 TB provisioned |
| Survives | Losing a node | Losing an AZ | Losing a region |
| Availability | 99.9% | 99.99% | 99.999% |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Track Opener: What Is a Key-Value Store?
You Already Use One: the Hashmap
You have written this line a hundred times (pseudocode):
textsessions["sess_7f3a"] = { "user_id": 1001, "cart_items": 3 }
That is a key-value store. A Python dict or a Java HashMap hashes the key to find a slot in an array, so a lookup takes about the same short time whether the map holds ten entries or ten million. We call that O(1) on average.
A key-value store is the same idea offered as a service over the network. You give it a key and a value; later you give it the key and get the value back. Redis, Amazon DynamoDB and Apache Cassandra all work this way at their core.
What teams keep in one:
| Use | Key | Value |
|---|---|---|
| Login sessions | session:7f3a9c | user ID, expiry, device |
| Shopping carts | cart:1001 | list of items |
| User profiles | user:1001 | name, email, settings |
| Feature flags | flag:dark_mode | on/off per user group |
What they have in common: the caller always knows the exact key, and it wants the answer fast.
What Changes When the Hashmap Spans Many Machines
On one machine, several hard problems are free:
| On one machine it's free | On many machines it's hard |
|---|---|
| After a crash, we check one disk to see what survived | A crash can hit some copies of a write and not others. Did the write happen? |
| All data fits (until it doesn't) | We must split the data, and every request must find the right piece |
| There is one copy, so it always agrees with itself | We keep copies so a crash loses nothing, and now copies can disagree |
| If the machine is up, it answers | Some machines are down or slow at every moment, and we must answer anyway |
Every problem in the three rounds is one of these four: surviving crashes, holding more than one machine can, keeping copies in agreement, and answering while machines fail.
The Question the Whole Track Answers
When the network between our machines breaks, a copy on one side can't learn about writes on the other side. At that moment we have two choices:
- Answer anyway with what we have, and risk returning an old value (we stay available).
- Refuse to answer until the copies can talk again (we stay consistent).
We can't have both while the network is broken. This is the core of the CAP theorem. So the question is:
We can't have perfect consistency and perfect availability when the network breaks. So which do we give up, when, and who chooses?
The answer gets sharper every round:
- Round 1: we pick once, for the whole cluster.
- Round 2: each request picks.
- Round 3: each table picks, per region.
Round 1 · Mid-level · "A KV Store for One Product Team"
~35 min · SDE II (L5) · 1 region, 3 AZs · ~10K QPS peak · ~1 TB · 99.9%
R1.1 Establish Design Scope
The interviewer says: "Design a key-value store." That's all. Before we draw anything, we ask questions. Each answer changes the design, so we say what it changes out loud.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How big is a value? | Up to 100 KB. Most are about 2 KB. | A value is small enough to send in one request and hold in memory. We don't need chunking or a separate blob store. |
| Read-heavy or write-heavy? | About 80% reads, 20% writes. | Reads must be cheap. Writes are still a real share, so the storage engine must handle steady writes well. |
| Is a stale read acceptable? | Yes. Eventual consistency is fine. | We don't need a leader or consensus on every request. That makes the design simpler and more available. |
| Do we need range scans ("all keys from A to F")? | No. | We can spread keys by hashing them. Hashing destroys key order, and that's fine here. |
| Must it survive losing a machine? | Yes. | We need copies of every key on more than one machine. |
| One region? | Yes, one AWS region. | Copies can sit in the region's Availability Zones (AZs), which are separate data centers, typically a millisecond or two apart. |
| Do keys expire? | Optionally. Sessions should expire on their own. | We need a time-to-live (TTL) per key. |
Out of scope for this round:
- Secondary indexes ("find users by email"). They need a second data structure kept in sync with the first. The team only looks up by key.
- Range scans. They need keys kept in sorted order across machines. We just said no.
- Multi-key transactions ("move an item from cart A to cart B, all or nothing"). They need a commit protocol across machines, such as two-phase commit. Nobody asked for it.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, some of it comes back.
R1.2 Functional Requirements, Derived Step by Step
We read the problem one phrase at a time and turn each phrase into an operation:
| Phrase from the problem | Operation |
|---|---|
| "Store a value under a key" | put(key, value) |
| "Read it back" | get(key) returns the value, or "not found" |
| "Remove it" | delete(key) |
| "Sessions should expire" | put(key, value, ttl_seconds): the key disappears on its own after the TTL |
That's the whole list for this round: three operations plus TTL.
Not yet: batch operations (many keys in one call), choosing consistency per request, and conditional writes ("write only if nobody changed it"). We don't need them at this scope, so we don't build them.
What Is a "Value"?
The store treats a value as bytes. It never looks inside. But the caller's data has a shape, and that shape decides where the rules about the data live.
Structured values follow a fixed schema: the same fields, the same types, every time.
textKey: "user:1001" Value: { id: INT64, email: STRING, status: ENUM, updated_at: TIMESTAMP }
Some databases enforce such a schema themselves. A relational table does, and so does a Cassandra CQL table with typed columns. Amazon DynamoDB does not: it types only the key attributes (the partition key and optional sort key), and every other attribute on an item is free-form. That makes DynamoDB items semi-structured, even when every item happens to look the same. Our store enforces no schema at all, so when a team wants structured values, the schema lives in the client, for example as a Protocol Buffers or Avro format that the writer checks before it calls put.
Semi-structured values carry their own field names, and two values under the same kind of key can have different fields:
textKey: "product:88213" Value: { "title": "Running Shoe", "price": 89.99, "attributes": { "color": "red", "size": "10" } } Key: "product:88214" Value: { "title": "Espresso Machine", "price": 349.00, "attributes": { "wattage": 1350 }, "warranty_years": 2 }
That is flexible, but every value repeats its field names (more bytes), and readers must cope with missing or unknown fields.
What both cost in our store: put replaces the whole value. To change one field of a 2 KB profile, the client reads the value, changes the field, and writes all 2 KB back, with a new version. An engine that stores each field separately, as Cassandra does with its per-column cells, can update one field alone. Ours can't, and at this scope we accept that.
R1.3 Non-Functional Requirements: the Questions
We state each quality in words first. The numbers come in R1.7, once we have a design to attach them to.
- Availability: survive a node loss. When one machine dies, every key must still be readable and writable. Hardware fails every day at fleet scale.
- Latency: fast enough to sit on every page load. A page may make several calls to the store. If each takes 50 ms, the page is slow.
- Durability: an acknowledged write is never lost. Once we say "OK", the data must survive a crash. Otherwise callers can't trust the "OK".
- Scalability: add machines, get more capacity. When data or traffic doubles, we add machines. We don't redesign.
- Consistency: eventual is fine. If a reader sees an old value for a moment, nothing breaks. All copies must agree eventually.
How availability and consistency pull against each other. We will keep several copies of every key. To be available, we want to answer even when some copies can't be reached. To be consistent, we want every answer to reflect the latest write, which means checking with enough copies. The more copies we wait for, the more consistent the answer and the more likely a slow or dead copy blocks us. Every design choice below sits somewhere on that line.
R1.4 The API
Three HTTP endpoints. The key is part of the URL.
Write a value
httpPUT /v1/kv/session:7f3a9c HTTP/1.1 Content-Type: application/json { "value": { "user_id": 1001, "device": "ios", "cart_items": 3 }, "ttl_seconds": 86400 }
json{ "key": "session:7f3a9c", "version": "1773648000123456" }
Read a value
httpGET /v1/kv/session:7f3a9c HTTP/1.1
json{ "key": "session:7f3a9c", "value": { "user_id": 1001, "device": "ios", "cart_items": 3 }, "version": "1773648000123456", "ttl_remaining_seconds": 86120 }
Delete a value
httpDELETE /v1/kv/session:7f3a9c HTTP/1.1
json{ "key": "session:7f3a9c", "deleted": true, "version": "1773648005000000" }
Every response carries a version. Clients should treat it as an opaque token and only compare it for equality. In this round it's the time at which the write was accepted. It will matter a lot later.
Why PUT and not POST? In HTTP, POST /things means "server, create a thing and pick its ID." Here the client already owns the key, so it addresses the resource directly: PUT /v1/kv/{key}. PUT is also idempotent: sending the same PUT twice leaves the same result as sending it once. That matters because networks time out after the server has already done the work. With PUT, the client simply retries. With a create-style POST, a retry could create a duplicate.
Status codes
| Code | Meaning | What the client does |
|---|---|---|
200 OK | Done | Nothing |
404 Not Found | The key doesn't exist, was deleted, or expired | Treat it as absent |
429 Too Many Requests | The caller is over its rate limit | Back off, then retry |
503 Service Unavailable | Not enough copies answered in time | Back off with random jitter, then retry |
Recap
- Three operations:
put,get,delete, plus an optional TTL. - Values up to 100 KB; the store treats them as opaque bytes.
- About 10K requests per second at peak, 80% reads, one region with 3 AZs.
- It must survive losing a machine and never lose an acknowledged write.
- Stale reads are acceptable; copies must agree eventually.
Let's build it, starting with the dumbest version that works.
R1.5 Design Evolution: From One Hashmap to a Replicated Store
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
One server. One hashmap in memory. An HTTP handler in front of it.
Synthesizing vector architecture diagram...
What's good about it: it's simple, every read and write takes microseconds, and there is exactly one copy, so it can never disagree with itself. For a prototype, this is the right answer.
Everything that follows exists because this server will crash, fill up, and one day not come back.
Step 1.1: Surviving a Restart
The problem: we deployed a new version, the server restarted, and every key is gone. The data only ever lived in memory. What would you do? How do we make writes survive a crash without making every write slow?
The WAL gives us the durability promise from R1.3: once we reply "OK", the write is on disk.
Synthesizing vector architecture diagram...
The order matters. The server writes the log and forces it to disk before it touches memory or answers. If the server dies after the fsync, replay restores the write. If it dies before, the client never got an "OK", so nothing was promised.
Primitive: Write-Ahead Log & LSM-Trees
Step 1.2: The Data No Longer Fits in Memory
The problem: the data grows past the machine's RAM, and the WAL takes an hour to replay after every restart. What would you do? Where does the data live now, and how do reads stay fast?
All disk writes in an LSM tree are sequential: WAL appends, MemTable flushes and compaction output. The disk never updates a record in place.
Synthesizing vector architecture diagram...
Writes flow down the left: WAL and MemTable, then a flush to Level 0, then compaction into bigger levels. Below Level 0, files in one level never overlap, so a key can be in at most one file per level. Reads check the MemTable, then ask each file's Bloom filter before touching the disk.
We use leveled compaction: each level is about 10 times larger than the one above, and within a level (below Level 0) each key lives in at most one file. A read then checks at most one file per level, and Bloom filters keep actual disk reads close to one.
Why 10 bits per key gives about 1%: the best number of bits per key for a false-positive rate p is -ln(p) / (ln 2)^2. For p = 0.01: 4.605 / 0.4805 ≈ 9.6 bits, which we round up to 10 bits, or 1.25 bytes per key.
Primitives: Write-Ahead Log & LSM-Trees · Bloom Filters · Drill: WAL/LSM compaction storm
Step 1.3: One Machine Isn't Enough
The problem: the product grows. One machine can't hold all the data, and it's also the single thing whose failure takes everything down. What would you do? How do we split keys across machines so every request finds the right one?
With 4 machines going to 5, hash % N moves a key unless hash % 4 == hash % 5, which holds for only 4 of every 20 hash values: 20% stay, 80% move. With consistent hashing, only the keys the new machine takes over move: about 1/5 = 20%.
Synthesizing vector architecture diagram...
Read it as a circle. Each key's hash lands between two tokens and belongs to the next token clockwise. Each node owns several tokens scattered around the ring, so adding a node steals a little from many others.
How many tokens per node? Cassandra used 256 for years. Since Cassandra 4.0 the default is 16, together with a token allocator that places them evenly. We use the same idea: a modest number of tokens, placed on purpose rather than at random.
Primitives: Consistent Hashing · Database Sharding & Partition Keys · Drill: Consistent hashing vnode skew
Step 1.4: Who Knows Where a Key Lives?
The problem: the client wants cart:1001. It doesn't know which of our machines owns that range, and ownership changes when machines join or die.
What would you do? How does each request reach the right machine?
Synthesizing vector architecture diagram...
Solid lines are requests. Dotted lines are gossip. Routers keep a local copy of the ring and never ask anyone on the request path.
Primitives: Gossip Protocol & Failure Detection · API Gateway & Reverse Proxy · Drill: Gossip failure detector flapping
Step 1.5: A Machine Died and Took Its Keys With It
The problem: one storage node's instance fails and never comes back. Every key in its ranges is gone. What would you do? How do we make sure one dead machine loses nothing and blocks nothing?
Synthesizing vector architecture diagram...
The walk skips B1 because AZ-b already has a copy on B2. The result is one copy per AZ, no matter how tokens happen to fall on the ring. Cassandra does the same with its NetworkTopologyStrategy, treating each AZ as a rack.
Step 1.6: When Is a Write Done?
The problem: the router sent a write to all three replicas. Two answered in 2 ms. The third is slow. What would you do? How many acknowledgments do we wait for before we tell the client "OK"? And how many copies does a read ask?
Why the sets must overlap. Three nodes. A write lands on two of them. A read asks two of them. Two plus two is four, but there are only three nodes, so at least one node is in both sets. This is the pigeonhole principle.
Where this guarantee stops. "The read quorum overlaps the write quorum" is not the same as linearizability, the guarantee that the system behaves like one single copy where every read sees every write that finished before it. Cases the overlap doesn't cover:
- Two concurrent writes to the same key: both may succeed, and the newer timestamp wins. One update is silently lost.
- A write that failed after reaching only one replica: the client got an error, but a later read may or may not see the value.
- Clock trouble: in this round the version is the router's clock at write time. If one router's clock runs ahead, its writes "win" against writes that really came later.
That's acceptable here, because the requirement is eventual consistency. We promise less than we deliver most of the time, and we're honest about the cases above.
Synthesizing vector architecture diagram...
The router sends to all three at once, not to two. That way a slow node never delays the reply: the first two ACKs are enough.
Primitive: Distributed Consensus (Raft & Paxos) (leaderless quorums vs leader-based replication)
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | One server, in-memory hashmap | Everything below |
| 1.1 | A restart loses all data | WAL + fsync + group commit; replay on restart | The log grows forever; data must fit in RAM |
| 1.2 | Data outgrows RAM | LSM tree: MemTable → SSTables, Bloom filters, leveled compaction | Reads touch several files; compaction uses I/O |
| 1.3 | One machine isn't enough | Consistent hashing (MurmurHash3) + virtual nodes | Someone must know the ring |
| 1.4 | Who knows where a key lives? | Stateless routers; ring and liveness via gossip + phi-accrual | Extra hop; failure detection is a guess |
| 1.5 | A dead machine loses its keys | N = 3 replicas on a preference list, one per AZ | Copies can disagree |
| 1.6 | When is a write done? | Quorums W = 2, R = 2, fixed cluster-wide | Not linearizable; concurrent writes collide; one setting for everyone |
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Follow a write from the top. The NLB spreads connections over six routers, two per AZ. A router hashes the key, finds the preference list (one node per AZ, for example A1, B2, C2), sends the write to all three, and answers after two ACKs. Each storage node runs the WAL-plus-LSM engine from steps 1.1 and 1.2 on an EBS gp3 volume. New SSTables are copied to S3, because a whole-cluster disaster (a bad bug, an operator mistake) is something replicas can't protect against.
Tracing one write: PUT cart:1001
- The client's connection lands on a router through the NLB.
- The router computes
MurmurHash3("cart:1001"), walks the ring, and gets the preference list[B2, A1, C1]. - It stamps the write with its clock as the version and sends it to all three.
- Each replica appends to its WAL, fsyncs (group commit), inserts into its MemTable, and ACKs.
- After two ACKs, the router returns
200 OKwith the version.
Tracing one read: GET cart:1001
- The router finds the same preference list.
- It asks two replicas: the one in its own AZ (cheaper and faster) plus one other.
- Each replica checks its MemTable, then Bloom filters, then reads at most a few 4 KB blocks.
- If the two versions differ, the router returns the newer one.
Backups. Each new SSTable is uploaded to S3 once; SSTables never change, so backup is incremental by nature. We also force a MemTable flush at least every 10 minutes, so the backup is never far behind. S3 is designed for 11 nines (99.999999999%) of object durability. That number belongs to S3, not to our cluster.
R1.7 Numbers
We now put numbers on the requirements and check that the design fits them.
Targets
| Quality | Target | Why this number |
|---|---|---|
| Availability | 99.9% | At most 0.1% × 8,760 h ≈ 8.8 hours down per year. Fine for one team's sessions and carts. |
| Latency | P99 under 10 ms | A page can make several calls and still load fast. |
| Durability | Acked write on 2 AZs' disks | W = 2, WAL fsynced |
| Consistency | Eventual | From R1.1 |
Traffic
We assume each daily user causes about 35 key-value operations a day: a session check per page view, cart reads, profile reads.
| Item | Math | Result |
|---|---|---|
| Requests per day | 10M DAU × 35 ops | 350M |
| Average QPS | 350M ÷ 86,400 s | ≈ 4,000 |
| Peak QPS | 4,000 × 2.5 (evening peak) | ≈ 10,000 |
| Peak reads / writes | 80% / 20% of 10,000 | 8,000 reads/s, 2,000 writes/s |
Storage
About 50 keys per user (session, cart, profile, preferences, flags):
| Item | Math | Result |
|---|---|---|
| Keys | 10M users × 50 | 500M |
| Record size | 64 B key + ~1,920 B value + 64 B metadata (version, TTL, flags) | ≈ 2 KB |
| Raw data | 500M × 2 KB | ≈ 1 TB |
| Replicated | 1 TB × 3 | 3 TB |
| Provisioned | 3 TB × 1.5 (compaction needs spare room to rewrite files; disks stay about two-thirds full) | 4.5 TB |
Fleet
We choose 6 storage nodes, 2 per AZ, each an r7g.xlarge (Graviton, 4 vCPU, 32 GiB) with a 750 GB gp3 volume: 4.5 TB ÷ 6 = 750 GB.
Why not 3 big nodes, one per AZ? It would work. But when a node dies, its replacement must copy all its data from peers before it serves. At a throttled 100 MB/s:
- 1.5 TB per node:
1,500,000 MB ÷ 100 MB/s = 15,000 s ≈ 4.2 hoursrunning on two copies. - 750 GB per node:
7,500 s ≈ 2.1 hours.
Smaller nodes also mean a node loss touches half the keys instead of all of them. Six nodes is the smallest fleet that gets both.
Disk I/O per node at peak (pessimistic: we count every replica write as one disk operation, though group commit merges many):
| Item | Math | Result |
|---|---|---|
| Write IOPS | 2,000 writes/s × 3 replicas ÷ 6 nodes | 1,000 |
| Read IOPS | 8,000 reads/s × 2 replicas × 10% that miss every cache ÷ 6 nodes | ≈ 267 |
| Total vs gp3 baseline | 1,267 ÷ 3,000 (every gp3 volume includes 3,000 IOPS and 125 MB/s at no extra charge) | ≈ 42% |
When one node dies, its reads move to the replicas of its ranges in the other two AZs, and its writes simply aren't made. The survivors stay well under the baseline.
Memory per node
| Item | Math | Result |
|---|---|---|
| Hot data cache | Hottest 2% of keys: 500M × 2% × 2 KB = 20 GB per full copy. Each AZ holds a full copy on 2 nodes | ≈ 10 GB per node |
| Bloom filters | 500M keys × 1.25 B = 625 MB per full copy; 3 copies ÷ 6 nodes | ≈ 0.3 GB per node |
| MemTables | a few × 64 MB | < 0.5 GB |
That fits in 32 GiB with plenty left for the OS page cache.
Network at peak: writes in 2,000 × 2 KB = 4 MB/s (32 Mbps), reads out 8,000 × 2 KB = 16 MB/s (128 Mbps), cross-AZ replication 2,000 × 2 KB × 2 copies = 8 MB/s (64 Mbps). Small.
Routers: 6 Fargate tasks (1 vCPU, 2 GB), two per AZ, about 1,700 requests/s each at peak.
Rough monthly cost (us-east-1 on-demand list prices; check the AWS Pricing Calculator before quoting):
| Line | Math | ≈ Monthly |
|---|---|---|
| Storage nodes | 6 × r7g.xlarge × 730 h × $0.2142 | $940 |
| EBS gp3 | 4,500 GB × $0.08 (baseline IOPS and throughput included) | $360 |
| Cross-AZ traffic | Replication: 800 avg writes/s × 2 KB × 2 copies = 3.2 MB/s ≈ 8.3 TB/month × $0.02/GB ≈ $170. Reads: one of the two replicas is remote: 3,200 avg reads/s × 2 KB ≈ 16.6 TB/month × $0.02 ≈ $330 | $500 |
| Routers | 6 Fargate Graviton tasks × ≈ $29 | $170 |
| Network Load Balancer | $16 base + processed bytes: 4,000 avg req/s × 2 KB = 8 MB/s ≈ 29 GB/hour; one NLB capacity unit covers 1 GB/hour at $0.006 → 29 × $0.006 × 730 h | $140 |
| S3 backups | under 1 TB compressed | $30 |
| Total | ≈ $2,100 |
Cross-AZ traffic is billed at $0.01 per GB in each direction, so $0.02 for every GB that crosses. CloudWatch and KMS add a little on top.
R1.8 Trade-Offs
Storage engine: LSM tree vs B+ tree vs in-memory
| LSM tree (chosen) | B+ tree | In-memory (Redis-style) with snapshots | |
|---|---|---|---|
| Writes | Sequential appends; fast | Updates pages in place: random I/O | Fastest |
| Reads | MemTable + Bloom filters; a few files | Usually one path down the tree | Fastest |
| Space | Needs compaction headroom | Pages partly empty | All data in RAM: most expensive per GB |
| Cost of each write | Rewritten several times by compaction (10–30× with leveled) | A small change rewrites a whole 4–16 KB page, plus the WAL copy | Almost none until persistence |
| What we give up | Some read speed and compaction I/O | Write throughput | Capacity and cost |
We pick the LSM tree because 20% of our traffic is writes and the data is bigger than RAM. A B+ tree is a fine choice for read-mostly data. At 1 TB, keeping everything in RAM costs far more for no requirement we have.
Request routers vs a smart client
A smart client library could hold the ring and talk to storage nodes directly, saving the extra hop (~0.3 ms). We choose routers because clients in any language then work over plain HTTP, and ring changes, auth and rate limits live in one place we deploy. We give up that 0.3 ms.
Leaderless vs leader-based replication
In a leader-based design, one replica per key range takes all writes and copies them to followers. Reads from the leader are consistent. But when the leader dies, writes to its range stop until a new leader is elected (seconds). In our leaderless design, any two of three replicas accept a write, so one dead node blocks nothing, at the price of copies that can disagree for a while. Our requirements chose availability and accept eventual consistency, so leaderless fits.
Our first consistency stance: we favor availability. As long as two of a key's three replicas are up, every request succeeds. Reads may be stale in the edge cases listed in step 1.6, and we tell callers so.
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A node dies | Gossip marks it down within seconds. Requests for its keys still get 2 of 3 replicas. | W = 2 and R = 2 still succeed. We launch a replacement, which streams its ranges from peers (about 2 hours) and serves only when done. The dead node is never "rejoined" with stale data. |
| A disk stalls | One node's latency jumps from 1 ms to hundreds of ms. Reads that happen to ask it get slow, and P99 climbs. | Hedged reads: if the second read answer hasn't arrived after a few ms, the router asks the third replica and uses whichever two answer first. Writes already need only 2 of 3. |
| Adding or removing a node | A new node owns ranges but holds none of their data yet. | Stream, then serve: the new node copies its ranges from their current owners first. While it catches up, writes for those ranges go to both old and new owners. A leaving node streams its ranges away before it leaves the ring. Always add nodes in threes, one per AZ, so every range keeps one copy per AZ. |
| Connection-pool exhaustion | Routers open a new TCP connection per request, run out of local ports, and requests stall. Worse when a node is slow and holds connections longer. | Each router keeps a fixed pool of long-lived connections to each storage node, with a cap on how long a request waits for one. When the cap is hit, fail fast with 503 rather than queue forever. |
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance · Drill: Circuit breaker cascading thread stall
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | A node loss blocks nothing (N = 3, W = R = 2); one replica per AZ; WAL + fsync for durability; SSTables backed up to S3 REL 9 · REL 10 |
| Performance Efficiency | LSM tree for sequential writes; Bloom filters skip files; ~42% of baseline disk I/O at 10K QPS; P99 under 10 ms PERF 3 |
| Security | Storage nodes in private subnets reachable only from routers; TLS in transit; EBS and S3 encrypted with KMS; callers authenticated by IAM identity at the router (below) SEC 3 · SEC 8 |
| Cost Optimization | About $2,100/month; six small nodes instead of three big ones, sized from the math COST 5 |
| Operational Excellence | Light this round: alarms on the golden signals: request latency P99, error rate, and disk usage per node OPS 8 |
| Sustainability | Light this round: Graviton instances do the same work with less energy than comparable x86 instances SUS 5 |
About IAM at the router. A service we build can't check an AWS SigV4 signature by itself, because it never sees the caller's secret key. A common pattern: the caller signs an STS GetCallerIdentity request and sends it to our router unsent; the router forwards it to STS, which replies with the caller's IAM role. The router caches that answer for the connection and checks the role against the table's allow list. HashiCorp Vault's AWS auth works this way.
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Asks scoping questions first, and says what each answer changes.
- Derives
put/get/delete/ TTL from the problem, and names what's out of scope. - Builds up from one server, and gives a reason for every component: WAL, LSM, consistent hashing, replication, quorums.
- Explains
W + R > Nand whyW = 2, R = 2survives one node loss. - Does back-of-the-envelope math and uses it to size the fleet.
- Names at least one cost of each choice.
Follow-up questions
-
"Why not
R = 1if eventual consistency is fine?" Answer:R = 1would meet the requirement and cut read latency and load. We choseR = 2because at this scale it costs little, and it means a client that just wrote usually reads its own write back, which session code tends to assume. If read load became the bottleneck,R = 1is the first dial to turn, and we'd tell callers reads can be staler. -
"What happens when the MemTable is full and the flush is slow?" Answer: the full MemTable becomes read-only and a new one takes writes while the old one flushes. If flushes keep falling behind, memory fills, so the engine must slow incoming writes (backpressure) rather than run out of memory.
-
"A key was written with TTL 1 hour. What does 'expire' mean on disk?" Answer: nothing is deleted at the hour mark. Reads check the expiry time and return
404once it's past. Compaction drops the expired value later, when it rewrites that file. (Round 2 refines this: with deletes in play, an expired value is handled like a tombstone.)
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
"hash(key) % N" | Changing N moves about 1 - 1/(N+1) of the keys; from 4 to 5 machines that's 80%. |
| "A master tells every request where the key lives" | A bottleneck and single point of failure on every request. |
"W = 3 for safety" | One slow or dead node then blocks every write for its keys; it defeats the purpose of having three copies. |
| "Dump memory to disk after every write" | Cost grows with the data size and still loses writes between dumps. |
| "Restore from backup when a node dies" | Loses every write since the backup; replicas are the answer to daily failures. |
Round 2 · Senior · "It Becomes the Company-Wide Platform"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 250K QPS peak · 103.5 TB provisioned · 99.99%
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built a key-value store for one product team: 10M daily users, about 10K requests per second at peak, about 1 TB of data, one region with three AZs, 99.9% availability, and eventual consistency. Each storage node writes to a write-ahead log and an LSM tree: a MemTable in memory, flushed to sorted SSTable files with Bloom filters, merged by leveled compaction. Keys are spread with consistent hashing and virtual nodes. Stateless routers hold the ring, which they learn by gossip, and send every write to three replicas, one per AZ. A write succeeds after two acknowledgments and a read asks two replicas, so
W + R > N. Six storage nodes, six routers, about $2,100 a month. Two costs are still open: the quorum is one fixed setting for everyone, and two concurrent writes to the same key silently collide."
Architecture v1, compact
textservices ──► NLB ──► 6 routers (ECS Fargate, 2 per AZ) │ MurmurHash3(key) → ring → preference list (1 node per AZ) │ write to all 3, reply after W=2 ACKs · read R=2 ┌───────────┼───────────┐ AZ-a AZ-b AZ-c A1 A2 B1 B2 C1 C2 6 × r7g.xlarge, 750 GB gp3 each └── WAL → MemTable (64 MB) → SSTables + Bloom filters (leveled) ──► S3 backups ring + liveness shared by gossip (phi-accrual failure detector)
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | A restart loses all data | WAL + fsync + group commit | Log grows; data must fit in RAM |
| 1.2 | Data outgrows RAM | LSM tree, Bloom filters (10 bits/key, ~1%), leveled compaction | Several files per read; compaction I/O |
| 1.3 | One machine isn't enough | Consistent hashing (MurmurHash3) + virtual nodes | Someone must know the ring |
| 1.4 | Who knows where a key lives? | Stateless routers; gossip + phi-accrual | Extra hop; failure detection is a guess |
| 1.5 | A dead machine loses its keys | N = 3, one replica per AZ (preference list) | Copies can disagree |
| 1.6 | When is a write done? | W = 2, R = 2, fixed cluster-wide | Not linearizable; collisions; one setting for all |
Open costs: the fixed quorum; concurrent writes collide silently; the version is a router's wall-clock time.
R2.1 The Scope Raise
Interviewer: "Your store worked so well that every team in the company wants it. We're at 200M daily users, about 250K requests per second at peak. Values can be up to 1 MB now."
Candidate: "Does it still live in one region?"
Interviewer: "Yes. But it must survive losing an entire AZ, not just a node, and the target is 99.99%. Reads should be under 3 ms at P95 and 8 ms at P99."
Interviewer: "Also, teams keep asking for three things. Batch calls, because they fetch 50 keys per page. A choice of consistency per request: the feed team wants speed, the billing team wants the latest value. And 'only write if the version is still 4', so two workers don't overwrite each other."
Interviewer: "Last thing. Hundreds of teams share this now. One team's bug can't take down everyone else."
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Traffic | ~10K QPS peak | 250K QPS peak (200K reads, 50K writes) |
| Data | 1 TB raw | 23 TB raw → 103.5 TB provisioned |
| Values | up to 100 KB | up to 1 MB |
| Survive | a node | an AZ |
| Availability | 99.9% (8.8 h/year) | 99.99% (52.6 min/year) |
| Latency | P99 < 10 ms | reads P95 < 3 ms, P99 < 8 ms; writes P95 < 2 ms, P99 < 5 ms |
| Consistency | eventual, fixed | tunable per request |
| Tenancy | one team | hundreds of teams |
The "Not yet" list from R1.2 is now mandatory: batch operations, per-request consistency, and conditional writes. And a new one: tenant isolation.
R2.2 What Breaks in the Round 1 Design
Before fixing anything, we say what fails and in what order we'll fix it.
| Round 1 component | How it fails at the new scope |
|---|---|
| 6 storage nodes | 23 TB of data needs about 100 TB of disk. The fleet is about 20 times too small. |
| Sized to lose one node | Losing an AZ removes a third of the fleet at once. Survivors take 1.5 times their load, and we have no headroom planned for that. |
Fixed cluster-wide W = 2, R = 2 | The feed team pays for consistency it doesn't need; the billing team gets less than it needs. |
| Router wall-clock version | Two teams' workers write the same key; the router with the faster clock wins, even when its write came first. Updates are lost silently. |
| Replacing a returning node by full rebuild | A node that was down 10 minutes streams its whole ~1 TB again. Too slow at 72 nodes, where patching alone restarts every node regularly. |
| Deletes | In an LSM tree, a delete can't just remove data; deleted keys can come back. Delete-heavy teams will pile up markers. |
| No per-team limits | One team's runaway job or one viral key can slow everyone's requests. |
The order we fix it in: consistency choices first (2.1–2.3), because they change the API. Then failure handling (2.4–2.6), then isolation (2.7). Sizing comes after the design, in R2.6.
R2.3 New Requirements and API Additions
Each new requirement changes the contract before it changes the internals.
Tables and tenants in the path. Every key now lives in a named table, and each table belongs to one team (a tenant). A table has quotas and settings.
httpPUT /v1/tables/carts/kv/cart:1001 HTTP/1.1 Content-Type: application/json X-Consistency-Level: QUORUM X-Idempotency-Key: a8f9c1d2-7e3b-4a55-8c01-9876543210ab { "value": { "items": ["book"] }, "ttl_seconds": 2592000 }
Table settings, as the platform stores them:
json{ "table": "carts", "tenant": "checkout-team", "conflict_mode": "siblings", "conditional_writes": false, "default_consistency": "QUORUM", "quota": { "reads_per_sec": 20000, "writes_per_sec": 5000 }, "max_value_bytes": 1048576, "sloppy_quorum": false }
Consistency per request. A header, with the table's default when it's missing:
| Level | Write waits for | Read asks | Use it for |
|---|---|---|---|
ONE | 1 of 3 ACKs | 1 replica | Feeds, counters for display, caches: speed over freshness |
QUORUM | 2 of 3 | 2 replicas | The default: reads see the latest acknowledged write in normal operation |
LOCAL_QUORUM | 2 of the 3 replicas in the caller's region | 2 in the caller's region | The same as QUORUM while we have one region; it matters when a second region arrives |
Conditional writes. Standard HTTP preconditions:
httpPUT /v1/tables/jobs/kv/job:5521 HTTP/1.1 If-Match: "4" Content-Type: application/json { "value": { "state": "RUNNING", "worker": "w-17" } }
If-Match: "4": write only if the current version is 4. Otherwise412 Precondition Failed.If-None-Match: *: write only if the key doesn't exist yet (create-only). Otherwise412.
This is why we never needed POST to create a key: "create only if absent" is a precondition on PUT, not a different verb.
Batch operations.
httpPOST /v1/tables/profiles/kv:batch-get HTTP/1.1 Content-Type: application/json X-Consistency-Level: ONE { "keys": ["user:10928", "user:10929", "user:10930"] }
json{ "records": [ { "key": "user:10928", "value": { "tier": "enterprise" }, "version": "1773648000123456789-0003" }, { "key": "user:10929", "value": { "tier": "free" }, "version": "1773647990000000000-0000" } ], "unprocessed_keys": ["user:10930"] }
httpPOST /v1/tables/profiles/kv:batch-write HTTP/1.1 Content-Type: application/json { "request_items": [ { "put_item": { "key": "user:10931", "value": { "tier": "pro" } } }, { "delete_item": { "key": "user:10932" } } ] }
json{ "unprocessed_items": [ { "put_item": { "key": "user:10931" }, "reason": "PARTITION_THROTTLED", "retry_after_ms": 25 } ] }
- Limits: up to 100 keys per batch-get; up to 25 items or 16 MB per batch-write (the same limits DynamoDB uses for
BatchGetItemandBatchWriteItem). - Partial success: the keys in one batch live on many different nodes. Some may time out or be throttled. We return what worked and list the rest as
unprocessed_*, and the client retries only those, with backoff. - Not atomic: each item commits on its own. All-or-nothing across keys would need a transaction protocol such as two-phase commit, which stays out of scope. See Two-Phase Commit & Saga.
Why POST only for batches. A batch is an action on many resources, not a replacement of one resource, so PUT doesn't describe it. 100 keys don't fit comfortably in a URL (proxies and load balancers often cap URLs at 2–8 KB), and a body on GET has no defined meaning in HTTP, so many proxies drop it. POST with a body models "do this action on these keys and tell me which ones worked."
Idempotency key. A plain PUT is safe to retry. A conditional one isn't: if the first attempt succeeded and the reply was lost, the retry sees version 5, not 4, and fails with 412 even though the write happened. The X-Idempotency-Key lets the router recognize the retry and return the first result. REL 3 · REL 4
Status codes, updated
| Code | Reason | What the client does |
|---|---|---|
200 OK | Done, or a batch with some unprocessed_* items | Retry only the unprocessed items |
400 Bad Request | For example, a value over 1 MB | Fix the request; don't retry |
404 Not Found | Missing, deleted or expired | Treat as absent |
412 Precondition Failed | The If-Match / If-None-Match condition was false | Re-read, then decide |
429 Too Many Requests | The table or one key is over its quota | Back off with full jitter; honor Retry-After |
503 Service Unavailable | Not enough replicas answered (QUORUM_NOT_REACHED) or overloaded | Back off with full jitter, then retry |
R2.4 Design Evolution: Hardening the Platform
Step 2.1: Teams Need Different Consistency
The problem: the feed team says every read waits for two replicas and they don't care about freshness. The billing team says they need to read the latest balance, every time. What would you do? How do both teams get what they need from one cluster?
| Write level | Read level | W + R > N? | What the caller gets |
|---|---|---|---|
QUORUM (2) | QUORUM (2) | 4 > 3: yes | Reads see the latest acknowledged write in normal operation |
QUORUM (2) | ONE (1) | 3 = 3: no | Fast reads that may be stale |
ONE (1) | QUORUM (2) | 3 = 3: no | Fast writes; an "OK" may be on one disk only until the others catch up |
ONE (1) | ONE (1) | 2 < 3: no | Fastest; eventual consistency only |
Primitive: Distributed Consensus (Raft & Paxos)
Step 2.2: Two Writes Collided
The problem: two clients add an item to cart_84920 at the same moment. Each write reaches a different pair of replicas first. After everything settles, the cart holds only one of the two items.
What would you do? When two versions of a key disagree, which one wins, and who decides?
Synthesizing vector architecture diagram...
Each write increments its own replica's counter. The first version is ahead on A1, the second on B1, so neither happened before the other. LWW would keep one; sibling mode keeps both and lets the client decide.
The rest of the path uses HLC too. Tombstones and read repair compare HLC timestamps. Keep clocks tight anyway: Amazon Time Sync on every node, an alarm at 10 ms of drift, and a node that drifts past 50 ms stops taking writes.
Go deeper: the vector-clock dominance rule
A clock V1 dominates V2 if V1[k] >= V2[k] for every replica k, and V1[k] > V2[k] for at least one. If neither dominates, the versions are concurrent. A record in sibling mode looks like this:
json{ "key": "cart_84920", "siblings": [ { "value": ["book"], "vector_clock": { "node-A1": 4, "node-B1": 2 } }, { "value": ["pen"], "vector_clock": { "node-A1": 3, "node-B1": 3 } } ] }
The client sends back the clocks it read (its context) with its next put, so the store knows that write has seen both siblings and can replace them.
Drill: Clock skew in ID generation (the same clock-going-backwards failure, in another system)
Step 2.3: Only Write If the Version Is Still 4
The problem: two workers both read job:5521 at version 4, state PENDING. Each wants to claim it by writing RUNNING with its own worker ID, but only if nobody else did first.
What would you do? How do we make "write only if the version is still 4" safe when both arrive at once?
Synthesizing vector architecture diagram...
Worker 1's proposal loses because the replicas promised a higher ballot to worker 2. When worker 1 retries, it reads version 5, the condition "version is 4" is false, and its caller gets 412 Precondition Failed. Exactly one worker owns the job.
Primitive: Distributed Consensus (Raft & Paxos) · Drill: Distributed lock fencing token
Step 2.4: A Replica Is Down for Ten Minutes
The problem: node C7 reboots after a kernel patch and is gone for ten minutes. Writes still succeed, since two of three replicas answer. But C7 misses ten minutes of writes to its ranges. What would you do? How does C7 catch up without streaming its whole terabyte, and what if two replicas are down?
Step 2.5: Replicas Drift Apart Silently
The problem: a hint expired, a disk flipped a bit, or a node was down past the hint window. Now one replica has an old or missing value, and nothing tells us. What would you do? How do we find and fix differences between replicas that hold about 1 TB each?
Synthesizing vector architecture diagram...
Start at the roots: they differ, so something below differs. The left halves hash to the same value on both nodes, so keys 0..128 are identical and are skipped without sending one key. Only the right half is synced, or split further in a deeper tree. Nodes that differ in a few keys exchange a few hashes plus those keys.
Why the digest read also saves money. The router sends the full read to the replica in its own AZ and the digest to a replica in another AZ. Only a 32-byte hash crosses AZs. If the digest replica turns out to be newer, the router fetches the full value from it: one extra round trip, only on a mismatch. COST 8
Step 2.6: How Do You Delete in a System That Never Forgets?
The problem: a team deletes user:552. One replica was down and missed the delete. Later, anti-entropy compares replicas, sees one of them still has user:552, and "repairs" the others by copying it back. The deleted user is back.
What would you do? How do we delete a key so it stays deleted?
Step 2.7: One Tenant or One Key Is Hammering Us
The problem: a celebrity's post goes viral. Their profile key gets 80,000 reads per second and the post's like counter gets 8,000 writes per second. Separately, one team's batch job starts writing as fast as it can. Everyone's P99 goes up. What would you do? How do we protect all the other tenants and keys?
Primitives: Distributed Rate Limiting · Distributed Cache Patterns & Eviction · Database Sharding & Partition Keys · Drills: Sharding tenant hotspot · Caching a hot product page
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | Teams need different consistency | Tunable W and R per request: ONE, QUORUM, LOCAL_QUORUM | Clients can choose badly |
| 2.2 | Two writes collided | LWW on HLC by default; vector-clock siblings opt-in | LWW drops concurrent writes; siblings push merging to clients |
| 2.3 | "Only if version is 4" | Conditional writes through a Paxos round | ~4 round trips on that path |
| 2.4 | A replica is down for minutes | Hinted handoff (3 h TTL, not counted toward W); sloppy quorum opt-in | Hints pile up; sloppy quorum breaks overlap |
| 2.5 | Replicas drift silently | Read repair (full + digest read) and Merkle anti-entropy every 6 h | Repair bandwidth must be scheduled |
| 2.6 | Deletes come back | Tombstones, TTL, gc_grace_seconds = 3 days | Tombstone buildup |
| 2.7 | A tenant or key hammers us | Quotas, per-key caps, router micro-cache, sub-key splitting | 429s; fan-out reads; router complexity |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Follow a request from the top. Route 53 resolves our endpoint name to the NLB (an alias record). The NLB spreads connections over about 30 router tasks in three AZs. A router enforces the table's quota, may answer a hot read from its 200 ms micro-cache, and otherwise hashes the key with MurmurHash3 and finds the preference list: one node in each AZ panel. Each storage node runs the engine panel: WAL, MemTable, SSTables with Bloom filters, a capped hint store and the Merkle repair job. New SSTables and WAL segments go to S3; metrics and traces go to CloudWatch and X-Ray.
Trace: a QUORUM write where one replica is down
Synthesizing vector architecture diagram...
The client gets its answer at step 7, as soon as two replicas ACK. The hint is saved after that and doesn't count toward W; it only shortens how long Rep3 stays stale.
Trace: a QUORUM read with read repair
Synthesizing vector architecture diagram...
The full value comes from the replica in the router's own AZ; only a hash crosses AZs. The router answers at step 6 and repairs Rep2 afterwards, so repair never adds latency to the read. The concession: with repair in the background, two back-to-back quorum reads can see the new value and then the old one (no monotonic reads), which is why Cassandra 4.0 made read repair blocking. We accept that for speed; tables that need monotonic reads can ask for blocking repair.
Hedged reads. A quorum read is only as fast as the slower of its two replicas. If the second answer hasn't arrived within 5 ms (a threshold we set near the 98th percentile of a single replica read, above the 3 ms read P95 target), the router sends the same read to the third replica and uses the first two answers. About 4% of reads hedge (the slower of two replicas passes the 98th percentile with probability 1 − 0.98² ≈ 4%), and read P99 stays near the threshold plus one replica read, about 6–8 ms. Writes don't need hedging: they already go to all three.
Go deeper: the per-operation execution matrix
| Operation | Coordinator action | I/O path | Guardrails | Latency target | Failure handling |
|---|---|---|---|---|---|
PUT QUORUM | MurmurHash3 → send to all 3 AZ replicas | WAL append (group-commit fsync) + MemTable insert | ring_epoch fencing; X-Idempotency-Key | P95 < 2 ms, P99 < 5 ms | A replica silent for 50 ms gets a hint (3 h TTL). Fewer than 2 ACKs: 503 QUORUM_NOT_REACHED. |
PUT with condition | Paxos: prepare, read, propose, commit | WAL + MemTable on commit | Ballot numbers; every write on the table uses this path | ~4 round trips, ≈ 5–10 ms | No majority: 503, retry with jitter. Condition false: 412. |
GET QUORUM | Full read (own AZ) + digest read; hedge the 3rd at 5 ms | MemTable → Bloom filter → block cache / page cache → SSTable block | HLC or vector-clock comparison | P95 < 3 ms, P99 < 8 ms | Mismatch: return newer, repair async. Digest replica newer: fetch its value (1 extra round trip). |
DELETE | Tombstone with HLC to all 3, wait for 2 | Tombstone appended to WAL + MemTable | Monotonic HLC so an older write can't un-delete | P95 < 2 ms, P99 < 5 ms | Kept for gc_grace_seconds (3 days), then purged by compaction |
| Anti-entropy | Every 6 h per range, incremental | Hash new SSTables into Merkle trees; compare | Range-ownership check before any repair write | Throttled to 50 MB/s | Only differing keys are streamed |
| MemTable flush | At 64 MB | Freeze → write L0 SSTable + Bloom filter | Monotonic SSTable generation ID | Background I/O | More than 8 L0 files: slow incoming writes (R2.8) |
Go deeper: the SSTable byte layout
text+------------------------------------------------------------------------------+ | Data block 0 (4 KB) | ZSTD-compressed [KeyLen | Key | ValLen | Val | TS | Ver] | Data block 1 (4 KB) | ... | ... | +------------------------------------------------------------------------------+ | Bloom filter block | bit array, m = 10 bits/key, k = 7 hash functions | Index block | sparse index: first key of each block → byte offset | Meta index block | pointers to the Bloom filter, stats and compression info | Footer (fixed size) | magic number | index offset | meta index offset +------------------------------------------------------------------------------+
A reader opens the footer first, finds the index, binary-searches it for the one 4 KB block that could hold the key, and reads only that block. k = 7 hash functions is the best choice for 10 bits per key: (m/n) × ln 2 = 10 × 0.693 ≈ 7. Because SSTables never change after they're written, they're safe to cache, to share between readers without locks, and to back up incrementally.
R2.6 Numbers and Cost
Same method as R1.7, about 25 times bigger.
Traffic
| Item | Math | Result |
|---|---|---|
| Ops per daily user | profile reads, session checks, flag checks across all products | ≈ 43.2 per day |
| Requests per day | 200M DAU × 43.2 | 8.64 billion |
| Average QPS | 8.64 × 10^9 ÷ 86,400 s | 100,000 (80,000 reads, 20,000 writes) |
| Peak QPS | 100,000 × 2.5 | 250,000 (200,000 reads, 50,000 writes) |
Storage (3-year horizon)
| Item | Math | Result |
|---|---|---|
| Record size | 64 B key + 1,024 B average value + 64 B metadata (version, clock, TTL, flags) | 1,152 B ≈ 1.15 KB |
| Keys | 10 billion at launch, growing to | 20 billion |
| Raw data | 20 × 10^9 × 1.15 KB | 23 TB |
| Replicated | 23 TB × 3 | 69 TB |
| Provisioned | 69 TB × 1.5 compaction headroom | 103.5 TB |
The average record is smaller than Round 1's 2 KB because most of the new keys from other teams are small: flags, counters, sessions. We size on uncompressed bytes. ZSTD block compression usually shrinks JSON values 2–4 times, so compression is headroom, never something we depend on.
Network at peak
| Flow | Math | Result |
|---|---|---|
| Ingress (writes) | 50,000 × 1.15 KB = 57.5 MB/s | 460 Mbps |
| Egress (reads) | 200,000 × 1.15 KB = 230 MB/s | 1.84 Gbps |
| Cross-AZ replication | 50,000 × 1.15 KB × 2 other AZs = 115 MB/s | 920 Mbps |
Memory
| Item | Math | Result |
|---|---|---|
| Hot cache | Hottest 2% of keys: 20 × 10^9 × 2% = 400M keys × 1.15 KB | 460 GB per full copy |
| Per node | Each AZ holds a full copy and serves its own full reads, so each AZ's 24 nodes cache the hot set: 460 GB ÷ 24 | ≈ 19 GB |
| Bloom filters | 20 × 10^9 keys × 1.25 B | 25 GB per full copy; × 3 ÷ 72 nodes ≈ 1 GB per node |
Why not cache the whole working set? If 20% of keys get 80% of reads, that's 4 billion keys, about 4.6 TB of RAM. Too expensive. The hottest 2% catches most of those reads.
Fleet
| Item | Math | Result |
|---|---|---|
| Disk per node | chosen: 1.5 TB of gp3 | |
| Node count | 103.5 TB ÷ 1.5 TB = 69 (23 per AZ), rounded up to 24 per AZ for one spare node's worth of room in each AZ | 72 nodes, 24 per AZ |
| Instance | r7g.2xlarge (Graviton3, 8 vCPU, 64 GiB): holds the 19 GB cache, ~1 GB of Bloom filters, MemTables, and the OS page cache | |
| Write IOPS per node at peak | 50,000 × 3 replicas ÷ 72 (pessimistic: group commit merges many) | ≈ 2,083 |
| Read IOPS per node at peak | 200,000 × 2 replicas × 10% cache misses ÷ 72 | ≈ 556 |
| Disk | gp3 at 6,000 IOPS and 250 MB/s per node | margin 6,000 ÷ (2,083 + 556) ≈ 2.3× |
The 250 MB/s is for compaction's large sequential reads and writes. A quick check: each node takes in 50,000 × 3 × 1.15 KB ÷ 72 ≈ 2.4 MB/s of new data at peak. Leveled compaction rewrites data about 10–30 times, so about 24–72 MB/s of compaction writes plus a similar amount of reads, under the 150 MB/s compaction cap we set in R2.8. 250 MB/s also stays within the instance's baseline EBS bandwidth; throughput bought above what the instance can push is paid for and never used.
The 60% rule: why a 2.3× margin and not 1.2×. When an AZ goes dark, every key still has two replicas, but all the load that three AZs shared now lands on two: each surviving node's load rises 1.5 times. To survive that without launching anything (static stability), we run at no more than 60% at peak, because 60% × 1.5 = 90% still fits. REL 10
Checking the disks for an AZ loss at peak, with 48 surviving nodes:
| Load | Math | Per node |
|---|---|---|
| Replica writes | 50,000 × 2 surviving replicas ÷ 48 | ≈ 2,083 |
| Hint writes | 50,000 (every write misses its AZ-c replica) ÷ 48; hints have their own volume, but we count them here to be safe | ≈ 1,042 |
| Reads | 200,000 × 2 × 10% ÷ 48 | ≈ 833 |
| Total | 3,958 of 6,000 | ≈ 66% |
The same pattern holds for CPU: at normal peak each node handles about (200,000 × 2 + 50,000 × 3) ÷ 72 ≈ 7,600 replica operations a second. We confirm with a load test that this keeps an r7g.2xlarge under 60% busy before we commit to the fleet; if it doesn't, CPU, not disk, sets the node count.
Monthly cost (one region, us-east-1 on-demand list prices; check the AWS Pricing Calculator before quoting exact figures):
| Line | Math | ≈ Monthly |
|---|---|---|
| Storage nodes | 72 × r7g.2xlarge × 730 h × $0.4284 | $22,500 |
| EBS gp3 | 72 × 1.5 TB = 108 TB × $0.08/GB = $8,640, plus per node 3,000 extra IOPS (× $0.005) and 125 MB/s extra throughput (× $0.04) = $20 × 72 = $1,440 | $10,100 |
| Cross-AZ replication | 20,000 avg writes/s × 1.15 KB × 2 copies = 46 MB/s ≈ 119 TB/month × $0.02/GB (plus 10–20% for repair and hints; ZSTD roughly halves it) | $2,400 |
| Cross-AZ reads | ≈ $0 with AZ-local full reads; a random replica for the full read would cost 80,000 × 1.15 KB × 2/3 ≈ 61 MB/s ≈ 159 TB/month ≈ $3,200 | $0 |
| Router tier | ~30 Fargate Graviton tasks (2 vCPU, 4 GB ≈ $58/month each), including AZ-loss headroom | $1,700 |
| S3 backups | ~10 TB of ZSTD-compressed SSTables + WAL segments; old copies moved to Glacier | $500 |
| Storage cluster subtotal | ≈ $37,200 | |
| Network Load Balancer | $16 base + processed bytes: 100,000 avg req/s × 1.15 KB = 115 MB/s ≈ 414 GB/hour; one NLB capacity unit covers 1 GB/hour at $0.006 → 414 × $0.006 × 730 h | $1,800 |
| Total | ≈ $39,000 |
About 60% of the bill is the storage fleet. The fleet runs 24/7 at a steady size, so a 3-year Compute Savings Plan takes roughly 40–50% off that line. COST 7 Spot Instances are never used for storage nodes: a 2-minute reclaim notice is shorter than a safe node drain.
Which numbers changed the design? The AZ-loss rule set the IOPS margin and the router count. The 1 MB value limit is why batch writes cap bytes (16 MB) as well as items (25 × 1 MB would be 25 MB). The fleet size (72) comes from disk, not from traffic. The per-key traffic of a viral key, not the total, forced step 2.7.
R2.7 Trade-Offs
Our CAP / PACELC stance: PA/EL. PACELC extends CAP: if there's a Partition, choose Availability or Consistency; Else, choose Latency or Consistency.
- During a partition (P): say AZ-c is cut off. Each key has two replicas on the side with AZ-a and AZ-b, so that side keeps serving
QUORUMandONE. The AZ-c side has one replica per key, so it servesONEbut returns503forQUORUM. We stay Available for any caller who acceptsONE; callers who asked forQUORUMget consistency or an error. - Else (E), in normal operation: by default we favor Latency (parallel sends, hedged reads, no leader), unless the caller asks for
QUORUMor a conditional write. - Quorum overlap is not linearizability. Concurrent writers can still race under LWW, and sloppy-quorum tables lose even the overlap. True compare-and-set goes through the Paxos path.
Five decisions, defended on performance, cost and maintainability (the method is in the Trade-Off Analysis Playbook):
| Decision | Why it wins | What we concede |
|---|---|---|
| EBS gp3 over instance-store NVMe REL 11 | A replaced instance reattaches its volume and catches up in minutes; instance-store data dies with the instance, so every replacement re-streams 1.5 TB. IOPS are bought separately from size. COST 6 | NVMe has lower, steadier latency and no per-GB bill. If the target were a sub-1 ms P99, we'd switch to storage-optimized i4g instances and accept slower replacement. |
| Router tier over a smart client PERF 1 | Clients in any language use plain HTTP; ring changes, quotas, auth and the micro-cache live in one place we deploy. | One extra hop (~0.3 ms). Our own highest-volume services may use a token-aware SDK that skips the router, as Cassandra drivers do. |
| LWW by default, siblings opt-in | Most values are overwritten whole (profiles, sessions, flags), and their clients never see a conflict. Only tables that need it pay for merging. | LWW silently drops one of two truly concurrent writes. Teams must choose sibling mode for carts and sets. |
| Leaderless over leader-based replication | No election pause when a node dies: any two of three replicas take writes. Load spreads over all replicas. | Replicas can disagree, so we need hints, read repair, anti-entropy and conflict modes. A leader-based design gets consistent reads almost for free. |
| Build it vs DynamoDB COST 5 · COST 11 | Our ≈ $39K/month is in the same range as DynamoDB provisioned capacity for this load (below), and we control latency, conflict modes, value size and hardware. | DynamoDB removes the on-call rotation, patching and capacity planning. Unless the interviewer says "build it", the honest first answer is "use DynamoDB". |
The DynamoDB estimate, derived (provisioned capacity with auto scaling at a 70% target, us-east-1 list prices; reserved capacity would lower it):
| Line | Math | ≈ Monthly |
|---|---|---|
| Writes | A 1.15 KB item costs 2 write units. Average 20,000 writes/s × 2 = 40,000 units, provisioned at ÷ 0.7 ≈ 57,000 × $0.00065 per unit-hour × 730 h | $27,000 |
| Reads | Eventually consistent read of an item up to 4 KB = 0.5 read unit. 80,000 × 0.5 = 40,000, ÷ 0.7 ≈ 57,000 × $0.00013 × 730 h | $5,400 |
| Storage | 23,000 GB × $0.25 | $5,800 |
| Total | ≈ $38,000 |
Two facts matter as much as the price. DynamoDB's maximum item size is 400 KB, so our 1 MB values would need to be split or stored in S3 with a pointer. And if the interviewer asks how DynamoDB works inside: it uses one leader per partition, elected with Multi-Paxos, and B-tree storage, not leaderless quorums. Our design follows the Dynamo paper and Cassandra, not DynamoDB.
R2.8 Failure Modes
| Trigger | What you'd see | How the design responds | Drill |
|---|---|---|---|
| Losing an AZ | 24 nodes and a third of routers vanish. Every key has 2 replicas left; surviving nodes' load rises 1.5×; hints start piling up. REL 1 · REL 10 | W = 2 and R = 2 still succeed. The 60% rule leaves headroom (disks ≈ 66% busy, R2.6). We don't launch anything during the event. EC2 vCPU and EBS quotas are held for the fleet plus replacements, with alarms at 80% of each quota. | – |
| Compaction falls behind | Write bursts fill MemTables faster than they flush. Level 0 files pile up (> 20), every read checks all of them, and read latency climbs across the cluster. REL 5 | Past 8 L0 files, the engine delays each write by 1 ms per extra file. Routers shed non-critical requests with 429 (concurrency limits). Compaction gets its own I/O budget, capped at 150 MB/s, so it never starves WAL writes. | WAL/LSM compaction storm |
| Hints fill the disk | During a 12-hour AZ outage, every write creates a hint. Unbounded, hints fill the surviving nodes' disks and crash them, turning a one-AZ outage into a full one. REL 11 | Hints live on a dedicated volume capped at 20 GB per node. 3 hours of hints at peak is 57.5 MB/s × 10,800 s ÷ 48 nodes ≈ 13 GB per node, so the cap holds for the whole hint window. After 3 hours hints are dropped and the returning node is repaired by anti-entropy (or rebuilt if gone past ~2.75 days). | – |
| Clients reconnect all at once | After a network blip, thousands of clients reconnect and retry together. Some routers still hold an old ring and send writes to nodes that no longer own those ranges. REL 5 | SDKs retry with full-jitter exponential backoff: sleep a random time between 0 and min(5,000 ms, 100 ms × 2^attempt), behind a circuit breaker. Every ring change increments a ring_epoch; storage nodes reject requests with an older epoch (409 FENCING_TOKEN_STALE), so the router refreshes its ring and resends. | WebSocket reconnect thundering herd |
| A celebrity key | One key's replicas saturate while 95% of the cluster is idle. PERF 3 | Per-key caps, the router micro-cache with single-flight, and sub-key splitting for counters (step 2.7). | Sharding tenant hotspot |
| A disk stalls | An EBS volume hits its limit or its host stalls; one node's latency goes from 1 ms to over 500 ms. | Hedged reads at 5 ms; every call carries a deadline and replicas drop requests whose deadline has passed; writes already need only 2 of 3. | – |
| Connection pools run dry | At 250K QPS, unpooled connections exhaust the router's ephemeral ports (Linux default 32,768–60,999), TIME_WAIT sockets pile up, and requests stall. | At most 64 long-lived, multiplexed connections from each router to each storage node; a cap of 10,000 requests waiting for a connection, then fail fast with 503 and Retry-After. Long-lived connections also avoid repeated mutual-TLS handshakes. | Circuit breaker thread stall |
| Adding capacity | Disks pass 60% full or IOPS pass 60% at peak. REL 7 | Add nodes in threes, one per AZ. Each new node takes many small token ranges from many peers and streams about 1 TB at 100 MB/s (≈ 3 hours) before it serves reads; writes for its ranges go to both old and new owners meanwhile. ring_epoch fences routers with the old ring. | Vnode skew |
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance · Reliability background: Reliability playbook
R2.9 Production Gotchas
1. The tombstone death spiral
- Symptom: a table's read P99 goes from 5 ms to seconds, and its disk usage keeps growing even though the team says it's deleting data.
- Cause: a queue-like pattern (insert, read, delete) or very short TTLs create millions of tombstones. Reads must step over them across many SSTables until
gc_grace_secondspasses and compaction removes them. Loweringgc_grace_secondswithout checking the repair schedule makes deleted keys reappear. - Fix: keep
gc_grace_secondsabove the invariant from step 2.6 (3 days here, not the 10-day default some systems ship with); trigger a tombstone compaction when more than 20% of an SSTable is tombstones; move queue workloads to SQS or Kinesis.
2. Last-write-wins on wall-clock time
- Symptom: occasional lost updates nobody can reproduce.
- Cause: one node's clock drifted 50 ms ahead (NTP trouble, a paused VM), so its writes beat newer writes from healthy nodes.
- Fix: HLC timestamps (step 2.2); Amazon Time Sync on every node; alarm at 10 ms of drift and stop writes from a node past 50 ms; sibling mode or conditional writes where a lost update matters.
3. Unbounded retries
- Symptom: a short compaction stall turns into a full outage.
- Cause: callers retry
503s immediately and without limit, multiplying traffic several times over, exactly when the cluster is weakest. - Fix: full-jitter exponential backoff in the SDK, circuit breakers that open when errors pass 10%, and a retry budget (retries at most 10% of requests) so retries can never multiply load.
4. Oversized MemTables
- Symptom: nodes get killed for running out of memory, or pause for seconds; the failure detector marks healthy nodes dead.
- Cause: someone raised the MemTable to 512 MB on a 16 GB node to cut flushes; several flush at once and memory runs out (on a JVM engine, long garbage-collection pauses too).
- Fix: cap total MemTable memory at 25% of RAM, with 64–128 MB per MemTable.
5. A sloppy quorum mistaken for strong consistency
- Symptom: a team reads right after a successful
QUORUMwrite and gets the old value, during a node failure. - Cause: the table had sloppy quorum on. The write's two ACKs came from a home replica and a stand-in node outside the preference list; the read asked two home replicas. The sets didn't overlap.
- Fix: sloppy quorum only as an explicit per-table setting labeled "eventual"; strict quorums (hints don't count toward
W) for tables that promise read-your-writes.
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | Survives an AZ loss at peak with the 60% rule (disks ≈ 66% after the loss); hinted handoff; read repair and Merkle anti-entropy; tombstone rule; published limits and quota alarms; backups every 60 s. REL 1 · REL 9 · REL 10 · REL 11 |
| Performance Efficiency | Per-request consistency; hedged reads; AZ-local full reads; micro-cache and sub-key splitting for hot keys. PERF 3 |
| Security | Per-table permissions; tenant key prefixes; mutual TLS inside the cluster; per-table KMS keys for personal data; PrivateLink access (below). SEC 3 · SEC 5 · SEC 8 · SEC 9 |
| Cost Optimization | The ≈ $39K breakdown; Savings Plan on the steady fleet; AZ-local reads save ≈ $3.2K; S3 gateway endpoint for backups; build vs DynamoDB compared with numbers. COST 5 · COST 7 · COST 8 |
| Operational Excellence | Golden-signal alarms, each tied to a first action; one-node-at-a-time deploys; monthly fault injection; weekly restore tests (below). OPS 6 · OPS 8 |
| Sustainability | Light this round: Graviton; TTL plus compaction actually frees disk; ZSTD compression; S3 lifecycle to Glacier. SUS 4 · SUS 5 |
Security in detail. Callers prove their IAM identity (the router verifies it through STS, as in Round 1; alternatively, VPC Lattice with IAM auth policies could front the routers in place of the NLB). IAM doesn't evaluate permissions for the actions of a service we built, so the router checks per-table permissions for GetItem, PutItem and DeleteItem in a policy store such as Amazon Verified Permissions. Multi-tenant tables add a rule that limits a caller to keys starting with its tenant ID, the same idea as DynamoDB's dynamodb:LeadingKeys condition. Storage nodes sit in private subnets; their security group admits only the routers and each other. Other teams reach the store through a PrivateLink endpoint, which an NLB-fronted service supports. TLS 1.3 from clients to routers; mutual TLS between routers and nodes and between nodes (gossip, hints, repair). EBS volumes and S3 backups are encrypted with KMS; tables holding personal data get their own customer-managed key, so access can be revoked and audited per table. EBS encryption calls KMS when a volume is attached, not on every I/O, so it doesn't add request latency. More on KMS envelope encryption in the Operational Excellence & Security playbook.
Operations in detail.
| Alarm | Threshold | Severity | First action |
|---|---|---|---|
| Write latency P99 | > 15 ms for 3 min | P2 | Check EBS queue length and the compaction backlog |
| Read latency P99 | > 25 ms for 3 min | P2 | Check Bloom filter hit rate, L0 file count, tombstones per read |
| L0 SSTable count | > 12 | P2 | Confirm backpressure is on; give compaction more CPU |
| Quorum failure rate | > 0.1% of requests | P1 | Is an AZ unreachable? Are hints absorbing it? |
| Dropped mutations | > 100/s | P1 | Replicas are dropping expired writes: turn on load shedding, scale routers, check repair |
EBS IOPS exceeded (VolumeIOPSExceededCheck) | > 0 for 5 min | P2 | Raise gp3 IOPS online (a volume allows at most 4 modifications in any 24 hours, so raise it well above need) |
| Throttled requests per table | > 1% | P3 | Find the hot key or tenant |
| Service quota usage (EC2 vCPU, EBS) | > 80% | P3 | Request the increase now |
Every request carries a trace ID through router and replicas, sampled at 1% into X-Ray, and per-partition request counts are logged so hot keys can be found after the fact. OPS 4 · REL 6
Deploys respect quorum: one node at a time, one AZ at a time, never two replicas of the same range down together. The first node in each AZ is a canary that bakes for 30 minutes and rolls back automatically if its P99 or error rate moves. Conflict mode, hedging threshold and compaction rate are runtime settings, so a bad value is reverted in seconds. Monthly AWS Fault Injection Service experiments stop a node, pause I/O on a volume, and cut an AZ off, while dashboards confirm the SLOs held. OPS 6 · REL 8 · REL 12
Backups: each new SSTable goes to S3 once, from one replica per range. WAL segments ship every 60 seconds from all three replicas, because a write acknowledged by W = 2 may be missing from any single one; that interval is the backup RPO. Uploads use an S3 gateway endpoint, which is free and keeps backup traffic off NAT gateways. Backups are copied to a second region and encrypted with KMS. Once a week, a job restores a random table into a scratch cluster and compares row counts and checksums, because an untested backup is not a backup. REL 9
R2.11 Round 2 Rubric and Follow-Ups
What a strong senior (L6) answer adds over L5
- Starts with "what breaks" and fixes it in a stated order.
- Offers consistency per request and explains exactly where
W + R > Nstops (not linearizable, sloppy quorum breaks it). - Handles conflicts with HLC and knows when vector clocks and siblings are worth it; knows compare-and-set needs consensus.
- Explains hints, read repair and Merkle anti-entropy, and the tombstone
gc_grace_secondsrule. - Sizes the fleet for an AZ loss (the 60% rule) and checks the disks after the loss.
- Volunteers the monthly cost, the DynamoDB alternative, and the concession behind each choice.
Follow-up questions
-
"Two clients increment the same counter under eventual consistency. How do you avoid losing updates?" Answer: store it as a PN-counter CRDT (conflict-free replicated data type). Each replica keeps its own running total of increments and of decrements. Merging two copies takes, for each replica, the larger of the two totals, so merges can happen in any order, any number of times, and all copies converge without locks. The counter's value is the sum of increments minus the sum of decrements. If the counter needs a strict limit, such as stock that must not go below zero, use a conditional write through the Paxos path instead.
-
"Why leveled compaction instead of size-tiered for this workload?" Answer: size-tiered compaction merges files of similar size, so several overlapping files can exist per tier and a read may check 4–8 files. Leveled compaction keeps each key in at most one file per level below L0, so a read checks about one file per level, 4–5 in total, and Bloom filters keep actual disk reads near one. That protects the 8 ms read P99. The price is more compaction writes (10–30× write amplification), which our 250 MB/s per node covers (R2.6).
-
"The budget is cut by 30%. What do you change first?" Answer: 30% of ≈ $39K is about $11.7K. A 3-year Savings Plan takes roughly $9–11K off the $22.5K instance line, and ZSTD on replication traffic saves about $1K more; together that's close to the target without changing the design. If more is needed, moving from EBS to instance-store NVMe removes most of the $10K EBS line, though storage instances with local NVMe (such as
r7gdori4g) cost more per hour, which gives part of it back, and node replacement gets slower (R2.7). We would not cut the AZ headroom: that's what the 99.99% target pays for.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
"W + R > N means linearizable" | It only means the read and write sets share a node. Concurrent writes, failed partial writes and sloppy quorums all break "reads see the latest write". |
| "Timestamps resolve conflicts" | Wall clocks drift and jump. HLC fixes ordering between writes that know about each other; it can't make two concurrent writes both survive. |
| "Quorum-read, then write, is compare-and-set" | Another writer can slip in between. Compare-and-set needs consensus (Paxos) on that key. |
| "Add nodes to fix a hot key" | A key lives on 3 replicas regardless of fleet size. |
| "Just delete the key" | Repair resurrects it. Deletes are tombstones kept for gc_grace_seconds. |
| "One setting for everyone" | Different tables need different consistency; make it per request with safe defaults. |
Round 3 · Architect · "It Goes Global"
~45 min · Principal (L7) · multi-region active-active · 1B MAU · ~1M QPS peak across regions · 99.999% for top-tier tables
R3.0 Where We Left Off
What the candidate says in the first 60 seconds of Round 3, and everything you need if you start here.
Round 2 in 60 seconds. "We run the company-wide key-value platform in one region across three AZs: 200M daily users, 250K requests per second at peak, 23 TB of data provisioned as 103.5 TB, 99.99% availability. 72 Graviton storage nodes, 24 per AZ, each with an LSM engine on 1.5 TB of gp3 at 6,000 IOPS, and about 30 stateless routers. Every key has three replicas, one per AZ. Callers pick consistency per request:
ONE,QUORUMorLOCAL_QUORUM. Conflicts resolve by last-write-wins on a hybrid logical clock, or by vector-clock siblings for tables that opt in; conditional writes go through Paxos. Missed writes are caught up by hints, read repair and Merkle anti-entropy; deletes are tombstones kept 3 days. We run at 60% at peak so losing an AZ fits. It costs about $39K a month. Our stance is PA/EL: under a partition,ONEkeeps working everywhere andQUORUMworks on the majority side; in normal operation we favor latency. Two costs are open: the whole platform lives in one region, so one region is one blast radius; and we have no answer for data residency or restoring a table to a point in time."
Architecture v2, compact
textservices ─► Route 53 ─► NLB ─► ~30 routers (Fargate, 3 AZs) quotas · micro-cache · HLC · Paxos for conditional writes MurmurHash3 → preference list (1 node per AZ) · W/R per request ┌─────────────────────────┼─────────────────────────┐ AZ-a: 24 nodes AZ-b: 24 nodes AZ-c: 24 nodes r7g.2xlarge · 1.5 TB gp3 · 6,000 IOPS └── WAL → MemTable → SSTables (leveled, Bloom) · hints (3 h) · Merkle repair (6 h) · tombstones (3 d) ──► S3: SSTables + WAL every 60 s (copied to a 2nd region) · CloudWatch + X-Ray
Rounds 1–2 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | A restart loses data | WAL + fsync + group commit | Log grows |
| 1.2 | Data outgrows RAM | LSM tree, Bloom filters, leveled compaction | Multi-file reads; compaction I/O |
| 1.3 | One machine isn't enough | Consistent hashing (MurmurHash3) + virtual nodes | Someone must know the ring |
| 1.4 | Who knows where a key lives? | Stateless routers, gossip, phi-accrual | Extra hop; detection is a guess |
| 1.5 | A dead machine loses keys | N = 3, one replica per AZ | Copies disagree |
| 1.6 | When is a write done? | W = 2, R = 2 | Not linearizable |
| 2.1 | Teams need different consistency | ONE / QUORUM / LOCAL_QUORUM per request | Clients can choose badly |
| 2.2 | Writes collide | LWW on HLC; vector-clock siblings opt-in | LWW drops concurrent writes |
| 2.3 | Compare-and-set | Paxos on conditional tables | ~4 round trips |
| 2.4 | A replica is down for minutes | Hinted handoff (3 h, not counted toward W); sloppy quorum opt-in | Hint buildup; sloppy breaks overlap |
| 2.5 | Silent drift | Read repair + Merkle anti-entropy | Repair bandwidth |
| 2.6 | Deletes come back | Tombstones, gc_grace_seconds = 3 d | Tombstone buildup |
| 2.7 | Noisy tenants, hot keys | Quotas, micro-cache, sub-key splitting | 429s, fan-out reads |
R3.1 The Scope Raise
Interviewer: "The company is now global. 1 billion monthly users on every continent, and every product runs on your store. Peak is around a million requests per second, summed across regions."
Interviewer: "Users in Tokyo are complaining that every call takes 150 ms. And leadership wants 99.999% for the tables behind login and payments, which means surviving the loss of a whole region."
Candidate: "Does every table need that?"
Interviewer: "No. Let each team choose how much data it can lose and how long it can be down. But some tables hold EU customers' personal data and must stay in the EU. And after last quarter's incident, where a bad script corrupted a table, teams want to restore a table to how it looked at a point in time."
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Users | 200M DAU | 1B MAU, 400M DAU |
| Traffic | 250K QPS peak | ~1M QPS, the sum of each region's own peak |
| Data | 23 TB raw, one copy set | ~69 TB unique, most of it copied to 3 regions: ~810 TB provisioned |
| Footprint | 1 region, 3 AZs | 3 regions, each with 3 AZs, active-active |
| Survive | an AZ | a region |
| Availability | 99.99% | 99.999% (5.26 min/year) for top-tier tables |
| New | – | Data residency; point-in-time restore; RPO and RTO per table |
RPO (recovery point objective) is how much recent data we may lose, measured in time. RTO (recovery time objective) is how long recovery may take.
R3.2 What Breaks in the Round 2 Design
| Round 2 property | How it fails at the new scope |
|---|---|
| One region | A region-wide event (power, network, a bad control-plane change) takes every replica of every key offline at once: 100% of requests fail with 503. |
| Distance | A caller in Tokyo reaching us-east-1 pays about 150 ms per round trip; Europe pays about 70–80 ms. The 3 ms read target is impossible from there. |
| Quorums | Spreading a key's replicas across regions and waiting for a majority would put a cross-region round trip in every write. |
| Residency | Replicas go wherever the ring says. Nothing keeps EU data in the EU, including backups. |
| Backups | We can recover to "about a minute ago", but not to "just before the bad script ran at 14:05". |
| Deploys | One fleet, one config. A bad deploy or config push can reach all 72 nodes, and in the next design all regions. |
The order we fix it in: geography and replication first (3.1–3.2), then residency (3.3), then disaster recovery and restore (3.4), then blast radius (3.5), then the question of whether we should run this at all (3.6).
R3.3 New Requirements and API Additions
Global table configuration. A table now says where it lives and how it recovers:
json{ "table": "profiles", "tenant": "identity-team", "regions": ["us-east-1", "eu-west-1", "ap-northeast-1"], "conflict_mode": "lww", "write_mode": "multi_region", "dr_tier": "active_active", "rpo_target_seconds": 1, "rto_target_seconds": 180, "pitr": { "enabled": true, "retention_days": 7 } }
A table that needs compare-and-set across the world gets a home region, where all its writes go:
json{ "table": "payment_idempotency", "regions": ["us-east-1", "eu-west-1", "ap-northeast-1"], "write_mode": "home_region", "home_region": "us-east-1", "conditional_writes": true, "dr_tier": "active_active" }
A residency table is pinned to its jurisdiction, backups included:
json{ "table": "eu_customer_pii", "regions": ["eu-west-1"], "residency": "EU", "backup_regions": ["eu-central-1"], "kms_key_region": "eu-west-1", "dr_tier": "backup_restore", "rpo_target_seconds": 60, "rto_target_seconds": 14400, "pitr": { "enabled": true, "retention_days": 35 } }
Placement can also go one level finer, by key prefix inside a table ("placement_rules": [{ "key_prefix": "eu:", "regions": ["eu-west-1"] }]). That keeps one table for a global product but makes the replicator check every key against the rules. We offer it, and recommend separate tables when a team can split its data cleanly.
Consistency levels on a multi-region table
| Level | Meaning | Latency |
|---|---|---|
LOCAL_QUORUM (default) | 2 of the 3 replicas in the caller's region | Same as Round 2 |
ONE | 1 replica in the caller's region | Fastest |
QUORUM | A majority of all 9 replicas (5), so at least 2 from another region | At least one cross-region round trip (70–150 ms). Allowed, discouraged; the SDK warns. |
A request in the wrong region. If a caller in us-east-1 asks for a key in eu_customer_pii, the router doesn't proxy it: proxying would move EU personal data through the US. It answers:
httpHTTP/1.1 421 Misdirected Request Content-Type: application/json { "error": "TABLE_NOT_IN_REGION", "table": "eu_customer_pii", "regions": ["eu-west-1"] }
The SDK reads table placement at startup, so in practice callers go to the right regional endpoint directly.
Point-in-time restore. Restore always creates a new table, so a restore can never overwrite live data by mistake (DynamoDB's PITR works the same way):
httpPOST /v1/tables/orders:restore HTTP/1.1 Content-Type: application/json { "restore_to_time": "2026-09-24T14:04:59Z", "target_table": "orders_restored_0924", "target_region": "us-east-1" }
httpHTTP/1.1 202 Accepted Content-Type: application/json { "restore_id": "rst_8c21", "status": "RUNNING", "earliest_restorable_time": "2026-09-17T14:05:00Z", "latest_restorable_time": "2026-09-24T15:40:00Z" }
The caller polls GET /v1/restores/rst_8c21 until the status is COMPLETED, checks the new table, then swaps its application over or copies back the rows it needs. latest_restorable_time trails "now" by up to the 60-second WAL shipping interval.
R3.4 Design Evolution: Going Global
Step 3.1: Users Are Far Away and a Region Can Die
The problem: a service in Tokyo pays 150 ms per call to us-east-1, and if us-east-1 has a regional outage, everything stops. What would you do? Where do the replicas go, and when is a write "done"?
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active
Step 3.2: Two Regions Changed the Same Key
The problem: a user has two devices. Within the same second, one adds a book to their cart through eu-west-1, and the other adds a pen through us-east-1. Both regions accept their write locally and replicate to the other. What would you do? What does each region end up with, and which tables can live with that?
| Table example | Conflict rule | Writes allowed in | If two regions write at once |
|---|---|---|---|
profiles | LWW on HLC | Every region | Higher HLC wins; the other edit is lost |
likes_counter | PN-counter CRDT | Every region | Both increments count |
carts | OR-set CRDT (or siblings) | Every region | Both items stay |
payment_idempotency | Home region + Paxos | us-east-1 only | Can't happen: one writer region |
Drill: Replication multi-region consistency
Step 3.3: This Data May Not Leave the Country
The problem: legal says the eu_customer_pii table must stay in the EU. Today our replicator sends every table to all three regions, and backups are copied to a second region that happens to be in the US.
What would you do? How do we keep some data in one jurisdiction without building a separate platform for it?
Step 3.4: A Region Is Gone. Now What?
The problem: at 09:12 UTC, us-east-1 stops answering. It holds a full copy of every global table and is the home region of payment_idempotency.
What would you do? What happens to traffic, to data, and to tables that live only there? And separately: how do we get orders back to how it looked at 14:04:59 yesterday?
The region evacuation procedure (practiced quarterly, see R3.9):
- Detect: Route 53 health checks fail for us-east-1; the cross-region replication-lag alarm fires in the surviving regions.
- Traffic moves on its own: latency routing stops answering with us-east-1; callers land in eu-west-1 and ap-northeast-1.
- Confirm capacity: both survivors were sized for this (R3.6). Check routers are scaling and quota alarms are quiet.
- Home-region tables: fence us-east-1 for those tables (bump their epoch), promote eu-west-1 as home. Their writes resume there.
- Tables pinned to us-east-1: start restores from their backup region, in order of tier and business priority.
- Tell callers what to expect: reads may be up to the replication lag behind; some single-region tables are down.
- Failback, later: the recovered region rejoins by cross-region anti-entropy first, and only then gets traffic back from Route 53. Home tables move back only on purpose, never automatically.
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active · REL 13
Step 3.5: One Bad Deploy Took Down Everything
The problem: a new compaction setting shipped to every node in every region within an hour. It had a bug that only showed under one table's data pattern. Every region went down together, so multi-region didn't help at all. What would you do? How do we stop one change from reaching everything at once?
Synthesizing vector architecture diagram...
Each wave reaches more of the fleet only after the previous one has run under real traffic without alarms. A bug that shows up in wave 1 has hit one cell in one region.
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance (a cell is a bulkhead at fleet scale) · OPS 6 · REL 10
Step 3.6: Should We Even Run This Ourselves?
The problem: the CFO sees a bill of about $300K a month plus a team that runs 540 storage nodes in three regions, and asks why we don't use DynamoDB Global Tables. What would you do? How do you answer, with numbers?
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Start at the top. Route 53 answers each caller with its nearest healthy region. Inside a region, the NLB feeds a thin cell router, which sends each table's requests to the one cell that holds it. Each cell is the full Round 2 design. Global tables replicate asynchronously between the matching cells of the three regions (drawn for cell 1; the others work the same way). EU-pinned tables live only in eu-west-1, and their backups go only to eu-central-1.
Trace: a write that replicates to another region
Synthesizing vector architecture diagram...
The caller's answer (step 4) doesn't wait for other regions. The streamer ships in the background and moves its checkpoint only after each peer region confirms. The time between steps 4 and 8 is the replication lag, and it's the RPO of an active-active table.
Trace: a region failover
Synthesizing vector architecture diagram...
Detection takes about 30 seconds and callers follow within their DNS TTL, so active-active tables are back in 1–3 minutes. Writes that reached us-east-1 but weren't shipped yet (about the last second) are missing from the other regions. If us-east-1 comes back with its disks, they ship then and conflict rules merge them; if its data is gone, they're lost. That window is the RPO.
R3.6 Numbers and Cost
Traffic
Every product now uses the store, so each daily user causes about twice Round 2's operations.
| Item | Math | Result |
|---|---|---|
| Daily users | 1B MAU × 40% | 400M DAU |
| Requests per day | 400M × 86.4 ops | 34.56 billion |
| Average QPS | 34.56 × 10^9 ÷ 86,400 s | 400,000 |
| Split by region | us-east-1 40%, eu-west-1 30%, ap-northeast-1 30% | 160K / 120K / 120K average |
| Each region's own peak | average × 2.5 | 400K / 300K / 300K |
| Sum of regional peaks | 400K + 300K + 300K | ≈ 1M QPS |
The regions peak at different hours of the UTC day, so the whole world never hits 1M at the same moment. But each region must be sized for its own peak, so capacity adds up to about 1M.
Data
| Item | Math | Result |
|---|---|---|
| Keys | across all products | 60 billion |
| Unique raw data | 60 × 10^9 × 1.15 KB | 69 TB |
| Global tables (80% of bytes) | stored in all 3 regions | 55.2 TB per region |
| Single-region tables (20%) | residency or cheap tier, split 40/30/30 | 5.5 / 4.1 / 4.1 TB |
| Raw per region | 55.2 + its share | ≈ 60 TB |
| Provisioned per region | 60 TB × 3 replicas × 1.5 headroom | 270 TB |
| Provisioned worldwide | 270 × 3 | ≈ 810 TB |
Fleet per region
| Item | Math | Result |
|---|---|---|
| Storage nodes | 270 TB ÷ 1.5 TB per node | 180 per region (60 per AZ; 3 cells of 60) |
| Worldwide | 180 × 3 | 540 nodes |
The fleet is sized by disk again. Is it big enough for traffic, including a region failover? Two checks for us-east-1, our busiest region.
Writes applied in a region are its own writes plus the global-table writes replicated in from the other two. Average global-table writes elsewhere: eu-west-1 120K × 20% × 80% = 19.2K/s, ap-northeast-1 the same.
| Case for us-east-1 | Reads/s | Writes applied/s | Disk IOPS per node | Replica ops per node |
|---|---|---|---|---|
| Normal peak (400K) | 320K | 80K own + 38.4K replicated = 118.4K | (118.4K × 3 + 320K × 2 × 10%) ÷ 180 ≈ 2,330 (39%) | (320K × 2 + 118.4K × 3) ÷ 180 ≈ 5,500 |
| eu-west-1 fails at a shared busy hour: half its 300K peak moves here (550K) | 440K | 110K own + 19.2K from Tokyo ≈ 129K | (129K × 3 + 440K × 2 × 10%) ÷ 180 ≈ 2,640 (44%) | (440K × 2 + 129K × 3) ÷ 180 ≈ 7,040 |
Both stay under Round 2's per-node load at peak (6,000-IOPS disks, about 7,600 replica operations a second), which we already sized at 60%. So the region-loss headroom costs no extra storage nodes: the fleet was bigger than traffic needed because of disk. The same check for eu-west-1 or ap-northeast-1 taking half of us-east-1's peak (500K) gives about 41% disk and 6,400 replica operations per node. Losing an AZ at normal peak still fits too (the same method as R2.6 gives about 58% disk use on the 120 surviving nodes). The router tier, which is sized by traffic, is where failover capacity costs money: 66 tasks in us-east-1 (sized for 550K) and 60 each in the other two (sized for 500K, their peak plus half of us-east-1's 400K).
Cross-region replication
| Item | Math | Result |
|---|---|---|
| Global-table writes, average | 400K × 20% × 80% | 64K/s |
| Bytes shipped | 64K × 1.15 KB × 2 destination regions | 147 MB/s ≈ 1.18 Gbps average |
| Per month | 147.2 MB/s × 2,592,000 s | ≈ 381 TB |
At peak hours it's higher; if all regions peaked together, 2.5 times the average (≈ 2.9 Gbps) is the ceiling.
A 12-hour split between regions leaves each streamer replica holding unsent mutations. us-east-1's global writes are 25.6K/s × 1.15 KB ≈ 29.4 MB/s, spread over 180 nodes: about 0.16 MB/s per node. The commit log is kept until both destination checkpoints pass it, so the backlog is about 7 GB per node after 12 hours. We keep up to 24 hours of backlog on a dedicated volume; beyond that, the cross-region Merkle comparison takes over.
Monthly cost, worldwide (us-east-1 list prices used for every region as a floor: other regions list higher; check the AWS Pricing Calculator):
| Line | Math | ≈ Monthly |
|---|---|---|
| Storage nodes | 540 × r7g.2xlarge × 730 h × $0.4284 | $168,900 |
| EBS gp3 | 810 TB × $0.08/GB = $64,800 + 540 × $20 extra IOPS/throughput = $10,800 | $75,600 |
| Cross-AZ traffic | Writes applied, all regions: (70.4K + 68.8K + 68.8K) ≈ 208K/s × 1.15 KB × 2 copies ≈ 478 MB/s ≈ 1,240 TB/month × $0.02 | $24,800 |
| Cross-region replication | 381 TB × $0.02/GB, the list rate out of us-east-1 and eu-west-1. This is a floor: traffic leaving Tokyo (about 30% of it) is billed at Tokyo's higher inter-region rate, so price that leg in the AWS Pricing Calculator | ≥ $7,600 |
| Routers | 186 Fargate tasks × $58 | $10,800 |
| Network Load Balancers | 3 × $16 + 400K req/s × 1.15 KB = 460 MB/s ≈ 1,656 GB/hour × $0.006 × 730 h | $7,300 |
| S3 backups + PITR | Local: ~30 TB of compressed SSTables + ~7 days of WAL from 3 replicas (≈ 66 TB compressed) ≈ 96 TB × $0.023 ≈ $2,200. Cross-region copies: ≈ 283 TB of new WAL a month × $0.02/GB transfer ≈ $5,700, plus ≈ 96 TB stored again ≈ $2,200. Plus requests | $10,900 |
| Route 53 | health checks and queries from services | < $100 |
| Total | ≈ $306,000 (a floor: see the cross-region line) |
Where the cross-AZ figure comes from: each region applies its own writes plus the global writes from the other two. us-east-1 applies 32K + 19.2K + 19.2K = 70.4K/s on average; each other region 24K + 25.6K + 19.2K = 68.8K/s.
Compared with Round 2's ≈ $39K: traffic grew 4×, provisioned storage 7.8×, cost about 7.6×. This is a storage-bound fleet, so cost follows the number of copies we keep. The biggest new line is cross-region transfer, but the biggest increase is the storage fleet, because global tables now exist three times. The obvious levers: a 3-year Savings Plan takes roughly 40–50% off the $169K instance line COST 7, and ZSTD on the replication stream roughly halves the transfer lines.
Active-active vs active-passive, in numbers. Storage nodes plus EBS cost about ($56.3K + $25.2K) ÷ 60 TB ≈ $1.36K per raw TB per region copy per month. The global tables hold 55.2 TB.
| Option for the global tables | Region copies | ≈ Storage cost | What we lose |
|---|---|---|---|
| Active-active in 3 regions (chosen for top tier) | 3 | $225K | – |
| One active region + one warm standby, far users cross the ocean | 2 | $150K | Tokyo pays ~150 ms per call; RTO grows to 10–30 min |
| Backup and restore only | 1 + S3 | $75K | RTO in hours; RPO about a minute |
Because the fleet is sized by disk, a warm standby saves less than people expect: it still needs every byte on disk. It saves more only if the standby uses denser storage (fewer, bigger nodes) and accepts that scale-up takes longer. So the real money decision is how many regions each table lives in, table by table.
Availability. 99.999% allows about 5.26 minutes of downtime a year. Two independent regions each at 99.99% would, on paper, both be down only 0.0001 × 0.0001 = 0.00000001 of the time. Real outages are rarely independent: a bad deploy, a shared config, or a global dependency hits every region at once. That's why cells and waves (step 3.5) matter more than the multiplication. Only tables on the active-active tier can meet 99.999%; tables that choose a cheaper tier accept a lower target, in writing.
R3.7 Trade-Offs
| Decision | Why we chose it | What we concede |
|---|---|---|
| Active-active over active-passive for top-tier tables REL 13 | Callers everywhere get local latency; failover takes minutes and is exercised every day, because every region serves real traffic. | A full copy per region (≈ $75K a month per extra copy of the global tables), and conflicts between regions. |
| Conflict rule per table: LWW, CRDTs, or a home region | Each table gets the weakest rule its data can tolerate: LWW is simplest, CRDTs keep every update for mergeable data, a home region gives true compare-and-set. | Teams must understand and choose. Home-region tables lose write availability while their home is down, until we promote a new home. |
| Cells over one big cluster per region REL 10 | A bad deploy or a poison-pill workload hits one cell: a third of the tables in one region. Each cell stays at a size we've already run. | More units to operate; a table must fit in a cell; moving tables is a migration. |
| Build over DynamoDB Global Tables (only if we have the team) COST 5 | Similar list cost at this scale; 1 MB values; CRDT and sibling modes; control over placement and hardware. | A 24/7 platform team for 540 nodes in 3 regions. Without that team, buy. |
Closing the loop on the track's question. In Round 1 we chose availability once, for everyone. In Round 2 each request chose. Now:
- Each table chooses its regions, its conflict rule and its recovery tier.
- In each region, the same table can behave differently: a home-region table accepts writes only in its home. During a split between regions, a global LWW or CRDT table keeps accepting writes everywhere (PA), while a home-region table refuses writes outside its home (PC). In normal operation, local quorums keep latency low (EL), while home-region writes from far away pay for consistency with latency (EC).
So the honest answer to "consistent or available?" is: per table and per region, chosen by the team that owns the data, with the cost written down.
R3.8 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| A region outage | Health checks fail for one region; its callers' errors spike, then move to the next-nearest region. | Route 53 stops answering with the region in about 30 s plus TTL. Survivors were sized for it (R3.6). Home-region tables are promoted after fencing. Single-region tables are restored from in-jurisdiction backups. Follow the evacuation procedure (step 3.4). |
| A network split between regions | Replication lag climbs without limit; each region still serves its own callers. | Every region keeps serving at LOCAL_QUORUM. Streamers queue up to 24 h of mutations (about 7 GB per node after 12 h). When the link heals they drain, and conflict rules merge overlapping writes. Home-region tables reject writes from the far side of the split. Beyond 24 h, cross-region Merkle repair catches up. |
| Replication lag breaks read-your-writes | A user saves a new email in eu-west-1; their next request is served by us-east-1 and shows the old one. | Keep a caller's traffic sticky to one region while it's healthy. For flows that must read their own writes, the SDK sends the version it last wrote (X-Min-Version); a router whose local copy is older waits briefly for replication or forwards the read to the region that took the write. |
| A correlated failure from a deploy | Errors rise in one cell right after a wave starts. | The wave stops and rolls back automatically; other cells and regions never got the change (step 3.5). |
| A failover that fails | The runbook is followed, but the surviving region can't launch nodes (quota), a restore fails because the backup's KMS key lived in the dead region, or callers ignore DNS TTLs. | Prevent each in advance: quotas held for full failover size in every region, alarmed at 80% REL 1; backups encrypted with a key in the region where they're stored, never only in the source region; SDKs that respect TTLs and retry with jitter; and quarterly game days that actually run the procedure (R3.9). |
Drills: Replication multi-region consistency · Raft split brain (why the old home region must be fenced before a new one takes writes)
R3.9 Runbook and Incident Response
Golden signals, per region and per cell OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Read / write latency P99, per cell | as in Round 2 (> 25 ms / > 15 ms for 3 min) | P2 | Find the cell; check its disks, L0 count and tombstones |
| Quorum failure rate, per cell | > 0.1% | P1 | Is an AZ in that region unreachable? |
| Cross-region replication lag, per region pair | > 5 s for 5 min | P2 | Check the link and streamer backlog; is a region degraded? |
| Streamer backlog, per node | > 50% of its volume | P1 | Split likely; confirm, and plan for Merkle catch-up |
| Route 53 health check status, per region | any unhealthy | P1 | Start the evacuation checklist |
| Backup freshness: age of the newest WAL segment in S3, per table | > 5 min | P2 | Restore RPO is at risk; check uploads |
| Restore test result, weekly | any mismatch | P2 | Treat as a failed backup until explained |
| Errors right after a wave | > baseline + 0.5% | P2 | The pipeline rolls back; don't resume the wave until understood |
Incident flow SEC 10
Synthesizing vector architecture diagram...
The first question is always the blast radius. Cells, AZs and regions each have a prepared response, so the on-call engineer picks one instead of inventing one.
Game days. Monthly, AWS Fault Injection Service stops nodes, pauses EBS I/O and cuts an AZ off in one region, while dashboards confirm the SLOs held. Quarterly, we evacuate a whole region during business hours and fail back. Anything that didn't go to plan gets a blameless Correction of Error (COE) with owners and dates. OPS 11 · REL 12
Restore testing. Weekly, an automated job restores a random table to a random point in time into a scratch cell, in a different region from the source, and compares row counts and checksums against the source at that time. This also proves the KMS keys and cross-region backup copies work. REL 9
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace the IDs with real ones.
text# 1. Storage node health in one region aws ec2 describe-instances --region eu-west-1 --filters Name=tag:Service,Values=kvstore-storage --query "Reservations[].Instances[].[InstanceId,State.Name]" # 2. A node's engine metrics: look for compaction_backlog, l0_tables, hints_pending, streamer_backlog_bytes curl -s http://storage-node-01.kv.internal:8080/metrics # 3. Route 53 health check status for a regional endpoint aws route53 get-health-check-status --health-check-id 1a2b3c4d-5e6f-7a8b-9c0d-111122223333 # 4. Replication lag for the last 15 minutes (query defined in the JSON file) aws cloudwatch get-metric-data --region us-east-1 --metric-data-queries file://replication-lag-query.json --start-time 2026-09-24T14:00:00Z --end-time 2026-09-24T14:15:00Z # 5. Raise IOPS on a saturated gp3 volume, with no downtime # (a volume allows at most 4 modifications in any 24 hours, so raise it well above need) aws ec2 modify-volume --volume-id vol-0a1b2c3d4e5f67890 --iops 10000 # 6. Newest WAL segments for a table in its backup bucket (backup freshness) aws s3 ls s3://kv-backups-eu-central-1/eu_customer_pii/wal/ --recursive --region eu-central-1 # 7. Start a game-day experiment from a prepared template aws fis start-experiment --experiment-template-id EXT1a2b3c4d5e6f7
To force a tombstone compaction on a delete-heavy table, the node's admin API takes:
httpPOST /admin/compact HTTP/1.1 Host: storage-node-01.kv.internal:8080 Authorization: Bearer <operator token> Content-Type: application/json { "table": "user_profiles", "force_tombstone_purge": true }
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Survives a region loss for active-active tables; RPO/RTO tier per table; home-region fencing; PITR; cells; quarterly evacuation drills; weekly restore tests. REL 9 · REL 10 · REL 12 · REL 13 |
| Performance Efficiency | Latency-based routing to the nearest region; LOCAL_QUORUM keeps writes local; async replication keeps other regions off the write path. PERF 4 |
| Security | Residency by placement; backups and KMS keys kept in-jurisdiction, with keys per region; 421 instead of proxying pinned data; SCPs that deny unused regions. SEC 7 · SEC 8 · SEC 10 |
| Cost Optimization | ≈ $306K/month derived; cross-region transfer priced; active-active vs warm standby vs backup-only compared per table; build vs DynamoDB Global Tables. COST 5 · COST 8 |
| Operational Excellence | Wave deploys across cells and regions; per-cell and per-region-pair alarms; incident flow by blast radius; game days and COEs. OPS 6 · OPS 8 · OPS 11 |
| Sustainability | Pick regions close to callers and, where latency allows, regions with lower-carbon power; Graviton; ZSTD on disk and on the replication stream; S3 lifecycle rules (backups older than 35 days to Glacier, deleted after a year); active-active capacity serves traffic every day instead of idling as standby. SUS 1 · SUS 2 · SUS 4 · SUS 5 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Turns "survive a region" into a per-table decision with a price, instead of one answer for all data.
- Thinks in blast radius: cells, waves, fencing, and failures that hit every region at once.
- Knows where DNS failover stops and what else a failover needs (capacity, quotas, keys, home-region promotion).
- Prices the whole fleet, finds what drives the cost (copies of data, not traffic), and says which lever moves it.
- Makes build vs buy an organizational decision (team, on-call, features), not a technical preference.
- Sets the rules other teams follow: table tiers, residency placement, default consistency levels.
Follow-up questions
-
"A user updates their email in Europe, then their next request lands in the US and shows the old email. What do you do?" Answer: first, keep each caller sticky to one healthy region, which removes most cases. For flows that must read their own writes, the SDK sends back the version it last wrote. A router whose replica is older than that either waits a few hundred milliseconds for replication or forwards the read to the region that took the write. We don't make every read cross-region; only the reads that ask for it pay.
-
"eu-west-1 is down. Legal says EU data can't leave the EU. What happens to EU customers?" Answer: tables pinned to eu-west-1 are unavailable until eu-west-1 returns or we launch a cluster in eu-central-1 and restore them from the backups we keep there, which takes hours. That was the table owners' choice when they picked the backup-and-restore tier. If that downtime isn't acceptable, the fix is a second EU region running active-active for those tables, and we can price it: about $1.36K per raw TB per month for the extra copy, plus transfer.
-
"Cut the global bill by 30%." Answer: 30% of ≈ $306K is about $92K. A 3-year Savings Plan on the steady storage fleet saves roughly $68–84K of the $169K instance line. The rest comes from copies: moving global tables that don't need a Tokyo copy from 3 regions to 2 saves about $1.36K per raw TB per month. We'd leave cells, AZ headroom and the top-tier tables alone, since those are what the 99.999% target pays for.
Track Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping questions | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5–15 min | Requirements + API | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Design steps 1.0–1.6 | Design steps 2.1–2.7 | Design steps 3.1–3.6 |
| 40–50 min | Numbers + trade-offs | Numbers, cost, trade-offs | Numbers, cost, trade-offs |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design, let me ask a few scoping questions."
- When the scope is raised: "Here's what breaks in the current design, and here's the order I'll fix it in."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What happens when a node dies?" | Three replicas, one per AZ, and W = R = 2, so one dead node blocks nothing; its replacement streams before it serves. | 1 | R1.5, R1.9 |
| "What happens when an AZ goes down?" (REL 10) | One replica per AZ keeps every key readable and writable, and running at 60% at peak absorbs the 1.5× load shift. | 2 | R2.6, R2.8 | |
| "What are your limits, and what if you hit AWS's?" (REL 1) | We publish per-value, per-batch, per-key and per-table limits, and hold EC2 and EBS quotas for the fleet plus failover, alarmed at 80%. | 2–3 | R2.8, R3.8 | |
| "How do you add capacity?" (REL 7) | One node per AZ at 60% disk or IOPS; new nodes stream their ranges before serving, fenced by the ring epoch. | 2 | R2.8 | |
| "How do you stop one slow node from hurting everyone?" (REL 5) | Hedged reads, deadlines, bounded connection pools, jittered retries and write backpressure. | 1–2 | R1.9, R2.8 | |
| "What's your RPO and RTO?" (REL 9, REL 13) | Chosen per table: about 1 s and minutes for active-active, about a minute and hours for backup-and-restore, with point-in-time restore on top. | 3 | R3.4, R3.6 | |
| "What if a whole region goes down?" (REL 13) | Active-active tables move with Route 53 health checks in minutes; home tables are promoted after fencing; pinned tables restore in-jurisdiction. | 3 | R3.4, R3.8 | |
| Performance | "Why an LSM tree?" (PERF 3) | Heavy writes on big data favor sequential writes on cheap disk; Bloom filters and caches recover read speed. | 1 | R1.2, R1.8 |
| "How do you handle a hot key?" (PERF 3) | Router micro-cache for hot reads, sub-key splitting for mergeable writes, throttling for the rest. | 2 | Step 2.7 | |
| "How do far-away users get low latency?" (PERF 4) | Latency-based routing to the nearest region and LOCAL_QUORUM, with async replication between regions. | 3 | Step 3.1 | |
| Cost | "What does this cost, and what's the biggest line?" (COST 5) | ≈ $2.1K, ≈ $39K and ≈ $306K a month for the three rounds; the storage fleet is always the biggest line, and a Savings Plan cuts it 40–50%. | 1–3 | R1.7, R2.6, R3.6 |
| "Where does data transfer bite?" (COST 8) | Cross-AZ replication is the price of surviving an AZ; AZ-local full reads avoid ≈ $3.2K; cross-region replication adds at least ≈ $7.6K; backups use a free S3 gateway endpoint. | 2–3 | R2.6, R3.6 | |
| "Should we just use DynamoDB?" (COST 11) | Similar list cost; DynamoDB wins on people, we win on 1 MB values and conflict modes. Without a platform team, use DynamoDB. | 2–3 | R2.7, step 3.6 | |
| Operations | "How do you deploy without losing quorum?" (OPS 6) | One node at a time, never two replicas of a range; a canary per AZ; in Round 3, cell by cell and region by region in waves. | 2–3 | R2.10, step 3.5 |
| "How do you know it's healthy?" (OPS 8) | Golden-signal alarms per cell and region pair, each tied to a first action, plus traces and per-partition counts. | 2–3 | R2.10, R3.9 | |
| "How do you learn from incidents?" (OPS 11) | Game days that run the real procedures, and a blameless COE for anything that surprised us. | 3 | R3.9 | |
| Security | "Who can read a tenant's keys?" (SEC 3) | Only callers whose verified IAM identity is allowed on that table, limited to keys with their tenant prefix. | 2 | R2.10 |
| "How is data protected?" (SEC 8, SEC 9) | KMS at rest with per-table keys for personal data, TLS 1.3 to clients, mutual TLS inside. | 1–2 | R1.10, R2.10 | |
| "How do you keep EU data in the EU?" (SEC 7) | Placement by table or prefix, backups and keys in-jurisdiction, 421 instead of proxying, and SCPs denying other regions. | 3 | Step 3.3 | |
| Sustainability | "How do you reduce its footprint?" (SUS 4, SUS 5) | Graviton, TTL plus compaction that really frees disk, compression, backup lifecycle rules. | 1–2 | R1.10, R2.10 |
| "How do you pick regions?" (SUS 1) | Close to callers first; among close options, lower-carbon regions; and no idle standby where active-active serves traffic anyway. | 3 | R3.10 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Partitioning | Consistent hashing with virtual nodes; explains why hash % N fails. | Handles hot keys (micro-cache, sub-key splitting) and noisy tenants (quotas); sizes the AZ-loss headroom. | Places data by jurisdiction and by cell; knows a table must fit its cell and how tables move. |
| Storage internals | WAL, MemTable, SSTables, Bloom filters; why LSM for writes. | Leveled vs size-tiered compaction, write amplification, Bloom filter sizing, the gc_grace_seconds rule. | Uses the immutability of SSTables for incremental backup and point-in-time restore. |
| Consistency | W + R > N and why W = R = 2 survives a node loss. | Where overlap stops (not linearizable, sloppy quorum); HLC vs vector clocks; why compare-and-set needs Paxos; PA/EL. | Conflict rule per table across regions (LWW, CRDTs, home region); PACELC per table and region. |
| Failure recovery | Node loss, disk stalls, adding nodes, connection pools. | AZ loss, hints, repair, compaction backpressure, jittered retries, fencing tokens. | Region loss, splits between regions, failovers that fail, correlated deploy failures; RPO/RTO per table. |
| Well-Architected trade-offs | Names a cost for each choice; rough monthly cost. | Volunteers the cost breakdown, the DynamoDB alternative and each concession. | Prices the fleet worldwide, finds the cost driver, makes build vs buy an organizational decision. |
| Evolving under new scope | Builds from a baseline, one problem at a time. | Opens with "what breaks", then fixes in a stated order without starting over. | Evolves the design and the operating model: tiers, cells, waves, and rules other teams follow. |