Caching — Redis, TTLs & Invalidation
AdvancedCache-aside with redis.asyncio: read the cache, miss → compute and SETEX with a TTL, write → invalidate. Design keys with versioned prefixes, guard hot keys against stampedes, and set HTTP Cache-Control for the layers you don't own.
Overview
When the same expensive query runs a thousand times a minute, you stop recomputing it. The workhorse pattern is cache-aside: try Redis first, on miss compute from the DB and store with a TTL, and on writes delete the affected keys so readers rebuild fresh data. Redis (async client, shared via lifespan like everything else) is the standard store because it is shared across workers and instances — an in-process dict cache holds N inconsistent copies under N workers, which is fine for static reference data and wrong for anything that changes. The two famous hard parts: invalidation (deleting the right keys on write — kept tractable by structured key naming and versioned prefixes) and the cache stampede (a hot key expires and 500 concurrent requests all hit the DB at once — bounded by per-key locks or short "recompute grace" windows). HTTP caching headers extend the same thinking to browsers and CDNs — cache layers you do not operate.
Cache-Aside with redis.asyncio
GET → miss → compute → SETEX, and DELETE on write. The decorator-free explicit version first — you should see every moving part once before hiding it.
# pip install redis
import json
import redis.asyncio as aioredis
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.redis = aioredis.from_url("redis://localhost:6379/0",
decode_responses=True)
yield
await app.state.redis.aclose()
app = FastAPI(lifespan=lifespan)
CACHE_VER = "v2" # bump when the cached SHAPE changes (see below)
def drives_key(branch: str) -> str:
return f"placement:{CACHE_VER}:drives:open:{branch}" # structured, greppable
@app.get("/drives/open")
async def open_drives(branch: str, request: Request):
r = request.app.state.redis
key = drives_key(branch)
if (cached := await r.get(key)) is not None: # 1. try cache
return json.loads(cached) # sub-ms hit
drives = await expensive_drives_query(branch) # 2. miss → DB (200ms)
await r.setex(key, 300, json.dumps(drives)) # 3. store, TTL 5 min
return drives
@app.post("/drives", status_code=201)
async def create_drive(payload: dict, request: Request):
drive = await insert_drive(payload) # write to DB first
# 4. INVALIDATE — readers rebuild on next request:
await request.app.state.redis.delete(drives_key(payload["branch"]))
return drive
# TTL doctrine: every key gets one, even with explicit invalidation —
# TTL is the safety net for the delete you forgot. Hot + rarely changing
# → longer TTL; user-visible-fresh (seat counts) → 10-30s micro-TTL still
# absorbs 99% of reads at 1000 rps.Keys, Stampedes & HTTP Cache Headers
Version your key prefix instead of hunting stale entries; single-flight a hot recompute; and use Cache-Control/ETag to recruit browsers and CDNs as your outermost cache tier.
# ── Key discipline ──
# pattern: app:{version}:{entity}:{qualifier}
# placement:v2:drives:open:CS
# placement:v2:student:1042:profile
# Deploy changes the cached JSON shape? bump v2 → v3: all old keys are
# instantly orphaned (and expire via TTL) — no scan-and-delete migration.
# Per-user caches: NEVER cache across users by accident — the user id
# belongs IN the key, or you serve Asha's profile to Ravi under load.
# ── Stampede: hot key expires, 500 requests hit the DB together ──
import asyncio
_locks: dict[str, asyncio.Lock] = {}
async def get_or_compute(r, key: str, ttl: int, compute):
if (hit := await r.get(key)) is not None:
return json.loads(hit)
lock = _locks.setdefault(key, asyncio.Lock())
async with lock: # one computer per key PER WORKER
if (hit := await r.get(key)) is not None: # re-check: someone filled it
return json.loads(hit)
value = await compute()
await r.setex(key, ttl, json.dumps(value))
return value
# Cross-instance single-flight → Redis SET key NX EX (a distributed lock),
# or serve the stale value while ONE request refreshes (stale-while-revalidate).
# ── The cache tier you don't run: browsers & CDNs ──
from fastapi import Response
@app.get("/courses") # public, same for everyone
async def courses(response: Response):
data = await get_courses()
response.headers["Cache-Control"] = "public, max-age=300" # CDN + browser
return data
@app.get("/me/orders") # private, per-user
async def my_orders(response: Response):
response.headers["Cache-Control"] = "private, no-store" # never shared!
return await load_orders()
# A CDN caching a per-user response under a shared URL is a data breach,
# not a performance bug. public/private on Cache-Control is that line.Key Points to Remember
- 1Cache-aside: read cache → miss computes + SETEX with TTL → writes DELETE keys
- 2Redis is shared across workers/instances; in-process dicts drift under multiple workers
- 3Versioned key prefixes make shape changes safe; user ids belong in per-user keys
- 4Stampedes need single-flight (locks / SET NX / stale-while-revalidate); Cache-Control recruits CDNs
Interview Questions
Sign in to ask AriaDesign caching for a results-day portal: 50k students refreshing marks — layers, TTLs, invalidation.
What is a cache stampede and how do you prevent one on a hot key?
A CDN served one user's order history to another — which header discipline failed?
Ask Aria about Caching — Redis, TTLs & Invalidation
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.