Design a Key-Value Store
Design a key-value store for a system design interview: consistent hashing, quorums and consistency choices at 50,000 operations per second.
Problem statement
The prompt is short and exact: design a key-value store. A key-value store is a store built around one promise, that a value saved under a key comes back when that key is presented. A key is the short name a value is saved under and asked for by, and a value is the data saved under a key, such as a name, a cart or a session. Three operations carry the whole service: put saves a value under a key, get returns the value saved under a key, and delete removes the value saved under a key.
Put saves, get returns
store = {}
store["user:1:name"] = "Ana"
store["user:1:city"] = "Oslo"
store["cart:9:items"] = "books"
print("get user:1:name ->", store.get("user:1:name"))
print("get user:1:city ->", store.get("user:1:city"))
print("keys stored:", len(store))Three puts store three values, and two gets return exactly the values those keys hold. The third line of the output counts the stored keys, which shows that each distinct key occupies its own entry in the store. Nothing in the exchange depends on the order of the puts, and nothing depends on the values resembling each other in any way.
An overwrite and a missing key
store = {"user:1:name": "Ana"}
store["user:1:name"] = "Ana Lee"
print("after overwrite:", store.get("user:1:name"))
print("missing key:", store.get("user:2:name"))
print("keys stored:", len(store))A second put under the same key replaces the first value, so the latest put is the value a later get returns. A get for a key that was never saved finds nothing, and the store reports that absence plainly instead of guessing or returning a neighbour. Overwrite and absence are both ordinary outcomes of the contract, not errors, and clients build on both behaviours every day.
Only the exact key works
store = {"user:1:name": "Ana", "user:1:city": "Oslo", "user:2:name": "Ben"}
print("exact key:", store.get("user:1:name"))
print("part of a key:", store.get("user:1"))
print("keys stored:", len(store))The store answers only the exact key presented, and a shorter part of a key is simply a different key that finds nothing. There is no prefix search, no pattern matching and no way to ask for every key that begins alike. That narrowness is the source of the speed: one exact key maps to one exact answer, with no scanning of other entries on the way.
Design a Key-Value Store: The Ask
store = {}
store["session:a"] = "Ana"
store["session:b"] = "Ben"
store["session:c"] = "Cid"
store["session:b"] = "Ben Fox"
del store["session:a"]
print("session:b ->", store.get("session:b"))
print("session:c ->", store.get("session:c"))
print("session:a ->", store.get("session:a"))
print("entries left:", len(store))Puts, an overwrite and a delete run against one store in the closing model, and two entries are left at the end. A get for the deleted key finds nothing, while the surviving keys still return their latest values. The model is tiny, but every behaviour the larger design must preserve is already visible: exact lookup, latest value wins, and deletion that truly removes.
The stated scale is the load named in the ask: the store must serve 50,000 gets and puts per second, combined across all clients. That rate is far beyond one comfortable machine once durability and copies are priced in, and it is the number that forces the store across many servers. The shape is the NoSQL shape built for one-key lookups, and it is deliberately not built for joins across keys or questions that range over every key.
In scope
- Put, get and delete for one exact key, with the latest put winning
- Serving 50,000 gets and puts per second across many servers
- Surviving the failure of a single server without losing finished writes
- A per-request choice between a fast read and a read that is sure to be newest
Out of scope
- Joins across keys and questions that scan or range over many keys
- Transactions that span several keys at once
- Secondary indexes that find keys by their values
- Consensus protocols and leader election in depth
Requirements
The requirements turn the bare prompt into a contract that later choices can be judged against. Each functional item names a behaviour together with the consequence that makes the behaviour matter. Each non-functional item names a bar, and the bars around durability, failure and read freshness are the ones that shape the architecture most strongly.
Functional requirements
- Put a value under a key: the value is stored, and a later get for that exact key returns it, so a client can rely on what it saved being what it reads back
- Get a value by its exact key: the answer is the latest finished value for that key, or a plain absence when the key was never saved or was deleted, so clients never receive a value belonging to another key
- Overwrite a key: a second put under the same key replaces the earlier value everywhere the design promises freshness, so stale copies cannot quietly become the answer
- Delete a key: the value is removed, and later gets find nothing, so deletion is a real operation rather than a hint the store may ignore
- Choose freshness per request: a caller can ask for a fast read or a sure read on the same key, so one store serves both latency-sensitive and correctness-sensitive traffic
Non-functional requirements
- Throughput at the stated scale: the store sustains 50,000 gets and puts per second, so no single server sits on the path of every request
- Durability of finished writes: a write that counts as done survives the failure of the server that received it, because copies live on other servers
- Availability through one failure: with copies on separate servers, one failed server leaves its ranges served by the copies that remain, so reads and writes continue at reduced strength
- Honest freshness: a read labelled strong returns the latest finished write, while a read labelled eventual may return an older value for a short time, and the two are never confused
- Even load: keys spread across servers so that no server holds a wildly larger share than the others, because the slowest server sets the pace for its ranges
Two tensions run through the whole design and never fully disappear. More copies make finished writes safer and reads more available, yet every added copy costs storage and makes each write slower to confirm. Asking more copies on a read makes the answer surer, yet every added copy makes the read slower and heavier. The mechanisms in the deep dives exist to place those tensions under explicit numbers instead of leaving them as slogans.
Scale estimates
The estimates begin from the one stated load figure and derive everything else in the open. The stated figure is 50,000 gets and puts per second, and one day holds 86,400 seconds. Replication is stated separately: each range of keys is kept in 3 copies, so the fleet stores three entries for every logical key before any other overhead is counted.
The scale of the problem
| Quantity | Value | How derived |
|---|---|---|
| Operations per second | 50,000 | Stated in the ask, gets and puts combined |
| Seconds in a day | 86,400 | Stated constant |
| Operations per day | 4,320,000,000 | 50,000 ร 86,400 |
| Copies per range (N) | 3 | Stated replication choice |
| Copies confirming a write (W) | 2 | Stated quorum choice |
| Copies asked by a read (R) | 2 | Stated quorum choice |
| W + R | 4 | 2 + 2, which is more than N at 3 |
| Virtual nodes per server | 100 | Chosen demo value for the ring |
The decisive multiplication is the daily volume: 50,000 operations per second ร 86,400 seconds = 4,320,000,000 operations a day. A single machine can hold a key-value store comfortably at small scale, and the one-machine design remains the right starting point for correctness. The daily volume is what breaks the single machine as a final answer, because every one of those operations must also be made durable and, for most keys, copied. Partitioning spreads the operations across servers, and replication multiplies the storage and the write work on each range.
The quorum arithmetic is the second decisive sum, and it is small enough to hold in one line: W + R = 2 + 2 = 4, and 4 is more than N at 3, so a read always meets the latest finished write on at least one copy. That overlap is bought with the storage multiplier of 3 copies and with writes that wait for 2 confirmations before counting as done. The demo ring places 100 virtual nodes per server, stated as a demo value, so that each server owns many small slices of the ring rather than one large arc.
API design
The API is deliberately tiny, because the promise is tiny: one key in, one value out. Three operations cover the contract, and each one names the copies it must reach before it may report success. The quorum numbers travel with the operations rather than hiding inside the servers, so a caller choosing a strong read knows exactly what extra work that choice buys.
Core operations
| Operation | Request | Response | Notes |
|---|---|---|---|
| Put | PUT with the key, the value and the freshness the write needs | Confirmation once W copies hold the write | With N = 3 and W = 2, one copy may be down and the write still counts |
| Get (strong) | GET with the exact key, marked strong | The value with the highest version among R copies | Asks R = 2 copies and takes the newest version, so the latest finished write wins |
| Get (eventual) | GET with the exact key, marked eventual | The value held by the first copy that answers | Asks fewer copies and answers faster, and may return an older value for a short time |
| Delete | DELETE with the exact key | Confirmation once W copies record the removal | Treated as a write, so the same quorum rule protects it |
All three operations are synchronous from the caller's point of view: the caller waits, and the answer means the promised quorum was reached. Nothing in the core contract needs a queue, because there is no spreading work behind a put or a get in the way a feed or a notification fans out. Background work still exists inside the store โ log compaction, copy repair and the movement of ranges when servers join or leave โ and that work runs off the request path so a busy repair never blocks a waiting client.
Failure answers are part of the shapes rather than surprises. A put that reaches fewer than W copies does not count as done, even if one copy holds it, and the caller is told the write failed so the value is never half-promised. A get for a missing key returns a plain absence, which is a successful answer about the key rather than an error about the request. Keeping those two cases distinct lets clients retry failed writes safely while treating absence as final information.
Data model
The data model has one entity that matters to clients and two structures that matter to the server holding it. The entity is the key-value pair itself, addressed by the exact key and carrying a version that rises with every write. Inside one server, durability comes from a write-ahead log, and speed comes from an index that remembers where each key's latest entry sits in that log.
Core entities and structures
| Entity | Key fields | Notes |
|---|---|---|
| Key-value pair | key, value, version | The client-visible record; the version is a plain counter that rises with every write |
| Write-ahead log entry | position, key, value | Appended in order before the index changes; the durable record of every write |
| Index entry | key, log position | Held in memory; maps each key to the position of its latest entry in the log |
| Copy of a range | range of keys, owning server | One stored replica of a range, kept on its own server; N = 3 copies per range |
Each write appends to the log first
log = []
index = {}
def put(key, value):
log.append((key, value))
index[key] = len(log) - 1
put("name", "Ana")
put("city", "Oslo")
put("name", "Ana Lee")
print("log entries:", len(log))
for key in sorted(index):
print(key, "at position", index[key])
print("get name ->", log[index["name"]][1])A put appends its entry to the log first and then records the entry position in the index, and a get follows the index straight to the latest entry. Writing the log before touching the index is the whole durability trick: if the machine stops between the two steps, the log entry survives and the index can be rebuilt to include it. The order of the two steps is therefore a correctness rule, not a performance preference.
A restart rebuilds the index from the log
log = [("name", "Ana"), ("city", "Oslo"), ("name", "Ana Lee")]
index = {}
for position, (key, value) in enumerate(log):
index[key] = position
for key in sorted(index):
print(key, "at position", index[key])
print("get name ->", log[index["name"]][1])
print("get city ->", log[index["city"]][1])The log survives a restart while the in-memory index is lost, and reading the log in order rebuilds the same index. Each later entry for a key replaces the earlier position during the rebuild, so the rebuilt index points at the latest value for every key. Restart cost grows with log length, which is the pressure that makes compaction necessary rather than optional.
Compaction keeps only the latest entry per key
log = [("name", "Ana"), ("city", "Oslo"), ("name", "Ana Lee"), ("job", "cook"), ("city", "Bergen"), ("name", "Ana Lee Fox")]
latest = {}
for key, value in log:
latest[key] = value
compacted = list(latest.items())
print("log entries before:", len(log))
print("log entries after:", len(compacted))
print("compacted log:", compacted)Repeated puts under one key leave older entries behind in the log, and log compaction is the rewrite of the log that keeps only the latest entry for each key. The model log shrinks from six entries to three, one per surviving key, and every get returns the same answer before and after the rewrite. Compaction spends work rewriting the log in exchange for a shorter log, faster restarts and less disk held by values nobody can still read.
A Key-Value Store on One Machine
log = [("name", "Ana"), ("city", "Oslo"), ("name", "Ana Lee"), ("job", "cook"), ("city", "Bergen"), ("name", "Ana Lee Fox"), ("job", "baker"), ("city", "Oslo")]
index = {}
for position, (key, value) in enumerate(log):
index[key] = position
print("entries in the log:", len(log))
print("get name after restart ->", log[index["name"]][1])
latest = {}
for key, value in log:
latest[key] = value
print("entries after compaction:", len(latest))
for key in sorted(latest):
print(key, "->", latest[key])The closing model runs a longer log through both steps in sequence: a rebuild after a restart, then a compaction. Eight entries rebuild into an index that still answers every key with its latest value, and compaction then reduces the log to three entries without changing any answer. The pair of steps is the complete one-machine story โ append for durability, index for speed, compact to stay short โ and the multi-server design keeps that story inside each server while adding placement and copies around it.
High-level architecture
A client request first meets a coordinator, the component that works out which range a key belongs to and which servers hold copies of that range. The coordinator consults the ring, the shared map of which server owns which slice of the key space, and then talks to the copy servers for that range. Each copy server runs the one-machine store internally, with its own write-ahead log and index, so durability on one server and durability across servers are separate mechanisms that compose.
โโโโโโโโโโโโ put / get / delete โโโโโโโโโโโโโโโโโ
โ Client โ โโโโโโโโโโโโโโโโโโโโโโถ โ Coordinator โ
โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
value or absence ring lookup โ
โผ
โโโโโโโโโโโโโโโโโ
โ Ring โ
โ key -> owners โ
โโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโ
โผ โผ โผ
โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ
โ Server A โ โ Server B โ โ Server C โ
โ log+index โ โ log+index โ โ log+index โ
โ copy of โ โ copy of โ โ copy of โ
โ the range โ โ the range โ โ the range โ
โโโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโThe write flow
- The client sends a put with the key and the value to a coordinator
- The coordinator reads the ring and finds the servers holding copies of the key's range
- Each reachable copy server appends the entry to its write-ahead log, updates its index and raises the version
- Once W = 2 copies have confirmed, the write counts as done and the coordinator answers the client
- A copy that was down receives the missed write later, during repair, and holds a stale version until then
The read flow
- The client sends a get with the exact key and the freshness it needs
- The coordinator finds the copy servers for the key's range from the ring
- A strong read asks R = 2 copies and returns the value carrying the highest version
- An eventual read takes the first copy that answers and returns its value, which may be older
- A key held by no copy returns a plain absence to the client
Every functional requirement lands on a component in the diagram. Put, get and delete land on the coordinator and the copy servers, durability lands on the write-ahead log inside each server, freshness choice lands on how many copies the coordinator asks, and survival of one failure lands on copies held by separate servers. No requirement depends on a component the diagram leaves out, and the ring is the single shared fact every coordinator must agree on for routing to stay consistent.
Deep dive: splitting the store with consistent hashing
One machine cannot carry 50,000 operations per second with copies and durability priced in, so the key space must split across servers. The placement rule decides which server owns which keys, and a bad rule punishes the store twice: uneven shares overload some servers, and clumsy re-placement moves enormous numbers of keys whenever a server joins or leaves. Consistent hashing is the placing of keys and servers on one ring, where a key belongs to the next server clockwise.
Three ways to place keys
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| Hash modulo server count | Hash the key and take the remainder modulo the number of servers | Simple to explain and evenly spread while the server count is fixed | Almost every key changes owner when a server joins or leaves, so growth triggers a near-total move |
| Fixed ranges by key prefix | Assign key ranges to servers by the start of the key | Ranges are easy to reason about and to list | Popular prefixes overload one server, and ranges must be split by hand as data grows |
| Consistent hashing | Place servers and keys on one ring; a key belongs to the next server clockwise | Only the keys of a joining or leaving server move; shares even out with virtual nodes | The ring must be shared and kept consistent across coordinators |
Keys belong to the next server clockwise
servers = [(10, "server A"), (40, "server B"), (70, "server C")]
keys = [("key-1", 5), ("key-2", 15), ("key-3", 35), ("key-4", 50), ("key-5", 65), ("key-6", 85)]
def owner(position):
for server_position, name in servers:
if position <= server_position:
return name
return servers[0][1]
counts = {}
for key, position in keys:
name = owner(position)
counts[name] = counts.get(name, 0) + 1
print(key, "at", position, "->", name)
for name in sorted(counts):
print(name, "owns", counts[name], "keys")Servers and keys sit at fixed positions on the ring, and each key belongs to the first server met clockwise from its position, wrapping to the start after the last server. That first server is the owner of the key, the server responsible for holding the key's range before copies are considered. The small ring spreads its keys evenly by construction of the positions, and the ownership rule needs nothing beyond the ordered positions to answer any key.
Only the leaving server's keys move
servers = [(10, "server A"), (40, "server B"), (70, "server C")]
keys = [("key-1", 5), ("key-2", 15), ("key-3", 35), ("key-4", 50), ("key-5", 65), ("key-6", 85)]
def owner(position, ring):
for server_position, name in ring:
if position <= server_position:
return name
return ring[0][1]
before = {key: owner(position, servers) for key, position in keys}
remaining = [entry for entry in servers if entry[1] != "server C"]
moved = []
stayed = []
for key, position in keys:
if owner(position, remaining) != before[key]:
moved.append(key)
else:
stayed.append(key)
print("moved keys:", moved)
print("moved:", len(moved))
print("stayed:", len(stayed))When one server leaves the ring, only that server's keys pass to the next server clockwise, and every other key keeps the same owner. The model shows two keys moving and four keys staying when the third server leaves. That locality is the property the whole design is bought for: a fleet change touches one share of the keys instead of reshuffling the entire store, so growth and failure recovery stay proportional to one server's load.
Virtual nodes spread one server around the ring
ring = [(5, "server A"), (20, "server B"), (35, "server C"), (50, "server A"), (65, "server B"), (80, "server C"), (88, "server A"), (93, "server B"), (98, "server C")]
keys = [("key-1", 3), ("key-2", 18), ("key-3", 33), ("key-4", 48), ("key-5", 63), ("key-6", 78)]
def owner(position):
for node_position, name in ring:
if position <= node_position:
return name
return ring[0][1]
counts = {}
for key, position in keys:
name = owner(position)
counts[name] = counts.get(name, 0) + 1
print(key, "at", position, "->", name)
for name in sorted(counts):
print(name, "owns", counts[name], "keys")A single position per server leaves balance to luck, because one large gap between neighbouring positions hands one server a large arc of the ring. A virtual node is one of many positions a single server places on the ring, so its keys spread around the circle in many small slices instead of one arc. The small model uses three virtual nodes per server and says so, and the design demo ring uses 100 virtual nodes per server, stated as a demo value, which smooths the shares far more finely.
Splitting the Store: Consistent Hashing
ring = [(10, "server A"), (25, "server B"), (40, "server C"), (55, "server A"), (70, "server B"), (85, "server C"), (100, "server A"), (115, "server B"), (125, "server C")]
keys = [("key-1", 5), ("key-2", 20), ("key-3", 35), ("key-4", 50), ("key-5", 65), ("key-6", 80), ("key-7", 95), ("key-8", 110), ("key-9", 120)]
def owner(position, nodes):
for node_position, name in nodes:
if position <= node_position:
return name
return nodes[0][1]
before = {key: owner(position, ring) for key, position in keys}
remaining = [entry for entry in ring if entry[1] != "server B"]
moved = []
stayed = []
for key, position in keys:
if owner(position, remaining) != before[key]:
moved.append(key)
else:
stayed.append(key)
print("moved keys:", moved)
print("moved:", len(moved))
print("stayed:", len(stayed))The closing model places virtual nodes for three servers around a larger ring and then removes one server entirely. Only the removed server's keys move to a new owner, and the remaining keys stay with the server that already held them. Virtual nodes change the balance of the shares without changing the movement rule, which is why the two ideas pair so often: the ring limits movement, and the many positions even out the load.
The verdict is consistent hashing with virtual nodes. Modulo placement is rejected because a single fleet change would move almost every key, turning routine growth into a store-wide migration. Fixed ranges are rejected because popular key prefixes concentrate load in a way the operator must repair by hand. The cost of the verdict is the ring itself: every coordinator must hold the same ring, hash values in the demos are written as plain data rather than worked out inside the programs, and a stale ring routes requests to the wrong owner until it refreshes.
Deep dive: copies and quorums
Partitioning decides where a key lives, and replication decides how many servers hold it. A copy is one stored replica of a range of keys, kept on its own server, so the copies of one range never share a single point of failure. The design keeps N = 3 copies for each range, and the quorum rule decides how many of those copies a write or a read must reach before it counts.
Three ways to keep data safe
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| One copy per range | Each range lives on exactly one server | Cheapest storage and the simplest writes | One failed server loses its ranges until it returns, and its keys stop being served |
| Copies with every-copy writes | Every write must reach all N copies before it counts | Every copy is always current, so any copy can answer any read | One down copy blocks every write to its range, which trades availability away |
| Copies with quorums | A write counts at W confirmations and a read asks R copies, with W + R > N | One copy may be down while writes and reads still count, and reads still meet the latest write | Copies can be stale between writes, so version checks decide which answer is newest |
A write lands on every copy with a rising version
copies = [{"value": None, "version": 0}, {"value": None, "version": 0}, {"value": None, "version": 0}]
def put(value):
for copy in copies:
copy["value"] = value
copy["version"] += 1
put("Ana")
put("Ana Lee")
for i, copy in enumerate(copies):
print("copy", i + 1, "holds", copy["value"], "at version", copy["version"])Each put writes all reachable copies and raises the version counter, and a version is a plain counter on each value that rises with every write, so the newest copy can be told from a stale one. After two puts, every copy holds the same value at the same version, which is the calm case the quorum rules are designed around. Versions matter precisely because the calm case is not guaranteed, and the next models show the disturbed cases.
A write counts at W confirmations, with one copy down
alive = [True, True, False]
copies = [{"value": "Ana", "version": 1}, {"value": "Ana", "version": 1}, {"value": "Ana", "version": 1}]
confirmations = 0
for i in range(3):
if alive[i]:
copies[i]["value"] = "Ana Lee"
copies[i]["version"] += 1
confirmations += 1
print("confirmations:", confirmations)
print("write counts as done:", confirmations >= 2)
for i, copy in enumerate(copies):
print("copy", i + 1, "holds", copy["value"], "at version", copy["version"])One copy is down and never confirms the write, yet the write still counts as done because the other copies reach W = 2. The down copy keeps the older value at the older version, and that staleness is recorded honestly by the version numbers rather than hidden. A write that reaches fewer than W copies does not count as done, even if one copy holds it, so a single surviving copy can never promote a write on its own authority.
A read asking R copies takes the newest version
copies = [("copy A", "Ana Lee", 2), ("copy B", "Ana", 1), ("copy C", "Ana Lee", 2)]
asked = copies[:2]
best = asked[0]
for entry in asked:
print(entry[0], "reports version", entry[2])
if entry[2] > best[2]:
best = entry
print("read returns:", best[1], "at version", best[2])One copy is stale and still holds the older version, and the read asks two copies and takes the answer with the highest version. The overlap rule is what makes that safe: W + R is 4, and 4 is more than N at 3, so the copies the read asks must share at least one copy with the copies the finished write reached. That shared copy carries the newest version into the comparison, and the version comparison does the rest.
Copies and Quorums
copies = [{"value": "Ana", "version": 1}, {"value": "Ana", "version": 1}, {"value": "Ana", "version": 1}]
alive = [True, True, False]
confirmations = 0
for i in range(3):
if alive[i]:
copies[i]["value"] = "Ana Lee"
copies[i]["version"] += 1
confirmations += 1
print("write confirmations:", confirmations)
print("write counts as done:", confirmations >= 2)
asked = [copies[1], copies[2]]
best = asked[0]
for copy in asked:
if copy["version"] > best["version"]:
best = copy
print("read returns:", best["value"], "at version", best["version"])
print("W + R > N:", 2 + 2 > 3)The closing model disturbs the write and the read at once: one copy is down during the write and stale during the read, yet the write still counts as done and the read still returns the newest value. The two copies the read asks overlap the copies the write reached, which is the overlap rule operating exactly as the arithmetic promised. Nothing in the outcome depends on luck or timing, because the guarantee is arithmetic rather than hopeful.
The verdict is N = 3 copies with W = 2 and R = 2. One copy per range is rejected because a single failure would silence whole ranges of keys. Every-copy writes are rejected because one down copy would block writes entirely, which wastes the very redundancy the copies were bought for. The cost of the verdict is real: storage triples for the same keys, writes wait for a second confirmation, and stale copies exist as a normal state that every read path must handle through versions.
Deep dive: choosing consistency per request
Copies create a question that one machine never faces: when copies disagree for a short time, which answer may a read return. Strong consistency means a read returns the latest finished write, even when the read must ask more copies to be sure. Eventual consistency means a read may return an older value for a short time, and the copies agree later. The design refuses to pick one answer for the whole store, and instead lets each request pick, so one key can serve both kinds of read.
Three consistency postures
| Option | How it works | Strengths | Weaknesses |
|---|---|---|---|
| Strong for every read | Every read asks enough copies to guarantee the newest version | No caller ever sees a stale value | Every read pays the multi-copy cost, including reads that would happily take a fast answer |
| Eventual for every read | Every read takes the first copy that answers | Reads are as fast and cheap as the fleet allows | Callers that need the newest value have no way to ask for it |
| Per-request choice | Each request marks itself strong or eventual, and the coordinator asks copies accordingly | Each caller pays only for the freshness it actually needs | Callers must understand the choice, and a wrong choice surfaces as a stale answer |
One key, two kinds of read
copies = [("copy A", "red", 2), ("copy B", "blue", 3)]
print("eventual read asks copy A ->", copies[0][1])
best = copies[0]
for entry in copies:
if entry[2] > best[2]:
best = entry
print("strong read asks every copy ->", best[1])An eventual read asks the first copy and may get the older value, while a strong read asks every copy and takes the newest version. The same key at the same moment returns two different answers to two different requests, and both answers are correct under the promise each request chose. The difference is not hidden inside the store, because the request itself carries the choice and the response honours exactly that choice.
Last-write-wins keeps the later time stamp, not the last arrival
arrivals = [(5, "red"), (9, "blue"), (7, "green")]
kept = arrivals[0]
for stamp, value in arrivals:
print("arrived: time", stamp, "value", value)
if stamp > kept[0]:
kept = (stamp, value)
print("kept:", kept[1], "from time", kept[0])Copies that were written at different moments need a rule for resolving two conflicting writes, and last-write-wins is a conflict rule that keeps the write with the later time stamp and drops the other. Several writes for one key arrive out of order in the model, and the kept value is the one with the highest time stamp rather than the one that arrived last. Arrival order is deliberately ignored, because arrivals reflect network timing rather than the order in which the writes were made.
A slow clock can erase a newer write
arrivals = [(10, "old address"), (8, "new address")]
kept = arrivals[0]
for stamp, value in arrivals:
print("arrived: time", stamp, "value", value)
if stamp > kept[0]:
kept = (stamp, value)
print("kept:", kept[1])
print("newer write kept:", kept[1] == "new address")The honest weakness of the rule is clock risk: server clocks can run slightly fast or slow, so the later time stamp is not always the truly newer write. A newer write carries the smaller time stamp in the model because its server clock runs slow, and last-write-wins keeps the older write while the newer write is lost. No amount of care in applying the rule repairs a wrong time stamp, which is why the risk belongs in the open rather than in a footnote.
Key-Value Store: Choosing Consistency
arrivals = [(12, "Ana"), (15, "Ana Lee"), (13, "A. Lee")]
kept = arrivals[0]
for stamp, value in arrivals:
print("arrived: time", stamp, "value", value)
if stamp > kept[0]:
kept = (stamp, value)
print("kept:", kept[1], "from time", kept[0])The closing model runs three writes for one profile name through the same rule, arriving out of order, and the write with the highest time stamp is kept. The kept write did not arrive last, and the rule does not care about arrival order at any point in the comparison. The example and the clock-risk model are the same mechanism seen from two sides: the rule is simple and predictable, and its single assumption is that time stamps deserve trust.
The verdict is a per-request choice with last-write-wins resolving conflicts. Strong everywhere is rejected because it taxes every read for a guarantee only some callers need, and eventual everywhere is rejected because it offers no path at all to a caller holding a correctness requirement. The costs are named plainly: callers carry the burden of choosing, and a slow clock can erase a newer write under the conflict rule. Vector clocks are a stronger tool for tracking which write came first, and a later course covers them.
Bottlenecks and failure modes
The first saturation point is not the fleet as a whole but a single hot range or a single hot key. Load spreads evenly only while requests spread evenly, and real traffic concentrates: one popular key can draw a large share of all requests while neighbouring keys sit idle. Storage growth is the slow pressure behind that sharp one, because the log on each server grows with every write and copies multiply whatever the fleet stores.
One failed copy is tolerated, two are not
for alive in [3, 2, 1]:
print("copies up:", alive, "write counts:", alive >= 2, "read counts:", alive >= 2)The quorum tolerates exactly one failed copy in this design, and the model prints the boundary without decoration. With two copies up, writes and reads still count, and with only one copy up, neither counts. That boundary is a chosen property rather than an accident: raising the tolerance would mean more copies or lower quorums, and each of those moves costs storage or freshness somewhere else.
A hot key stands out in the counts
requests = ["key-7", "key-7", "key-1", "key-7", "key-2", "key-7", "key-7", "key-3", "key-7", "key-7"]
counts = {}
for key in requests:
counts[key] = counts.get(key, 0) + 1
for key in sorted(counts):
print(key, "asked", counts[key], "times")
print("total requests:", len(requests))A hot key is a key asked for far more than the others, the familiar hot spot in a new shape. In the model run, most of the requests ask for one key, and that key's server takes most of the load while the other servers sit nearly idle. Consistent hashing cannot fix a hot key, because the key hashes to one place by design, and every request for it follows the same route to the same owner and its copies.
- One copy server fails: its ranges stay served by the remaining copies, writes and reads still count at quorum, and the failed server's copies are stale until repaired
- Two copy servers for one range fail: that range stops counting writes and reads, while every other range keeps working, so the failure stays local to the affected keys
- A coordinator holds a stale ring: requests route to a former owner, which forwards or refuses them until the ring refreshes, and no data is lost because the copies themselves are untouched
- The write-ahead log grows unchecked: restarts slow down and disk fills with superseded entries, until compaction rewrites the log and restores the short form
- A hot key saturates its owner: reads for that key queue behind each other while the rest of the fleet idles, and only spreading that key's reads relieves it
The degradation order is deliberate throughout. Freshness degrades before availability when a read falls back to a faster answer, and availability of one range degrades before the store as a whole notices anything. What must never degrade is the durability of a finished write: a write counted as done at W = 2 copies survives the loss of any one of them, and a write that cannot reach its quorum reports failure instead of quietly weakening the promise. How replicas agree on a leader is a deeper topic, covered in the Advanced course, and the failure behaviour described here assumes that machinery rather than replacing it.
Trade-offs
Every earlier choice bought something by giving something up, and the summary collects those exchanges in one place. The table carried over from the source design names the recurring four: copies, strong reads, eventual reads and compaction. The paragraphs after the table add the placement and conflict choices, so no verdict given in the deep dives is missing from the accounting.
Key-value store trade-offs
| Choice | Gain | Cost |
|---|---|---|
| More copies | Reads survive more failures | More storage and slower writes |
| Strong reads | The newest value comes back | More copies asked on each read |
| Eventual reads | A fast answer from one copy | An older value may come back |
| Compaction | A shorter log | Work spent rewriting the log |
More copies cost more storage for the same keys
keys = ["name", "city", "job", "cart"]
for copies in [1, 3]:
print("copies:", copies, "stored entries:", len(keys) * copies)The storage cost of copies is arithmetic rather than opinion: the same keys stored with one copy occupy a quarter of the entries they occupy with three copies in the model, and the ratio holds at any scale. Survival and storage are the same dial turned in opposite directions, which is why N is a stated design choice rather than a default nobody chose. A store that cannot afford the tripled storage must say openly that it accepts weaker survival, instead of discovering the fact during an outage.
The decisions and their costs
| Decision | Chosen | Given up |
|---|---|---|
| Placement | Consistent hashing with virtual nodes | The simplicity of modulo placement; the ring must stay consistent |
| Replication | N = 3 with W = 2 and R = 2 | Single-copy storage cost; writes wait for a second confirmation |
| Consistency | Per-request choice between strong and eventual | One uniform freshness promise for every caller |
| Conflict rule | Last-write-wins by time stamp | Safety when a server clock runs slow |
| Hot key relief | Spread reads across copies and cache the value | Extra cache machinery for a small number of keys |
Key-Value Store: Trade-Offs and Wrap-Up
requests = ["song-1", "song-1", "song-2", "song-1", "song-3", "song-1", "song-1", "song-1"]
counts = {}
for key in requests:
counts[key] = counts.get(key, 0) + 1
for key in sorted(counts):
print(key, "asked", counts[key], "times")
alive = 2
print("total requests:", len(requests))
print("copies up:", alive, "write counts:", alive >= 2, "read counts:", alive >= 2)The closing model combines the two live pressures in one small run: most requests chase one hot key while one of the three copies is down. The store still counts its writes and reads, because two copies remain up and the quorum is still met. The hot key remains the load problem to fix, and the sound first move is to spread its reads across its copies and cache its value, so one server stops taking every request for it.
Taken together, the design spends storage and write confirmations to buy survival and honest freshness at 50,000 operations per second. Placement keeps fleet changes cheap, quorums keep one failure ordinary, and the per-request choice keeps fast reads fast without lying to careful ones. A smaller store with gentler load should give some of that back โ fewer copies, simpler placement โ because the right shape follows the stated load rather than habit.
Common questions
How does a key-value store find the right server for a key?
Placement runs through consistent hashing on a ring shared by the coordinators. The key and the servers sit at positions on the ring, and the key belongs to the first server met clockwise from its position, with virtual nodes spreading each server across many positions. The demo ring uses 100 virtual nodes per server, stated as a demo value, and only the keys of a joining or leaving server change owner.
Why do W and R add up to more than N?
The overlap is the guarantee, stated in one sum: with N = 3, W = 2 and R = 2, the value of W + R is 4, which is more than 3. Any group of 2 copies a write reached and any group of 2 copies a read asks must share at least one copy out of the 3. That shared copy carries the latest finished version into the read, and the read returns the highest version it is shown.
What is a write-ahead log for?
The write-ahead log is the durable record that makes one server trustworthy before copies across servers are considered. Each write is appended to the log first, in order, and only then does the in-memory index change to point at the new entry. A restart rebuilds the index by reading the log from start to end, and compaction rewrites the log keeping only the latest entry per key, so restarts stay fast and the log stays short.
What should be done about a hot key?
A hot key is relieved by spreading its reads rather than by moving the key, because its copies already sit on separate servers. Reads for the hot key are shared across its 3 copies and its value is cached, so no single server takes every request for it. The quorum settings stay untouched, since raising W would send even more traffic to the same copies instead of spreading the load.
Related designs: the URL shortener stores its codes in a store of this shape, the rate limiter keeps its counters in one, and the chat system, the news feed, the autocomplete service, the notification system and the social app each lean on key-value storage for their hottest lookups.
The estimate at a glance
| Measure | Value |
|---|---|
| Operations per second | 50,000 |
| Operations per day | 4,320,000,000 |
| Copies per range (N) | 3 |
| Write quorum (W) | 2 |
| Read quorum (R) | 2 |
| W + R | 4 |
| Virtual nodes per server (demo) | 100 |
Exercise
What is a sound first move for a hot key?