Requirements & scope
Problem Statement & Requirements
Design a distributed, in-memory caching system that sits between application servers and persistent storage to dramatically reduce read latency, offload databases, and absorb traffic spikes — similar to Redis, Memcached, or Amazon ElastiCache.
Functional Requirements
- GET / SET / DELETE — basic key-value operations with sub-millisecond latency
- TTL (Time-to-Live) — automatic key expiration
- Rich data structures — strings, hashes, lists, sets, sorted sets, bitmaps, HyperLogLog, streams
- Atomic operations — INCR, DECR, SETNX (set-if-not-exists), CAS (compare-and-swap)
- Pub/Sub — publish-subscribe messaging for cache invalidation and real-time events
- Lua scripting — execute atomic multi-step operations server-side
- Persistence — optional RDB snapshots and AOF (append-only file) for durability
- Cluster mode — automatic sharding across nodes with rebalancing
- Replication — master-replica for high availability and read scaling
Non-Functional Requirements
- Ultra-low latency — < 1 ms for cache hits (p99 < 2 ms)
- High throughput — 100K-1M+ operations/second per node
- High availability — 99.999% uptime with automatic failover
- Horizontal scalability — add nodes to increase capacity linearly
- Memory efficiency — maximize useful data per GB of RAM
- Consistency — strong consistency for single-key operations; eventual consistency across replicas
- Predictable performance — no garbage collection pauses, no swap, bounded tail latency
Out of Scope
- Full database replacement (we're designing a cache, not a primary data store)
- SQL query caching (application-level concern)
- Multi-region active-active replication (mention as extension)
Scale estimations
Scale Estimations
Traffic
| Metric | Value |
|---|---|
| Read operations / second | 10M RPS (across cluster) |
| Write operations / second | 1M RPS (10:1 read-write ratio) |
| Peak reads / second | 30M RPS (3x average) |
| Number of application servers | 5,000 |
| Connections per app server | ~20 (connection pool) |
| Total client connections | ~100,000 |
Storage
| Metric | Value |
|---|---|
| Total unique keys | 5 billion |
| Average key size | 50 bytes |
| Average value size | 500 bytes |
| Average entry (key + value + metadata overhead) | ~650 bytes → round to 1 KB |
| Total working set | 5B × 1 KB = ~5 TB |
| Hot data (20% accessed in any hour) | ~1 TB |
| Memory per node (typical) | 64-128 GB usable |
| Number of nodes for 5 TB | ~50-80 primary nodes |
| With replication (1 replica each) | 100-160 total nodes |
Bandwidth
| Metric | Value |
|---|---|
| Read bandwidth (10M × 500 B avg value) | ~5 GB/s cluster-wide |
| Write bandwidth (1M × 500 B) | ~500 MB/s cluster-wide |
| Per-node bandwidth (80 nodes) | ~70 MB/s per node |
| Replication traffic | ~500 MB/s (async to replicas) |
Hardware Estimate (Per Node)
| Component | Spec |
|---|---|
| CPU | 8-16 cores (Redis is mostly single-threaded per shard, but I/O threads help) |
| RAM | 128 GB (64 GB usable for data, rest for OS/fragmentation/fork overhead) |
| Disk | 500 GB NVMe SSD (for RDB snapshots + AOF persistence) |
| Network | 25 Gbps NIC |
| Nodes in cluster | 80 primary + 80 replica = 160 total |
Layered architecture
Phase 1 of 4 · Client & edge
Start with the entry points
First, define who uses the system and where requests enter. This is the boundary of the design, not yet a complete request path.
- Application
- On a miss, load from the database and populate the cache with a bounded TTL.
- Hash router
- Use stable key routing and connection pooling to distribute load across shards.
This first layer defines the system boundary. The next chapters add application logic and data dependencies. After examining the request paths and tradeoffs, we will add operational safeguards and bring the complete architecture together.
API & contracts
Phase 2 of 4 · Application & compute
Add the application logic
Keep the entry points and add the services that do the work. Give each service a clear responsibility before defining its contracts. Storage and background dependencies come next.
- Cache primaries
- Store hot values and apply an explicit eviction policy when memory fills.
- Local L1 cache
- Use short-lived in-process caching selectively to absorb extreme hot-key traffic.
API Design
Core Operations
# String operations
SET key value [EX seconds] [PX milliseconds] [NX|XX]
GET key → value or nil
MGET key1 key2 key3 → [val1, val2, val3] (batch read)
MSET key1 val1 key2 val2 → OK (batch write, atomic)
INCR key → new_value (atomic increment)
SETNX key value → 1 if set, 0 if already exists (mutex primitive)
# Hash operations (object caching)
HSET user:123 name "Alice" age 30 city "Tokyo"
HGET user:123 name → "Alice"
HGETALL user:123 → {name: "Alice", age: "30", city: "Tokyo"}
# List operations (queue, feed)
LPUSH queue:emails msg1 msg2 → push to head
RPOP queue:emails → pop from tail (FIFO queue)
LRANGE feed:user:123 0 19 → get first 20 items
# Set operations (unique collections)
SADD tags:article:456 "redis" "cache" "design"
SISMEMBER tags:article:456 "redis" → 1 (true)
SINTER tags:article:456 tags:article:789 → intersection
# Sorted set operations (leaderboards, priority queues)
ZADD leaderboard 9500 "player-A" 8200 "player-B" 9800 "player-C"
ZREVRANGE leaderboard 0 9 WITHSCORES → top 10 players
ZRANK leaderboard "player-A" → rank
# TTL and expiration
EXPIRE key 3600 → expires in 1 hour
TTL key → seconds remaining (-1 = no expiry, -2 = doesn't exist)
# Pub/Sub
PUBLISH channel:inventory msg → broadcast to all subscribers
SUBSCRIBE channel:inventory → receive messages
# Atomic transactions (MULTI/EXEC)
MULTI
SET account:A:balance 900
SET account:B:balance 1100
EXEC → atomic execution of both commands
# Lua scripting (complex atomic operations)
EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else return 0 end"
1 lock:resource "lock-token-123"
→ atomic compare-and-delete (used for distributed locks)Application-Level Caching Patterns (Client-Side)
# Cache-Aside (Lazy Loading) — most common
def get_user(user_id):
cached = cache.get(f"user:{user_id}")
if cached:
return deserialize(cached)
user = db.query("SELECT * FROM users WHERE id = ?", user_id)
cache.set(f"user:{user_id}", serialize(user), ex=3600)
return user
# Write-Through
def update_user(user_id, data):
db.update("UPDATE users SET ... WHERE id = ?", data, user_id)
cache.set(f"user:{user_id}", serialize(data), ex=3600)
# Write-Behind (Write-Back)
def update_user_async(user_id, data):
cache.set(f"user:{user_id}", serialize(data), ex=3600)
queue.enqueue("db_write", {"user_id": user_id, "data": data})
# Background worker flushes to DB in batches
# Read-Through (cache library handles miss transparently)
# Configured at infrastructure level, not application codeData model
Phase 3 of 4 · Data & async work
Connect data and background work
The services now need their dependencies: durable state, fast lookups, or work that continues outside a request. Separate these responsibilities rather than treating every component as a database.
- Cache replicas
- Replicate cache state for availability while accepting a defined amount of staleness.
- Primary database
- Keep recoverable authoritative data outside the cache; cache failures must not destroy records.
- Invalidation
- Invalidate or update affected entries on writes and use TTL as a bound, not as perfect consistency.
Data Model
Internal Memory Layout
Data Structure Encodings (Memory Optimization)
Cluster Slot Assignment
Core design decisions
Core Design Decisions
Decision 1: Single-Threaded vs Multi-Threaded Execution
This is the most debated design choice in cache systems.
Option A: Single-Threaded Command Execution (Redis)
| Pros | Cons |
|---|---|
| No locks, no race conditions | Can't use multiple cores for one shard |
| Atomic operations are free | Single slow command (KEYS *) blocks everything |
| Simple, predictable latency | Max ~300K ops/sec per shard for complex ops |
| Easier to reason about | Fork for persistence competes for memory |
Option B: Multi-Threaded (Memcached / Dragonfly)
| Pros | Cons |
|---|---|
| Uses all CPU cores per node | Lock contention on hot keys |
| Higher single-node throughput | Multi-key atomicity requires distributed locks |
| Better for large values (parallel serialization) | Harder to reason about race conditions |
| Fewer nodes for same total throughput | More complex implementation |
Recommendation
Single-threaded command execution (Redis model) for most use cases:
- Simplicity and correctness trump raw throughput per node
- Network is the bottleneck, not CPU — single thread saturates the NIC
- Scale horizontally via sharding (more shards, more nodes)
- I/O threads in Redis 6+ handle the socket bottleneck
Multi-threaded (Dragonfly) when:
- You want to minimize node count (cost optimization)
- Workload is dominated by large values (serialization-heavy)
- You can tolerate more complex operational model
Decision 2: Eviction Policies
Decision 3: Persistence Strategy
Consistent Hashing & Data Distribution
Why Consistent Hashing
Redis Cluster Approach (Hash Slots)
Request flows
Detailed Flow Diagrams
Cache-Aside Read Flow
Write-Invalidate Flow (Cache + DB Consistency)
Cluster Redirect Flow (MOVED / ASK)
Failover Flow (Sentinel / Cluster)
Performance & caching
Caching Patterns Deep Dive
Pattern Comparison
When to Use Which Pattern
| Pattern | Best For | Avoid When |
|---|---|---|
| Cache-Aside | General-purpose caching; read-heavy workloads | Write-heavy with strong consistency needs |
| Read-Through | Same as cache-aside but with cleaner code | Custom loading logic varies per key type |
| Write-Through | Read-after-write consistency required | Write-heavy but data rarely re-read |
| Write-Behind | Write-heavy, can tolerate brief data loss risk | Financial / transactional data |
| Refresh-Ahead | Predictable hot keys (config, leaderboards) | Long-tail access patterns |
Redis vs Memcached — When to Use Which
Advanced design
A hot key can overwhelm one shard even when the cluster has plenty of free memory. Replicating reads or keeping a small local copy may help, but both add invalidation work. Measure the key’s request rate separately from total cluster traffic before assuming that adding partitions will distribute its load.
Edge cases
Handling Edge Cases
Cache Stampede / Thundering Herd
Problem: Hot key expires → 10,000 threads all miss simultaneously
→ all 10,000 query DB → DB overwhelmed
Solution 1: Distributed Lock (Setnx)
Thread 1: SETNX lock:user:123 "1" EX 5 → OK (acquired)
Thread 2: SETNX lock:user:123 "1" EX 5 → FAIL (wait)
Thread 3: SETNX lock:user:123 "1" EX 5 → FAIL (wait)
...
Thread 1: fetches from DB → SET user:123 → DEL lock:user:123
Threads 2,3,...: retry GET → HIT
Solution 2: Probabilistic Early Expiration (best for high-throughput)
Actual TTL = 3600s
Each read: if current_time > (expire_time - delta * beta * ln(rand()))
→ preemptively refresh (only one thread statistically wins)
→ Cache never fully expires under load
Solution 3: Background Refresh
TTL = 3600s, but at 3000s (80% of TTL), background thread refreshes
→ Key never expires → zero stampede
→ Requires tracking hot keysHot Key Problem
Problem: One key gets 100K reads/sec (celebrity profile, flash sale item)
→ Single shard/node overwhelmed
Solution 1: Local In-Process Cache (L1)
App server caches hottest keys in process memory (HashMap with TTL)
→ Short TTL (5-10s) to limit staleness
→ Zero network overhead for hits
→ Each app server has its own copy
Solution 2: Key Replication (read replicas for hot keys)
Detect hot key → replicate to N random shards with suffix:
"user:celebrity" → "user:celebrity#r1", "user:celebrity#r2", ...
Client randomly picks one → distributes load across N nodes
Solution 3: Shard-level Read Replicas
Hot shard gets more read replicas (3 → 6)
Client reads from any replica → load distributed
Detection: track per-key access frequency at proxy layer
→ Key exceeding 10K RPS threshold → trigger hot key mitigationCache Penetration
Cache Avalanche
Problem: Many keys expire at the SAME time (e.g., all set at startup with same TTL)
→ Massive simultaneous cache miss → DB overload
Solution: Jittered TTL
base_ttl = 3600
jitter = random(0, 600) // ±10 minutes
actual_ttl = base_ttl + jitter
→ Expirations spread over 10-minute window instead of all at once
Solution: Staggered warming
On service restart, don't load all cache at once
→ Load in batches of 1000, with 100ms delay between batches
→ Prevents overwhelming both cache and DBCache-Database Consistency
Tradeoffs
Tradeoffs & Design Decisions Summary
| Decision | Option A | Option B | Chosen | Why |
|---|---|---|---|---|
| Threading | Single-threaded (Redis) | Multi-threaded (Dragonfly) | Single-threaded | Simplicity, atomicity for free, network-bound anyway; scale via sharding |
| Eviction | LRU | LFU | allkeys-lfu (Redis 4.0+) | Resists scan pollution; better hit ratio for skewed distributions |
| Persistence | RDB snapshots | AOF log | Hybrid RDB+AOF | Fast recovery (RDB) + minimal loss (AOF everysec) |
| Cluster Topology | Consistent hashing (ring) | Fixed hash slots (Redis Cluster) | Hash slots | Simpler, deterministic, fine-grained slot migration |
| Caching Pattern | Cache-aside | Write-through | Cache-aside | Only caches accessed data; cache failure = degraded, not broken |
| Invalidation | Update cache on write | Delete cache on write | Delete | Avoids race conditions; idempotent; simpler |
| Replication | Synchronous | Asynchronous | Async | Sub-ms latency; sync replication adds 1+ RTT per write |
| Serialization | JSON | MessagePack / Protobuf | Protobuf | 3-5x smaller than JSON; faster serialization; worth the complexity |
| Connection | Per-request | Connection pool | Pool (20-50 per app) | Eliminates TCP/TLS handshake per request; reduces server socket count |
| Hot Key | Single shard handles all | L1 local cache + key replication | L1 cache + detection | Zero network for hottest keys; replicas for extreme cases |
Reliability & fault tolerance
Phase 4 of 4 · Operations & resilience
Add operational safeguards
With the request paths and tradeoffs established, add the controls that help detect trouble and keep the system operating. The final diagram retains every layer introduced so far.
- Cache protection
- Coalesce concurrent misses and jitter expiry to reduce stampedes during cold starts.
Reliability & Fault Tolerance
Single Points of Failure & Mitigations
Graceful Degradation
Level 1 (single replica failure):
→ Primary continues serving. No impact.
→ New replica auto-provisioned.
Level 2 (single primary failure):
→ Sentinel promotes replica in 5-15s.
→ Brief connection errors during failover.
→ Clients reconnect to new primary via Sentinel.
Level 3 (multiple shard failures):
→ Affected key ranges unavailable.
→ Application circuit breaker activates → fall back to DB.
→ Other shards continue normally.
Level 4 (full cache cluster down):
→ ALL reads go to database.
→ Database must handle full load (capacity plan for this!).
→ Rate limiting to protect DB.
→ Cache rebuilt on restart (warm-up takes minutes to hours).
Key design principle:
Cache is an OPTIMIZATION, not a requirement.
The system MUST function (slower) without cache.
Never put data ONLY in cache unless you accept loss.Data Loss Scenarios
Production architecture
High-Level Architecture
Component Breakdown
Full System Architecture (Production-Grade)
Further exploration
Remove the busiest cache node during peak traffic. Estimate the new load on its neighbors and on the backing database. Compare consistent hashing, replication, and request coalescing: each protects a different part of the recovery path, and none removes the need to bound concurrent cache fills.
Interview playbook
Interview Tips
Start with caching patterns — Explain cache-aside vs write-through vs write-behind. Most interviews expect you to know these cold. Draw the flow for cache-aside (check cache → miss → query DB → populate cache).
Discuss consistency immediately — "How do you keep cache and DB in sync?" Explain why DELETE is safer than UPDATE, the double-write race condition, and CDC as the gold standard.
Cache stampede is the #1 follow-up question — Have the three solutions ready: distributed lock (SETNX), probabilistic early expiration, and background refresh. Know when each applies.
Know your eviction policies — Don't just say "LRU". Explain why approximate LRU (sampling) is used over true LRU (no linked list overhead), and when LFU beats LRU (scan resistance).
Hot key problem — Interviewers love this. Solution: L1 in-process cache (5-10s TTL) for zero-network hot reads, then key replication across shards for extreme cases.
Explain why single-threaded works — "Isn't single-threaded slow?" No — in-memory ops are 100-500 ns, network is 50K+ ns. CPU isn't the bottleneck. Single-thread gives atomicity for free. Scale horizontally via sharding.
Cluster topology — Explain hash slots (16,384), how slot migration works live with MOVED/ASK redirects, and why cross-AZ replica placement matters.
Persistence tradeoffs — RDB (fast recovery, data loss) vs AOF (minimal loss, slower recovery) vs hybrid (best of both). Mention fork overhead and why replicas should handle RDB saves.
Size your cache — Don't guess. Calculate: key count × (key size + value size + overhead). Remember jemalloc fragmentation (~10-20%). Account for replication doubling the node count.
End with operational concerns — Memory monitoring (never let it hit maxmemory without an eviction policy), slow log analysis, replication lag alerting, and the importance of connection pooling (100K app connections → 1K with pools → manageable for cache nodes).