Distributed Caching Patterns
Design caching strategies that reduce latency, lower database load, and scale horizontally. Covers cache-aside, read-through, write-behind, cache invalidation, distributed cache topologies, and the patterns that make caching reliable in production.
Caching is the most effective performance optimization available. A well-designed cache reduces database load by 90%, cuts response times from 200ms to 2ms, and enables systems to handle 10x more traffic. A poorly designed cache causes stale data, cache stampedes, and harder-to-debug inconsistencies.
Caching Patterns
Cache-Aside (Lazy Loading):
1. Check cache for data
2. Cache miss → query database
3. Store result in cache
4. Return to caller
Best for: Read-heavy workloads, data that tolerates staleness
Risk: Cache miss thundering herd
Read-Through:
1. Application reads from cache
2. Cache miss → cache queries database itself
3. Cache stores and returns result
Best for: Simplifying application code
Risk: Cache library must support this pattern
Write-Through:
1. Application writes to cache
2. Cache writes to database synchronously
3. Both updated atomically
Best for: Data that must be fresh immediately
Risk: Write latency increased (cache + DB)
Write-Behind (Write-Back):
1. Application writes to cache
2. Cache writes to database asynchronously
3. Write returns immediately after cache update
Best for: Write-heavy workloads, performance-critical
Risk: Data loss if cache fails before async write completes
Redis as Distributed Cache
import redis
import json
from functools import wraps
class DistributedCache:
def __init__(self, redis_url, default_ttl=300):
self.redis = redis.Redis.from_url(redis_url)
self.default_ttl = default_ttl
def get(self, key):
data = self.redis.get(key)
return json.loads(data) if data else None
def set(self, key, value, ttl=None):
self.redis.setex(
key,
ttl or self.default_ttl,
json.dumps(value, default=str)
)
def invalidate(self, key):
self.redis.delete(key)
def invalidate_pattern(self, pattern):
"""Invalidate all keys matching pattern."""
keys = self.redis.keys(pattern)
if keys:
self.redis.delete(*keys)
# Cache-aside decorator
def cached(cache, key_fn, ttl=300):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
key = key_fn(*args, **kwargs)
# Check cache
result = cache.get(key)
if result is not None:
return result
# Cache miss: call function
result = await func(*args, **kwargs)
# Store in cache
cache.set(key, result, ttl)
return result
return wrapper
return decorator
# Usage
@cached(cache, key_fn=lambda user_id: f"user:{user_id}", ttl=600)
async def get_user(user_id: str):
return await db.query("SELECT * FROM users WHERE id = $1", user_id)
Cache Invalidation
"There are only two hard things in computer science:
cache invalidation and naming things." — Phil Karlton
Strategies:
TTL (Time-to-Live):
Data expires after fixed time
Simple, eventual consistency
Event-Based:
When data changes → invalidate cache
Immediate consistency, complex implementation
Version-Based:
Cache key includes version number
user:123:v5 → update increments to v6
Old version naturally expires via TTL
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Cache everything | Memory waste, stale data | Cache hot data, let cold data expire |
| No TTL | Data stale forever | Always set reasonable TTL |
| Cache stampede | DB overwhelmed on cache miss | Lock-based or probabilistic early expiry |
| Caching database queries | Hard to invalidate | Cache at entity/result level |
| Single cache instance | Single point of failure | Redis Cluster or Sentinel |
Caching is not free magic — it is a trade-off between freshness, consistency, and performance. Every cache entry is a promise that the data is probably correct.