Caching strategico con Redis in Laravel: il caso dei cataloghi e-commerce

Caching strategico con Redis in Laravel: il caso dei cataloghi e-commerce

Un catalogo e-commerce con centinaia di migliaia di referenze, sotto il carico di un picco stagionale, vive o muore sul caching. Senza, ogni pagina prodotto martella il database con le stesse query, i tempi di risposta esplodono e il server cade proprio quando arriva il traffico che volevi. Con un caching fatto bene, lo stesso server regge dieci volte il carico senza accorgersene. Il problema è che cachare male non è neutro: serve dati sbagliati. E in un e-commerce un dato sbagliato ha un nome preciso e un costo reale, un prezzo vecchio mostrato in pagina, una disponibilità inesistente che porta a un ordine inevadibile. Ho visto un catalogo servire per ore una disponibilità a magazzino che non c'era più, perché l'invalidazione della cache era stata lasciata "pigra", affidata alla scadenza naturale. La diagnosi di questo articolo parte proprio da lì: cosa conviene cachare e cosa è pericoloso, come si invalida davvero quando cambiano prezzi e disponibilità, e come si usano TTL e tag per non mostrare mai un prezzo che non esiste più.

Cosa conviene cachare, e cosa è pericoloso cachare

Non tutti i dati di un catalogo hanno lo stesso profilo, e il primo errore è trattarli allo stesso modo. Da una parte c'è la roba stabile e costosa da calcolare: l'albero delle categorie, le descrizioni dei prodotti, le schede tecniche, i frammenti di pagina renderizzati, le faccette di filtro. Cambiano di rado, si leggono moltissimo, e cacharli aggressivamente è puro guadagno. Dall'altra c'è la roba volatile e ad alto rischio: il prezzo e la disponibilità a magazzino. Cambiano spesso, e servirne una versione vecchia non è un dettaglio estetico, è un danno commerciale e a volte legale.

La regola che applico è semplice da enunciare e difficile da rispettare sotto pressione: si cacha aggressivamente ciò che è stabile, e si tratta ciò che è volatile con il massimo sospetto. Questo non significa "non cachare mai i prezzi", significa che il prezzo lo cachi solo se hai un meccanismo di invalidazione preciso che scatta nell'istante in cui cambia, oppure con una finestra di staleness così corta da rendere il rischio accettabile. Per la disponibilità, dove un falso positivo (mostrare disponibile ciò che è esaurito) genera ordini inevadibili, la barra è ancora più alta: spesso conviene non cacharla affatto, o cacharla per pochissimi secondi, accettando il costo della query in cambio della correttezza.

In un catalogo, il costo di una cache miss è qualche millisecondo; il costo di servire un prezzo o una disponibilità sbagliati è un ordine al prezzo errato o un cliente che paga per merce che non c'è. Quando i due costi sono così asimmetrici, la correttezza vince sulla velocità, sempre.

L'invalidazione è il problema difficile, non il caching

C'è una battuta tra programmatori secondo cui le due cose difficili dell'informatica sono l'invalidazione della cache e dare un nome alle cose. Sul caching è letteralmente vero: mettere un valore in cache è banale, decidere quando toglierlo è dove si gioca tutto. L'approccio sbagliato, quello che ha causato il problema di disponibilità che ho citato, è l'invalidazione pigra: metti un TTL lungo e speri che vada bene. Va bene per le categorie, è una bomba a orologeria per i prezzi.

L'approccio corretto per i dati volatili è l'invalidazione guidata dagli eventi: quando il modello cambia, la cache che lo riguarda viene buttata via nello stesso momento. In Laravel questo si aggancia naturalmente agli event del modello Eloquent: sul saved o updated di un prodotto si invalida la sua cache.

// App\Models\Product
protected static function booted(): void
{
    static::saved(function (Product $product) {
        Cache::tags(['products', "product:{$product->id}"])->flush();
    });
}

Qui entrano in gioco i tag, che sono lo strumento per invalidare in modo chirurgico. Attenzione a un vincolo tecnico che va conosciuto: i tag della cache sono supportati solo da Redis, Memcached e DynamoDB, non dai driver file o database (lo specifica la documentazione ufficiale del caching di Laravel). È una delle ragioni per cui, su un catalogo serio, Redis non è un'opzione tra le tante, è il presupposto. Questa logica multilivello, dove ogni tipo di dato ha la sua strategia di invalidazione, l'ho descritta in dettaglio parlando di caching multilivello in Laravel per l'alto traffico.

Se gestisci un e-commerce che sotto carico rallenta o, peggio, serve dati incoerenti, e vuoi rimettere ordine nella strategia di caching, nel mio profilo professionale trovi l'esperienza concreta su cataloghi ad alta complessità e infrastrutture ad alto traffico.

TTL e tag: le due leve, e come si combinano

TTL e tag non sono alternativi, sono leve diverse per problemi diversi, e la strategia buona li usa insieme. Il TTL è la rete di sicurezza temporale: anche se la tua invalidazione a eventi avesse un buco, il dato scade comunque dopo un tempo massimo. È il limite superiore alla durata di un errore. I tag sono il bisturi: ti permettono di invalidare esattamente l'insieme di chiavi che un cambiamento tocca, senza svuotare tutta la cache. La combinazione che uso è TTL corto sui dati volatili come ulteriore garanzia, più invalidazione a tag sull'evento di cambiamento; TTL lungo sui dati stabili, dove l'invalidazione a evento basta e avanza.

// frammento di catalogo: stabile, TTL lungo + tag per invalidazione mirata
$tree = Cache::tags(['categories'])->remember('category_tree', now()->addDay(), function () {
    return Category::with('children')->get();
});

Il punto da interiorizzare è che il TTL da solo è invalidazione pigra travestita, mentre i tag da soli sono fragili se l'evento di invalidazione non scatta sempre. Insieme coprono i rispettivi punti ciechi.

I livelli di caching: dal database al bordo della rete

Un errore comune è pensare alla cache come a un solo strato. Su un catalogo serio i livelli sono almeno quattro, e ognuno cattura un costo diverso. Il primo è la cache dei risultati di query o degli oggetti: l'albero delle categorie, un set di prodotti, un modello costoso da idratare, messi in Redis come ho mostrato sopra. È il livello che toglie pressione al database. Il secondo è la cache dei frammenti renderizzati: invece di ricomporre l'HTML di una scheda prodotto o di una griglia a ogni richiesta, si cacha il frammento già renderizzato, risparmiando anche il costo del template engine. Il terzo è la cache HTTP, gli header Cache-Control ed ETag che permettono al browser del cliente e ai proxy intermedi di non richiedere nemmeno la pagina se non è cambiata. Il quarto, sui cataloghi ad altissimo traffico, è la full-page cache al bordo, con un reverse proxy come Varnish davanti all'applicazione che serve le pagine anonime senza nemmeno svegliare PHP.

La regola che lega i livelli è che si cacha il più vicino possibile all'utente ciò che è uguale per tutti, e il più vicino possibile al dato ciò che è personalizzato. Una griglia di categoria anonima può vivere in full-page cache al bordo; il prezzo specifico per un cliente con listino dedicato non può, e va calcolato vicino al dato. Sbagliare questo abbinamento è il modo classico per ritrovarsi a servire la pagina di un cliente a un altro, l'incidente di caching più imbarazzante che esista. Ogni livello ha anche la sua invalidazione, e progettarli in modo coerente, così che un cambio di prezzo si propaghi correttamente attraverso tutti gli strati, è esattamente il lavoro che distingue un caching che accelera da uno che mente.

Il cache stampede: quando l'invalidazione stessa diventa il problema

C'è un fallimento subdolo che colpisce proprio i cataloghi ad alto traffico, ed è il cache stampede. Quando una chiave molto richiesta scade, tutte le richieste che la cercano nello stesso istante trovano la cache vuota e si lanciano insieme a ricalcolare lo stesso valore, martellando il database tutte in una volta. L'invalidazione, fatta nel momento sbagliato su una chiave calda, diventa essa stessa la causa del crollo.

Laravel offre due risposte. La prima è il lock atomico con Cache::lock(), che fa sì che una sola richiesta ricalcoli il valore mentre le altre aspettano. La seconda, più elegante per i dati di lettura, è il pattern stale-while-revalidate, disponibile in modo nativo dalla versione 11.23 con Cache::flexible(): serve il valore "stantio" mentre lo ricalcola in background, così nessun utente paga il costo del ricalcolo.

// fresh per 5 minuti, servibile come stale per altri 10 mentre si rigenera dietro le quinte
$products = Cache::flexible("category:{$id}:products", [300, 600], function () use ($id) {
    return Product::where('category_id', $id)->paginate(24);
});

Cache::flexible() accetta un array a due valori: il primo è la finestra in cui il dato è considerato fresco, il secondo il limite oltre il quale va ricalcolato in modo bloccante; nel mezzo, il valore stantio viene servito subito e rigenerato dopo aver risposto all'utente. È perfetto per i frammenti di catalogo dove una manciata di secondi di staleness non fa danni, e va combinato con un sistema di code attivo, perché la rigenerazione in background vi si appoggia. Questo tipo di ottimizzazione va sempre misurata, non immaginata, ed è la ragione per cui un load test serio con k6 prima del go-live è parte integrante del lavoro, non un accessorio.

Il pattern che rende sicura la disponibilità: la prenotazione atomica

Se la disponibilità è il dato più pericoloso da cachare, la soluzione non è solo "cacharla poco", è separare la visualizzazione dalla verità. In navigazione mostro un'indicazione di disponibilità che può tollerare qualche secondo di staleness, perché lì sto solo informando; ma nel momento in cui il cliente aggiunge al carrello o va al checkout, non leggo una cache, eseguo una decrementazione atomica del contatore di stock sulla fonte autoritativa. Redis è perfetto per questo, perché le sue operazioni sui contatori sono atomiche: una prenotazione che porta lo stock sotto zero fallisce in modo deterministico, e quel fallimento blocca l'oversell prima che diventi un ordine inevadibile.

Il principio è che la cache risponde alla domanda "quanto è probabile che sia disponibile?", utile per la navigazione, mentre la prenotazione atomica risponde alla domanda "è davvero disponibile adesso, e lo riservo per me?", che è l'unica che conta al momento dell'impegno. Confondere le due, e fidarsi della cache di navigazione anche al checkout, è esattamente come nascono gli oversell nei venerdì di picco. Tenerle separate ti permette di cachare aggressivamente la prima senza alcun rischio sulla seconda, perché la decisione irreversibile non passa mai da un dato potenzialmente vecchio. È lo stesso principio che applico ai dati fiscali e ai pagamenti: la lettura può essere veloce e approssimata, l'impegno deve essere lento e certo.

Si può cachare il prezzo e la disponibilità?

La risposta onesta è: sì, ma a condizioni precise, e non allo stesso modo. Il prezzo si può cachare se l'invalidazione scatta nell'istante in cui cambia (a evento) e con un TTL corto come rete di sicurezza: un prezzo cambia per una decisione gestionale, quindi l'evento esiste e lo puoi intercettare. La disponibilità è più insidiosa, perché cambia anche per fatti che non passano dal tuo backend nel momento esatto (un ordine concorrente, una sincronizzazione con il magazzino), e un falso "disponibile" costa più di un falso "esaurito". Lì la mia regola è cacharla pochissimo o per niente sui punti di decisione d'acquisto, e verificarla comunque al momento dell'aggiunta al carrello e del checkout, dove l'errore diventa irreversibile. La cache rende veloce la navigazione; la verità sulla disponibilità si controlla quando il cliente sta per impegnarsi, mai prima e basta.

Tirando le fila, il caching di un catalogo non è una questione di "attivare Redis e via", è una questione di trattare ogni dato per quello che è. Si cacha aggressivamente ciò che è stabile e costoso, perché lì il guadagno è netto e il rischio nullo; si maneggia con i guanti ciò che è volatile e ad alto rischio, perché lì un dato vecchio costa più di una query in più. L'invalidazione, non il caching, è la parte difficile, e va fatta guidata dagli eventi e non lasciata alla scadenza pigra, con i tag a fare da bisturi e il TTL a fare da rete. E quando il traffico è alto si progetta contro lo stampede, con i lock o con lo stale-while-revalidate nativo, perché su un catalogo sotto picco è il momento dell'invalidazione, non il caching, a poter mandare giù tutto. Se hai un e-commerce che sotto carico rallenta o mostra prezzi e disponibilità incoerenti, e vuoi una strategia di caching che sia veloce senza mai mentire al cliente, contattami per una consulenza diretta: di solito il problema non è la mancanza di cache, è il fatto che si sta cachando la cosa sbagliata nel modo sbagliato.

Ultima modifica: