Skip to content
ZERONE
Torna agli insights
Infrastruttura2026-04-18 · 6 min di letturaDa Case 04

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:

  1. asyncio.Lock per worker — impedisce che un singolo worker spari internamente la stessa query più volte se più coroutine girano in parallelo.
  2. 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