Design a Distributed Cache (Redis)
Design a horizontally scalable in-memory key-value cache like Redis or Memcached that sits in front of a database. The interesting problems are how keys map to nodes (consistent hashing), what to evict when memory fills (LRU), how to survive node loss (replication), and how to avoid stampedes and hot keys melting single nodes.
1Requirements
Functional
- • GET, SET with TTL, and DELETE operations on string keys and binary-safe values.
- • Distribute keys across N cache nodes and route each request to the right node client-side.
- • Evict least recently used entries when a node reaches its memory limit.
- • Support adding or removing nodes with minimal key redistribution.
- • Optional read replicas per shard for failover and read scaling.
Non-functional
- • p99 latency under 1 ms for GET within the same datacenter.
- • Sustain 100k+ operations per second per node; scale linearly by adding shards.
- • Losing one node must not take the cache tier down and must invalidate at most 1/N of keys.
- • Eventual consistency with the source of truth is acceptable; the database remains authoritative.
- • Memory-bounded: hard cap per node with predictable eviction, never OOM.
- • Cache failures must degrade to database reads, never to user-facing errors.
2Back-of-envelope estimation
| Working set | 200 GB | 100M cached objects x 2 KB average (key + value + overhead). |
| Node count | 8 shards | 200 GB / 32 GB usable RAM per node (64 GB box, half reserved for spikes and fork copies) = 6.25, round to 8. |
| Read throughput | 500k ops/sec | 50M DAU x 100 reads/day = 5B reads/day, about 58k/sec average, 10x peak = 580k/sec; 8 nodes at 100k each covers it. |
| Hit ratio impact | 95% hits | At 500k ops/sec, 95% hit ratio leaves 25k/sec on the database; at 90% it doubles to 50k/sec, so every hit-ratio point matters. |
| Rebalance cost | 1/9 of keys | With consistent hashing, adding a 9th node moves only about 11% of keys; naive mod-N hashing would remap about 89%. |
3API design
GET /cache/{key}Returns the value and remaining TTL, or 404 on miss. Client library hashes the key to pick the node before this call.
PUT /cache/{key}?ttl=300Sets a value with a TTL in seconds. Body is raw bytes. Overwrites move the entry to the head of the LRU list.
DELETE /cache/{key}Explicit invalidation, used by write-through paths after a database update.
GET /admin/ringReturns the current hash ring membership and virtual node layout so clients can refresh their routing table.
4High-level design
Clients embed a smart library that owns routing: it hashes each key onto a consistent hash ring and talks directly to the owning node, so there is no central proxy to bottleneck. Each physical node is placed on the ring 100 to 200 times as virtual nodes, which smooths out load imbalance from an uneven hash distribution and lets heterogeneous machines take proportional shares.
Each node is a single-threaded (or sharded-per-core) event loop over an in-memory hash map, paired with a doubly linked list for LRU ordering. Every GET moves the entry to the head; when memory passes the cap, the tail is evicted. TTLs are enforced lazily on read plus a background sampler that scans a few random keys per tick, which is how Redis actually does it.
The dominant usage pattern is cache-aside: the application reads the cache, falls back to the database on a miss, then populates the cache with a TTL. Writes go to the database first and then delete (not update) the cache key, because delete-on-write avoids races where an older value overwrites a newer one. TTLs act as the safety net for any missed invalidation.
For availability, each shard gets an async replica. On primary failure, a sentinel process (or the cluster's gossip protocol) promotes the replica and clients refresh the ring. Because replication is async, a promoted replica may serve slightly stale data, which is acceptable for a cache where the database is the source of truth.
Two failure amplifiers get dedicated treatment: cache stampedes (thousands of concurrent misses on the same expired key all hitting the database) are handled with per-key mutex locks or probabilistic early refresh; hot keys (one celebrity key exceeding a single node's capacity) are handled with client-local caching and key duplication across nodes.
5Data model
cache_entry (in-memory)
key, value_bytes, expires_at, lru_prev, lru_next, size_bytesLives in a hash map; prev/next pointers thread it into the LRU list. No disk persistence needed for a pure cache.
ring_config
node_id, host, port, vnode_count, status, updated_atSmall config record in etcd or a config service; clients watch it to rebuild the ring on membership change.
shard_stats
node_id, used_bytes, max_bytes, hits, misses, evictions, ops_per_secExported to the metrics system; hit ratio and eviction rate are the two alerts that matter.
6Deep dives
Consistent hashing and virtual nodes
Naive routing uses hash(key) mod N, but when N changes almost every key maps to a new node, so a single scale-out event flushes the whole cache and stampedes the database. Consistent hashing fixes this by hashing both nodes and keys onto a circular space (say 0 to 2^32); each key belongs to the first node clockwise from it. Adding a node steals keys only from its clockwise neighbor, about 1/N of the total.
With one point per physical node the ring is lumpy: random placement can give one node 3x the arc of another, and removing a node dumps its entire range onto a single neighbor. Virtual nodes solve both problems: each physical node claims 100+ points, so ranges average out statistically and a failed node's load spreads across many survivors instead of one.
An alternative worth mentioning is Rendezvous (highest random weight) hashing: for each key, score every node with hash(key, node) and pick the max. It gives perfect balance with no ring state, at O(N) per lookup, which is fine for small clusters and is simpler to implement correctly.
Cache stampede protection
When a popular key expires, every concurrent request misses simultaneously and all of them query the database and recompute, which is exactly the load spike the cache existed to prevent. Three defenses stack well. First, per-key locking: the first miss acquires a short-lived lock (SET key_lock NX PX 3000 in Redis), recomputes, and fills the cache; other requests either wait briefly and re-read, or serve the stale value if you keep one.
Second, probabilistic early expiration (the XFetch algorithm): each reader recomputes before actual expiry with a probability that rises as the deadline approaches, scaled by how long the recompute takes. Statistically one client refreshes early and everyone else keeps hitting the warm entry, so the expiry cliff never happens.
Third, for known-hot keys, do not let them expire at all: a background refresher recomputes them on a schedule and writes them with a long TTL as a crash backstop. This turns the read path into pure cache hits at the cost of a small always-on job.
Hot keys and skewed load
Consistent hashing balances key counts, not key traffic. A single viral key (a celebrity profile, a flash-sale product) can drive more requests than one node can serve, and no amount of resharding helps because one key cannot be split by hashing. Detection comes first: sample requests client-side or track per-key counters with a count-min sketch to find the top-K keys cheaply.
The two standard fixes: replicate the hot key under derived names (key#1 through key#10, each hashing to a different node, readers pick one at random) so reads spread across 10 nodes; or cache it in-process in each application server with a very short TTL of 1 to 5 seconds, which removes the network hop entirely and typically absorbs 99% of the traffic to that key.
The tradeoff of both is staleness fan-out: invalidation now has to touch 10 copies, or wait out the local TTL. For read-heavy hot keys that change rarely, this is almost always the right trade.
Replication and failover semantics
Cache replication is about availability, not durability: the goal is that losing a node costs you a hit-ratio dip on 1/N of keys, not an outage. Async primary-replica replication is standard; the replica applies the primary's write stream with some lag, and a monitor promotes it when the primary stops answering pings for a few seconds.
The classic hazard is split brain: a network partition makes the monitor promote the replica while the old primary still serves writes from clients that can reach it. For a cache this is survivable (worst case, stale reads until TTLs expire), which is why cache systems accept far looser failover semantics than databases. Redis Cluster requires a majority of masters to agree before failover, which bounds the damage.
A pragmatic MVP skips replication entirely: on node death, clients treat its range as a miss and fall through to the database while the ring heals. Whether that is acceptable depends on whether your database can absorb 1/N of cache traffic for a few minutes; do that arithmetic before adding replicas.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Two Node.js (TypeScript) cache server processes + a client library with a consistent hash ring, all on a single $12 VPS; Postgres as the backing store.
- 01Build the LRU store class: Map for O(1) lookup, doubly linked list for recency order, maxBytes cap with tail eviction.
- 02Wrap it in a tiny TCP or HTTP server exposing GET, SET with TTL, and DELETE; run two instances on ports 7001 and 7002.
- 03Write the client library: build a ring of 128 virtual nodes per server using sha1(node + '#' + i), route each key with binary search over sorted ring points.
- 04Implement cache-aside in a demo API route: check cache, on miss query Postgres, SET with a 300 second TTL plus 10% random jitter.
- 05Add stampede protection: a per-key in-flight promise map in the client so concurrent misses for the same key share one database query.
- 06Kill one cache process while load testing and verify requests fall through to Postgres and the ring reroutes after the config refresh.
- 07Add a /stats endpoint per node (hits, misses, evictions) and confirm the hit ratio exceeds 90% under a zipfian load test.
LRU cache with map + doubly linked list
typescriptinterface Node { key: string; val: Buffer; expiresAt: number; prev: Node | null; next: Node | null; }
export class LRU {
private map = new Map<string, Node>();
private head: Node | null = null; // most recent
private tail: Node | null = null; // least recent
private used = 0;
constructor(private maxBytes: number) {}
get(key: string): Buffer | undefined {
const n = this.map.get(key);
if (!n) return undefined;
if (n.expiresAt < Date.now()) { this.remove(n); return undefined; }
this.remove(n); this.pushFront(n); // refresh recency
return n.val;
}
set(key: string, val: Buffer, ttlMs: number) {
const old = this.map.get(key);
if (old) this.remove(old);
const n: Node = { key, val, expiresAt: Date.now() + ttlMs, prev: null, next: null };
this.pushFront(n);
this.used += val.length + key.length;
while (this.used > this.maxBytes && this.tail) this.remove(this.tail); // evict LRU
}
private pushFront(n: Node) {
this.map.set(n.key, n);
n.next = this.head; n.prev = null;
if (this.head) this.head.prev = n;
this.head = n;
if (!this.tail) this.tail = n;
}
private remove(n: Node) {
this.map.delete(n.key);
this.used -= n.val.length + n.key.length;
if (n.prev) n.prev.next = n.next; else this.head = n.next;
if (n.next) n.next.prev = n.prev; else this.tail = n.prev;
}
}Consistent hash ring with virtual nodes
typescriptimport { createHash } from "crypto";
function hash32(s: string): number {
return createHash("sha1").update(s).digest().readUInt32BE(0);
}
export class Ring {
private points: { h: number; node: string }[] = [];
constructor(nodes: string[], vnodes = 128) {
for (const node of nodes)
for (let i = 0; i < vnodes; i++)
this.points.push({ h: hash32(node + "#" + i), node });
this.points.sort((a, b) => a.h - b.h);
}
lookup(key: string): string {
const h = hash32(key);
let lo = 0, hi = this.points.length - 1;
while (lo < hi) { // first point with h >= key hash
const mid = (lo + hi) >> 1;
if (this.points[mid].h < h) lo = mid + 1; else hi = mid;
}
return this.points[this.points[lo].h >= h ? lo : 0].node; // wrap around
}
}Stampede protection via shared in-flight promise
typescriptconst inflight = new Map<string, Promise<Buffer>>();
export async function getOrLoad(
key: string,
cacheGet: (k: string) => Promise<Buffer | undefined>,
cacheSet: (k: string, v: Buffer, ttlMs: number) => Promise<void>,
loadFromDb: (k: string) => Promise<Buffer>
): Promise<Buffer> {
const hit = await cacheGet(key);
if (hit) return hit;
const pending = inflight.get(key);
if (pending) return pending; // piggyback on the miss already in flight
const p = (async () => {
try {
const val = await loadFromDb(key);
const jitter = 1 + Math.random() * 0.1; // avoid synchronized expiry
await cacheSet(key, val, Math.floor(300_000 * jitter));
return val;
} finally {
inflight.delete(key);
}
})();
inflight.set(key, p);
return p;
}Bottlenecks & failure modes
- ⚠Cache stampede on popular key expiry can multiply database load by 100x in milliseconds.
- ⚠Hot keys concentrate traffic on one node regardless of shard count; need detection plus key duplication or local caching.
- ⚠Full cache flush on deploy or mod-N rehashing causes a cold-start database hammering; consistent hashing and warmup are mandatory.
- ⚠Large values (over ~100 KB) block the single-threaded event loop and spike p99 for all keys on that node.
- ⚠Async replication lag means a failover can resurrect stale values; TTLs bound the staleness window.
Key takeaways
- ▸Consistent hashing with virtual nodes is the core routing idea: membership changes move only 1/N of keys.
- ▸LRU is a hash map plus a doubly linked list; every operation is O(1).
- ▸Delete-on-write plus TTL backstop beats update-on-write for cache-aside correctness.
- ▸Stampedes and hot keys are the two production killers; per-key locks and key duplication are the standard answers.
- ▸A cache must fail open: any cache error degrades to a database read, never a user error.