Chapter 21: Caching Strategies

Interview Focus: Caching questions test your understanding of trade-offs between consistency, performance, and complexity. Be ready to discuss cache invalidation (hardest problem), eviction policies, and multi-layer architectures. Always ask about access patterns and consistency requirements.

1. Why Cache

Latency Comparison

Storage Layer Typical Latency Speedup vs DB Use Case
CPU L1 Cache 0.5 ns 20,000,000x CPU registers
CPU L2 Cache 7 ns 1,400,000x CPU cache lines
RAM 100 ns 100,000x In-process cache (e.g., local map)
Redis/Memcached (same DC) 1 ms 10x Distributed cache
Database (same DC) 10 ms 1x Source of truth
Database (cross-region) 100 ms 0.1x Geo-distributed DB

Benefits Beyond Latency

Benefit Impact Example
Throughput Redis: 100K ops/sec vs DB: 10K ops/sec Handle 10x more traffic with same DB
Cost 95% cache hit rate = 20x fewer DB queries $1K/mo cache vs $20K/mo DB scale-out
DB Protection Prevents thundering herd on source system Traffic spike doesn't kill DB
Network Savings Reduce cross-region bandwidth Cache in each region vs global DB

Cache Hierarchy

graph TB Client[Client Browser] CDN[CDN Edge Cache
Latency: 10-50ms] Gateway[API Gateway Cache
Latency: 1-5ms] AppCache[Application Cache
Redis/Memcached
Latency: 1-2ms] LocalCache[Local In-Memory
Latency: 0.1ms] DBCache[DB Query Cache
Latency: 2-5ms] DB[(Database
Latency: 10-50ms)] Client -->|Static assets| CDN Client -->|API request| Gateway Gateway --> LocalCache LocalCache -->|Miss| AppCache AppCache -->|Miss| DBCache DBCache -->|Miss| DB style CDN fill:#E8F4FF style Gateway fill:#E8F4FF style AppCache fill:#FFE8E8 style LocalCache fill:#FFE8E8 style DBCache fill:#E8FFE8 style DB fill:#FFE8CC
Interview Tip: When asked "How would you improve performance?", always consider caching first. Quantify the benefit: "If 90% of reads are for the same 10 products, caching gives us 9x fewer DB hits."

2. Cache-Aside (Lazy Loading)

Read Flow

sequenceDiagram participant App participant Cache participant DB Note over App,DB: Cache Hit Scenario App->>Cache: GET user:123 Cache-->>App: {name: "Alice", ...} Note over App,DB: Cache Miss Scenario App->>Cache: GET user:456 Cache-->>App: null (miss) App->>DB: SELECT * FROM users WHERE id=456 DB-->>App: {name: "Bob", ...} App->>Cache: SET user:456 {data} TTL=3600 Cache-->>App: OK App-->>App: Return data to client

Write Flow

sequenceDiagram participant App participant Cache participant DB App->>DB: UPDATE users SET name='Charlie' WHERE id=123 DB-->>App: OK App->>Cache: DELETE user:123 Cache-->>App: OK Note over App,Cache: Next read will cache miss
and load fresh data

Trade-offs

Aspect Pros Cons
Complexity Simple to implement
Application controls logic
Cache logic scattered in code
Every read needs cache check
Consistency Writes invalidate stale data
DB always authoritative
Read-after-write may return stale
(if another node updated cache)
Performance Only cache what's accessed
No wasted space
First access is slow (cold cache)
Cache miss = 2 network calls
Resilience Cache failure doesn't block reads
Degraded performance only
Stampede risk on cache failure
All traffic hits DB

Implementation Patterns

Pattern Code Example When to Use
Simple Cache-Aside data = cache.get(key)
if !data:
  data = db.query()
  cache.set(key, data, ttl)
Most common pattern
Read-heavy workloads
Write-Invalidate db.update(data)
cache.delete(key)
Simple consistency model
Next read gets fresh data
Write-Update db.update(data)
cache.set(key, data, ttl)
Read-after-write performance
Risk: race conditions
Versioned Cache key = "user:123:v" + hash(data)
cache.set(key, data)
Immutable cache entries
No invalidation needed
Interview Question: "Why not update cache on write instead of delete?" Answer: Race conditions. Thread A writes v1 to DB, thread B writes v2 to DB, thread B updates cache to v2, thread A (delayed) updates cache to v1. Cache now has stale data. Deletion is safer.

3. Write-Through Cache

Write Flow

sequenceDiagram participant App participant Cache participant DB App->>Cache: SET user:123 {data} Cache->>DB: UPDATE users WHERE id=123 DB-->>Cache: OK Cache-->>App: OK Note over App,DB: Cache and DB updated atomically
Read always hits cache App->>Cache: GET user:123 Cache-->>App: {data} (always fresh)

Trade-offs

Aspect Pros Cons
Consistency Cache always consistent with DB
No stale reads possible
Write latency = cache + DB latency
Both must succeed (availability risk)
Performance Reads always fast (cache hit)
No cache miss penalty
Writes 2x slower (sequential)
Wasted space (cache all writes)
Complexity Application code simpler
Cache handles DB sync
Cache must support write-through
Rollback logic complex
Durability Data persisted to DB immediately
No data loss on cache failure
Slower than write-behind
Not suitable for high-write systems

Use Cases

Scenario Why Write-Through Example
Read-heavy, strong consistency Every read hits cache, always fresh User profile service (read 100x more than writes)
Write-once, read-many Cache populated on write, many reads hit cache Blog posts, product catalog
Small working set All active data fits in cache Session store (active sessions only)
Regulatory compliance Immediate DB persistence required Financial transactions, audit logs
Interview Trap: "Write-through guarantees consistency" - FALSE if multiple cache nodes exist. Node A writes to cache A and DB, but cache B still has stale data. Need cache invalidation protocol (e.g., Redis Pub/Sub) across nodes.

4. Write-Behind (Write-Back) Cache

Write Flow

sequenceDiagram participant App participant Cache participant Queue participant Worker participant DB App->>Cache: SET user:123 {data} Cache-->>App: OK (immediate) Cache->>Queue: Enqueue write Note over Queue,DB: Async processing (seconds/minutes later) Worker->>Queue: Poll for writes Queue-->>Worker: Batch of 100 writes Worker->>DB: BATCH UPDATE users DB-->>Worker: OK Worker->>Queue: ACK batch

Trade-offs

Aspect Pros Cons
Performance Writes extremely fast (cache latency only)
Batch DB writes (100x fewer queries)
Read-after-write from DB sees stale data
Query cache, not DB
Durability High throughput for write-heavy loads
DB protected from spikes
DATA LOSS RISK if cache fails
Must persist queue to disk
Consistency Eventual consistency acceptable
Analytics, social feeds
Writes may be lost or reordered
Not suitable for financial data
Complexity DB write batching optimizes throughput
Coalesce multiple writes to same key
Complex failure handling
Retry logic, dead letter queue

Failure Scenarios

Failure Type Impact Mitigation
Cache Node Crash All queued writes lost (seconds to minutes of data) Redis AOF persistence (disk writes)
Replicate write queue to multiple nodes
DB Unavailable Write queue grows unbounded
Memory exhaustion
Queue size limit, reject new writes
Spill to disk or object storage
Worker Process Crash In-flight batch lost (1-100 writes) Queue visibility timeout
Requeue unACKed messages
Network Partition Cache and DB diverge
Conflict resolution needed
Last-write-wins with timestamps
Or CRDTs for complex types

Use Cases

Scenario Why Write-Behind Example
Write-heavy workloads 10-100x write throughput vs DB Social media likes/views counters
Bursty traffic Smooth out spikes to DB Black Friday sales, live events
Idempotent aggregations Coalesce writes: increment counter once Page view analytics (increment by batch sum)
Eventual consistency OK Stale data acceptable (seconds lag) Leaderboards, recommendation training data
Critical Question: "When would you choose write-behind over write-through?" Answer: High write volume + eventual consistency acceptable + can tolerate rare data loss. Never for financial/legal data. Always mention the data loss risk.

5. Read-Through Cache

Read Flow

sequenceDiagram participant App participant Cache participant DB Note over App,DB: Cache Hit App->>Cache: GET user:123 Cache-->>App: {data} Note over App,DB: Cache Miss App->>Cache: GET user:456 Cache->>DB: SELECT * FROM users WHERE id=456 DB-->>Cache: {data} Cache->>Cache: Store in cache (TTL=3600) Cache-->>App: {data} Note over App,Cache: App doesn't know if hit/miss
Cache handles DB fetch

Comparison: Read-Through vs Cache-Aside

Aspect Cache-Aside (Lazy Loading) Read-Through
Responsibility Application checks cache, loads from DB on miss Cache library handles DB fetch transparently
Code Complexity Cache logic in every read path
data = cache.get() or db.fetch()
Cache configured once with loader
data = cache.get(key)
Consistency Application controls invalidation
Flexible but error-prone
Cache library handles it
Uniform but less control
Thundering Herd Manual locking needed
Race: multiple threads load same key
Built-in: single load per key
Other threads wait for first load
Cache Warming Explicit preload code
for key: cache.set(key, db.get())
Automatic on first access
Or bulk load API
Libraries Redis, Memcached (manual logic) Guava Cache, Caffeine, Ehcache

Implementation Details

Feature Mechanism Benefit
Single-Flight On cache miss, first thread loads data
Other threads wait (lock key)
Prevents stampede: N requests = 1 DB query
Async Refresh Return stale data immediately
Load fresh data in background
Zero read latency spike on expiry
Bulk Loading cache.getAll([k1, k2, k3])
Single DB query for all misses
Batch DB fetch: 1 query vs N queries
Fallback on Failure If DB load fails, return cached stale data
Or propagate error
Graceful degradation vs fail-fast

Example: Guava Cache (Java)

LoadingCache<String, User> cache = CacheBuilder.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .refreshAfterWrite(8, TimeUnit.MINUTES)  // Async refresh before expiry
    .build(new CacheLoader<String, User>() {
        public User load(String userId) throws Exception {
            return database.getUser(userId);  // Called on miss
        }
    });

// Application code - cache miss handled transparently
User user = cache.get("user:123");  // Blocks if loading, returns immediately if cached
When to Choose: Read-through when you want uniform caching logic across codebase and library support. Cache-aside when you need fine-grained control over invalidation or use distributed cache (Redis) that doesn't support read-through natively.

6. Cache Invalidation

"There are only two hard things in Computer Science: cache invalidation and naming things." - Phil Karlton

Invalidation Strategies

Strategy Mechanism Pros Cons
TTL (Time-To-Live) Expire after fixed duration
SET key value EX 3600
Simple, no coordination needed
Works across distributed caches
Stale data until expiry
Thundering herd on simultaneous expiry
Write-Invalidate Delete cache entry on DB write
DELETE key
Immediate consistency
Next read gets fresh data
Wasted cache space (deleted entries)
Cache miss penalty on next read
Event-Driven DB change triggers invalidation
CDC (Change Data Capture) or triggers
Invalidates across all cache nodes
Application doesn't manage it
Complex infrastructure (Debezium, Kafka)
Lag between write and invalidation
Versioning Include version in key
user:123:v5
No invalidation needed (immutable)
Old versions auto-evicted (LRU)
Multiple versions waste space
Version tracking complexity
Tagging Group keys by tag, invalidate tag
tags:product:789 → [key1, key2]
Bulk invalidation (1 delete vs N)
Product change = invalidate all views
Tag management overhead
Not all caches support tags

TTL Best Practices

Scenario TTL Rationale
User session 30 min - 24 hrs Balance freshness vs re-login UX
Product catalog 1-24 hrs Infrequent updates, high read volume
User profile 5-60 min Moderate update frequency
Social feed 30-300 sec High update rate, eventual consistency OK
Real-time prices 1-10 sec Volatile data, short TTL or no cache
Analytics aggregates 1-24 hrs Expensive to compute, approximate OK

Thundering Herd on Expiry

sequenceDiagram participant C1 as Client 1 participant C2 as Client 2 participant C100 as Client N participant Cache participant DB Note over C1,DB: Popular key expires at t=0 C1->>Cache: GET product:popular (miss) C2->>Cache: GET product:popular (miss) C100->>Cache: GET product:popular (miss) Note over Cache: All requests miss simultaneously C1->>DB: SELECT * FROM products WHERE id=popular C2->>DB: SELECT * FROM products WHERE id=popular C100->>DB: SELECT * FROM products WHERE id=popular Note over DB: 1000s of identical queries
DB overload / timeout DB-->>C1: {data} DB-->>C2: {data} DB-->>C100: {data} C1->>Cache: SET product:popular {data} C2->>Cache: SET product:popular {data} C100->>Cache: SET product:popular {data}

Mitigation Strategies

Technique How It Works Trade-off
Locking First miss acquires lock, others wait
SETNX lock:key 1 EX 10
1 DB query, but others block (latency spike)
Probabilistic Early Expiry Refresh before expiry with probability
refresh if rand() < (time_left / TTL)
Spreads refresh load, small stale window
Stale-While-Revalidate Return stale data, refresh async
HTTP: Cache-Control: stale-while-revalidate=60
Zero latency spike, brief staleness
Jittered TTL Add random jitter to expiry
TTL = 3600 + rand(0, 300)
Prevents simultaneous expiry of bulk-loaded keys
Red Flag Answer: "Just use a shorter TTL" makes thundering herd worse (more frequent expiry). Better: probabilistic refresh or stale-while-revalidate.

7. Eviction Policies

Common Algorithms

Policy Mechanism Hit Rate Complexity Use Case
LRU
(Least Recently Used)
Evict item not accessed for longest time
Doubly-linked list + hash map
High (80-95%)
temporal locality
O(1) get/set
Moderate memory
General purpose
Most common choice
LFU
(Least Frequently Used)
Evict item with fewest accesses
Min-heap by access count
Higher for skewed access
(Zipfian distribution)
O(log n) get/set
More complex
Zipfian workload
(80/20 rule)
FIFO
(First In First Out)
Evict oldest inserted item
Simple queue
Lower (60-75%)
Ignores access pattern
O(1) get/set
Simple
Streaming data
Time-series
ARC
(Adaptive Replacement)
Balances recency + frequency
Two LRU lists (recent + frequent)
Highest (85-98%)
Adapts to workload
O(1) get/set
2x memory overhead
Mixed workload
Unknown pattern
Random Evict random item Lowest (50-70%)
No intelligence
O(1) get/set
Trivial
Uniform access
Redis default
TTL-based Evict expired items first
Min-heap by expiry time
N/A (not LRU-based) O(log n) cleanup
Periodic scan
Session stores
Time-sensitive data

Hit Rate Comparison (Simulated Workload)

Workload Type LRU LFU FIFO ARC Random
Temporal locality (recent items reaccessed) 92% 78% 71% 93% 65%
Zipfian (80% requests → 20% keys) 88% 94% 72% 93% 68%
Uniform (equal access probability) 75% 74% 73% 76% 73%
Scan (sequential, no reuse) 12% 8% 15% 14% 11%
Mixed (50% temporal, 50% Zipfian) 89% 86% 71% 92% 67%

Redis Eviction Policies

Policy (maxmemory-policy) Behavior When to Use
allkeys-lru Evict least recently used among all keys General caching (most common)
allkeys-lfu Evict least frequently used among all keys Skewed workload (hot keys)
volatile-lru Evict LRU among keys with TTL set Mix of cache + persistent data
volatile-ttl Evict soonest-expiring keys first Time-sensitive data (sessions)
allkeys-random Evict random key Uniform access, simplicity
noeviction Return errors on memory full Never evict (bounded dataset)
Follow-up Question: "Why not always use ARC?" Answer: (1) Patent restrictions (IBM patent until 2018), (2) 2x memory overhead vs LRU, (3) Negligible benefit if workload is purely temporal or Zipfian (not mixed). LRU is simpler and "good enough" for 90% of cases.

8. Cache Stampede

Problem: Concurrent Misses

graph TB subgraph "Normal Operation" A1[Request 1] --> Cache1[Cache: HIT] A2[Request 2] --> Cache2[Cache: HIT] A3[Request N] --> Cache3[Cache: HIT] end subgraph "Cache Stampede - Expired Key" B1[Request 1] --> Miss1[Cache: MISS] B2[Request 2] --> Miss2[Cache: MISS] B3[Request N] --> Miss3[Cache: MISS] Miss1 --> DB1[(DB Query)] Miss2 --> DB2[(DB Query)] Miss3 --> DB3[(DB Query)] DB1 --> Overload[DB Overload
Timeouts] DB2 --> Overload DB3 --> Overload end style Overload fill:#FF6B6B style Cache1 fill:#51CF66 style Cache2 fill:#51CF66 style Cache3 fill:#51CF66

Solutions Comparison

Solution How It Works Latency Impact Complexity
Locking (Pessimistic) First request locks key, loads data
Others wait for lock release
High: Waiters block for DB query latency (10-100ms) Medium: Need distributed lock (Redis SETNX)
Probabilistic Early Expiry Refresh before TTL expires
if rand() < (now - created) / TTL
Low: One request pays refresh cost, others hit cache Low: Simple calculation
Stale-While-Revalidate Return expired data immediately
Refresh in background
Zero: All requests get instant response (stale) Medium: Background job scheduler
Request Coalescing Deduplicate in-flight requests
N concurrent requests → 1 DB call
Medium: First request waits, others join result High: Track in-flight requests (sync primitives)

Locking Implementation

def get_with_lock(key):
    data = cache.get(key)
    if data:
        return data

    lock_key = f"lock:{key}"
    lock_acquired = cache.set(lock_key, "1", nx=True, ex=10)  # 10s timeout

    if lock_acquired:
        try:
            data = db.query(key)
            cache.set(key, data, ex=3600)
            return data
        finally:
            cache.delete(lock_key)  # Release lock
    else:
        # Another thread is loading, wait and retry
        time.sleep(0.1)
        return get_with_lock(key)  # Retry (or loop with timeout)

Probabilistic Early Expiry

def get_with_early_refresh(key):
    entry = cache.get_with_metadata(key)  # Returns {data, created_at, ttl}

    if not entry:
        # Hard miss - load from DB
        data = db.query(key)
        cache.set(key, data, ex=3600)
        return data

    # Probabilistic refresh before expiry
    age = now() - entry.created_at
    refresh_probability = age / entry.ttl

    if random() < refresh_probability:
        # Refresh in background (non-blocking)
        background_task(lambda: refresh_cache(key))

    return entry.data  # Return cached data (may be slightly stale)

# Refresh probability increases as TTL approaches:
# Age=0s (just cached): 0% chance
# Age=1800s (50% of 3600s TTL): 50% chance
# Age=3500s (97% of TTL): 97% chance

Stale-While-Revalidate

def get_stale_while_revalidate(key):
    entry = cache.get_with_expiry(key)  # Returns {data, expired}

    if entry and not entry.expired:
        return entry.data  # Fresh data

    if entry and entry.expired:
        # Return stale data immediately
        background_task(lambda: refresh_cache(key))
        return entry.data  # Stale but instant

    # No data at all - synchronous load
    data = db.query(key)
    cache.set(key, data, ex=3600)
    return data

# HTTP header equivalent:
# Cache-Control: max-age=3600, stale-while-revalidate=60
# Browser can use stale data for 60s after expiry while fetching fresh
Best Practice: Use locking for critical data (payments, inventory). Use stale-while-revalidate for user-facing reads (feeds, recommendations) where brief staleness is acceptable. Combine with jittered TTLs to prevent simultaneous expiry.

9. Multi-Layer Caching

Typical Architecture

graph TB Browser[Browser Cache
5-60 min
Per-user] CDN[CDN Edge Cache
1-24 hrs
Regional] Gateway[API Gateway Cache
1-5 min
Cross-user] LocalCache[Application In-Memory
Caffeine/Guava
1-10 min] Redis[Distributed Cache
Redis Cluster
10 min - 24 hrs] DBCache[Database Query Cache
MySQL Query Cache
Seconds] DB[(Database
Source of Truth)] Browser -->|Cache miss| CDN CDN -->|Cache miss| Gateway Gateway --> LocalCache LocalCache -->|Miss| Redis Redis -->|Miss| DBCache DBCache -->|Miss| DB style Browser fill:#E3F2FD style CDN fill:#E1F5FE style Gateway fill:#E0F7FA style LocalCache fill:#E0F2F1 style Redis fill:#F1F8E9 style DBCache fill:#FFF9C4 style DB fill:#FFEBEE

Layer Characteristics

Layer Latency Hit Rate Scope Invalidation
Browser Cache 0 ms (instant) 60-80% (user-specific) Single user Cache-Control headers, ETags
CDN (CloudFront, Fastly) 10-50 ms 70-90% (static assets)
30-50% (API)
Regional Cache-Control, purge API, versioned URLs
API Gateway (Kong, NGINX) 1-5 ms 40-70% (shared API) Data center HTTP Cache-Control, manual invalidation
Application In-Memory 0.1 ms 50-80% (per-instance) Single app instance TTL, manual eviction, pub/sub invalidation
Distributed Cache (Redis) 1-2 ms 80-95% (shared) Cross-instance TTL, explicit delete, pub/sub broadcast
DB Query Cache 2-5 ms 60-90% (repeated queries) DB instance Auto-invalidate on table write

Request Flow Example

Request Layer Hits Total Latency Cost
User A: GET /products/123 (1st visit) Browser miss → CDN miss → Gateway miss → Redis miss → DB hit 50ms (RTT) + 10ms (DB) $0.0001 (DB query)
User A: GET /products/123 (reload) Browser hit (304 Not Modified) 0ms (cached) $0 (no backend request)
User B: GET /products/123 (different user) Browser miss → CDN hit 20ms (edge server) $0.00001 (CDN bandwidth)
Server C: Internal API call LocalCache hit (in-process) 0.1ms (memory) $0 (free)
Server D: Internal API call (cold instance) LocalCache miss → Redis hit 1ms (Redis same AZ) $0.000001 (Redis)

Invalidation Challenges

Challenge Scenario Solution
Cache Coherence Product updated in DB
CDN + Redis + LocalCache have stale data
Pub/Sub broadcast: DB write → Kafka → all cache layers invalidate
Cascading Invalidation Invalidate Redis → CDN still serves stale Versioned URLs (/v2/products/123)
Or CDN purge API (Fastly, CloudFront)
Thundering Herd Amplification TTL expires across all layers simultaneously
1000s of servers hit DB
Staggered TTLs: Browser (5 min) > CDN (10 min) > Redis (20 min)
Partial Failure Redis invalidation succeeds, CDN purge fails
Inconsistent state
Retry queue for invalidation
Or rely on TTL as fallback

HTTP Cache-Control Headers

Header Effect Example
max-age=3600 Cache for 1 hour (browser + CDN) Static assets (CSS, JS)
private Only browser cache (not CDN) User-specific data (profile)
public Allow CDN caching Product catalog (same for all users)
no-cache Revalidate with origin (ETag check) Frequently updated content
no-store Never cache (not even browser) Sensitive data (payment info)
s-maxage=7200 CDN-specific TTL (overrides max-age) Cache longer at CDN than browser
stale-while-revalidate=60 Serve stale for 60s while refreshing Prevent stampede on expiry
Design Question: "How would you cache a product page?" Answer: Static assets (images, CSS) → CDN with 1-year max-age + versioned URLs. Product data API → CDN with 10-min TTL + Redis with 30-min TTL + stale-while-revalidate. User-specific (cart count) → no CDN, only browser cache (private, 5 min).

10. Consistency

Cache vs Database Divergence

Scenario Cause Impact Detection
Race Condition Thread A writes DB, thread B writes cache with stale data Cache has old version indefinitely (until TTL) Compare cache vs DB periodically
Failed Invalidation DB write succeeds, cache delete fails (network error) Stale read until TTL expires Monitor invalidation errors
Partial Update Multi-key transaction: update DB (key1, key2), invalidate only key1 Inconsistent view (key1 fresh, key2 stale) Transactional invalidation or tagging
DB Replication Lag Write to primary, cache reads from replica (lagging) Cache has stale data from replica Read-your-writes fails

Consistency Models

Model Guarantee Implementation Trade-off
Strong Consistency Cache always reflects latest DB write
Read-your-writes guaranteed
Write-through cache
Synchronous invalidation
No cache on write
High latency (DB in critical path)
Lower throughput
Eventual Consistency Cache eventually matches DB
Stale reads possible (bounded time)
Async invalidation (pub/sub)
TTL-based expiry
Write-behind cache
Stale data for seconds/minutes
Simpler, higher performance
Read-Your-Writes User sees own writes immediately
Others may see stale
Sticky routing (user → same cache node)
Or invalidate user-specific cache on write
Harder to load balance
Complex routing
Monotonic Reads Never see older version after newer
No time travel
Session affinity
Version numbers in cache keys
Sticky sessions harm load balancing

Facebook's Lease Approach

Facebook uses a lease-based invalidation protocol (TAO) to handle race conditions:

sequenceDiagram participant Client participant Cache participant DB Note over Client,DB: Read Path Client->>Cache: GET user:123 Cache-->>Client: MISS + lease token (ABC123) Client->>DB: SELECT * FROM users WHERE id=123 DB-->>Client: {name: "Alice"} Client->>Cache: SET user:123 {data} WITH_LEASE ABC123 Cache-->>Client: OK (if lease still valid) Note over Client,DB: Write Path (invalidates leases) Client->>DB: UPDATE users SET name='Bob' WHERE id=123 DB-->>Client: OK Client->>Cache: DELETE user:123 Cache->>Cache: Invalidate all leases for user:123 Cache-->>Client: OK Note over Client,Cache: Concurrent reader's lease now invalid Client->>Cache: SET user:123 {stale_data} WITH_LEASE XYZ789 Cache-->>Client: REJECTED (lease invalidated)

Lease Protocol Steps

Step Action Purpose
1. Cache Miss Cache returns MISS + lease_token Grant permission to populate cache
2. Read DB Client queries database Fetch authoritative data
3. Set with Lease SET key value WITH_LEASE token Populate cache only if lease valid
4. Concurrent Write DELETE key → invalidate all leases for that key Prevent stale SET from succeeding
5. Lease Rejection SET with invalidated lease → rejected Stale data never enters cache

Alternative: Read-After-Write Pattern

def update_with_consistency(key, new_value):
    # Write to DB
    db.update(key, new_value)

    # Invalidate cache
    cache.delete(key)

    # Option 1: Warm cache immediately (read-your-writes)
    fresh_data = db.query(key)
    cache.set(key, fresh_data, ttl=3600)

    # Option 2: Return DB data directly (bypass cache for this request)
    return fresh_data

# Risk: If another thread writes between delete and set, race condition
# Mitigation: Use versioned keys or leases
Interview Gotcha: "Just invalidate cache on write" is insufficient for strong consistency. Race: Thread A writes DB v1, Thread B writes DB v2, Thread B invalidates cache, Thread A (delayed) populates cache with v1. Cache now stale. Need leases or write-through for strong consistency.

11. When NOT to Cache

Anti-Patterns

Scenario Why Caching Fails Better Approach
Low Hit Rate Every request is unique (random query params)
Cache miss rate >50%
Optimize DB queries (indexes)
Or normalize cache keys
Highly Dynamic Data Data changes every second (stock prices, live scores)
TTL < 1s = cache overhead > benefit
Push updates (WebSocket)
Materialized views in DB
Large Objects 100MB video file per cache entry
Network transfer > DB query time
CDN for static assets
Stream from object storage
Security-Sensitive PII, passwords, payment info
Cache breach = data leak
Never cache sensitive data
Use encrypted DB queries
Complex Consistency Multi-key transactions, foreign keys
Invalidation logic error-prone
Rely on DB consistency
Or cache denormalized views
Cold Data Accessed once per month
Evicted before second access
Let DB handle it
Cache only hot data (top 10%)

Decision Framework

graph TD Start[Should I cache this?] Start --> Accessed{Accessed frequently?
Hit rate >50%?} Accessed -->|No| SkipCache[Don't cache:
optimize DB instead] Accessed -->|Yes| ReadHeavy{Read-heavy?
Read:Write >10:1?} ReadHeavy -->|No| WriteCache[Consider write-behind
if write throughput issue] ReadHeavy -->|Yes| Latency{DB query >10ms?} Latency -->|No| SkipCache2[Don't cache:
DB fast enough] Latency -->|Yes| Consistency{Strong consistency
required?} Consistency -->|Yes| WriteThrough[Use write-through
or leases] Consistency -->|No| ConsistencyLevel{Stale data
acceptable?} ConsistencyLevel -->|No| ReadYourWrites[Cache-aside +
read-your-writes] ConsistencyLevel -->|Yes| CacheAside[Cache-aside +
TTL invalidation] style SkipCache fill:#FFCDD2 style SkipCache2 fill:#FFCDD2 style WriteThrough fill:#C8E6C9 style CacheAside fill:#C8E6C9 style ReadYourWrites fill:#FFF9C4 style WriteCache fill:#FFF9C4

Cost-Benefit Analysis

Factor Cache Benefit Cache Cost Break-Even
Latency DB 50ms → Cache 1ms
49ms saved per hit
Cache miss = 2 network calls
1ms penalty
Hit rate >2% (49ms gain > 1ms penalty * 98% misses)
Throughput DB 10K QPS → Cache 100K QPS
10x capacity
Cache cluster: 3 nodes × $100/mo Hit rate >80% (avoid DB scale-out at $5K/mo)
Cost DB query $0.0001
Cache query $0.000001
Cache infrastructure + ops 1B queries/month → $1K cache vs $100K DB
Interview Answer: "When would you not cache?" Red flags: low hit rate, high churn (data changes constantly), security concerns (PII), or complexity exceeds benefit (simple DB query). Always measure hit rate before scaling cache infrastructure.

12. Sizing & Monitoring

Working Set Calculation

Metric Formula Example
Active Keys Keys accessed in past N hours 1M users × 5 sessions/day = 200K active sessions
Avg Value Size Measure with MEMORY USAGE key Session: 2KB, User profile: 10KB, Product: 5KB
Working Set Size Active keys × Avg size × (1 + overhead) 200K × 2KB × 1.2 = 480MB
Total Cache Size Working set × (1 / target eviction rate) 480MB × 2 = 960MB (50% buffer for growth)

Redis Overhead

Component Overhead Notes
Key storage Key length + 8 bytes Pointer + metadata
String value Value length + 3 bytes SDS (Simple Dynamic String) header
Dict entry 24 bytes per key Hash table node
Redis object 16 bytes per key redisObject wrapper
Total overhead ~50 bytes + key length 20-30% overhead typical

Hit Rate Targets

Hit Rate Interpretation Action
>95% Excellent - cache is right-sized Monitor for traffic growth
80-95% Good - typical for most workloads Optimize keys with low hit rates
50-80% Fair - cache too small or TTL too short Increase cache size or TTL
Analyze eviction patterns
<50% Poor - cache not effective Investigate: wrong data cached?
Keys too diverse? Reconsider caching

Monitoring Metrics

Metric Formula / Command Alert Threshold
Hit Rate hits / (hits + misses)
Redis: INFO stats → keyspace_hits / keyspace_misses
<80% (sustained)
Eviction Rate evicted_keys / sec
Redis: INFO stats → evicted_keys
>1000/sec (cache too small)
Memory Usage used_memory / maxmemory
Redis: INFO memory
>90% (risk of eviction storms)
Latency (p99) SLOWLOG GET or client-side timing >5ms (network issue or large values)
Connection Count INFO clients → connected_clients >10K (need connection pooling)
Commands/Sec INFO stats → instantaneous_ops_per_sec >80% of max throughput

Cache Warming Strategies

Strategy When to Use Implementation
Lazy (No Warming) Cache-aside, working set small
Cold start acceptable
First request populates cache
Hit rate ramps over minutes
Batch Preload Predictable hot keys (top products)
Deploy or restart
On startup: for key in hot_keys: cache.set(key, db.get(key))
Replay Access Log Unpredictable working set
Want same hit rate post-restart
Log cache accesses → replay top N keys on restart
Progressive Warming Large working set (>10GB)
Avoid DB overload
Warm 10% of traffic at a time
Blue-green deployment with cache
Background Refresh Data changes overnight (prices)
Morning traffic spike
Cron job pre-populates cache before peak hours

Capacity Planning Example

# Given:
# - 10M active users/day
# - 80/20 rule: 20% of users = 80% of traffic
# - Avg 10 API calls/user/day
# - Cache user profiles (10KB) and session (2KB)

Hot users = 10M × 20% = 2M
Cache entries = 2M users × (1 profile + 1 session) = 4M keys
Memory = 2M × 10KB + 2M × 2KB = 24GB
With overhead (1.3x) = 31GB
With growth buffer (2x) = 62GB

Redis configuration:
- 3-node cluster (replication factor 2)
- 64GB RAM per node
- maxmemory-policy: allkeys-lru
- TTL: session 30min, profile 1hr
Sizing Rule of Thumb: Cache size = working set × (1 + overhead) × (1 / hit_rate_target). For 95% hit rate, working set = 80% of cache. For 80% hit rate, working set = 50% of cache. Always provision 2x for growth and traffic spikes.

Production Implementation Examples

Cache Stampede Prevention: XFetch Algorithm (Probabilistic Refresh)

Actual production pseudocode for preventing thundering herd using probabilistic early refresh:

def get_with_probabilistic_refresh(key, ttl, beta=1.0):
    """
    XFetch algorithm: Probabilistically refresh before expiry to prevent stampede.

    Args:
        key: Cache key
        ttl: Time-to-live in seconds (e.g., 3600 for 1 hour)
        beta: Refresh probability multiplier (default 1.0)
              Higher beta = more aggressive refresh (more stale data)
              Lower beta = less aggressive (more stampede risk)

    Returns:
        Cached value (may trigger async refresh)
    """
    # Attempt to get value with metadata (created_at, expiry)
    value, expiry = cache.get_with_ttl(key)

    if value is None:
        # Hard cache miss - synchronously recompute
        value = recompute(key)
        cache.set(key, value, ttl)
        return value

    # Value exists - calculate probabilistic refresh
    now_ts = time.time()
    remaining = expiry - now_ts

    # XFetch probability formula:
    # P(refresh) increases as expiry approaches
    # P = 0 at creation, P ≈ 1 near expiry
    refresh_probability = beta * random.random() * (ttl / remaining)

    if refresh_probability >= 1.0:
        # Probabilistic early refresh triggered
        # Refresh in background (non-blocking)
        background_task(lambda: refresh_cache_async(key, ttl))

    # Always return cached value immediately (even if refreshing)
    return value

def refresh_cache_async(key, ttl):
    """Background refresh job (runs in thread pool or async worker)"""
    try:
        new_value = recompute(key)
        cache.set(key, new_value, ttl)
    except Exception as e:
        log_error(f"Cache refresh failed for {key}: {e}")
        # Keep stale cache entry (graceful degradation)

# Refresh probability examples (TTL=3600s, beta=1.0):
# Time since creation | Remaining | P(refresh)
# -------------------+-----------+------------
# 0s (just cached)    | 3600s     | ~0% (3600/3600 × rand(0-1) = ~0-1)
# 1800s (50% TTL)     | 1800s     | ~50% (3600/1800 × rand(0-1) = ~0-2 = 50% >1)
# 3500s (97% TTL)     | 100s      | ~97% (3600/100 × rand(0-1) = ~0-36 = 97% >1)
# 3600s (expired)     | 0s        | 100% (handled by cache miss path)
Interview Question: "How does XFetch prevent stampede without stale data?" Answer: As expiry approaches, probability of refresh increases. One request (selected probabilistically) refreshes cache in background before expiry. Other concurrent requests continue serving cached data. No stampede (only 1 refresh), minimal staleness (refresh happens before expiry), zero latency spike (async refresh).

Capacity Planning Worked Example

Design a Redis cache cluster for a high-traffic service:

Requirements

Metric Value Notes
Service QPS 100,000 queries/sec Peak traffic (2x average)
Cache-Eligible Traffic 90% (90K QPS) 10% writes or non-cacheable
Target Hit Rate 85% Standard for well-tuned cache
Unique Keys 1,000,000 (1M) Active working set
Average Value Size 2 KB JSON user profile

Step 1: Cache QPS Calculation

Cache QPS = Total QPS × Cache-Eligible %
          = 100,000 × 0.9
          = 90,000 cache lookups/sec

DB QPS (with cache) = Cache QPS × (1 - Hit Rate)
                    = 90,000 × 0.15
                    = 13,500 DB queries/sec

DB QPS (without cache) = 90,000 queries/sec

DB Load Reduction = 90,000 / 13,500 = 6.7x
                  → Cache reduces DB load by 85%

Step 2: Memory Sizing

Raw Data Size = Unique Keys × Avg Value Size
              = 1,000,000 × 2 KB
              = 2,000,000 KB = 2 GB

Redis Overhead (20%):
- Key storage: ~50 bytes per key = 50 MB
- Redis object metadata: ~16 bytes per key = 16 MB
- Hash table overhead: ~24 bytes per key = 24 MB
- Fragmentation: ~10% of data size = 200 MB
Total Overhead ≈ 20% = 400 MB

Total Memory Required = 2 GB + 0.4 GB = 2.4 GB

Safety Buffer (for growth, spikes, evictions):
- Add 50% buffer = 2.4 GB × 1.5 = 3.6 GB
- Round up to next instance size: 4 GB

Step 3: Redis Cluster Sizing

Configuration Memory per Node Nodes Total Memory High Availability
Option 1: Single Master 4 GB 1 master + 2 replicas 12 GB (3× for HA) Automatic failover, read replicas
Option 2: Sharded Cluster 2 GB per shard 2 shards × (1 master + 1 replica) 8 GB (2× per shard) Horizontal scale, distributed QPS

Chosen Configuration: Option 1 (Single Master + 2 Replicas)

  • Node Type: AWS r6g.large (16 GB RAM, 2 vCPU, $0.133/hr)
  • Cluster: 1 master + 2 replicas = 3 nodes
  • Cost: 3 nodes × $0.133/hr × 730 hrs/mo = $291/month

Step 4: ROI Calculation

Without Cache:
- DB QPS: 90,000 queries/sec
- DB instance cost (to handle 90K QPS): ~$5,000/month (provisioned IOPS, large instance)

With Cache:
- DB QPS: 13,500 queries/sec (6.7x reduction)
- DB instance cost: ~$750/month (smaller instance, fewer IOPS)
- Cache cost: $291/month (Redis cluster)
- Total: $1,041/month

Savings = $5,000 - $1,041 = $3,959/month

ROI = $3,959 / $291 = 13.6x return on cache investment
    = $47,508/year savings
Production Reality Check: This ROI assumes cache hit rate remains at 85%. In practice, hit rate degrades over time due to key diversity, TTL tuning issues, or traffic pattern changes. Monitor hit rate weekly and adjust cache size or TTL policies. A 10% drop in hit rate (85% → 75%) increases DB QPS from 13.5K to 22.5K (67% increase).

Facebook's TAO Cache (Simplified Architecture)

Facebook's social graph cache (TAO: The Associations and Objects) uses a two-tier cache hierarchy:

graph TB Client[Client Request
e.g., GET user:123:friends] L1[L1 Cache - Per Region
Small, Hot Data
Redis: 10GB per DC] L2[L2 Cache - Per Region
Large, Warm Data
Memcached: 1TB per DC] DB[(MySQL Database
Master in Primary Region)] Invalidation[Invalidation Queue
Kafka/Pub-Sub] Client -->|1. Check L1| L1 L1 -->|Hit: 90%| Client L1 -->|Miss: 10%| L2 L2 -->|Hit: 95%| Client L2 -->|Miss: 5%| DB DB -->|Write| Invalidation Invalidation -->|Broadcast| L1 Invalidation -->|Broadcast| L2 style L1 fill:#FFE8E8 style L2 fill:#E8F4FF style DB fill:#E8FFE8 style Invalidation fill:#FFF4E1

TAO Architecture Details

Layer Purpose Size Hit Rate Latency
L1 Cache Hot data (recent posts, active users) 10 GB per datacenter 90% (of all requests) 0.5-1 ms
L2 Cache Warm data (older posts, less active users) 1 TB per datacenter 95% (of L1 misses) 2-5 ms
Database Cold data, source of truth PB-scale (sharded MySQL) 100% (authoritative) 20-50 ms (cross-region)

Consistency via Invalidation Messages

TAO uses a write-through invalidation protocol:

  1. Write Path:
    • Client writes to database (e.g., UPDATE users SET status='online')
    • DB write triggers invalidation message to Kafka/pub-sub
    • All L1 and L2 caches in all regions subscribe to invalidation topic
    • Caches delete affected keys (e.g., DELETE user:123:status)
  2. Propagation Latency: 10-50ms (eventual consistency across regions)
  3. Handling Lag: Brief window of stale reads (acceptable for social data)

Key Optimizations

Optimization Technique Benefit
Lease-Based Invalidation Cache misses get a lease token; writes invalidate leases Prevents stale writes from race conditions (see Consistency section)
Read-Through L1→L2 L1 miss automatically checks L2, populates L1 on hit Reduces DB queries: 90% + (10% × 95%) = 99.5% total hit rate
Regional Caching Each region has own L1/L2 cache (data replicated) Low latency for global users, survives region failures
TTL + Invalidation Hybrid Keys have short TTL (5 min) as fallback if invalidation fails Bounded staleness: worst case 5 min, typical <50ms
Interview Deep Dive: "Why does Facebook use two cache layers instead of one large cache?" Answer: (1) Hit rate distribution follows Zipfian law: top 1% of keys get 50% of traffic. L1 caches hot keys in small, fast storage. (2) Cost: L1 uses expensive, low-latency RAM (Redis); L2 uses cheaper, higher-capacity storage (Memcached). (3) Fault tolerance: L1 can fail/restart without overwhelming L2. Total cost: 10GB L1 + 1TB L2 cheaper than 1.01TB single-tier cache.

Real-World Lessons: Cache Stampede in Production

Case study from a major e-commerce site (Black Friday):

Incident Timeline

Time Event Impact
00:00 AM Black Friday sale launches, traffic surges 10x Cache hit rate normal (90%)
00:15 AM Popular product page cache expires (TTL=15 min) 10,000 concurrent requests hit expired key
00:15:01 All 10,000 requests miss cache, query DB DB query time spikes: 50ms → 5 seconds (100x)
00:15:05 DB connection pool exhausted (max 500 connections) Timeouts, 500 errors, users see "Site unavailable"
00:20 AM Manual rollback of traffic to backup region Service restored, but 5 min downtime = $500K lost sales

Root Cause & Fix

  • Root Cause: No stampede protection. Cache expiry triggered thundering herd: 10K concurrent cache misses → 10K DB queries.
  • Immediate Fix: Deploy cache locking (SETNX lock pattern). First miss acquires lock, others wait. Reduced 10K queries to 1 query.
  • Long-Term Fix: Implement XFetch probabilistic refresh. Keys refresh before expiry (no hard expiry stampede). Jittered TTL (15 min ± 2 min) prevents simultaneous expirations.
  • Monitoring: Add alerting on cache miss rate >20% and DB connection pool >80% capacity.
Cost of Stampede: 5 minutes of downtime during Black Friday = $500K lost sales + brand damage. Cache stampede prevention (XFetch + locking) costs $0 to implement but saves millions. Always implement before production traffic spikes.