Design a Distributed Cache
Problem Statement#
"Design a distributed cache like Redis or Memcached. Clients store and read key-value pairs, entries can expire, and when a node runs out of memory it evicts whatever was used least recently."
This one is unusual. Half of it is a data structures question you could answer on a whiteboard in ten minutes, and half of it is a genuinely hard distributed systems question. Interviewers ask it very differently depending on what they want to see.
The two halves pull in opposite directions, and that is the whole point of the question. On one node, a cache is a hash map and a linked list. Across fifty nodes it is a routing problem, a failure detection problem, and a "what happens when everyone wants the same key" problem.
In this design you'll learn:
- How to get
get,setand eviction all running in O(1) on one node - How expiry actually works, and why scanning every key is not an option
- Why production caches deliberately do not implement exact LRU
- How consistent hashing spreads keys across nodes, and why plain
hash(key) % Ncan take down your database - What to do about a single key that ten thousand clients want at the same time
One framing to hold on to throughout: a cache miss is survivable, so almost every trade-off here should be resolved in favour of speed and availability.
Requirements Gathering#
Ask enough to find out which half of this problem the interviewer cares about. The answers about scale, in particular, change everything after the first ten minutes.
Candidate: "Is this a cache service that other teams call over the network, or an in-process library?"
Interviewer: "A service. Clients connect to it over the network."
Candidate: "Just plain key-value, or do we need richer types like Redis lists and sorted sets?"
Interviewer: "Plain key-value is fine. get, set, delete."
Candidate: "Can entries expire on their own?"
Interviewer: "Yes, an optional TTL per key."
Candidate: "And when a node fills up?"
Interviewer: "Evict the least recently used entries. LRU."
Candidate: "What scale are we sizing for? Total data and peak request rate."
Interviewer: "Up to 1 TB of data and 100,000 requests per second at peak."
Candidate: "What's the latency target? A cache that is not fast is not worth having."
Interviewer: "Single digit milliseconds. Call it p99 under 10ms for both get and set."
Candidate: "If a replica is a few hundred milliseconds behind and a client reads a stale value, is that a problem?"
Interviewer: "No. Availability over consistency. A stale or missing value just means the caller goes to the origin."
Candidate: "Does the data need to survive a restart?"
Interviewer: "No. Treat it as pure cache. Durability is out of scope."
Candidate: "How big are values, typically?"
Interviewer: "Small. Mostly a few hundred bytes, occasionally a few KB."
Functional and Non-Functional Requirements#
Functional Requirements#
set(key, value)with an optional TTLget(key)returns the value, or nothing if it is missing or expireddelete(key)removes an entry- An entry past its TTL is never returned, and eventually stops using memory
- When a node reaches its memory limit, it evicts the least recently used entries
Non-Functional Requirements#
| Requirement | Target |
|---|---|
| Latency | p99 under 10ms for get and set |
| Consistency | AP. Stale reads and lost writes on failover are acceptable |
| Scale | 1 TB of data, 100,000 requests/sec at peak |
| Memory | A node must never exceed its configured limit. Eviction is mandatory, not best effort |
| Availability | Losing a node degrades hit rate, it does not take the cache down |
Out of scope: durability across restarts, transactions, strong consistency, range queries or secondary indexes, rich data types.
Capacity Estimates#
There are two independent sizing questions here. Work out both and take the larger answer.
One number people miss, and it is worth saying out loud because it is where real deployments get caught:
Small values are expensive. Size the cluster on entry count, not just on payload bytes, and say so. An interviewer who has actually run a cache in production will notice.
High-Level Design#
Build the single node first. The distributed part only makes sense once you know what is being distributed.
Version 1: a hash map#
A cache is a hash map. Every language ships one, and it gives O(1) lookup and insert.
That satisfies requirement one, and nothing else. It grows without bound and never forgets anything.
Version 2: add expiry#
Store a deadline next to each value instead of a bare value. On read, check the deadline before returning.
This is correct but leaky. An expired key that nobody reads again sits in memory forever, because the only thing that removes it is a read that never comes. Deep dive 1 fixes that.
Version 3: add LRU eviction#
Now the interesting bit. Eviction needs two things at once: find any entry instantly, and know which entry was used longest ago. A hash map gives the first and not the second. A list gives the second and not the first. So use both, pointing at the same objects.
The hash map maps a key to its list node. The list is ordered by recency: whatever is just behind the head was touched most recently, whatever sits just before the tail is the eviction candidate.
- On get: look the key up in the map, then unlink its node and splice it in behind the head. Both O(1), because a doubly linked list node knows its own neighbours.
- On set: create the node, put it in the map, push it behind the head. If memory is now over the limit, unlink nodes from in front of the tail until it fits, deleting each from the map as you go.
- On delete: unlink and drop from the map.
Every operation, eviction included, is O(1). Use sentinel head and tail nodes so you never write a special case for an empty list or a single element.
Then: 1 TB does not fit on one machine#
Fifty nodes, each running the structure above over its own slice of the keyspace.
The client hashes the key, walks the ring to find the owning node, and talks to it directly. One network hop, no proxy tier in the path. Each shard streams its writes to a replica asynchronously. A small config service (or gossip between the nodes) tells clients what the ring looks like and who is currently primary for each shard.
Everything from here is a deep dive into one of those arrows.
Deep Dives#
1. Expiry without scanning everything#
Checking the deadline on read is necessary but not sufficient. Keys that expire and are never read again never get cleaned up, and a workload with lots of short-lived keys will slowly fill the node with garbage.
The obvious fix is a background job that walks every entry and deletes the expired ones. Do not do this. At 5 billion entries, a full scan is minutes of CPU, and on a single-threaded server it blocks every client for the duration.
Use probabilistic sampling instead, which is what Redis does:
The logic is neat. If a large fraction of your sampled keys are expired, there is probably a lot of garbage, so keep going. If most samples come back alive, the keyspace is clean and you stop. Work per cycle is bounded, so it never blocks, and the expired fraction stays statistically low without anyone ever scanning the whole keyspace.
Note the two mechanisms cover different gaps. Lazy expiry guarantees correctness (an expired key is never returned, even a microsecond after it expires). Sampling guarantees memory is reclaimed for keys nobody reads. You need both, and saying that explicitly is the difference between a good and an average answer here.
For senior interviews ↓ why not a heap of deadlines, and the clock skew trap
Why not keep a min-heap ordered by expiry time? It gives exact, immediate cleanup in O(log n). The cost lands on the write path: every set with a TTL is a heap insert, every overwrite is a heap update, and at 100,000 writes a second that is real CPU plus a data structure that has to be locked alongside the map and the list. Sampling pushes the cost onto a background loop that can be throttled, and "mostly clean, eventually" is all a cache needs. Different trade, and for a cache the sampling side wins.
Store absolute deadlines, never remaining time. This one bites people in replication. If the primary ships "this key has 300 seconds left" to a replica, and the replication stream is 2 seconds behind, the replica's copy now lives 2 seconds longer than it should. Every hop extends the TTL. Ship expires_at as an absolute epoch timestamp computed once by the node that accepted the write, and the deadline means the same thing everywhere.
That only works if the clocks agree, so keep NTP tight across the fleet. A node whose clock runs 30 seconds fast will serve misses for keys that are still perfectly valid, which looks exactly like a mysterious hit-rate drop on one node.
2. Exact LRU is correct, and real caches avoid it#
The hash map plus linked list gives textbook LRU, and it is what the interviewer wants to see you derive. Then comes the follow-up worth preparing for: what happens to that design on a 16-core machine serving 100,000 requests a second?
The problem is that a read mutates the list. Every get splices a node to the front, which means every read takes a write lock on a structure shared by every core. Your gets are logically read-only and physically not, so they serialise. The linked list becomes the bottleneck long before memory or network do.
Production caches all back away from exact LRU in some form:
Perfectly accurate recency, O(1) everywhere, and easy to reason about. The right thing to design on the whiteboard, and correct at moderate request rates or with per-shard locking that keeps contention local.
The ceiling is the list lock. Once reads contend on it, accuracy is costing you throughput.
Store a last-accessed timestamp on each entry. A read just writes that field, no shared structure touched. When you need to evict, sample a handful of random keys and drop the oldest of them.
This is what Redis does. It is approximate (you might evict the second-least-recently-used instead of the actual one) and for a cache that is completely fine. Sample size is a dial: more samples means closer to true LRU and more CPU per eviction.
Keep the list, but only move an entry to the front if it has not already been moved in the last N seconds. Memcached does roughly this with a 60 second window.
A key read a thousand times a second gets spliced once a minute instead of a thousand times a second, which removes almost all the churn while keeping ordering good enough. Less clean to explain than sampling, but it preserves a real ordered list if you want one.
Decision: design the exact version, then name the ceiling yourself. "I'd implement hash map plus doubly linked list, and I'd expect the list lock to show up in a profile before anything else does. At that point I'd switch to sampled eviction with an access timestamp, which is what Redis does and which trades a bit of accuracy for removing the shared write on the read path." Saying that unprompted is a strong signal.
For senior interviews ↓ when LRU is the wrong policy entirely
LRU is defeated by a scan. One analytics job reads a million cold keys, each one lands at the head of the list, and your entire hot working set gets pushed out the back. Hit rate falls off a cliff and the origin database takes the difference. LRU treats "touched once, five seconds ago" as more valuable than "touched ten thousand times over the last hour", which is exactly backwards here.
LFU counts accesses instead of recency, which makes it immune to that scan. Its own weakness is the mirror image: something popular last week keeps a high count and squats in memory long after anyone wants it. Fix that with periodic decay of the counters, which is what Redis's LFU mode does.
W-TinyLFU is what you would actually reach for now. It keeps a compact frequency sketch (count-min, a few bits per counter, so it can track far more keys than it stores) and uses it as an admission filter: when a new item arrives and something must go, it only admits the newcomer if the sketch says it is accessed more often than the current eviction candidate. A scan of cold keys never gets admitted in the first place, so the working set survives. Caffeine uses this and measurably beats LRU on real traces.
Worth knowing the shape of all three. In the interview, pick LRU because the requirements said LRU, and mention that you would want the policy to be pluggable.
3. Sharding: why hash(key) % N is dangerous#
Fifty nodes, so the routing question is which node owns a given key. The naive answer is hash(key) % 50.
That works until the node count changes. Go from 50 to 51 and nearly every key maps somewhere new. In a database that would be a painful migration. In a cache it is worse than painful, and it is worth being precise about why:
Every relocated key is an instant cache miss, and all those misses hit your origin at the same moment. Your database is sized for the ~5% of traffic that misses today. Resize the cluster with modulo hashing and it briefly gets close to 100% of 100,000 requests a second. Adding capacity to your cache takes down your database. That is the real argument, and it lands much harder than "we would have to move some keys."
Consistent hashing avoids it. Hash node identifiers onto a fixed ring (say 0 to 2^32), hash each key onto the same ring, and a key belongs to the first node you meet walking clockwise. Add a node and it takes over only the arc between itself and its predecessor, so roughly 1/N of keys move and everything else stays put.
Virtual nodes are not optional. With one position per node, fifty random points on a ring produce badly uneven arcs, and you end up with nodes holding two or three times their fair share. Give each physical node 150 to 200 positions on the ring and the law of large numbers smooths it out. Virtual nodes also fix removal: without them, a dying node dumps its entire keyspace onto exactly one successor, which promptly falls over too. With them, its load spreads across every remaining node.
Where does the ring live?
- Client-side. The client library holds the ring and connects to the owning node directly. One hop, lowest latency, no extra tier to run. The cost is that ring updates have to reach every client, and clients get more complex. Redis Cluster works this way, with nodes returning a redirect if a client's view is stale.
- Proxy tier. Clients talk to a proxy that owns the routing. Dumb clients, topology changes in one place, but every request pays an extra network hop and the proxy tier is now something else to scale and keep alive.
Decision: client-side routing. With a 10ms p99 budget, spending a hop on a lookup you can do locally in microseconds is hard to justify, and the ring is small enough to push to clients cheaply.
For senior interviews ↓ adding a node is still a small stampede
Consistent hashing reduces the blast radius from "everything" to "1/N of keys", but 1/N of 100,000 requests a second is still 2,000 requests a second of sudden miss traffic. At 50 nodes that is usually absorbable. At 5 nodes it is 20,000, and that can hurt.
Three ways to soften it, in increasing order of effort. Ramp traffic to the new node over a few minutes so misses trickle rather than arrive at once. Warm it first by replaying a sample of recent keys into it before it starts serving. Or move the keys: have the predecessor hand off the arc's contents directly, so the new node starts warm and the origin never sees the misses at all. Redis Cluster's slot migration does the last one.
Also: consistent hashing only balances well if the hash function scatters well. Use MurmurHash or xxHash. A weak hash, or hashing a key format with a lot of shared structure, clusters keys onto a few arcs and no amount of virtual nodes will save you.
4. Replication, failover, and whether you need it at all#
A node dies. Its share of the keyspace vanishes. What happens next depends on choices you make now.
Synchronous replication means the primary waits for the replica to acknowledge before it acks the client. No writes are lost on failover, but every write pays an extra network round trip, and a slow replica makes every write slow. For a store whose entire purpose is being fast, and whose data is by definition reconstructible, that is the wrong trade.
Asynchronous replication means the primary acks immediately and ships the write to the replica in the background. Writes stay fast. A primary that crashes loses the last few milliseconds of writes, which for a cache means a handful of extra misses. Nobody notices.
Decision: async, one replica per shard, placed in a different availability zone so a zone failure does not take both copies.
Failover needs more than a replica. Three pieces:
- Failure detection. Nodes gossip heartbeats, or a coordinator polls. Either way you need a timeout that is long enough not to trip on a GC pause and short enough to matter.
- Agreement. One node thinking another is dead is not enough, it may be the one with the broken network link. Require a quorum of nodes to agree before promoting a replica.
- Fencing. Every promotion bumps an epoch number for that shard. The old primary, if it comes back, sees a higher epoch and refuses to serve. Without this, a node that was merely unreachable rejoins and starts answering for a shard it no longer owns, and clients get different answers for the same key depending on who they ask.
For senior interviews ↓ the case for skipping replication entirely
"Replicate everything" is the reflex answer, and it is worth interrogating, because on 1 TB it doubles your memory bill.
Losing one shard out of fifty means 2% of keys start missing. Those requests fall through to the origin, which serves them and repopulates the cache. If your origin can absorb 2% of peak traffic arriving at once, the cluster self-heals in seconds and the replica bought you nothing but cost.
So the real question is not "is replication best practice", it is "can my origin survive losing a shard?" Two things push the answer to no. If the origin is a database sized on the assumption of a high cache hit rate, 2,000 extra queries a second arriving instantly can tip it over, and then you are in a cascading failure where the cache cannot refill because the origin is down. And if a single miss is expensive (a heavy join, a fan-out to several services, an ML inference call), even a small burst of them is a problem.
The answer that lands well: "I'd replicate, because our origin is a database tuned for a 95% hit rate and I don't want a node failure to become a database outage. But if the origin were cheap to read, I'd skip replication, spend the memory on a bigger cache, and accept the miss burst." Showing you know it is a cost decision rather than a rule is the point.
5. Hot keys#
One key gets a wildly disproportionate share of traffic. A viral post, a feature flag that every request reads, the inventory count for the item in today's flash sale.
Consistent hashing does not help here, and it is worth being clear why: sharding spreads keys across nodes, and this is one key. It lives on exactly one node, and that node now absorbs a load that was meant for fifty.
For hot reads:
- A client-side micro-cache is the best fix, and the most overlooked. Have the client library keep the handful of hottest keys in local process memory for a second or two. It removes the network hop entirely, costs nothing to run, and scales automatically because it gets better the more clients you have. The price is a second of staleness, which the requirements already said is fine. Try this before anything clever.
- Replicate the hot key across nodes. Write it as
key#0throughkey#9, have readers pick a suffix at random, and the read load spreads over ten nodes. Writes now have to update all ten copies, so this only suits keys that are read constantly and written rarely. - Read replicas for the hot shard. Spin up extra replicas of just that shard and round-robin reads across them. Heavier to operate, and it helps the whole shard rather than targeting the one key.
For hot writes, your options narrow, because writes to one key cannot be spread without changing what the key means:
- Split the value. A counter becomes ten counters,
counter#0tocounter#9, each incremented by a random writer. Reads sum all ten. Writes scale linearly; reads cost ten lookups instead of one. - Coalesce at the client. Hold increments in process for 100ms and flush one combined write. A thousand increments a second per client becomes ten. You trade a little freshness for an order of magnitude less write traffic, which for counters is almost always the right call.
Detection is the part people skip. Every fix above requires knowing which key is hot, and most teams find out from a latency alert, which is far too late. Keep sampled per-key request counters on each node, or a count-min sketch (a few KB tracks the top talkers across millions of keys), and expose the top N. Then a hot key is a dashboard you look at rather than an incident you debug.
Worth saying plainly in the interview: a hot key is usually a property of the application, not the cache. But the cache is the only place that can see it happening, so giving it the telemetry to report one is part of the design.
6. Holding a 10ms p99#
On a single node the operations are microseconds. Everything that pushes you toward 10ms is the network and the tail.
- Pooled, persistent connections. A TCP handshake per request is a round trip you cannot afford. Clients hold a pool of open connections per node and reuse them.
- A compact binary protocol, not HTTP. For a 200-byte value, HTTP headers can be larger than the payload, and parsing them costs more than the lookup. Redis and Memcached both use small purpose-built protocols over raw TCP for exactly this reason.
- Batch and pipeline.
MGET k1 k2 k3is one round trip instead of three. A page needing 30 cached values should make one request, not thirty. This is usually the single biggest latency win available. - One hop. Client-side routing means the client reaches the right node first time, with no proxy or lookup service in the path.
Then the tail-latency traps, which is where p99 actually goes wrong:
- One slow command blocks everything. Redis processes commands on a single thread per shard, so one
KEYS *or one operation over a huge value freezes that shard for every client. Do not expose O(n) commands on a production cache, and cap value sizes. - Garbage collection. A JVM cache node holding 24 GB of live objects can pause for hundreds of milliseconds. This is why serious caches are written in C or Rust, or keep data off-heap.
- Memory fragmentation. An allocator holding 24 GB in small objects can have real usage drift well above what you think you are storing. If the box starts swapping, your microsecond lookup becomes a disk read. Track resident memory, not just the bytes you counted.
Data Model#
There is no database here. The data model is the in-memory layout on each node.
Two details that matter more than they look:
used_bytes must count the bookkeeping, not just len(key) + len(value). If you only count payload you will be evicting far too late, discover the process using twice what you budgeted, and get OOM killed at what your own metrics call 60% full.
with_ttl exists so the janitor samples from keys that can actually expire. Sampling the full keyspace when only 5% of keys have a TTL wastes 95% of the work.
API Design#
This is not a REST API, and explaining why is worth a sentence in the interview.
HTTP would put a few hundred bytes of headers and a text parse in front of a lookup that takes microseconds, on a request whose entire payload is often smaller than the headers. With a 10ms p99 budget and 100,000 requests a second, that overhead is the design.
MGET deserves to be in the first draft of the API rather than added later. Most callers want several keys at once, and giving them one round trip instead of N is the cheapest latency improvement in the whole system.
Failure Scenarios and Edge Cases#
For senior interviews ↓ what breaks and how the system recovers
A shard's primary dies
Gossip detects the missed heartbeats, a quorum agrees it is gone, and its replica is promoted with a bumped epoch. Clients learn the new topology and retry. During the detection window (a second or two) requests for that shard fail, and clients treat a failed get as a miss and go to the origin. Degraded hit rate, not an outage.
The network partitions the cluster
Each side may believe the other is dead. The quorum requirement means the minority side cannot promote anything, so you do not end up with two primaries for one shard. Clients stuck on the minority side see misses and fall through to the origin. For a cache that is exactly the behaviour you want, and it is why the epoch check has to be enforced on the serving path and not just at promotion time.
A node rejoins with old data
It was partitioned away for ten minutes and its copy of the keyspace is stale. Do not let it serve. On rejoin it compares its epoch, sees it is behind, and flushes. An empty node is safe (misses, which refill) while a stale node is not (wrong answers, silently). Resyncing from the current primary is nicer but flushing is the correct default, and it is a one-line answer that shows you understand which failure mode is worse.
Cache stampede on a popular key
A hot key expires. Ten thousand in-flight requests all miss simultaneously and all hit the origin for the same value. Two fixes, both on the client: single flight, where one request per key per process actually fetches while the rest wait on its result, and jittered TTLs, so a batch of keys written together does not all expire in the same second. Optionally serve the stale value while one request refreshes in the background.
Evictions spike and hit rate collapses
The cluster is too small for the working set, so eviction starts throwing out data that is about to be read again, which increases misses, which increases writes, which increases evictions. Alert on eviction rate, not just memory used. Memory used sits at 100% by design in a healthy cache and tells you nothing; a rising eviction rate is the actual signal that you are undersized.
One huge value or one slow command
A 50 MB value stalls the node while it is copied, and an O(n) command freezes a single-threaded shard for everyone. Enforce a maximum value size at write time, and do not expose full-keyspace commands in production.
Clock skew across nodes
TTLs are absolute timestamps, so a node whose clock is 30 seconds fast expires keys early and a slow one serves them past their deadline. Keep NTP tight and alert on drift. This shows up as an unexplained hit-rate dip on one node, which is miserable to debug if you have not thought about it in advance.
A client is holding a stale ring
It routes to a node that no longer owns the key. That node replies with a redirect carrying the current epoch and owner, the client updates its ring and retries. Clients must handle redirects, or every topology change becomes an outage for whoever had not refreshed yet.
Code#
The node: LRU with TTL#
The ring: routing a key to a node#
Small enough that every client holds a copy and routes locally, with no lookup hop.
Trade-off Summary#
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| LRU structure | Hash map plus doubly linked list | Sampled LRU with a timestamp | Exact and O(1) everywhere; switch to sampling once the list lock caps throughput |
| Eviction policy | LRU (as required) | LFU, W-TinyLFU | LRU was the requirement; W-TinyLFU is what you would pick if a scan-resistant cache mattered |
| Expiry | Lazy on read plus sampled janitor | Full scan, or a min-heap of deadlines | A scan blocks the node; a heap taxes every write. Sampling bounds the work |
| TTL representation | Absolute expires_at | Remaining seconds | Replication lag silently extends a relative TTL at every hop |
| Sharding | Consistent hashing with ~200 vnodes | hash(key) % N | Modulo rehashes almost everything on a resize, and the miss burst hits your database |
| Routing | Client-side ring | Proxy tier | Saves a network hop on a 10ms budget, and one less tier to keep alive |
| Replication | Async, one replica, different AZ | Synchronous | A round trip per write to protect data that is reconstructible by definition |
| Replication at all | Yes, if the origin cannot absorb a shard's traffic | None, spend the RAM on cache | It is a cost decision, not a rule. Doubling 1 TB of memory is real money |
| Hot reads | Client-side micro-cache first | Key replication, read replicas | Removes the hop entirely and costs nothing to operate |
| Protocol | Compact binary over pooled TCP | HTTP or REST | Headers can exceed the payload, and parsing them costs more than the lookup |
Follow-up Questions#
Basic
Q: Why a hash map and a doubly linked list rather than one structure?
They solve different halves. The hash map finds any key in O(1) but knows nothing about access order. A list tracks order but finding an item in it is O(n). Point both at the same entry objects and you get both properties: the map finds the node instantly, and because the node is doubly linked it knows its own neighbours, so unlinking and re-splicing it at the head is O(1) too. Eviction is then just "remove whatever sits before the tail."
Q: Why consistent hashing instead of hash(key) % N?
Because modulo remaps nearly every key when the node count changes, and in a cache every remapped key is an instant miss. Resizing a 50-node cluster would send close to 100% of 100,000 requests a second at your origin database at once, which is how adding cache capacity causes a database outage. Consistent hashing moves only about 1/N of keys, so the miss burst is small enough to absorb.
Q: Why is asynchronous replication the right choice here?
Synchronous replication puts a network round trip in front of every write and makes a slow replica everyone's problem. What it buys you is not losing writes on failover. In a cache, a lost write means a miss, and a miss means going to the origin, which is the normal path anyway. Paying latency on every single write to avoid a handful of extra misses during a rare failover is a bad trade.
Q: If keys are spread evenly, why are hot keys still a problem?
Sharding distributes keys, and a hot key is one key. It hashes to one position on the ring and lives on one node, so consistent hashing has nothing to spread. That node takes traffic meant for the whole cluster. Fixing it means either caching the value closer to the caller, or deliberately storing several copies under different key names so readers spread out.
Q: Why not just expose this over a REST API?
For a 200-byte value with a 10ms p99 budget, HTTP headers can be bigger than the payload and parsing them costs more than the cache lookup itself. Real caches use small binary protocols over persistent, pooled TCP connections, with pipelining so a client can send many requests without waiting for each reply. That is also what makes MGET worth having: one round trip for thirty keys instead of thirty.
Senior follow-ups ↓
Q: Exact LRU writes to a shared list on every read. How does that hold up on a 16-core node at 100k requests a second?
Badly. Logically read-only gets are physically writes, so they contend on one lock and serialise, and the list becomes the bottleneck before memory or network. Production caches step away from exact LRU for this reason. Redis keeps an access timestamp on each object and samples a few random keys at eviction time, choosing the oldest of the sample, so a read only touches its own entry. Memcached keeps the list but only re-splices an item if it has not been bumped in the last minute, which removes almost all the churn. Both accept slightly wrong eviction choices in exchange for removing the shared write, and for a cache that is clearly the right trade.
Q: Do you actually need replication in a cache?
Not automatically, and it doubles the memory bill on 1 TB. Losing a shard out of fifty means 2% of keys miss and the cluster repopulates itself in seconds. The question is whether the origin survives that: a database sized for a 95% hit rate can be tipped over by 2,000 extra queries a second arriving instantly, and then the cache cannot refill because the origin is down. Same if a single miss is expensive, like a heavy join or a model inference. So replicate when the origin is fragile or misses are costly, and otherwise spend the money on a bigger cache and a better hit rate.
Q: A node rejoins after being partitioned away for ten minutes. What do you do with its data?
Flush it. Its copy is stale and it may still think it is primary for shards that failed over while it was gone. Compare its epoch against the current one for each shard: if it is behind, it must not serve that shard, and the simplest safe action is to drop its data and come back empty. An empty node causes misses, which fix themselves. A stale node serves wrong answers, silently, to a random subset of clients. Resyncing from the current primary is nicer if you want the hit rate back faster, but flush is the correct default.
Q: A popular key expires and ten thousand clients miss at once. How do you stop the stampede?
Two mechanisms, both client-side. Single flight: the first request for a missing key does the origin fetch while every other request for that key waits on the same in-flight result, so one fetch serves all of them. Jittered TTLs: add a random few percent to every TTL so keys written in the same batch do not all expire in the same second, which is how a stampede goes from one key to ten thousand. If staleness is acceptable you can also serve the expired value while a single background request refreshes it, so nobody waits at all.
Q: An analytics job reads a million cold keys and your hit rate collapses. Why, and what fixes it?
That is LRU's defining weakness. Every one of those cold keys gets inserted at the head of the recency list, and each insertion pushes your genuinely hot working set one step closer to the tail. Scan a million keys and you have evicted your entire useful cache with data nobody will read again. LRU cannot tell "touched once, just now" apart from "touched constantly all day", and ranks the wrong one higher. The proper fix is an admission policy: W-TinyLFU keeps a compact frequency sketch and only admits a new item if it looks more popular than the item it would evict, so a stream of one-hit keys never gets in. Cheaper mitigations are a separate cache or namespace for batch workloads, or a client flag that reads without promoting the entry.
Minute-by-Minute Interview Playbook#
0 to 5 min: Requirements
Ask the questions above, and pin down scale early because it decides how much of the interview is data structures versus distribution. Get the interviewer to confirm LRU specifically rather than assuming it. Write down AP, the 10ms target, 1 TB, and 100k req/sec where everyone can see them.
5 to 9 min: Capacity estimates
Size on throughput and on memory separately, then take the larger: about 8 nodes for throughput, about 50 for memory, so memory decides. Mention per-entry overhead. It is a small thing that signals you have watched a cache run out of memory before.
9 to 20 min: Single node design
Build it up in three steps: hash map, then TTL, then hash map plus doubly linked list for LRU. Draw the two structures pointing at the same entries and walk a get through both. Mention sentinel nodes. This part should be fluent, because the rest of the interview needs the time.
20 to 42 min: Deep dives
Lead with sharding and consistent hashing, and frame the modulo problem in terms of the origin database falling over rather than just key movement. Cover virtual nodes and where the ring lives. Then replication and failover, including epochs. Then hot keys. If you get a chance, raise the exact-LRU lock problem yourself: it is the highest-signal thing you can say in this interview, because almost nobody brings it up unprompted.
42 to 45 min: Wrap up
Cover one failure (a primary dying and a replica being promoted with a bumped epoch) and one operational point (alert on eviction rate, not memory used, because memory sits at 100% by design). Close with: "One node is a hash map and a linked list. Fifty nodes is consistent hashing with virtual nodes, async replication with epoch-fenced failover, and client-side routing to keep it to one hop. Every trade-off went toward speed, because a miss is survivable."
Green flags
- Derives the hash map plus doubly linked list without being led to it
- Knows lazy expiry and active cleanup solve different problems and names both
- Explains the modulo problem in terms of origin load, not just key movement
- Brings up virtual nodes without being asked
- Raises the read-path lock in exact LRU and knows what Redis does instead
- Treats replication as a cost decision rather than a default
- Reaches for a client-side micro-cache on hot keys before anything more exotic
- Alerts on eviction rate rather than memory used
Red flags
- Uses a plain hash map and hand-waves eviction as "we'll clean it up somehow"
- Proposes scanning every key to expire entries
- Uses
hash(key) % Nand does not notice what a resize does - Skips virtual nodes and assumes the ring balances itself
- Puts synchronous replication on the write path of a cache
- Designs a REST API with a 10ms p99 requirement
- Thinks sharding solves hot keys
- Counts only payload bytes toward the memory limit
Further Reading#
Try a different category