Index/Distributed Cache

SponsorGitHub
Design15 min

Distributed Cache

Building Redis: a hundred nodes holding a terabyte of hot data with sub-millisecond reads.

Primary concepts and the hard part

Concepts
consistent hashingvirtual nodesLRU evictionreplicationhot keyscache stampede/penetration/avalanchegossip/membership protocolsclient-side vs proxy-based routing
The hard part they’re probing
Rebalancing. What happens when you add or remove a node? Naive hash % N invalidates ~80% of the cache and stampedes the origin database. This is the canonical consistent-hashing question, and the second probe is hot keys, which consistent hashing does not solve.

Requirements

Functionalwhat it must do
get(key), set(key, value, ttl), delete(key)
TTL-based expiry
Configurable eviction when memory is full
Add/remove nodes without a full cache flush
Optional replication for read scaling and availability
Non-functionalhow well it must do it
p99 under 1ms for a hit
Millions of ops/sec across the cluster
Cache is not the source of truth — losing it must degrade performance, never correctness
Node failure must not take down the application tier
Out of scopedeliberately left out
Durable persistence (mention it's optional)
Complex data types
Transactions
Scale
Ops:            10M/sec
Dataset:        1 TB hot working set
Nodes:          ~100 × 16 GB RAM
Avg value:      ~1 KB → ~1B keys
Key metric:     HIT RATE. A drop from 95% → 80% triples origin load.
Conclusion
The cluster is memory-bound, and hit rate is the number everything else serves. Any design decision that risks mass invalidation (like naive hashing on resize) is therefore an availability decision for the database behind it, not just a cache inefficiency.

API / Model

GET
key
value | NOT_FOUND
SET
key value [EX ttl]
OK
DEL
key
count
INCR
key
atomic, no read-modify-write race
int
MGET
k1 k2 k3
batching cuts round trips
values
primary keyforeign key → referenced columnnullableHover a table or column to trace its keys

High-level architecture

DatabaseCacheFocusClick a node for details

There is no proxy tier: a smart client library inside each application server routes every request straight to a cache node. The origin database sits behind the nodes and is only read on a miss.

  1. The application calls the smart client, which hashes the key, such as user:1234, against its own copy of the ring. Before that, it can answer the hottest keys from its optional tiny L1 cache.
  2. On the consistent hashing ring, the key belongs to the first node clockwise from its hash. Every physical node sits at about 150 virtual node positions, so keys spread evenly and a resize remaps only about 1/N of them.
  3. The client connects directly to that node, with no proxy hop in between.
  4. The node finds the key in its hash map and moves the entry to the front of its LRU list, both in O(1). It runs commands on a single thread and evicts from the tail of the list when memory is full.
  5. On a hit, the value goes straight back to the client.
  6. On a MISS, the value is read from the origin database and SET back into the same node, so the next read for that key hits.

Two flows run beside the request path. Each node streams writes asynchronously to its own replica, and membership uses gossip in the SWIM style, moving a node through suspicion and confirmation before declaring it dead and changing the ring the clients use. The stampede, penetration and avalanche failure modes all surface at the miss path, as extra load on the origin database.


Trade-offs and deep dives

01
Why consistent hashing, concretely

With hash(key) % 4 and a fifth node added, every key's destination changes from % 4 to % 5 — roughly 80% of the cache is suddenly on the wrong node, so 80% of requests miss and hit the database simultaneously. That's not a cache inefficiency, it's a database outage. Consistent hashing bounds the disruption to ~1/N of keys, moving only from the single adjacent node clockwise. Removing a node is equally surgical.

02
Virtual nodes are not optional

With, say, eight physical nodes placed randomly on the ring, the arcs between them vary enormously, so one node might own 3x its fair share. Placing each physical node at ~150 positions averages out the randomness and produces near-uniform distribution. They also enable weighting — a machine with twice the RAM gets twice the vnodes — and make removal smoother, since the departing node's load spreads across many neighbours instead of dumping entirely on one.

03
Client-side routing vs a proxy — a real trade-off
Smart clientProxy tier (twemproxy, Envoy)
Latency1 hop2 hops
Client complexityhigh (ring logic in every language)thin clients
Config rolloutmust update every clientupdate proxies only
Failure isolationclient bugs are everywhereproxy is a contained tier

Smart clients win on latency, which is the entire point of a cache. Proxies win on operability, especially in polyglot environments where reimplementing ring logic in five languages is a liability. Say which you'd choose and why — for a single-language shop, smart client; for a large polyglot org, proxy.

04
LRU implementation

Hash map plus doubly-linked list gives O(1) for both lookup and reordering: on access, unlink the node and move it to the head; on eviction, drop the tail. Worth being able to sketch, since it's a common coding question in its own right.

Real systems approximate. Redis samples a handful of random keys and evicts the least recently used among them, because maintaining exact LRU ordering costs memory per entry and adds contention. Approximate LRU is nearly as good and much cheaper — a nice example of accepting an approximation where the cost of exactness isn't justified.

05
Eviction policy choice

LRU is the sensible default (matches temporal locality). LFU handles stable popularity better but suffers cache pollution — something hugely popular last month keeps a high count forever — so it needs aging. TTL-only is right when staleness, not memory, is the binding constraint. Mention that most systems combine: LRU for eviction, TTL for correctness.

06
Hot keys are NOT solved by consistent hashing

This is the follow-up that catches people. The ring distributes keys evenly; it cannot help when one single key receives a million requests per second. All that traffic maps to one node by definition. Mitigations:

  • Client-side L1 cache for the hottest keys — a tiny in-process cache absorbs repeat reads before they leave the app server. Cost: per-server copies can briefly diverge.
  • Key replication with a suffix — store the value under key#0..#9 and have clients read a random one. Spreads read load across ten nodes; writes must update all ten.
  • Dedicated nodes for known-hot keys.

Naming the L1 cache as the first-line answer is the practical one, since it requires no cluster changes.

07
The three failure modes, with fixes
  • Stampede/thundering herd: a hot key expires and a thousand concurrent misses hit the database at once. Fix with request coalescing (first miss takes a lock, others wait for its result), probabilistic early expiry, or stale-while-revalidate.
  • Penetration: repeated requests for keys that exist nowhere, so the cache never helps. Fix by caching the negative result with a short TTL, and/or a Bloom filter in front to reject definitely-absent keys.
  • Avalanche: many keys expire simultaneously (or the cluster restarts cold), dumping full load on the origin. Fix with TTL jitter, cache warming before serving traffic, and a circuit breaker in front of the database so it degrades instead of collapsing.

Being able to name all three and give a fix each is a fast, high-value signal.

08
Replication is optional and changes the guarantees

A pure cache needs no replication — a lost node means a miss, and the origin repopulates. Add async replicas when you want read scaling or to avoid a cold-start stampede after a node dies. Async replication means replicas can serve stale values; since this is a cache, that's usually fine, but say it rather than glossing over it.

09
Cache invalidation

Prefer deleting a key over writing a new value into it. Two concurrent updates writing to the cache can land out of order and leave the older value cached indefinitely; deleting is idempotent and forces a fresh read. For correctness-critical invalidation, drive it from change-data-capture on the database so every write invalidates, including ones made by other services or by hand.

10
Single-threaded execution

Redis executes commands on one thread, which is why INCR is atomic with no locking and why it's a safe distributed counter. The consequence: one expensive command (KEYS * on a large keyspace) blocks every other client. Never run unbounded commands in production — use SCAN with a cursor instead.

11
Memory management

Fragmentation is real: a 16 GB node does not hold 16 GB of values. Use a slab allocator (Memcached's approach) or jemalloc with monitoring. Set maxmemory explicitly with an eviction policy, because the failure mode of not doing so is the OS OOM-killer terminating the node, which is far worse than evicting a few keys.


Possible follow-up questions

6 questions·try answering before you reveal