Caching Strategies
Cache-aside, CDN, eviction, stampede prevention.
Theory
Caching stores frequently accessed data in a fast layer (memory, CDN edge) to reduce latency and backend load. The fundamental trade-off is consistency vs speed — cached data may be stale until invalidated or expired.
Cache-aside (lazy loading): app checks cache first; on miss, reads DB, writes to cache, returns. App owns invalidation on writes. Most common pattern — simple but requires careful TTL and invalidation logic to avoid stale reads.
Write-through: app writes to cache and DB synchronously — cache always warm but write latency includes cache write. Write-behind (write-back): app writes to cache, async flush to DB — highest write throughput but risk of data loss on cache failure before flush.
Write-around: writes go directly to DB, bypassing cache — avoids flooding cache with write-heavy data never read again. Good for write-once-read-rarely workloads like audit logs.
TTL (time-to-live) bounds staleness. Add jitter (random ±10%) to TTLs so keys don't all expire simultaneously. Shorter TTL for frequently changing data; longer for static reference data. No TTL means manual invalidation only — dangerous if forgotten.
Cache stampede: many requests miss the same hot key simultaneously, all hammer the DB. Mitigations: mutex lock (only one thread recomputes), probabilistic early expiration (refresh before TTL ends), request coalescing (singleflight pattern), or pre-warming on deploy.
Redis supports rich data structures (strings, hashes, sorted sets), persistence (RDB/AOF), pub/sub, and Lua scripting — use as cache + session store + rate limiter. Memcached is simpler (strings only), multithreaded, no persistence — pure ephemeral cache at massive scale.
Eviction policies: LRU (Least Recently Used) evicts oldest accessed keys — good general default. LFU (Least Frequently Used) keeps hot keys longer — better for skewed access patterns. Redis also offers allkeys-lru, volatile-lru, allkeys-lfu. Monitor evicted_keys metric — high eviction means undersized cache.
Architecture Diagram
Client request
|
Cache lookup
/ \
HIT MISS
| |
return Origin / DB
+ populate cacheExamples
import redis, json
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def get_user(user_id: int) -> dict:
key = f"user:{user_id}"
cached = r.get(key)
if cached:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if user:
r.setex(key, 300, json.dumps(user)) # TTL 5 min + jitter in prod
return user
def update_user(user_id: int, data: dict):
db.execute("UPDATE users SET ... WHERE id = %s", user_id, data)
r.delete(f"user:{user_id}") # invalidate on write
def get_with_lock(key: str, loader, ttl=300):
val = r.get(key)
if val:
return json.loads(val)
lock_key = f"lock:{key}"
if r.set(lock_key, "1", nx=True, ex=10): # acquire lock
try:
val = r.get(key) # double-check
if val:
return json.loads(val)
data = loader()
r.setex(key, ttl, json.dumps(data))
return data
finally:
r.delete(lock_key)
else:
time.sleep(0.05) # wait for winner
return get_with_lock(key, loader, ttl)
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
appendonly yes
# CLI checks
redis-cli INFO stats | grep evicted_keys
redis-cli --latency-history
redis-cli MEMORY USAGE user:12345
Interview Questions
What is cache-aside and when do you use it?
App manages cache: read cache → on miss load DB → populate cache. Use for read-heavy workloads where stale data within TTL is acceptable. Invalidate or update cache on writes. Most flexible pattern — works with any cache store.
Compare write-through vs write-behind caching.
Write-through: synchronous cache + DB write — strong consistency, higher write latency. Write-behind: write cache first, async DB flush — higher throughput, risk of data loss if cache dies before flush. Use write-behind only when durability can be relaxed or dual-written.
What is a cache stampede and how do you prevent it?
Many concurrent requests miss the same expiring hot key and all hit the DB. Fix with: distributed lock so one request rebuilds, singleflight coalescing, probabilistic early refresh before expiry, or external pre-warming. Always add TTL jitter to spread expirations.
Redis vs Memcached — when to choose each?
Redis: data structures, persistence, pub/sub, Lua, replication — multi-purpose. Memcached: pure key-value, multithreaded, simpler, excellent for horizontal scale-out caching only. Choose Memcached for massive simple cache; Redis when you need structures or persistence.
LRU vs LFU eviction — which is better?
LRU evicts by last access time — good when recency predicts future use. LFU evicts least frequently used — better for stable hot sets (always-popular items). Redis 4+ supports LFU. Monitor hit ratio; if hot keys get evicted, increase memory or switch policy.
How do you invalidate cache on database updates?
Options: delete cache key on write (simple), update cache with new value (write-through), publish invalidation event via message queue for distributed caches, or versioned keys (user:123:v5). Avoid TTL-only for data that must be immediately consistent after writes.
FAANG: Design caching for a news feed at 1M QPS.
CDN for static assets. Redis cluster for feed fragments keyed by user_id + cursor with short TTL. Cache-aside with singleflight on miss. Precompute feeds for active users async. Shard Redis by user_id hash. Monitor p99 latency and evicted_keys; size for working set not full dataset.
What metrics indicate cache health?
Hit ratio (hits / (hits + misses)), latency p50/p99, evicted_keys rate, memory usage vs maxmemory, connection count, and backend QPS drop after cache deploy. Target >90% hit ratio for hot paths; investigate sudden miss spikes as stampede or invalidation bugs.
Best Practices
- Cache only idempotent, reconstructable data — never cache auth tokens or user-specific secrets in shared layers without encryption.
- Add jitter to TTLs (
TTL + random(0, 60s)) so hot keys do not all expire at once. - Use cache-aside for most read-heavy paths — simpler invalidation than write-through when DB is source of truth.
- Monitor hit ratio and miss latency — a cache with 40% hits may still add latency if misses are slower than direct DB.
- Size eviction policy to workload: LRU for general traffic, LFU for skewed hot-key patterns.
- Invalidate on write explicitly — TTL alone causes stale reads during the expiry window.
Common Mistakes
- Caching without a stampede guard — one expiry triggers thousands of concurrent DB queries and melts the database.
- Using infinite TTL on frequently updated data — users see stale prices, permissions, or inventory for hours.
- Treating Redis as durable storage — cache loss should be recoverable; persist critical state in the DB.
- Caching large blobs (multi-MB JSON) — evicts useful keys and increases network overhead; cache pointers or slices instead.
- No cache warming after deploy — cold start sends 100% traffic to DB until cache fills.
Trade-off Analysis
Caching Strategies improves cache-aside, cdn, eviction, stampede prevention. but adds operational and cognitive complexity — justify with load and team size.
Favor simplicity until metrics (p99 latency, error rate, cost) prove the pattern necessary.
Every redundancy layer trades capital/operational cost for availability — align with explicit SLO targets.
Document accepted inconsistency windows and recovery behavior before production cutover.
Cheat Sheet
Practical Exercises
Wrap a DB query with Redis GET/SET. On miss, query Postgres and SETEX with 300s TTL. Measure hit ratio under repeated reads.
Expire a hot key while running 50 concurrent clients. Add a mutex or request coalescing layer and compare DB query count before/after.
Set 1000 keys with identical TTL vs jittered TTL. Graph expiry-time DB load spikes.