Cache senza lock è thundering herd: 14 endpoint, 8 worker, un database morto
L'expire della cache è l'unico momento in cui i worker paralleli diventano costosi tutti insieme. Senza un lock per chiave, ogni refresh manda l'intero carico sul percorso più lento.
I sintomi: PostgreSQL primary con load spike di 15–17 a cadenza di 5 minuti. Timeout sul backend. Il monitoring mostrava brevi serie di 502, poi di nuovo 200. Ogni volta che lo spike svaniva, sul backend non si vedeva nulla di sospetto.
Prima ipotesi: connessioni long-running. No. Seconda: un job batch specifico. Anche no.
La verità era nella cache.
Cosa stava succedendo
Un endpoint centrale di monitoraggio restituiva un aggregato — COUNT(*) FILTER (... WHERE quality_score >= 80) su 1,4 M righe. Tempo della query: circa 450 ms sotto carico normale. Cached per 5 minuti in Redis. Ogni 5 minuti la cache scadeva.
Il backend girava con otto worker uvicorn. Nella frazione di secondo dopo l'expire della cache, le richieste colpivano tutti gli otto worker simultaneamente. Ogni worker controllava la cache, la trovava vuota e sparava l'aggregazione in parallelo. Otto query COUNT(*) FILTER concorrenti sulla stessa tabella → buffer thrashing, lock contention, ingorgo su disk-I/O. PostgreSQL crolla.
Il primo worker che finisce scrive il risultato nella cache. Gli altri sette buttano via il loro. Otto query per una risposta.
Perché 14 endpoint invece di uno
Nell'audit abbiamo trovato 14 altri endpoint con lo stesso pattern. Tutti usavano un semplice decorator @cache(ttl=300). Nessuno aveva un lock.
L'implementazione sembrava innocua:
async def get_quality_count():
cached = await redis.get("quality_count")
if cached:
return int(cached)
# Cache miss → ALL workers race to fill
count = await run_expensive_query()
await redis.set("quality_count", count, ex=300)
return count
Su una dev machine con un solo worker: perfetto. In produzione con otto worker e un pattern di traffico cadenzato strettamente: catastrofe.
Il fix: un lock per chiave, due livelli
from redis.asyncio import Redis
import asyncio
from contextlib import asynccontextmanager
_worker_locks: dict[str, asyncio.Lock] = {}
@asynccontextmanager
async def cache_lock(key: str, redis: Redis, ttl: int = 30):
# Short-circuit dentro lo stesso worker
local = _worker_locks.setdefault(key, asyncio.Lock())
async with local:
# Distributed lock fra tutti i worker
got = await redis.set(f"lock:{key}", "1", ex=ttl, nx=True)
if got:
try:
yield True
finally:
await redis.delete(f"lock:{key}")
else:
# Un altro worker sta ricostruendo la cache.
# Attesa breve, poi rileggi.
await asyncio.sleep(0.1)
yield False
async def get_quality_count():
cached = await redis.get("quality_count")
if cached is not None:
return int(cached)
async with cache_lock("quality_count", redis) as have_lock:
if have_lock:
count = await run_expensive_query()
await redis.set("quality_count", count, ex=300)
return count
# Breve attesa — ora la cache dovrebbe essere calda
cached = await redis.get("quality_count")
if cached is not None:
return int(cached)
# Fallback: query diretta, ma con budget di timeout
return await run_expensive_query(timeout=2.0)
Due livelli sono intenzionali:
asyncio.Lockper worker — impedisce che un singolo worker spari internamente la stessa query più volte se più coroutine girano in parallelo.- Redis lock globalmente — impedisce che N worker ricostruiscano la cache contemporaneamente.
Risultato
Dopo il rollout su tre endpoint critici:
- CPU load sul DB stabile al 53 % (prima 91 % nei picchi).
- Query P99 sull'endpoint di monitoraggio da 1,8 s a 210 ms.
- Zero timeout di backend nella settimana successiva.
Cosa abbiamo imparato
- Una cache senza lock è una bomba a orologeria che esplode esattamente una volta per intervallo di TTL. Più corto il TTL, più frequente.
- L'approccio decorator (
@cache(ttl=300)) è invisibile negli ambienti dev a singolo worker e fallisce solo sotto carico. - Audit obbligatorio a ogni fix: se un pattern è sbagliato su un endpoint, probabilmente è sbagliato su più. Trovali tutti.
Il principio di base: «Se è costoso e può accadere in parallelo, serve un lock per chiave, non per richiesta.»
Stesso fuoco anche da te?
Probabilmente abbiamo già visto qualcosa di simile. Parliamone.
Iniziamo a parlarne→