Design a Distributed Cache
A distributed cache sits between your application servers and your database, absorbing the majority of read traffic and reducing database load by 80–95%. Designing one correctly requires solving distributed key placement (consistent hashing), memory pressure management (eviction policies), thundering-herd protection (cache stampede), and fault tolerance (replication and automatic failover). Redis Cluster is the canonical production reference for all of these problems.
Design it yourself
Don't just read it — drag components onto a canvas and get Aria's interviewer review.
Requirements
Functional
- GET key — return the cached value or a cache miss signal in under 1ms at p99
- SET key value ttl — store a value with an optional time-to-live; overwrites silently on re-set
- DEL key — explicitly evict a key from the cache
- Consistent key distribution — the same key must always map to the same cache node (with minimal remapping when nodes are added or removed)
- Automatic failover — if a primary node fails, a replica is promoted without operator intervention
Non-Functional
- 1M+ cache operations per second across the cluster
- p99 read latency < 1ms from application server to cache node
- 99.99% availability — applications must degrade gracefully on a cache miss, not fail hard
- Cache hit ratio ≥ 90% in steady state
- Memory-efficient — cache nodes are sized to hold the hot working set, not the entire dataset
Capacity Estimation
| Target hit ratio | 90% (10% fall through to DB) |
| Read throughput | 1M ops/sec → ~333K ops/sec per node (3-node cluster) |
| Avg value size | 1 KB (JSON object) |
| Working set (hot 20%) | 20M keys × 1KB = 20 GB across cluster |
| Memory per node | ~8 GB data + 2 GB overhead = 10 GB RAM per node |
| Network bandwidth | 333K ops/sec × 1KB = ~333 MB/s per node |
High-Level Components
Client Library (Lettuce / Jedis)
The application-side library responsible for hashing keys to the correct cluster node using CRC16 mod 16384, following MOVED redirects when the cluster topology changes, and connection pooling. Lettuce (async, non-blocking) is preferred over Jedis for high-throughput Spring Boot services.
Redis Cluster (Primary Nodes)
Three or more primary nodes that collectively own all 16,384 hash slots. Each primary handles reads and writes for its portion of the keyspace. Nodes communicate via the Redis Cluster Bus (port + 10000) for gossip, heartbeats, and failure detection.
Replica Nodes
Each primary has at least one replica that asynchronously replicates all writes. Replicas can serve read traffic (with eventual-consistency trade-off) to scale read throughput. On primary failure, the remaining primaries vote to promote the replica within ~5 seconds.
Consistent Hash Ring
The logical mechanism for mapping keys to nodes. Physical nodes are represented by many virtual nodes (vnodes) on the ring, ensuring even key distribution. When a node is added or removed, only the keys on the adjacent arc are remapped — not the entire keyspace.
Eviction Manager
Each Redis node enforces a maxmemory limit. When memory is full, the configured eviction policy (allkeys-lru, volatile-lfu, etc.) determines which keys are evicted. This runs inline with every write command, so it adds no background latency spikes.
Database (Source of Truth)
PostgreSQL or Cassandra behind the cache layer. Cache misses fall through to the database. The application is responsible for populating the cache after a miss (cache-aside) or the write path populates the cache proactively (write-through).
Architecture Diagram
Deep Dives
Consistent Hashing and Virtual Nodes
Naive modular hashing (`hash(key) % N`) is catastrophic when a node is added or removed: nearly all keys remap to different nodes, invalidating the entire cache. Consistent hashing solves this by placing both nodes and keys on a circular hash ring.
How it works: Each cache node is hashed to a position on a ring of 2^32 positions. A key is hashed to a ring position and assigned to the first node encountered clockwise. When a node is removed, only the keys between that node and its predecessor migrate — roughly `1/N` of the keyspace.
Virtual nodes (vnodes): If each physical node has only one ring position, the key distribution is uneven by chance. With vnodes, each physical node is hashed multiple times (e.g. 150 times) using suffixes like `node1#1`, `node1#2`, ... `node1#150`. This yields a smooth statistical distribution. The number of vnodes per node can also be weighted by hardware capacity.
Redis Cluster does not use a ring directly. Instead it uses 16,384 fixed hash slots. `slot = CRC16(key) % 16384`. Hash slots are then assigned to nodes (e.g. slots 0–5460 → node A, 5461–10922 → node B, 10923–16383 → node C). This is simpler to rebalance because moving slots between nodes is an explicit operator action (or done by the cluster auto-rebalancer). The effect is equivalent to consistent hashing with coarse-grained virtual nodes.
Hotspot prevention: A single viral key (e.g. a product page during a flash sale) can overload the node that owns its slot. Mitigations: (1) use key hashing tags to spread related keys — `{user:42}:profile` and `{user:42}:cart` share the hash tag `user:42` and always land on the same node, enabling multi-key operations, but split keys like `product:{shard1}:42` across nodes intentionally; (2) use local in-process caches (Caffeine) as an L1 in front of Redis to absorb single-key hotspots without hitting the network.
Java — Consistent hash ring with virtual nodes
// Simplified consistent hash ring — illustrative, not Redis Cluster internals
import java.util.SortedMap;
import java.util.TreeMap;
import java.security.MessageDigest;
public class ConsistentHashRing {
private static final int VIRTUAL_NODES = 150;
private final SortedMap<Long, String> ring = new TreeMap<>();
public void addNode(String node) {
for (int i = 0; i < VIRTUAL_NODES; i++) {
long hash = hash(node + "#" + i);
ring.put(hash, node);
}
}
public void removeNode(String node) {
for (int i = 0; i < VIRTUAL_NODES; i++) {
ring.remove(hash(node + "#" + i));
}
}
/** Returns the node responsible for the given key. */
public String getNode(String key) {
if (ring.isEmpty()) throw new IllegalStateException("No nodes in ring");
long hash = hash(key);
SortedMap<Long, String> tail = ring.tailMap(hash);
// wrap around to the start of the ring if no node is clockwise
long position = tail.isEmpty() ? ring.firstKey() : tail.firstKey();
return ring.get(position);
}
private long hash(String input) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes());
// use first 8 bytes as a long
long h = 0;
for (int i = 0; i < 8; i++) {
h = (h << 8) | (digest[i] & 0xFF);
}
return h;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}Eviction Policies: LRU, LFU, and TTL
When a Redis node reaches its `maxmemory` limit, it must evict keys before accepting new writes. Choosing the wrong policy wastes cache space on cold data and degrades hit ratio.
LRU (Least Recently Used) — evicts the key that was accessed least recently. Optimal when access patterns follow temporal locality (the cache is a sliding window of recent activity). Use `allkeys-lru` when you want every key to be eviction-eligible, or `volatile-lru` to restrict eviction to keys that have a TTL set.
LFU (Least Frequently Used) — evicts the key accessed the fewest times over a decay window. Optimal for stable hot/cold distributions (e.g. product catalogue where top-100 items are always popular). Redis LFU uses a logarithmic counter with a decay factor (`lfu-decay-time`). Use `allkeys-lfu` for workloads where frequency is more predictive than recency.
TTL (volatile-ttl) — among keys with a TTL set, evicts the one closest to expiry. Useful when you want to preserve long-lived keys and sacrifice near-expired ones first.
noeviction — Redis rejects writes when memory is full and returns an OOM error. Never use this for a cache (use it only for Pub/Sub or stream workloads where you cannot afford data loss).
Rule of thumb: use LRU for user-session and real-time caches, LFU for content/catalogue caches with skewed popularity, TTL-based eviction when you manage memory lifecycle via TTLs already.
Java — LinkedHashMap LRU and Caffeine L1 cache
// Java LinkedHashMap as a simple LRU cache — mirrors Redis LRU eviction semantics
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
/**
* @param capacity maximum number of entries before LRU eviction kicks in
*/
public LruCache(int capacity) {
// accessOrder=true: iteration order is LRU order (least recently accessed first)
super(capacity, 0.75f, /* accessOrder= */ true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// LinkedHashMap calls this after every put(); return true to evict eldest
return size() > capacity;
}
// Usage example — thread-safe wrapper
public static <K, V> Map<K, V> threadSafe(int capacity) {
return java.util.Collections.synchronizedMap(new LruCache<>(capacity));
}
}
// Caffeine (production-grade LRU/LFU in-process cache)
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
Cache<String, String> l1Cache = Caffeine.newBuilder()
.maximumSize(10_000) // LFU eviction via TinyLFU algorithm
.expireAfterWrite(Duration.ofSeconds(30))
.recordStats() // exposes hit/miss ratio via cache.stats()
.build();Cache Stampede and Thundering Herd
A cache stampede (thundering herd) occurs when a popular cached key expires and multiple application threads simultaneously find the cache empty, all fan out to the database at once, and flood it with identical queries. Under heavy traffic this can bring the database down.
Mutex / distributed lock: The first thread to detect a miss acquires a short-lived Redis lock (SET NX EX). All other threads either wait or return a stale value. Only the lock holder executes the DB query and repopulates the cache. Simple but adds latency to the first request after expiry.
Probabilistic Early Recomputation (PER): Keys are refreshed *before* they expire using a probabilistic decision: the closer a key is to expiry, the higher the chance a given request will trigger a background refresh. This smooths out the expiry cliff. Formula: `recompute if -β × log(rand()) > ttlRemaining`. A single background thread does the refresh; all other requests continue serving the still-valid cached value with zero added latency.
Background refresh (read-through with async reload): Set the cache TTL to a short soft TTL. Embed the expiry timestamp inside the cached value as metadata. When an application thread reads the value and sees the embedded timestamp is past, it spawns an async task to refresh the cache and returns the stale value immediately. The next reader will get a fresh value. This is the most user-invisible approach — zero latency impact — but requires tolerating briefly stale data.
Java — Mutex lock and Probabilistic Early Recomputation
// Mutex lock — only one thread fetches from DB on cache miss
@Service
public class CacheService {
private static final Duration LOCK_TTL = Duration.ofSeconds(5);
private static final Duration CACHE_TTL = Duration.ofMinutes(10);
private static final String LOCK_PREFIX = "lock:";
private final RedisTemplate<String, String> redis;
private final UserRepository db;
public String getUser(String userId) {
String cacheKey = "user:" + userId;
String cached = redis.opsForValue().get(cacheKey);
if (cached != null) return cached;
// Attempt to acquire a distributed mutex
String lockKey = LOCK_PREFIX + cacheKey;
Boolean acquired = redis.opsForValue()
.setIfAbsent(lockKey, "1", LOCK_TTL);
if (Boolean.TRUE.equals(acquired)) {
try {
// Double-check after acquiring lock — another thread may have populated
cached = redis.opsForValue().get(cacheKey);
if (cached != null) return cached;
String value = db.findById(userId).orElseThrow();
redis.opsForValue().set(cacheKey, value, CACHE_TTL);
return value;
} finally {
redis.delete(lockKey);
}
}
// Could not acquire lock — return stale value or wait briefly
// For simplicity, fall back to DB directly here (acceptable under lock contention)
return db.findById(userId).orElseThrow();
}
}
// Probabilistic Early Recomputation (PER) — background refresh before expiry
public String getUserWithPER(String userId) {
String cacheKey = "user:" + userId;
CachedValue<String> cached = redis.opsForValue().get(cacheKey); // value + embedded expiresAt
if (cached == null) return fetchAndCache(userId, cacheKey);
double beta = 1.0;
double remainingTtl = cached.expiresAt() - System.currentTimeMillis() / 1000.0;
// Probabilistically decide whether to refresh early
if (-beta * Math.log(Math.random()) > remainingTtl) {
CompletableFuture.runAsync(() -> fetchAndCache(userId, cacheKey)); // async, non-blocking
}
return cached.value();
}Redis Cluster Topology and Hash Slots
Redis Cluster distributes data across nodes using 16,384 hash slots. The slot for a key is computed as `CRC16(key) % 16384`. Slots are assigned to primary nodes; each primary replicates its slots to one or more replica nodes.
Typical 3-primary, 3-replica layout: - Node A (primary): slots 0–5460 | Node D (replica of A) - Node B (primary): slots 5461–10922 | Node E (replica of B) - Node C (primary): slots 10923–16383 | Node F (replica of C)
MOVED redirects: When a client sends a command to the wrong node, that node replies with `MOVED 3999 127.0.0.1:6381`, telling the client which node owns that slot. A smart client (Lettuce, Jedis Cluster) caches the slot-to-node map and follows MOVEDs automatically, then updates its routing table. This adds one extra round-trip only when the topology changes.
ASK redirects: During a slot migration (cluster resharding), keys for a slot may be split between the source and target node. The source node replies `ASK` for keys it no longer holds. The client sends an ASKING command to the target node, then retries the operation — without updating its cached routing table, since the migration is not yet complete.
Replica promotion (automatic failover): When a primary fails, its replicas detect the failure via gossip heartbeat timeouts (default: `cluster-node-timeout = 15s`). The replica with the most up-to-date replication offset campaigns for election. Other primaries vote; the winning replica promotes itself, takes ownership of the dead node's hash slots, and gossips the new topology to all peers. The client receives MOVED errors during the ~5s window and retries automatically.
Java — Lettuce Cluster client with replica reads and hash tags
// Lettuce Redis Cluster client — Spring Boot configuration
import io.lettuce.core.cluster.RedisClusterClient;
import io.lettuce.core.cluster.api.StatefulRedisClusterConnection;
import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;
@Configuration
public class RedisClusterConfig {
@Bean
public RedisClusterClient redisClusterClient() {
// Provide seed nodes — Lettuce discovers the full topology via CLUSTER INFO
return RedisClusterClient.create(List.of(
RedisURI.create("redis://node-a:6379"),
RedisURI.create("redis://node-b:6379"),
RedisURI.create("redis://node-c:6379")
));
}
@Bean
public StatefulRedisClusterConnection<String, String> clusterConnection(
RedisClusterClient client) {
StatefulRedisClusterConnection<String, String> conn = client.connect();
// Allow reads from replicas — eventual consistency, higher read throughput
conn.setReadFrom(ReadFrom.REPLICA_PREFERRED);
return conn;
}
}
// Using hash tags to co-locate related keys on the same slot
// Both keys hash using only the content inside {}, i.e. "user:42"
// → guaranteed same slot → safe to use MGET or pipelined operations
String profileKey = "{user:42}:profile";
String cartKey = "{user:42}:cart";
String sessionKey = "{user:42}:session";
RedisAdvancedClusterCommands<String, String> commands = connection.sync();
List<KeyValue<String, String>> values = commands.mget(profileKey, cartKey, sessionKey);Caching Patterns: Cache-Aside, Write-Through, and Write-Behind
The relationship between the cache and the database is controlled by a caching pattern. Each has different consistency, latency, and failure-mode trade-offs.
Cache-aside (lazy loading): The application is responsible for both reading and writing the cache. On a cache miss, the app reads from the DB and writes the result to the cache. The DB is always the authoritative source. Pro: cache only contains data that was actually requested; easy to implement. Con: first request after a cold start or expiry always hits the DB (miss penalty); stale data risk if a DB write does not invalidate the cache.
Write-through: Every write to the DB is also written to the cache synchronously. The cache is always up to date. Pro: zero stale reads after a write. Con: every write takes two round-trips (cache + DB); cache fills with data that may never be read (write-heavy, read-light workloads waste memory).
Write-behind (write-back): Writes go to the cache immediately and are asynchronously flushed to the DB in the background. Pro: write latency is minimal (cache write only); absorbs write bursts. Con: if the cache node crashes before the flush, writes are lost; requires a durable write buffer (Redis AOF or a separate queue). Appropriate for counters, analytics, and user activity feeds — workloads where losing a few seconds of writes is tolerable.
Which to choose: Use cache-aside for most read-heavy services. Use write-through when consistency is critical and your write rate is moderate. Use write-behind for high-throughput counters and leaderboards where the DB would otherwise become a bottleneck.
Java — Cache-aside, write-through, and write-behind patterns
// Cache-aside pattern — standard Spring Boot service
@Service
public class ProductService {
private static final Duration TTL = Duration.ofMinutes(15);
private final RedisTemplate<String, Product> redis;
private final ProductRepository db;
public Product getProduct(String productId) {
String key = "product:" + productId;
// 1. Cache hit
Product cached = (Product) redis.opsForValue().get(key);
if (cached != null) return cached;
// 2. Cache miss — fetch from DB
Product product = db.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 3. Populate cache
redis.opsForValue().set(key, product, TTL);
return product;
}
// Write-through — keep cache consistent on every update
public Product updateProduct(String productId, ProductUpdateRequest req) {
Product product = db.save(db.findById(productId)
.orElseThrow()
.applyUpdate(req));
// Synchronously update cache — never serve stale data after a write
redis.opsForValue().set("product:" + productId, product, TTL);
return product;
}
}
// Write-behind — fire-and-forget DB flush for counters
@Service
public class ViewCountService {
private final RedisTemplate<String, Long> redis;
private final ApplicationEventPublisher events;
public void recordView(String productId) {
String key = "views:" + productId;
Long count = redis.opsForValue().increment(key); // in-memory, sub-millisecond
// Async flush to DB every 100 views or on a scheduled task
if (count != null && count % 100 == 0) {
events.publishEvent(new FlushViewCountEvent(productId, count));
}
}
}Key Trade-offs
Consistency vs availability during network partition
Redis Cluster continues to serve the nodes that remain reachable during a partition, even if some slots are temporarily unavailable. A cache that becomes unavailable is far worse than one that briefly serves stale data — the DB fallback path handles misses. Choosing CP (stopping writes on partition) would mean cache unavailability ripples into DB overload.
LRU vs LFU eviction policy
LRU wins when access patterns have strong temporal locality — recently used keys are most likely to be used again (user sessions, hot tweets). LFU wins when popularity is stable over time (product pages, static content) — it avoids evicting frequently accessed keys that happen to have a brief quiet period. Using the wrong policy can cut hit ratio by 10–20%.
Replication factor (1 replica vs 2 replicas per primary)
A single replica provides failover protection at a 2x memory cost. Two replicas survive two simultaneous node failures and also scale read throughput across three nodes per slot, but triple the memory cost. For caches (where a miss gracefully falls through to the DB), 1 replica is sufficient. Use 2 replicas only when DB fallback latency is unacceptable (e.g. user-facing API latency SLAs under 5ms).
Synchronous (write-through) vs asynchronous (write-behind) cache population
Write-through guarantees the cache is always consistent with the DB immediately after a write, but doubles write latency (two round-trips). Write-behind minimises write latency by flushing asynchronously, but a cache crash before flushing loses writes. Mixing both in the same system is common: write-through for entity data, write-behind for counters and leaderboards.
Interview Tips
- 1Open by distinguishing cache-aside, write-through, and write-behind before drawing any boxes. Interviewers expect you to justify the choice, not just pick one.
- 2When explaining Redis Cluster, draw the 16,384 hash slots explicitly. Say "CRC16(key) % 16384" — using the exact formula signals you have worked with it in production, not just read about it.
- 3Always bring up cache stampede proactively. Describe the mutex-lock mitigation, then mention probabilistic early recomputation (PER) as the low-latency alternative — this almost always surprises interviewers and differentiates strong candidates.
- 4Distinguish LRU from LFU with a concrete example: "For a news feed I would use LRU because last hour's articles drive 90% of traffic; for a product catalogue I would use LFU because the top-100 bestsellers are always popular." Concrete examples beat abstract definitions.
- 5For hotspot keys (e.g. a viral tweet), mention two defences: (1) local in-process L1 cache (Caffeine) in front of Redis so the network hop is eliminated entirely; (2) Redis hash tags to explicitly co-locate related keys, and explicit shard-splitting using key suffixes for read-heavy singletons.
- 6Finish your design by quantifying hit ratio: "At 90% hit ratio and 1M ops/sec, only 100K ops/sec reach the database — a 10x reduction in DB load." Interviewers appreciate candidates who tie architecture to numbers.
Discussion
Discussion
Sign in to join the discussion.