Design a Distributed Cache: Consistent Hashing, Replication, and Failure Modes
Design a distributed cache for production: consistent hashing, replication, failure modes, and real-world gotchas from 15 years of building caches..
20+ years shipping large-scale distributed systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
Design a distributed cache by partitioning data across nodes (e.g., consistent hashing), replicating for fault tolerance, and handling failures with automatic failover. Key choices: cache eviction policy (LRU, TTL), consistency model (eventual vs strong), and cluster membership (gossip vs central coordinator).
Imagine a library where books are spread across multiple rooms. A distributed cache is like having a librarian who knows exactly which room holds each book, and if one room catches fire, there's a backup copy in another room. You don't need to search the whole library—just ask the librarian, and you get the book fast.
You've scaled your database to 10 replicas and it's still melting under read load. The classic fix? A distributed cache. But slap a Redis cluster in front without understanding the internals, and you'll trade one fire for another—I've seen a misconfigured cache take down a payments service at 3am because of a thundering herd. Here's what everyone gets wrong: a distributed cache isn't just a faster hashmap. It's a distributed system with all the attendant failure modes—partitioning, replication lag, split-brain, and cascading failures. By the end of this, you'll design a cache that survives node failures, handles hot keys, and doesn't amplify load on your database. You'll know exactly when to use consistent hashing vs. a lookup service, how to pick replication factor, and what to do when your cache cluster goes split-brain.
Why You Can't Just Use a Hashmap: Partitioning Strategies
A single-node cache is trivial—just a hashmap with eviction. But when your dataset exceeds one machine's RAM, you must partition. The naive approach: hash(key) % N. Works until you add or remove a node—then almost every key remaps, causing a cache stampede. I've seen this bring down a social media feed service when they scaled from 5 to 6 nodes. The fix: consistent hashing. It maps keys to a ring of hash values; each node owns a range. Adding a node only remaps a fraction of keys. But consistent hashing has its own gotcha: uneven load if nodes are few. Solution: virtual nodes—each real node appears multiple times on the ring. This spreads keys more evenly. Here's a minimal implementation in Go:
Replication: How to Survive a Node Failure Without a Stampede
Partitioning alone is fragile. If a node dies, all its keys are gone. The database gets hammered. The naive fix: replicate each key to N nodes. But how? Write-through: write to all replicas synchronously—slower but consistent. Write-behind: write to one, async replicate—faster but risk data loss. In production, I use a quorum-based approach: write to W replicas, read from R replicas, with W+R > N. This gives tunable consistency. For a cache, eventual consistency is usually fine—set TTL and accept stale reads. But watch out: if you read from a replica that hasn't received the write yet, you get stale data. The fix: read-repair—on read, if a replica has a stale version, update it. Or just accept staleness within TTL. Here's a simple replication layer:
Failure Detection and Cluster Membership: Gossip vs. Central Coordinator
How does a node know another node is dead? Polling a central coordinator (like ZooKeeper) is simple but creates a single point of failure and a bottleneck. Gossip protocols (like SWIM) are decentralized: each node periodically pings a random peer. If no response, it asks others to confirm. After a quorum of confirmations, the node is marked dead. I've used both. For caches under 50 nodes, a coordinator is fine—just make sure it's replicated. For larger clusters, gossip scales better. But gossip has a gotcha: false positives due to network hiccups. Use a suspicion mechanism: before declaring dead, wait for multiple rounds. Here's a minimal gossip failure detector:
Cache Eviction Policies: LRU, TTL, and the Thundering Herd
When memory fills, something must go. LRU evicts the least recently used key. But under a thundering herd—many requests for the same missing key—LRU can evict other hot keys, causing cascading misses. The fix: TTL-based eviction with random early expiration. Set a TTL and a jitter (e.g., TTL ± 10%). This spreads re-fetches over time. Also, use a 'lock around the cache' pattern: when a key is missing, only one request fetches from the database; others wait. Here's a Go implementation:
Consistency Models: When Stale Data Is Fine (and When It's Not)
Caches are inherently stale—that's the trade-off for speed. But how stale? Eventual consistency: after a write, all replicas converge eventually. Strong consistency: all replicas see the write before any read returns. For a cache, strong consistency kills performance—you'd need synchronous replication and quorum reads. In practice, most caches use eventual consistency with a TTL. But if you're caching user sessions or inventory counts, stale data can cause real bugs. The fix: use a version number or timestamp. On read, check if the cached version is newer than a threshold. If not, fetch from source. Here's a versioned cache:
When Not to Use a Distributed Cache
Distributed caches add complexity: network latency, consistency headaches, operational overhead. If your dataset fits in one machine's RAM, use an in-process cache (e.g., sync.Map in Go, ConcurrentHashMap in Java). If your read rate is low, a database with proper indexing is simpler. If you need strong consistency, a cache is the wrong tool—use a database with read replicas. I've seen teams add Redis to a 2-server setup and then spend weeks debugging split-brain. Don't be that team. Start simple, measure, then add a cache only when you have a proven bottleneck.
Interview Questions That Actually Get Asked
In system design interviews, you'll be asked to design a cache like Redis or Memcached. Expect these: 'How does consistent hashing handle node additions?' 'How would you replicate data across data centers?' 'What happens when a cache node fails and all requests go to the database?' 'How do you prevent a thundering herd?' 'When would you use a write-through vs write-behind cache?' The key is to discuss trade-offs: consistency vs. availability, latency vs. throughput, simplicity vs. scalability. Show you've thought about failure modes.
The 4GB Container That Kept Dying
- Hot keys are silent killers.
- Always monitor per-key size and access frequency.
- Use consistent hashing with virtual nodes to spread load, but also handle oversized values explicitly.
curl localhost:8080/debug/hashringcurl localhost:8080/debug/nodes| File | Command / Code | Purpose |
|---|---|---|
| consistent_hash.go | "hash/crc32" | Why You Can't Just Use a Hashmap |
| replicated_cache.go | "sync" | Replication |
| gossip_failure.go | "math/rand" | Failure Detection and Cluster Membership |
| thundering_herd.go | "sync" | Cache Eviction Policies |
| versioned_cache.go | type VersionedEntry struct { | Consistency Models |
Key takeaways
Interview Questions on This Topic
How does consistent hashing handle the addition of a new node without causing a cache stampede?
Frequently Asked Questions
20+ years shipping large-scale distributed systems. Written from production experience, not tutorials.
That's Real World. Mark it forged?
3 min read · try the examples if you haven't