Learning outcomes
- Implement Cache-Aside pattern with explicit TTL invalidation
- Protect database backends from thundering herd spikes
Mental model
The Cache-Aside (Lazy Loading) pattern positions an in-memory data store (Redis) beside the primary database. The application queries Redis first; on a cache miss, it reads from PostgreSQL, writes the result to Redis with a TTL, and returns the response.
Theory
When a popular cache key expires under high concurrency (e.g. 1,000 concurrent requests/sec), all workers encounter a cache miss simultaneously and hammer PostgreSQL. This is the Thundering Herd (Cache Stampede) problem.
Defense requires acquiring a distributed Redis lock (redis.set(lock_key, "1", nx=True, ex=5)) so that only ONE worker queries PostgreSQL while other workers wait briefly or receive stale cached data.
import json
import redis.asyncio as aioredis
from fastapi import FastAPI, Depends
redis_client = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
async def get_cached_user_profile(user_id: int, db_session) -> dict:
cache_key = f"user:profile:{user_id}"
cached_data = await redis_client.get(cache_key)
if cached_data:
return json.loads(cached_data)
# Thundering Herd Defense via mutex lock
lock_key = f"lock:{cache_key}"
acquired = await redis_client.set(lock_key, "1", nx=True, ex=5)
if acquired:
try:
profile = await fetch_user_from_db(db_session, user_id)
await redis_client.setex(cache_key, 300, json.dumps(profile))
return profile
finally:
await redis_client.delete(lock_key)
else:
# Wait briefly for lock holder to populate cache
await asyncio.sleep(0.05)
return await get_cached_user_profile(user_id, db_session)
Alternatives and trade-offs
- Read-Through Cache: Cache layer intercepts database reads transparently; complex middleware setup.
- Cache-Aside: Simple application-level caching control; risks serving stale data if invalidation logic fails on state updates.
Failure modes and misconceptions
- Missing Cache Invalidation on Writes: Updating a user record in PostgreSQL without executing
redis_client.delete(cache_key)causes users to see stale data until TTL expires. - Infinite Cache Lock Deadlock: Acquiring a distributed lock without setting an explicit
ex(expiration TTL) locks out all concurrent workers permanently if the process crashes mid-query.
Decision scenario
Implement Cache-Aside with Redis set(nx=True, ex=5) distributed locking on high-traffic product catalog endpoints to prevent database crash loops caused by cache stampedes.
Learning outcomes
- Implement the Cache-Aside pattern with explicit Redis TTL expiration.
- Defend against Thundering Herd / Cache Stampede spikes using Redis distributed locks.
- Invalidate cached data deterministically during database mutation operations.
Trade-offs
Redis caching dramatically reduces database query load and response latency, but increases system state complexity and requires strict cache invalidation rules.
Evidence assessment
Theory and decision mastery
Decision scenario
A user updates their email address in the database, but subsequent API calls continue returning their old cached email.
How should application code prevent stale cache reads during database updates?
Primary sources
- FastAPI Framework Architecture & Dependency Injection Specification — Tiangolo, verified 2026-07-22