Caching a Più Livelli: Browser, CDN e Redis Senza Servire Dati Vecchi
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:
| Livello | Dove vive | Chi la svuota | Difficoltà di invalidazione |
|---|---|---|---|
| Browser | Dispositivo dell'utente | Non controllabile direttamente | Alta (scade, non si cancella) |
| CDN / reverse proxy | Infrastruttura condivisa | Purge API, TTL | Media |
| Cache applicativa | Redis, Memcached | Codice applicativo | Bassa |
| Database | Buffer pool, materialized view | Engine o job | Bassa |
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
privateo 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:
| Pattern | Come funziona | Pro | Contro |
|---|---|---|---|
| Cache-aside | L'app legge dalla cache, se manca legge dal DB e scrive in cache | Semplice, controllo totale | Logica duplicata in ogni punto di lettura |
| Read-through | La cache stessa si occupa di caricare dal DB | Codice applicativo pulito | Serve una libreria che lo supporti |
| Write-through | Si scrive in cache e DB insieme | Cache quasi sempre coerente | Scritture più lente |
| Write-behind | Si scrive in cache, il DB viene aggiornato dopo | Scritture veloci | Rischio 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à
- 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.
- 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.
- 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:
- Il lettore A fa cache miss e legge il valore vecchio dal database.
- Il writer B aggiorna il database e cancella la chiave.
- 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: Cookiesu 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,ETageVaryvanno 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.