Se il tuo database va in affanno ogni volta che un utente carica una pagina, il problema non è il database: è che non stai usando la cache nel modo giusto. Lo vediamo continuamente in progetti che ci arrivano: query MySQL che girano in 800ms, API che rispondono in 2 secondi, codice PHP che ricalcola ogni volta lo stesso risultato. E intanto Redis resta fermo, come un motore Ferrari parcheggiato in garage.
Noi, di Meteora Web, abbiamo iniziato a lavorare con Redis su Laravel e server Linux nel 2018. Da allora, ogni volta che ottimizziamo un sito o una piattaforma, la cache è il primo interruttore da accendere. Non perché siamo fan della complessità, ma perché un dato letto una volta e messo in cache può essere servito in 1 millisecondo invece che in 200. E in un’applicazione web, quei millisecondi sono fatturato.
In questa guida approfondiamo tre pattern fondamentali: cache-aside, write-through e l’uso corretto del TTL. Non ci limitiamo alla teoria: vedremo codice funzionante, scenari reali e il “perché” di ogni scelta.
Il problema alla radice: perché la cache da sola non basta
Immagina di avere un e-commerce che mostra il catalogo prodotti. Ogni richiesta interroga MySQL, fa join su join, calcola disponibilità. Funziona, ma con 100 utenti simultanei il server rallenta. Con 500 va in timeout.
La prima idea è “cache tutto”. Butti le query in Redis, imposti TTL a 24 ore e chiudi. Ma poi arriva lo stock che si aggiorna in tempo reale e la cache serve dati vecchi. Oppure cancella una chiave e la ricarichi, ma nel frattempo un altro processo ha già letto il dato vecchio. I pattern servono proprio a gestire queste situazioni: coerenza dei dati, latenza e carico sul database.
Non esiste un pattern universale. La scelta dipende da quanto il dato è volatile, quanto è costoso da generare, e quanto tolleri l’obsolescenza. Vediamoli uno per uno.
Cache-Aside (o Lazy Loading): il pattern più usato
Come funziona
Il pattern cache-aside è il più semplice e comune. L’applicazione non si fida della cache come fonte primaria: prima chiede a Redis, se non trova (cache miss) allora interroga il database, popola la cache e restituisce il dato. Se trova (cache hit), restituisce direttamente.
In codice PHP con predis (client Redis per PHP) e un ipotetico repository:
function getProduct(int $id): array
{
$cacheKey = "product:{$id}";
$cached = $this->redis->get($cacheKey);
if ($cached !== false) {
return json_decode($cached, true);
}
// Cache miss: vai al database
$product = $this->db->query("SELECT * FROM products WHERE id = ?", [$id]);
if ($product) {
// Imposta cache con TTL di 3600 secondi (1 ora)
$this->redis->setex($cacheKey, 3600, json_encode($product));
}
return $product;
}Vantaggi: semplice, adattivo (solo i dati richiesti vengono cacheizzati), evita di riempire Redis con dati mai letti.
Svantaggi: in caso di cache miss, si paga il costo del database più la scrittura in cache. In picco di richieste su dati non ancora in cache (es. lancio prodotto nuovo) può creare carico improvviso. Inoltre, se due richieste concorrenti fanno miss contemporaneamente, entrambe vanno al database (thundering herd).
Quando usarlo
Ideale per dati letti frequentemente ma modificati raramente: profili utente, articoli di blog, configurazioni. Per il nostro cliente e-commerce moda, abbiamo usato cache-aside per le schede prodotto: i dati cambiavano al massimo una volta al giorno (prezzi, descrizioni), il TTL di 12 ore garantiva freschezza.
Operativo: implementazione con Laravel Cache
Laravel astrae Redis dietro la facade Cache. Ecco come implementare cache-aside con il metodo remember:
use Illuminate\Support\Facades\Cache;
function getProductCached(int $id): array
{
$cacheKey = "product:{$id}";
return Cache::remember($cacheKey, 3600, function () use ($id) {
// callback eseguito solo su cache miss
return Product::find($id)->toArray();
});
}remember gestisce atomicamente il cache miss e il set. Attenzione: in ambienti multi-server con concorrenza, questo non previene il thundering herd (due server possono eseguire il callback contemporaneamente). Per mitigare, usare Cache::lock o un meccanismo di mutex.
Write-Through: la cache come scrittura trasparente
Come funziona
In write-through, ogni scrittura passa prima dalla cache e poi dal database (o viceversa, a seconda della strategia). L'idea è che quando aggiorni un dato, lo aggiorni immediatamente nella cache. Così la cache è sempre coerente con il database per i dati scritti.
Esempio: aggiornamento prezzo prodotto.
function updateProductPrice(int $id, float $newPrice): void
{
$cacheKey = "product:{$id}";
// 1. Scrittura nel database
$this->db->query("UPDATE products SET price = ? WHERE id = ?", [$newPrice, $id]);
// 2. Aggiorna la cache (o cancella, vedi sotto)
$product = $this->db->query("SELECT * FROM products WHERE id = ?", [$id]);
$this->redis->setex($cacheKey, 3600, json_encode($product));
}Vantaggi: la cache è sempre aggiornata dopo ogni scrittura. I successivi read hit trovano il dato fresco.
Svantaggi: ogni scrittura fa due operazioni (DB + Redis). Se il dato non viene mai letto dopo la scrittura, abbiamo sprecato risorse. Inoltre, se la scrittura fallisce su uno dei due store, bisogna gestire la transazione (non facile).
Una variante leggera è il cache-invalidate on write: invece di riscrivere la cache, la si cancella. Il prossimo read farà cache-aside e ripopolerà dal DB. Questo riduce la complessità e il carico di scrittura, ma introduce un microscopico buco di coerenza: tra la cancellazione e il successivo read, la cache non ha il dato.
Quando usare write-through
Dati modificati frequentemente e letti ancor più frequentemente. Ad esempio, il carrello di un e-commerce: ogni aggiunta/rimozione va scritta in DB e in cache perché l'utente ricarica più volte la pagina carrello. Write-through garantisce che l'ultima modifica sia subito visibile.
Operativo: write-through con Laravel e transazioni
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
function updateProductPriceWithCache(int $id, float $newPrice): void
{
DB::transaction(function () use ($id, $newPrice) {
// Aggiorna DB
DB::table('products')->where('id', $id)->update(['price' => $newPrice]);
// Recupera il record aggiornato
$product = DB::table('products')->find($id);
// Scrive in cache (write-through)
Cache::put("product:{$id}", (array) $product, 3600);
});
}Usando una transazione garantiamo che se la scrittura DB fallisce, la cache non viene toccata. Per coerenza totale, potremmo anche cancellare la cache invece di aggiornarla (write-invalidate), ma nell'esempio manteniamo write-through.
TTL: il termometro della freschezza
Perché il TTL non è un optional
Ogni chiave in Redis ha un Time To Live. Se non lo imposti, il dato resta in memoria per sempre → occupazione infinita, dato mai aggiornato. Se lo imposti troppo corto, continui a fare cache miss. Se troppo lungo, rischi di servire dati vecchi anni.
Regola pratica: il TTL deve essere paragonabile al tempo massimo in cui il dato può essere servito senza generare errori di business. Per i prezzi di un e-commerce con aggiornamenti giornalieri, 12-24 ore va bene. Per lo stock in tempo reale, pochi minuti. Per un post su Instagram, ore.
Gestione della scadenza
In Redis si usa EXPIRE o SETEX. La scadenza è lazy (Redis controlla quando accedi alla chiave) e periodica (ogni 100ms scansiona un campione). Non è granulare a livello di millisecondi, ma per il 99% dei casi è sufficiente.
Attenzione: se aggiorni una chiave con SET, perdi il TTL precedente. Deve essere reimpostato esplicitamente o usare SETEX.
# Imposta chiave con TTL di 300 secondi
SET product:123 '{"id":123,"price":29.99}' EX 300
# Verifica TTL residuo
TTL product:123
# Estendi TTL
EXPIRE product:123 600Strategie di TTL dinamico
Se hai dati con diversa frequenza di aggiornamento, puoi calcolare il TTL in base alla popolarità: dati molto richiesti hanno TTL più lungo, dati poco richiesti TTL più corto. Ma in pratica, una costante sensata (es. 3600 secondi) è la scelta pragmatica per il 90% dei casi.
Noi, di Meteora Web, abbiamo risolto un problema di stock su un e-commerce fashion usando TTL a 60 secondi per i dati di disponibilità (aggiornati da ERP ogni 30 secondi) e TTL a 3600 secondi per le descrizioni prodotto. Il database MySQL è passato da 500 query/sec a 15 query/sec. La latenza media è scesa da 400ms a 12ms.
Errori comuni e come evitarli
- Cache invalidation manuale: cancellare chiavi a mano è un incubo. Usa sempre TTL o pattern di scadenza automatica.
- Dimenticare il serializzatore: Redis memorizza byte. Se salvi oggetti PHP o strutture complesse, assicurati di serializzare (json, igbinary, msgpack). In Laravel,
serializeè preconfigurato. - Thundering herd su chiavi calde: per dati che scadono e vengono richiesti da centinaia di processi contemporaneamente, usa cache lock o mutex per far sì che solo un processo rigeneri la cache.
- Chiavi senza namespace: usa prefissi come
product:123,user:456. Evita collisioni e facilita la cancellazione per pattern (DEL product:*è pericoloso, meglio SCAN).
In sintesi — cosa fare adesso
- Analizza i tuoi colli di bottiglia. Monitora le query più lente sul DB. Identifica i dati letti molto più spesso che scritti.
- Scegli il pattern giusto: cache-aside per dati a lettura prevalente, write-through per dati che cambiano spesso e devono essere sempre coerenti.
- Imposta TTL consapevoli. Non lasciare mai una chiave senza scadenza. Parti da 3600 secondi e regola dopo aver monitorato i cache hit ratio.
- Implementa con Laravel Cache o Redis nativo. Usa gli esempi sopra come punto di partenza. Se sei su Laravel, sfrutta
rememberper cache-aside. - Testa la concorrenza. Simula 50 richieste contemporanee su una chiave non in cache. Se il DB va in affanno, aggiungi un lock o un meccanismo di mutex.
Se vuoi approfondire come abbiamo strutturato una cache layer per una piattaforma proprietaria basata su Laravel, puoi leggere la nostra guida su JSONB in PostgreSQL: schemi flessibili e query performanti — un'altra tecnica per velocizzare query complesse.
E ricorda: la cache non risolve tutto, ma senza di essa paghi ogni volta il conto del database. Noi abbiamo iniziato a usare Redis nel 2018 e da allora ogni applicazione che costruiamo parte da lì. Perché un'applicazione veloce non è un lusso: è un fatturato che non perdi.
Sponsored Protocol