🏗️ Distributed Cache System — High-Level Design¶
Target Level: Senior/Staff Engineer | Focus: Distributed caching, consistency, eviction, high availability
1. SYSTEM OVERVIEW¶
Purpose: Multi-layered distributed cache providing low-latency data access with pluggable eviction policies.
Scale: 10M requests/second peak, 500GB cache capacity, 99.999% availability
Users: Internal microservices, API endpoints, Database query layer
Use Cases: Session caching, API response caching, Database query result caching, Rate limiter backing store
Constraints: p99 latency <5ms, no data loss on single node failure, support LRU/LFU/TTL/ARC
2. HIGH-LEVEL ARCHITECTURE¶
┌──────────────────────────────────────────────┐
│ Client Applications │
│ (Microservices, API Servers, Worker Pods) │
└──────────────────────┬───────────────────────┘
│
┌────────▼────────┐
│ Cache Client │
│ (Sidecar/Lib) │
│ - Consistent │
│ hashing │
│ - Circuit │
│ breaker │
└────────┬────────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐┌─────▼─────┐┌─────▼─────┐
│ Cache ││ Cache ││ Cache │
│ Shard 1 ││ Shard 2 ││ Shard N │
│ (Primary) ││ (Primary) ││ (Primary) │
└─────┬─────┘└─────┬─────┘└─────┬─────┘
│ │ │
┌─────▼─────┐┌─────▼─────┐┌─────▼─────┐
│ Replica ││ Replica ││ Replica │
│ (Read) ││ (Read) ││ (Read) │
└───────────┘└───────────┘└───────────┘
┌────────────────────────────────┐
│ Cluster Manager │
│ (Raft/Consul for consensus) │
│ - Node membership │
│ - Shard rebalancing │
│ - Leader election │
└────────────────────────────────┘
🎬 Animated Sequence Diagram¶
🎬 Animated LRU Cache Sequence — Get/Set → Eviction Check → Cache Update → Return. Click ▶ to play/pause. Created with Remotion.
2.5 CLASS DIAGRAM¶
classDiagram
class CacheStats {
<<dataclass>>
+hits: int
+misses: int
+evictions: int
+expirations: int
+hit_rate() float
}
class BaseCache {
<<abstract>>
-_capacity: int
-_default_ttl: Optional~float~
-_clock: Clock
-_map: Dict~K, _Node~
-_expiry_heap: List~tuple~
+capacity: int
+stats: CacheStats
+get(key: K, default: V) Optional~V~
+put(key: K, value: V, ttl: float) None
+delete(key: K) bool
+purge_expired() int
-_insert(node: _Node)* None
-_touch(node: _Node)* None
-_unlink(node: _Node)* None
-_victim()* _Node
}
class LRUCache {
-_order: _DList
+keys_mru_to_lru() List~K~
}
class LFUCache {
-_buckets: Dict~int, _DList~
-_min_freq: Optional~int~
+frequency(key: K) int
}
class _DList {
-_head: _Node
-_tail: _Node
+push_front(node: _Node) None
+remove(node: _Node) None
+back() Optional~_Node~
}
class _Node {
+key: K
+value: V
+expires_at: Optional~float~
+freq: int
+prev: _Node
+next: _Node
}
class ThreadSafeCache {
-_cache: BaseCache
-_lock: Lock
-_inflight: Dict~K, _Flight~
+get(key: K, default: V) Optional~V~
+put(key: K, value: V, ttl: float) None
+delete(key: K) bool
+purge_expired() int
+get_or_load(key: K, loader: Callable, ttl: float) V
+stats: CacheStats
}
class _Flight {
+done: Event
+value: V
+error: Optional~BaseException~
}
class StripedCache {
-_shards: List~ThreadSafeCache~
+get(key: K, default: V) Optional~V~
+put(key: K, value: V, ttl: float) None
+delete(key: K) bool
+get_or_load(key: K, loader: Callable, ttl: float) V
+stats: CacheStats
}
BaseCache <|-- LRUCache
BaseCache <|-- LFUCache
BaseCache *-- CacheStats : stats
BaseCache "1" *-- "0..capacity" _Node : key to node map
LRUCache *-- "1" _DList : recency order
LFUCache *-- "*" _DList : one per frequency
_DList o-- "*" _Node : linked in order
ThreadSafeCache o-- "1" BaseCache : wraps under one lock
ThreadSafeCache *-- "*" _Flight : single-flight loads
StripedCache *-- "N" ThreadSafeCache : shards, hash of key mod N
StripedCache ..> BaseCache : factory builds each shard
3. KEY COMPONENTS & INTERVIEW Q&A¶
Cache Node (Go/C++)¶
- In-memory hash table + eviction data structures
- LRU: Doubly linked list + HashMap O(1)
- LFU: Frequency list + HashMap O(1) amortized
- TTL: Priority queue of expiry times
🔴 Interview Question: "How does consistent hashing distribute keys across nodes?"
✅ Answer:
import bisect, hashlib
def _h(s: str) -> int:
# Stable across processes. Python's hash() of str is randomized per
# process (PYTHONHASHSEED), so clients would disagree on placement.
return int.from_bytes(hashlib.md5(s.encode()).digest()[:8], "big")
class ConsistentHashRing:
def __init__(self, nodes, vnodes=150):
# Sort once; re-sorting per lookup would be O(n log n) per request.
self._ring = sorted((_h(f"{n}#{i}"), n) for n in nodes for i in range(vnodes))
self._points = [p for p, _ in self._ring]
def node_for(self, key: str) -> str:
i = bisect.bisect(self._points, _h(key)) % len(self._ring) # wrap around
return self._ring[i][1]
Why virtual nodes? With one point per node, arc lengths are very uneven and a removed node dumps its whole range on a single neighbour. With ~100–200 points per node, load evens out (a few % spread) and a departing node's keys scatter across all survivors. Vnode counts can also be weighted by node capacity.
Alternative: Redis Cluster uses 16,384 fixed hash slots (CRC16(key) mod 16384) assigned to nodes explicitly; resharding moves slots, and clients learn the map via MOVED/ASK redirects.
Replication Layer¶
- Leader-follower per shard: Writes go to primary, async replication to replica
- Read from replica: Cache-aside pattern, primary for write-through
- Failover: If primary fails, promote replica (30s detection + 10s promotion)
🔴 Interview Question: "What happens during cache replication lag?"
✅ Answer: After a write, subsequent reads from stale replicas see old data. Mitigation:
1. Read-your-writes: Track writes in client session, route reads for recently-written keys to primary
2. Configurable consistency: --consistency=strong → always read from primary
3. Version check: The client remembers the version it last wrote for a key; a replica whose copy is older than that version makes the client fall back to the primary
Cluster Manager¶
- Gossip protocol for node membership
- Raft consensus for configuration changes
- Automatic shard rebalancing on scale events
🔴 Interview Question: "How does cache rebalancing work when adding a new node?"
✅ Answer (Detailed):
When a new cache node joins the cluster, rebalancing happens in 5 phases to minimise disruption:
-
Membership detection — The new node announces itself via gossip protocol. Within seconds, every node in the cluster knows about the addition. The cluster manager (Raft leader) confirms the join.
-
Consistent hash ring update — The new node adds N virtual nodes (e.g., 150) to the ring. Each virtual node hashes to a position on the ring. Keys whose nearest clockwise node was previously shard X now map to the new node. Approximately 1/N of all keys remap (where N is the new total node count).
-
Key migration — two options, and for a cache the first is usually right:
- Let them miss. The ~1/N remapped keys miss on the new node and reload from the source of truth. Simple; the cost is a temporary DB load spike, so add nodes one at a time and off-peak.
-
Warm the new node. While the ring change is pending, on a miss at the new owner, read the old owner and copy the value over (dual-read), or stream the affected ranges in the background. Clients with stale ring state are corrected by a redirect from the node they hit (Redis Cluster's
MOVED/ASK), then refresh their map. -
Proactive hot-key migration — A background goroutine/thread walks the keyspace and migrates frequently-accessed ("hot") keys before they're requested. This avoids the MOVED redirect penalty for popular keys.
-
Rolling rebalancing completion — The cluster operator monitors:
- Redirect rate (should decay to near-zero within minutes)
- Per-node memory utilisation (should converge to uniform)
- Client error rates (should remain flat)
Failure scenarios:
- Node crashes during rebalance: The cluster manager detects failure via gossip timeout. The rebalance pauses, the dead node's virtual nodes are removed from the ring, and its keys remap to remaining nodes.
- Network partition: Only the side holding a Raft majority can change membership or promote replicas; the minority side cannot commit config changes. Cache nodes on the minority side may keep serving reads (possibly stale) unless clients are configured to fail closed — for a cache, serving slightly stale data is usually the right choice. Redis Cluster's equivalent: a primary isolated from the majority stops accepting writes after cluster-node-timeout.
4. CACHE STRATEGIES COMPARISON¶
| Strategy | Read | Write | Consistency | Use Case |
|---|---|---|---|---|
| Cache-aside | Miss → load from DB | Write DB, invalidate cache | Eventual | General purpose |
| Write-through | Same as aside | Write cache + DB on the same path | Fresher, but not atomic (crash between writes diverges) | Read-heavy data that must be fresh after writes |
| Write-behind | Same as aside | Write cache, async to DB | Eventual | High throughput |
| Refresh-ahead | Predict and pre-load | — | Eventual | Predictable access |
5. EVICTION STRATEGY SELECTION¶
| Strategy | When to Use | When NOT to Use |
|---|---|---|
| LRU | Temporal locality (session cache) | Scan-heavy workloads (bulk reads thrash) |
| LFU | Popularity-driven access (product cache) | Shifting popularity: old hot items never age out, new items are evicted first (needs decay or W-TinyLFU) |
| TTL | Fixed expiry (rate limiter counters) | No access pattern awareness |
| ARC | Mixed workloads | Implementation complexity |
| 2Q | Good balance | Tuning parameters needed |
6. SCALABILITY & RELIABILITY¶
Bottleneck: At 10M req/s, throughput, not memory. One Redis primary handles roughly 100–200K simple ops/s (more with pipelining or I/O threads), so 10M req/s needs ~60–100 primaries; memory alone (500 GB / 50 GB) would suggest only 10.
Solution: Shard by key hash across ~80 primaries (≈125K ops/s each, leaving headroom) + 80 replicas. Each holds ~6–7 GB, so smaller memory-optimised instances suffice. Hot keys still concentrate on one shard regardless of shard count: replicate them to L1 in-process caches or split them (key#1..key#k) and read a random copy.
Cache avalanche prevention:
1. Uniform TTL + jitter: TTL = base_TTL + random(0, TTL_jitter) — prevents mass expiry
2. Circuit breaker: If DB can't handle reload traffic, return stale cache instead
3. Rate limiting per origin: Limit number of concurrent cache misses
Thundering herd protection: Single-flight per key — the first miss loads from the DB, concurrent misses wait for its result (the LLD's get_or_load). Across servers: a short SET lock:k NX PX 2000 or memcache-style leases.
7. COST (Monthly)¶
| Component | Nodes | Cost |
|---|---|---|
| Cache primaries (~16 GB memory-optimised, ~$150/mo each) | 80 | ~$12,000 |
| Replicas | 80 | ~$12,000 |
| Cluster manager (3-node) | 3 | ~$600 |
| Bandwidth + Monitoring | — | ~$2,000 |
| Total | ~$27,000 |
Rough on-demand list prices; the point is that request rate, not data size, sets the node count.