Torna al blog
Caching a Più Livelli: Browser, CDN e Redis Senza Servire Dati Vecchi

Caching a Più Livelli: Browser, CDN e Redis Senza Servire Dati Vecchi

10 min di lettura1 visualizzazioni
di Marco Salmaso
Ogni cache è una copia, e ogni copia è un potenziale disallineamento. Guida pratica ai livelli di caching — browser, CDN, applicazione, database — tra direttive HTTP, invalidazione, cache stampede e metriche che dicono davvero qualcosa.

La cache viene spesso presentata come una questione di velocità: metti qualcosa davanti al database e le cose vanno più veloci. In realtà il problema interessante è un altro: una cache è una copia dei dati, e ogni copia prima o poi diverge dalla fonte. La domanda vera non è "quanto fa risparmiare?" ma "per quanto tempo sono disposto a servire una risposta vecchia, e a chi?".

Un problema di correttezza prima che di prestazioni

Chi mette cache di solito parte da un sintomo: una pagina lenta, una query che pesa, un endpoint chiamato troppo spesso. La soluzione immediata è aggiungere uno strato che memorizza il risultato. Il punto è che quel risultato ha una vita propria: può essere letto da utenti diversi, in momenti diversi, con permessi diversi. Servire a un utente la risposta pensata per un altro non è un problema di performance, è un incidente di sicurezza.

Prima di scrivere una riga di codice conviene rispondere a tre domande per ogni dato che si vuole cachare:

  • Per quanto tempo la copia è accettabile (freschezza).
  • Chi decide se è ancora buona (il client, un proxy intermedio, il server).
  • Come la si butta via quando la fonte cambia (invalidazione).

I livelli di cache che si incontrano in un'applicazione web sono in genere quattro, e ognuno ha un costo di invalidazione diverso:

LivelloDove viveChi la svuotaDifficoltà di invalidazione
BrowserDispositivo dell'utenteNon controllabile direttamenteAlta (scade, non si cancella)
CDN / reverse proxyInfrastruttura condivisaPurge API, TTLMedia
Cache applicativaRedis, MemcachedCodice applicativoBassa
DatabaseBuffer pool, materialized viewEngine o jobBassa

Più il livello è vicino all'utente, più è veloce e più è difficile da sgonfiare. Da qui una regola pratica: cache aggressiva ai bordi solo per contenuti che cambiano raramente, cache breve o nulla per il resto.

Livello 1: browser e direttive HTTP

Il browser ha un sistema di caching completo e standardizzato. Non serve nessuna libreria: basta rispondere con gli header giusti.

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30
ETag: "a1b2c3d4"
Vary: Accept-Encoding, Accept-Language

Le direttive che contano davvero:

  • max-age — secondi di freschezza per browser e cache condivise.
  • s-maxage — come max-age ma solo per le cache condivise (CDN, proxy). Ha precedenza.
  • public / private — una risposta con dati personali va marcata private o non verrà (correttamente) salvata in una cache condivisa.
  • no-store — non salvare nulla, mai.
  • no-cache — nome fuorviante: la risposta può essere salvata, ma va rivalidata prima di ogni uso.
  • stale-while-revalidate — estensione che permette di servire una copia scaduta mentre si aggiorna in background.

L'ETag è il meccanismo di rivalidazione. Il client rimanda l'identificatore con If-None-Match e il server, se il contenuto non è cambiato, risponde 304 Not Modified senza corpo. È un ottimo compromesso: nessun dato trasferito, ma nessuna informazione vecchia servita.

Attenzione a Vary: quell'header cambia la chiave di cache. Vary: Accept-Encoding è normale e sano. Vary: Cookie su una risposta pubblica significa, di fatto, hit ratio vicino a zero, perché quasi ogni richiesta ha un cookie diverso.

Livello 2: CDN e reverse proxy

Una CDN decide cosa servire in base a una chiave di cache, che di solito è composta da metodo HTTP, URL e dagli header elencati in Vary. Tutto ciò che influenza la risposta ma non è nella chiave è un potenziale problema.

Un caso classico: la risposta contiene un URL assoluto costruito a partire dall'header Host o X-Forwarded-Host, che però non fa parte della chiave. Un attaccante può inviare una richiesta con un host arbitrario, far cachare quella variante e servirla a tutti gli altri. Si chiama cache poisoning ed è uno dei modi più semplici per trasformare un errore di configurazione in un problema di sicurezza.

Altre cose da tenere a mente:

  • Purge per URL è lo strumento base. Il purge per tag (o surrogate key) è molto più comodo ma non tutte le CDN lo offrono allo stesso modo.
  • HTML personalizzato difficilmente è cacheabile in condiviso. Le alternative sono frammenti (ESI), rendering lato client, oppure cache solo per utenti anonimi.
  • Errori: valutate se cachare anche risposte 404 o 500. Un 500 cachato per dieci minuti può trasformare un problema breve in un disservizio lungo.
  • Query string: molte CDN includono per default l'intera query string nella chiave. Se avete parametri di tracking, state moltiplicando le varianti inutili.

Livello 3: la cache applicativa

Qui si ragiona di pattern, non di header. I quattro più comuni:

PatternCome funzionaProContro
Cache-asideL'app legge dalla cache, se manca legge dal DB e scrive in cacheSemplice, controllo totaleLogica duplicata in ogni punto di lettura
Read-throughLa cache stessa si occupa di caricare dal DBCodice applicativo pulitoServe una libreria che lo supporti
Write-throughSi scrive in cache e DB insiemeCache quasi sempre coerenteScritture più lente
Write-behindSi scrive in cache, il DB viene aggiornato dopoScritture velociRischio di perdita dati

Il cache-aside resta il default pragmatico:

import json
import redis

r = redis.Redis(decode_responses=True)

def get_user(user_id: int) -> dict:
    key = f"user:{user_id}:v1"
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)

    user = db_fetch_user(user_id)          # query lenta
    r.set(key, json.dumps(user), ex=300)   # TTL: 5 minuti
    return user

Notate il :v1 nella chiave. Se un domani cambiate la forma della risposta, passate a v2: le vecchie chiavi scadono da sole e nessuno legge un JSON con lo schema sbagliato. È un'invalidazione gratuita, e costa una stringa.

Vale anche la pena ricordare che il database stesso è una cache: il buffer pool di PostgreSQL tiene in memoria le pagine calde, e una materialized view è a tutti gli effetti una copia persistente.

REFRESH MATERIALIZED VIEW CONCURRENTLY daily_sales;

Il CONCURRENTLY evita il lock in lettura, ma richiede un indice unico e non è gratis: va misurato.

Invalidazione: tre strategie in ordine di complessità

  1. TTL. Non invalidi nulla, lasci scadere. Semplice, prevedibile, e l'unica strategia che sopravvive a un bug nel codice di invalidazione. Il prezzo è la finestra di dati vecchi.
  2. Invalidazione per evento. Quando il dato cambia, cancelli la chiave. Coerente, ma ogni punto di scrittura deve ricordarsi di farlo: il bug è sempre dietro l'angolo.
  3. Versioning delle chiavi. Non cancelli niente: cambi la chiave. Elegante, ma va progettato dall'inizio.

Molte applicazioni sane usano tutte e tre: TTL come rete di sicurezza, invalidazione per gli eventi importanti, versioning per i cambi di schema.

La race tra scrittura e invalidazione

C'è un bug che quasi tutti scrivono almeno una volta. Sequenza:

  1. Il lettore A fa cache miss e legge il valore vecchio dal database.
  2. Il writer B aggiorna il database e cancella la chiave.
  3. Il lettore A scrive in cache il valore vecchio che aveva letto al passo 1.

Risultato: la cache contiene un valore vecchio, e ci resterà per tutto il TTL. Si mitiga con delete-before-write + delete-after-write, con TTL brevi, oppure accettando la finestra e dichiarandola. Non esiste una soluzione gratuita: esiste una scelta consapevole.

C'è anche un problema di percezione, spesso ignorato: se dopo un POST l'utente ricarica la pagina e la GET è cacheata per sessanta secondi, non vedrà la propria modifica. Non è un bug di coerenza globale, ma è un bug di esperienza utente. La soluzione più semplice è non cachare le risposte autenticate, oppure invalidare selettivamente la chiave dell'utente che ha appena scritto.

Cache stampede e thundering herd

Una chiave molto richiesta scade. Cento richieste arrivano nello stesso istante, trovano tutte la cache vuota e ricalcolano lo stesso valore. Il database, che stava benissimo, prende un picco cento volte più grande del normale.

Le contromisure, dalla più semplice alla più raffinata:

  • Jitter sul TTL: ex=300 + random.randint(0, 30) evita che chiavi scritte insieme scadano insieme.
  • Stale-while-revalidate: serve la copia scaduta mentre una sola richiesta aggiorna.
  • Lock distribuito / single flight: solo un processo ricalcola, gli altri aspettano.
  • Scadenza probabilistica anticipata: si ricalcola un po' prima della scadenza, con probabilità crescente nel tempo.

Il lock in Redis è la versione più immediata:

import random
import time

def get_with_lock(key: str, ttl: int = 300):
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)

    lock = r.lock(f"lock:{key}", timeout=5)
    if lock.acquire(blocking_timeout=2):
        try:
            value = compute_expensive_value()
            r.set(key, json.dumps(value), ex=ttl + random.randint(0, 30))
            return value
        finally:
            lock.release()

    time.sleep(0.1)
    return get_with_lock(key, ttl)

Da usare con giudizio: il lock distribuito introduce un nuovo punto di failure e la ricorsione va limitata. Spesso jitter più stale-while-revalidate bastano.

Cosa misurare davvero

L'hit ratio è la metrica più citata e la meno utile da sola. Un hit ratio del 99% può convivere con dati sbagliati serviti per ore.

hit_ratio     = hit / (hit + miss)
latenza_media = hit_ratio * t_hit + (1 - hit_ratio) * t_miss

Quello che conviene guardare:

  • Hit ratio per chiave, non solo globale: le chiavi rare a basso hit ratio sono spesso quelle che costano di più.
  • Latenza p50 e p95 con e senza cache: se il p95 non migliora, il collo di bottiglia è altrove.
  • Costo del miss: quanto pesa ricalcolare. Un miss da due millisecondi non giustifica un'architettura complessa.
  • Finestra di staleness attesa: dichiaratela in un documento, poi verificate che i fatti le corrispondano.
  • Percentuale di invalidazioni per evento che falliscono: è il silenzioso che genera i bug peggiori.

Errori comuni

  • Cachare tutto e non invalidare niente sperando nel TTL. Funziona finché il business non chiede che i dati siano aggiornati "subito".
  • Vary: Cookie su risposte condivise, che azzera i vantaggi della CDN.
  • TTL identici ovunque, che sincronizzano le scadenze e amplificano i picchi.
  • Cachare errori e 404 indistintamente, allungando la vita dei problemi.
  • Dimenticare che la cache è condivisa fra utenti: il test manuale con un solo account non lo rivela mai.
  • Non versionare lo schema del valore cachato, lasciando in giro JSON con campi che non esistono più.
  • Misurare solo la media, ignorando la coda che l'utente percepisce.

Conclusione

Il caching è una decisione su quanto a lungo tolleri dati vecchi, non un interruttore per la velocità. I punti chiave:

  • Ogni livello — browser, CDN, applicazione, database — ha un costo di invalidazione diverso: sii aggressivo dove puoi svuotare facilmente.
  • Le direttive HTTP fanno gran parte del lavoro: Cache-Control, ETag e Vary vanno capiti, non copiati.
  • L'invalidazione è il capitolo difficile: TTL come rete di sicurezza, eventi per le cose importanti, versioning delle chiavi per i cambi di schema.
  • Le race tra scrittura e lettura esistono. Scegli consapevolmente quale finestra di incoerenza accettare.
  • Contro lo stampede: jitter, stale-while-revalidate, e solo dopo un lock.
  • Misura hit ratio, latenza di coda e costo del miss. Un hit ratio alto non è una prova di correttezza.

Se stai iniziando oggi, il percorso più sicuro è: direttive HTTP corrette, TTL brevi con jitter, chiavi versionate. Aggiungere complessità è facile in seguito; togliere dati sbagliati dalla cache degli utenti non lo è.


Foto di copertina di Taylor Vick su Unsplash.