Caching is the single most effective performance optimization in distributed systems. A well-designed cache reduces database load, decreases response latency, and improves system throughput. This article covers the major caching patterns, eviction policies, distributed caching with Redis, CDN caching, and the hardest problem in computer science: cache invalidation.
Caching Patterns
Cache-Aside (Lazy Loading)
Cache-aside is the most common caching pattern. The application checks the cache first. On a cache miss, it reads from the database and populates the cache.
class CacheAside:
def __init__(self, cache, database):
self.cache = cache
self.database = database
def get_user(self, user_id):
# 1. Try cache first
cached = self.cache.get(f"user:{user_id}")
if cached is not None:
return cached
# 2. Cache miss: read from database
user = self.database.query("SELECT * FROM users WHERE id = ?", user_id)
if user:
# 3. Populate cache for next time
self.cache.set(f"user:{user_id}", user, ttl=3600)
return user
def update_user(self, user_id, data):
# 1. Update database
self.database.execute("UPDATE users SET name = ? WHERE id = ?",
data['name'], user_id)
# 2. Invalidate cache (not update!)
self.cache.delete(f"user:{user_id}")
**Advantages**:
**Disadvantages**:
Write-Through
Write-through caches update the cache synchronously when data is written to the database.
class WriteThrough:
def __init__(self, cache, database):
self.cache = cache
self.database = database
def update_user(self, user_id, data):
# 1. Update database
self.database.execute("UPDATE users SET name = ? WHERE id = ?",
data['name'], user_id)
# 2. Update cache synchronously
user = self.database.query("SELECT * FROM users WHERE id = ?", user_id)
self.cache.set(f"user:{user_id}", user, ttl=3600)
**Advantages**:
**Disadvantages**:
Write-Behind (Write-Back)
Write-behind caches write to the cache immediately and asynchronously update the database.
import asyncio
class WriteBehind:
def __init__(self, cache, database):
self.cache = cache
self.database = database
self.write_queue = asyncio.Queue()
self._start_flusher()
def _start_flusher(self):
"""Background task that flushes writes to database."""
async def flusher():
while True:
# Batch writes and flush periodically
batch = []
for _ in range(100): # Batch size
try:
item = await asyncio.wait_for(
self.write_queue.get(), timeout=1.0
)
batch.append(item)
except asyncio.TimeoutError:
break
if batch:
self._flush_to_database(batch)
asyncio.create_task(flusher())
def update_user(self, user_id, data):
# 1. Update cache immediately
user = {**self.cache.get(f"user:{user_id}", {}), **data}
self.cache.set(f"user:{user_id}", user)
# 2. Queue database update
self.write_queue.put_nowait({
"type": "update_user",
"user_id": user_id,
"data": data
})
**Advantages**:
**Disadvantages**:
Refresh-Ahead
Refresh-ahead proactively refreshes the cache before data expires.
class RefreshAhead:
def __init__(self, cache, database, refresh_threshold=0.8):
self.cache = cache
self.database = database
self.refresh_threshold = refresh_threshold # Refresh when 80% of TTL elapsed
def get_user(self, user_id):
cached = self.cache.get(f"user:{user_id}")
if cached is None:
user = self.database.query("SELECT * FROM users WHERE id = ?", user_id)
self.cache.set(f"user:{user_id}", user, ttl=3600)
return user
# Check if we should refresh
ttl = self.cache.ttl(f"user:{user_id}")
if ttl < 3600 * (1 - self.refresh_threshold):
# Asynchronously refresh in background
self._async_refresh(f"user:{user_id}", user_id)
return cached
def _async_refresh(self, cache_key, user_id):
"""Background refresh task."""
import threading
def refresh():
user = self.database.query("SELECT * FROM users WHERE id = ?", user_id)
if user:
self.cache.set(cache_key, user, ttl=3600)
threading.Thread(target=refresh, daemon=True).start()
Cache Eviction Policies
Least Recently Used (LRU)
Evicts the item that was accessed least recently. Good for workloads with temporal locality.
Cache: [A(1min ago), B(30s ago), C(5s ago), D(now)]
A is accessed least recently -> evict A
Redis implements LRU approximation with `maxmemory-policy allkeys-lru`.
Least Frequently Used (LFU)
Evicts the item accessed least frequently. Good for workloads with skewed popularity.
Cache: [A(100x), B(50x), C(30x), D(5x)]
D is least frequently accessed -> evict D
Redis supports LFU with `maxmemory-policy allkeys-lfu`.
Time-To-Live (TTL)
Evicts items based on their TTL. Items expire regardless of access pattern. Essential for all caching systems.
First In, First Out (FIFO)
Evicts the oldest item regardless of access frequency. Simple but less effective than LRU.
Choosing an Eviction Policy
| Workload | Best Policy |
|----------|-------------|
| Uniform access (all items equally likely) | FIFO or TTL |
| Temporal locality (recent items more likely) | LRU |
| Skewed access (some items much more popular) | LFU |
| Time-sensitive data (session, expiring offers) | TTL |
| Unknown | LRU + TTL |
Distributed Caching with Redis
Redis is the dominant distributed cache. It provides in-memory data structures, replication, persistence, and high availability.
Redis Cluster Setup
# docker-compose.yml for Redis Cluster
version: '3'
services:
redis-cluster:
image: redis:7-alpine
command: redis-cli --cluster create
127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002
127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005
--cluster-replicas 1
ports:
- "7000-7005:7000-7005"
Redis Caching Best Practices
import redis
import json
class RedisCache:
def __init__(self, redis_url):
self.client = redis.from_url(redis_url)
def get_or_compute(self, key, compute_func, ttl=300):
"""Cache-aside with compute function."""
cached = self.client.get(key)
if cached is not None:
return json.loads(cached)
value = compute_func()
self.client.setex(key, ttl, json.dumps(value))
return value
def get_batch(self, keys):
"""Batch cache get using pipeline."""
pipeline = self.client.pipeline()
for key in keys:
pipeline.get(key)
results = pipeline.execute()
return {
key: json.loads(val) if val else None
for key, val in zip(keys, results)
}
Cache Sharding
For very large caches, shard across multiple Redis nodes.
import hashlib
class ShardedRedis:
def __init__(self, nodes):
self.nodes = nodes # List of Redis clients
def _get_node(self, key):
"""Determine which node holds this key."""
hash_val = int(hashlib.md5(key.encode()).hexdigest(), 16)
return self.nodes[hash_val % len(self.nodes)]
def get(self, key):
node = self._get_node(key)
return node.get(key)
def set(self, key, value, ttl=300):
node = self._get_node(key)
node.setex(key, ttl, value)
CDN Caching
Content Delivery Networks (CDNs) cache static and dynamic content at edge locations close to users.
Cache Control Headers
# Nginx: Static asset caching headers
location /static/ {
expires 365d;
add_header Cache-Control "public, immutable";
}
location /api/content/ {
# Dynamic content: shorter cache
expires 5m;
add_header Cache-Control "public, must-revalidate";
}
location /api/user/ {
# Private content: no CDN caching
add_header Cache-Control "private, no-cache";
}
CDN Cache Invalidation
# CloudFront: Invalidate specific paths
aws cloudfront create-invalidation \
--distribution-id E123456 \
--paths "/api/content/*" "/index.html"
# Fastly: Purge by key
curl -X POST https://api.fastly.com/service/SERVICE/purge \
-H "Fastly-Key: $API_KEY" \
-H "Surrogate-Key: product:1234" \
-H "Accept: application/json"
Cache Invalidation
Cache invalidation is notoriously difficult. These strategies help.
Time-Based Invalidation (TTL)
The simplest approach. Every cache entry has a TTL. Data is stale until the TTL expires.
Always safe: stale data is eventually replaced.
Always simple: no complex invalidation logic.
Limitation: data can be arbitrarily stale within the TTL window.
Event-Driven Invalidation
When data changes, publish an invalidation event.
# Event-driven invalidation
class EventDrivenCache:
def __init__(self, cache, message_bus):
self.cache = cache
self.message_bus = message_bus
# Subscribe to invalidation events
self.message_bus.subscribe("cache.invalidate", self.handle_invalidation)
def handle_invalidation(self, event):
key = event.data['key']
self.cache.delete(key)
log.info(f"Invalidated cache key: {key} due to {event.data['reason']}")
Write-Through Invalidation
Invalidate (or update) the cache as part of the write transaction.
def update_product(product_id, data):
with transaction():
# Update database
db.execute("UPDATE products SET price = ? WHERE id = ?",
data['price'], product_id)
# Invalidate cache in same transaction if possible
cache.delete(f"product:{product_id}")
# Publish invalidation for other cache nodes
message_bus.publish("cache.invalidate", {"key": f"product:{product_id}"})
Conclusion
Choose cache-aside for most general-purpose caching. Use write-through when read consistency is critical. Use write-behind when write performance is paramount. Use refresh-ahead for predictable access patterns. Set appropriate TTLs as a safety net. Use Redis for distributed caching with proper cluster configuration. Use CDNs for content delivery to global users. Remember that cache invalidation is hard: prefer TTLs over complex invalidation logic, use event-driven invalidation when TTLs are insufficient, and always have a fallback to the original data source.