Cache-aside and invalidation, stampede control, N+1 elimination, keyset pagination, HTTP caching altitude, and LLM-related cache choices.
Lesson 6 · Caching & perf
Cache-aside, invalidation, N+1, cursor pagination
Speed is a correctness budget under load
Caching without invalidation design is lying to users with confidence. N+1 queries pass unit tests and die in production. Pagination choices decide whether list endpoints scale. This lesson is hot-path performance for backends that serve product APIs and AI features with heavy metadata reads.
Measure first: p50/p95/p99 latency, DB time vs app time, cache hit ratio, rows examined. Premature Redis is theater if the real bug is an N+1 or missing index. Senior answers pick the bottleneck with evidence.
Cache-aside (lazy loading)
Read path: get from cache → miss → load DB → set cache with TTL. Write path: update DB → delete (invalidate) cache key (common) or update carefully. Cache-aside keeps the DB as source of truth. TTL bounds staleness when invalidation misses.
python
1def get_project(project_id):2 key = f'project:{project_id}'3 cached = redis.get(key)4 if cached is not None:5 return deserialize(cached)6 project = db.fetch_project(project_id)7 if project is None:8 return None9 redis.setex(key, 60, serialize(project)) # TTL seconds10 return project1112def update_project(project_id, fields):13 db.update_project(project_id, fields)14 redis.delete(f'project:{project_id}') # invalidate
Invalidation is the hard part
Key design encodes invalidation: user:{id} is easy; feed:{user}:{page} explodes. Prefer primary-key caches + cheap recompute for lists, or version stamps (user:{id}:v{n}). Stampede: on expiry of hot keys, single-flight / mutex so one DB load fills many waiters.
What not to cache
Per-user secrets with shared keys, unbounded keys from raw user input (cache poisoning / memory blowup), and data that must be read-your-writes without versioning. Negative caching (cache 404s briefly) helps storm of missing ids but needs short TTL.
N+1 queries
ORM classic: load 50 runs, then each touches run.model → 50 extra queries. Fix: join/eager load, or batch WHERE id IN (...). In GraphQL, DataLoader. In hand-rolled SQL, select what the list view needs in one query. Detect with query counts in tests.
sql
1-- N+1 (bad): 1 + N queries2SELECT * FROM runs WHERE tenant_id = $1 LIMIT 50;3-- then per row: SELECT * FROM models WHERE id = $run.model_id;45-- Fixed: join or two-step batch6SELECT r.*, m.name AS model_name7FROM runs r8JOIN models m ON m.id = r.model_id9WHERE r.tenant_id = $110ORDER BY r.created_at DESC11LIMIT 50;
Cursor pagination performance
Keyset/cursor: WHERE (created_at, id) < ($c_ts, $c_id) ORDER BY created_at DESC, id DESC LIMIT n with a matching index. Avoid OFFSET 100000 — databases still walk rows. Stable sort ties need the id tiebreaker.
sql
1-- Keyset pagination (Postgres)2SELECT *3FROM llm_runs4WHERE tenant_id = $15 AND (created_at, id) < ($cursor_ts, $cursor_id)6ORDER BY created_at DESC, id DESC7LIMIT 50;8-- Index: (tenant_id, created_at DESC, id DESC)
HTTP caching (brief)
For public GETs: ETag/If-None-Match, Cache-Control. Private authenticated data: usually Cache-Control: private, no-store at the browser; use server-side Redis instead. CDNs help public docs, not tenant-private runs.
LLM hot paths
Cache embeddings for identical normalized text when product-safe. Cache auth session and feature flags. Do not cache non-deterministic completions as if they were immutable without an explicit content hash key. Prompt templates can be cached; user-specific PII outputs usually should not live in shared caches.
Embedding caches need a normalization contract (unicode, whitespace) or you thrash on trivial variants. Version the embedding model in the cache key — mixing vectors from two models is a silent product bug. Invalidate or namespace by model id on upgrades.
Connection pools and timeouts
API p99 often dies on pool exhaustion, not missing Redis. Size DB pools per instance × instances against Postgres max_connections. Set statement timeouts so one bad query cannot hold workers forever. Cache cannot fix a pool leak — measure wait time on acquiring connections.
Payload size and serialization cost
Returning 2MB JSON lists will dominate CPU and egress even with perfect SQL. Project columns for list views; put large artifacts behind separate GET. Prefer compact ids and avoid nesting huge blobs. Compression helps wire size; it does not remove parse cost on the client or server.
Read replicas — use carefully
Replicas scale reads but introduce lag. After a write, reading your own update from a replica can show stale data — route read-your-writes to primary or use session consistency tokens. Do not put strongly consistent billing reads on a lagging replica without saying so.
Interview answers — caching & perf
01Q: Cache-aside vs write-through? Aside is default for app objects; write-through keeps cache warm on write at higher write latency; pick via read/write ratio and staleness.
02Q: Invalidation strategy? Delete-on-write for PK keys; TTL safety net; versioned keys for complex graphs; avoid undelimited key explosion.
03Q: Stampede? Single-flight lock, probabilistic early refresh, or slightly staggered TTLs on hot keys.
04Q: Find N+1? Query counters in tests, APM spans, log SQL count per request id in staging.
05Q: OFFSET vs cursor? Cursor/keyset for large data; OFFSET only for small admin pages.
06Q: What to measure? Hit ratio, p99, DB time, rows examined, error rate after cache put — not only average latency.
07Q: Cache poisoning? Do not key raw untrusted input without normalization/allowlists; cap key cardinality.
08Q: Read-your-writes? Invalidate primary keys on write, sticky primary reads, or version tokens — pure TTL cache may show stale self-view.
09Q: Redis failure mode? Degrade to DB with load shed; do not let cache outage become full outage without a plan.
10Q: When not to cache? Tiny QPS already under budget; highly mutable strongly consistent paths where complexity exceeds gain.
After updating a project name, users still see the old name for minutes. Cache-aside was used with TTL 10m and no invalidation on write. Fix?
AIncrease TTL to 24h so the cache is “more stable.”BDelete/update the project cache key in the write path; keep TTL as a safety bound.CRemove the database and serve only Redis as system of record.
List endpoint does one query for runs then per-run query for owner email. 50 runs → 51 queries. Name and fix?
AN+1 query pattern — join or batch-load owners in one additional query / eager load.BCache miss storm — only Redis can fix relational fan-out.CCursor pagination bug — switch to OFFSET 0 always.
Why add id as a tiebreaker in keyset pagination ordered by created_at?
ABecause UUIDs are required by the HTTP specification for all lists.BEqual timestamps would otherwise skip/duplicate rows across pages; (created_at, id) makes a total order.CSo clients can sort client-side without server ORDER BY.
Hot key expires; 500 app servers stampede the DB. Mitigation?
ASingle-flight / request coalescing so one load refills cache while others wait; optional early refresh.BDisable the database when Redis expires so traffic stops.CSet TTL to 0 so keys never exist and stampedes cannot occur.
Which data is a poor candidate for a shared Redis cache without careful design?
APublic documentation HTML with long Cache-Control freshness.BPer-tenant API responses keyed only by path without tenant id in the key.CFeature flag config documents with short TTL and versioned keys.