Stage 3 · Build
Redis
Caching Patterns
Cache-aside, write-through, write-behind, and TTL strategy.
Cache-Aside Pattern
Cache-aside (lazy loading) is the most common caching pattern. The application checks the cache first; on miss, it reads from the database and populates the cache. This pattern is simple and handles read-heavy workloads well.
import redis
import json
r = redis.Redis()
def get_user(user_id):
cache_key = f"user:{user_id}"
# Check cache first
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Cache miss — read from database
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
if user:
# Populate cache with 5 minute TTL
r.setex(cache_key, 300, json.dumps(user))
return user
def update_user(user_id, data):
# Update database
db.execute("UPDATE users SET name = %s WHERE id = %s", data["name"], user_id)
# Invalidate cache (let next read repopulate)
r.delete(f"user:{user_id}")The key principle: read from cache, miss reads from DB, write invalidates cache. Invalidation is simpler and safer than trying to keep the cache synchronized with every write.
Every read is a cache check followed by a potential DB query. For read-heavy workloads (90%+ reads), this is efficient. For write-heavy workloads, consider write-through or write-behind.
Write-Through Pattern
def create_order(user_id, items):
# Write to database
order = db.execute(
"INSERT INTO orders (user_id, total) VALUES (%s, %s) RETURNING *",
user_id, sum(item["price"] for item in items)
)
# Write to cache simultaneously
cache_key = f"order:{order['id']}"
r.setex(cache_key, 3600, json.dumps(order))
# Invalidate related caches
r.delete(f"user:{user_id}:orders")
return order
def get_order(order_id):
cache_key = f"order:{order_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
order = db.query("SELECT * FROM orders WHERE id = %s", order_id)
if order:
r.setex(cache_key, 3600, json.dumps(order))
return orderWrite-through ensures the cache is always consistent with the database. The tradeoff is higher write latency (writing to both DB and cache) and more complex code.
Write-Behind Pattern
# Writes go to cache immediately, database updated asynchronously
def record_metric(metric_name, value):
# Write to cache immediately (fast)
r.zadd(f"metrics:{metric_name}", {str(time.time()): value})
# Queue for async database write
r.xadd("metric_queue", {
"name": metric_name,
"value": str(value),
"timestamp": str(time.time())
})
# Background worker processes the queue
def metric_worker():
while True:
messages = xreadgroup("metrics_group", "worker1",
streams={"metric_queue": ">"}, count=100)
for stream, entries in messages:
for entry_id, data in entries:
db.execute(
"INSERT INTO metrics (name, value, timestamp) VALUES (%s, %s, %s)",
data["name"], data["value"], data["timestamp"]
)
xack("metric_queue", "metrics_group", entry_id)Write-behind is ideal for high-throughput metrics, analytics, and logging where slight delay in database persistence is acceptable. It dramatically reduces database write load.
TTL Strategy
TTL (Time-To-Live) determines how long cached data lives. Too short means frequent cache misses. Too long means stale data. The right TTL depends on your data freshness requirements.
| Data Type | Recommended TTL | Rationale |
|---|---|---|
| Session data | 30 min - 1 hour | Sessions expire, freshness not critical |
| User profiles | 5 - 15 min | Balance freshness vs performance |
| Product catalog | 1 - 24 hours | Changes infrequently, can tolerate lag |
| API responses | 30s - 5 min | Depends on real-time requirements |
| Configuration | 1 hour - 24 hours | Rarely changes, use event-driven invalidation |
-- Set TTL on write
SET cache:products:1 '{"id": 1, "name": "Widget"}' EX 300
-- Check remaining TTL
TTL cache:products:1 # 274 (seconds remaining)
PTTL cache:products:1 # 274000 (milliseconds)
-- Remove TTL (make permanent)
PERSIST cache:products:1
-- Update TTL without changing value
EXPIRE cache:products:1 600 # extend to 10 minutes
-- Scan for keys with specific pattern (production-safe)
SCAN 0 MATCH cache:products:* COUNT 100Use consistent TTLs across related cache entries. If user profiles expire in 5 minutes, their permissions should expire in 5 minutes too — otherwise you serve stale permissions after the profile refreshes.
Cache Invalidation
# Strategy 1: Delete on write (most common)
def update_product(product_id, data):
db.execute("UPDATE products SET name = %s WHERE id = %s", data["name"], product_id)
r.delete(f"product:{product_id}")
r.delete("products:list") # invalidate list cache too
# Strategy 2: Versioned keys
def get_product(product_id):
version = r.get(f"product:{product_id}:version") or "1"
cache_key = f"product:{product_id}:v{version}"
return r.get(cache_key)
def invalidate_product(product_id):
# Increment version — old keys become orphaned
r.incr(f"product:{product_id}:version")
# Strategy 3: Publish/subscribe for distributed invalidation
def publish_invalidation(entity, entity_id):
r.publish("cache_invalidation", f"{entity}:{entity_id}")Strategy 1 is simplest and sufficient for most cases. Strategy 2 avoids race conditions but creates orphaned keys. Strategy 3 works across multiple application servers sharing a cache.
Cache Stampede
A cache stampede occurs when many requests hit a cache miss simultaneously, all hitting the database at once. This can overwhelm the database and cause cascading failures.
import redis
import json
import time
r = redis.Redis()
def get_product_with_locking(product_id):
cache_key = f"product:{product_id}"
lock_key = f"lock:{cache_key}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Try to acquire lock
acquired = r.set(lock_key, "1", nx=True, ex=10) # 10s expiry
if acquired:
try:
# Winner fetches from DB
product = db.query("SELECT * FROM products WHERE id = %s", product_id)
r.setex(cache_key, 300, json.dumps(product))
return product
finally:
r.delete(lock_key)
else:
# Other requests wait and retry
time.sleep(0.1)
return get_product_with_locking(product_id) # retryThe lock ensures only one request fetches from the database while others wait briefly for the cache to be populated. The TTL on the lock prevents deadlocks if the fetcher crashes.
Instead of waiting for cache expiry, refresh the cache probabilistically before it expires. This spreads refresh requests over time and prevents synchronized stampedes. Check the redis-lock library for production-ready implementations.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.