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
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
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) |
Most common pattern Read-heavy workloads |
| Write-Invalidate | db.update(data) |
Simple consistency model Next read gets fresh data |
| Write-Update | db.update(data) |
Read-after-write performance Risk: race conditions |
| Versioned Cache | key = "user:123:v" + hash(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)
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
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 pathdata = cache.get() or db.fetch() |
Cache configured once with loaderdata = 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 codefor 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 durationSET 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 writeDELETE 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 keyuser:123:v5 |
No invalidation needed (immutable) Old versions auto-evicted (LRU) |
Multiple versions waste space Version tracking complexity |
| Tagging | Group keys by tag, invalidate tagtags: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}
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 waitSETNX lock:key 1 EX 10 |
1 DB query, but others block (latency spike) |
| Probabilistic Early Expiry | Refresh before expiry with probabilityrefresh 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 expiryTTL = 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
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 expiresif 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
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
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 / secRedis: INFO stats → evicted_keys |
>1000/sec (cache too small) |
| Memory Usage | used_memory / maxmemoryRedis: 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
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:
- 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)
- Client writes to database (e.g.,
- Propagation Latency: 10-50ms (eventual consistency across regions)
- 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.