System Design · Guide

Caching Strategies

Cache-aside, CDN, eviction, stampede prevention.

— min read System Design

Theory

Caching trades freshness for speed — always define TTL, invalidation, and stampede protection for hot keys.

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 cache

Examples

python — cache-aside
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
python — stampede lock
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 — config & monitoring
# 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

Watch for these patterns — they cause most production incidents around Caching Strategies.
  • 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

Cache-asideApp reads cache first, loads DB on miss, writes back
Write-throughWrite to cache and DB synchronously
Write-behindWrite to cache first, async flush to DB
TTLTime-to-live — automatic expiry per key
LRU / LFUEvict least recently / least frequently used
Cache stampedeMany requests miss cache simultaneously
JitterRandomize TTL to spread expiry thundering herd
SETEXRedis: set key with expiry in one command
Cache-miss ratePrimary SLO metric for cache effectiveness

Practical Exercises

Cache-aside in code

Wrap a DB query with Redis GET/SET. On miss, query Postgres and SETEX with 300s TTL. Measure hit ratio under repeated reads.

Stampede simulation

Expire a hot key while running 50 concurrent clients. Add a mutex or request coalescing layer and compare DB query count before/after.

TTL jitter experiment

Set 1000 keys with identical TTL vs jittered TTL. Graph expiry-time DB load spikes.