Loading…
Loading…
Design a distributed in-memory key-value cache similar to Redis or Memcached. The cache must support GET, SET, and DELETE with TTL. It should scale horizontally and handle node failures gracefully.
Client
│
├── Cache Client Library (consistent hashing router)
│ │
│ Consistent hash ring → selects cache node for each key
│
├── Cache Node 1 [10 GB in-memory HashMap + LRU]
├── Cache Node 2
├── Cache Node 3
└── Cache Node N
[Coordination Service (ZooKeeper / etcd)]
→ tracks live nodes
→ notifies clients of topology changes
Consistent Hashing: keys are mapped to a virtual ring. Each node owns a range of the ring. Adding/removing a node only remaps keys from the adjacent node — not the entire keyspace.
LRU is the practical default; LFU earns its extra bookkeeping cost specifically when access patterns are skewed enough that recency alone would evict genuinely "hot" data.
// Client SDK (not HTTP — internal TCP binary protocol)
cache.get("user:123") → String | null
cache.set("user:123", json, 3600) → OK
cache.delete("user:123") → OK
cache.mget(["k1", "k2", "k3"]) → Map<String, String>
Why not HTTP? HTTP overhead (~200 bytes per request) is significant at 1M QPS. Redis uses a custom binary protocol (RESP) over raw TCP — ~4× lower latency.
HashMap (O(1) GET/SET/DELETE):
HashMap<String, CacheEntry> store;
class CacheEntry {
byte[] value;
long expiresAt; // epoch millis, -1 = no TTL
LRUNode lruNode; // pointer into doubly-linked LRU list
}
LRU via Doubly Linked List + HashMap:
TTL expiration — two strategies:
Each primary cache node has 1 replica:
Primary Node → (async) → Replica Node
On primary failure: replica promoted. Client updated via ZooKeeper watch.
These solve different problems and are often combined, not substitutes for each other:
A production distributed cache typically does both: the keyspace is partitioned across N nodes (via consistent hashing), and each partition is additionally replicated for read scaling and failover within that partition — partitioning solves "the dataset is too big for one node," replication solves "one node's data needs to survive that node's failure and serve more concurrent reads."
Q: Why use consistent hashing with virtual nodes to place keys?
A: Plain hash(key) % N remaps almost every key when a node is added or removed, which is effectively a full cache flush and a stampede on the database. Consistent hashing moves only about 1/N of the keys. Virtual nodes (many ring positions per physical node) even out the distribution, and spread a failed node's load across all the others, instead of onto one neighbour.
Q: Should a cache replicate data? A: It depends on the cost of a miss. Pure caches often don't replicate: a lost node means misses that are refilled from the source of truth. If misses are expensive, or the cache holds hot data the database couldn't withstand losing, replicate each shard to a replica (Redis primary–replica, with automatic failover), accepting extra memory and the risk of slightly stale reads from replicas.
Q: How do you handle a hot key that overloads one cache node?
A: Detect it with per-key request metrics. Then add a small in-process cache on the application servers for that key, or replicate the key under several suffixed names (key#1..key#n) and read one at random. Request coalescing ensures only one miss per key goes to the database at a time.
Q: What eviction policy should the cache use? A: LRU is a solid default for recency-driven workloads. LFU, or modern approximations such as TinyLFU, protect frequently used keys from being flushed by one-off scans. Always pair eviction with TTLs, so stale data eventually expires, and monitor the eviction rate: a high rate means the cache is undersized for the working set.
Q: How do clients learn about topology changes when nodes join or leave?
A: Either clients fetch the cluster map from a configuration service (or the cache cluster itself, as Redis Cluster does with MOVED redirections) and refresh it on errors, or a proxy layer (Twemproxy, Envoy) hides topology from clients entirely. Proxies simplify clients, but add a network hop.
Q: What consistency can clients expect between the cache and the database? A: Usually eventual. With cache-aside and invalidate-on-write, there's a small window in which stale data can be served, bounded by TTLs. For data that must be exact, such as balances or inventory at checkout, read from the database, or use write-through with care. A distributed cache is an accelerator, not the source of truth.