Level 2 · 25 min
Cache Patterns
Cache patterns define how the application interacts with the cache and the underlying database. The right pattern depends on read/write ratios, consistency requirements, and tolerable staleness.
Cache-Aside
Cache-Aside gives the application explicit control and maximum observability — cache hits and misses are visible in application code and metrics. The cold-start problem is real: after a Redis failover or explicit FLUSHALL, 100% of requests fall through to the database simultaneously. Mitigation: a cache-warming job that pre-populates hot keys on startup using the same key schema as the read path. Key schema discipline is critical: a write path using "order:" + orderId and a read path using "orders:" + orderId never share cache entries, causing permanent 0% hit rate that is invisible until someone compares key naming conventions in code review. On write, delete the cache key rather than updating it. Race scenario with update: T1 reads DB (stale value) → T2 writes DB and updates cache → T1 writes its stale value to cache, overwriting T2's fresh entry. Deleting on write ensures any concurrent reader who misses will fetch from the DB post-commit, always getting the current value.
Write-Through and Write-Behind
A production write-behind incident: a fintech company cached user balance writes immediately and flushed to PostgreSQL every 500ms via a background worker. During a 90-second DB maintenance window, the flush queue accumulated 2.3 million pending writes. When the DB came back, the flush worker processed the queue sequentially over 8 minutes. During that window, 12 users who had initiated transfers saw inconsistent states — cache showed the post-transfer balance, DB showed the pre-transfer balance. For any crash during the 8-minute catchup, those writes would have been permanently lost. Write-Behind is appropriate for non-critical metrics: page view counters, analytics events, rate-limit counters — data where losing a few seconds of writes is acceptable. It must never be used for financial state, inventory, or any data requiring ACID guarantees. Production insight from Redis in Action: Carlson demonstrates write-behind for page-view analytics using Redis LPUSH to accumulate counters and periodic LRANGE+LTRIM flushes to the database — an explicit acknowledgment that the pattern trades durability for throughput, making it suitable only for data whose loss is "acceptable". For the write-through alternative, Carlson shows the Cache-Aside pattern for database row caching, noting that the cache serves as a transparent read-through layer that "can reduce page load time and database load" by eliminating repeated DB reads — the key correctness invariant being that every write hits the DB synchronously before the cache is updated.
Cache Stampede
Cache stampede mathematics: N concurrent requests arrive at TTL expiry. Each queries the DB independently, taking T_db seconds each. At 200 featured products × 500 concurrent users = 100,000 simultaneous DB queries. PostgreSQL max_connections=200 means 99,800 are queued or rejected with "too many connections." Probabilistic Early Expiration (PER): should_refresh = (now - item.set_time + beta * log(rand())) > ttl. With beta=1, some requests begin refreshing 2-3 log(N) seconds before TTL — proactively warming the cache before mass expiry. Implementation: store the item's set-time alongside the value (as a Hash field or JSON field) and check PER on each read. Distributed mutex: SET lock:{key} 1 NX PX 5000. Only one request populates cache; others either block briefly or serve stale data if available. Prefer serving stale over triggering a DB query avalanche — a 1-second-old product description is almost always better than a 503 error.
Code example
// Cache-Aside implementation
class OrderService {
Order getOrder(String orderId) {
String key = "order:" + orderId;
// 1. Check cache
String cached = redis.get(key);
if (cached != null) return deserialize(cached);
// 2. Cache miss: read from DB
Order order = orderRepository.findById(orderId);
// 3. Populate cache with TTL
redis.setex(key, 300, serialize(order)); // 5 min TTL
return order;
}
void updateOrder(Order order) {
orderRepository.save(order);
// Invalidate cache — don't update, to avoid stale-read race
redis.del("order:" + order.getId());
}
}