Usare Memcached in Java
Memcached è un sistema di caching in memoria distribuito, ad alte prestazioni, originariamente sviluppato per ridurre il carico sui database in applicazioni web ad alto traffico. Si tratta di un servizio leggero che memorizza coppie chiave-valore direttamente nella RAM, offrendo tempi di accesso nell'ordine dei microsecondi. In questo articolo vedremo come integrare Memcached in un'applicazione Java, esaminando i client disponibili, i pattern di utilizzo più comuni e le strategie di gestione della cache in scenari reali.
Architettura e concetti fondamentali
Memcached opera secondo un modello client-server in cui i client comunicano con uno o più server Memcached tramite un protocollo testuale o binario. Ogni server gestisce una porzione della memoria totale disponibile e i client, attraverso un algoritmo di hashing consistente, determinano su quale server memorizzare o recuperare un determinato valore. Questa architettura permette una scalabilità orizzontale praticamente lineare: aggiungendo nuovi nodi al cluster, la capacità complessiva della cache aumenta in modo proporzionale.
Le caratteristiche principali di Memcached includono l'assenza di persistenza (i dati esistono solo in memoria), una politica di eviction basata su LRU (Least Recently Used), il supporto per TTL (Time To Live) configurabile per ogni chiave e un limite massimo di un megabyte per singolo valore. Queste caratteristiche lo rendono ideale per memorizzare risultati di query costose, sessioni utente, frammenti di HTML renderizzato o oggetti serializzati di uso frequente.
Installazione del server Memcached
Prima di scrivere codice Java, è necessario disporre di un server Memcached funzionante. Su sistemi Debian e Ubuntu l'installazione avviene tramite il gestore di pacchetti, mentre per ambienti di sviluppo è spesso preferibile utilizzare Docker per evitare di installare software sul sistema host.
# Installazione su Ubuntu/Debian
sudo apt update
sudo apt install memcached
# Verifica dello stato del servizio
sudo systemctl status memcached
# Avvio tramite Docker
docker run -d --name memcached-server -p 11211:11211 memcached:latest
# Test di connessione con telnet
telnet localhost 11211
La porta predefinita di Memcached è la 11211. Una volta connessi tramite telnet, è possibile inviare comandi come stats per verificare lo stato del server, set per memorizzare un valore o get per recuperarlo. Questo approccio è utile per il debugging, ma in produzione l'interazione avviene sempre attraverso un client di alto livello.
Scelta del client Java
Nell'ecosistema Java esistono diversi client per Memcached, ciascuno con punti di forza specifici. I tre più diffusi sono spymemcached, XMemcached e AWS ElastiCache Memcached Client. Il primo, sviluppato originariamente da Dustin Sallings, è asincrono per natura e si basa su NIO. Il secondo offre un'API sia sincrona che asincrona ed è generalmente considerato più robusto in scenari multi-server. Il terzo è un fork di spymemcached ottimizzato per l'utilizzo con AWS ElastiCache e include il supporto nativo per l'auto-discovery dei nodi.
Per gli esempi che seguono utilizzeremo principalmente spymemcached, dato che rappresenta lo standard de facto e ha un'API estremamente pulita. Inizializziamo un nuovo progetto Maven aggiungendo la dipendenza appropriata nel file pom.xml:
<dependencies>
<dependency>
<groupId>net.spy</groupId>
<artifactId>spymemcached</artifactId>
<version>2.12.3</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.9</version>
</dependency>
</dependencies>
Prima connessione e operazioni di base
L'oggetto principale per interagire con Memcached in spymemcached è MemcachedClient. La sua creazione richiede una lista di indirizzi dei server Memcached del cluster. Anche con un singolo server, l'API è progettata per scalare in modo trasparente quando si aggiungono nuovi nodi.
package com.example.cache;
import net.spy.memcached.MemcachedClient;
import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;
public class BasicCacheExample {
public static void main(String[] args) throws Exception {
// Creazione del client connesso al server locale
MemcachedClient client = new MemcachedClient(
new InetSocketAddress("localhost", 11211)
);
// Memorizzazione di un valore con TTL di 60 secondi
client.set("user:1001", 60, "Mario Rossi");
// Recupero del valore memorizzato
Object value = client.get("user:1001");
System.out.println("Valore recuperato: " + value);
// Aggiornamento del valore esistente
client.replace("user:1001", 60, "Luigi Bianchi");
// Cancellazione esplicita della chiave
client.delete("user:1001");
// Chiusura della connessione con timeout
client.shutdown(5, TimeUnit.SECONDS);
}
}
Il metodo set accetta tre parametri: la chiave, il TTL espresso in secondi e il valore da memorizzare. Quando il TTL viene impostato a zero, il valore rimane in cache fino a quando non viene esplicitamente rimosso o l'algoritmo LRU non lo seleziona per l'eviction. Il metodo get restituisce un Object, quindi è necessario effettuare un cast esplicito al tipo atteso oppure gestire il caso in cui la chiave non esista (in tal caso viene restituito null).
Operazioni asincrone e futures
Uno dei punti di forza di spymemcached è la sua natura intrinsecamente asincrona. Tutte le operazioni di scrittura restituiscono un oggetto OperationFuture che può essere utilizzato per attendere il completamento dell'operazione o per verificarne il successo. Questo approccio è particolarmente utile quando si desidera massimizzare il throughput senza bloccare il thread chiamante.
package com.example.cache;
import net.spy.memcached.MemcachedClient;
import net.spy.memcached.internal.OperationFuture;
import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;
public class AsyncCacheExample {
public static void main(String[] args) throws Exception {
MemcachedClient client = new MemcachedClient(
new InetSocketAddress("localhost", 11211)
);
// Operazione asincrona di scrittura
OperationFuture<Boolean> future = client.set("product:42", 300, "Smartphone");
// Esecuzione di altre operazioni mentre la scrittura è in corso
System.out.println("Operazione inviata, attesa del risultato...");
// Attesa del completamento con timeout
boolean success = future.get(2, TimeUnit.SECONDS);
if (success) {
System.out.println("Scrittura completata con successo");
} else {
System.out.println("Scrittura fallita");
}
// Recupero asincrono tramite GetFuture
Object result = client.asyncGet("product:42").get(1, TimeUnit.SECONDS);
System.out.println("Prodotto recuperato: " + result);
client.shutdown(5, TimeUnit.SECONDS);
}
}
L'utilizzo delle future permette di implementare pattern di pipelining in cui più operazioni vengono inviate al server in rapida successione, raccogliendo i risultati solo quando necessario. Questa tecnica è fondamentale in applicazioni che devono effettuare decine o centinaia di lookup in cache per generare una singola pagina o risposta API.
Serializzazione di oggetti complessi
Memcached memorizza dati come array di byte, quindi qualsiasi oggetto Java che non sia un tipo primitivo o una stringa deve essere serializzato. Spymemcached utilizza per default la serializzazione Java standard, che richiede che le classi implementino l'interfaccia Serializable. Tuttavia, per ragioni di performance e compatibilità, è spesso preferibile utilizzare formati come JSON tramite librerie come Jackson o Gson.
package com.example.cache;
import java.io.Serializable;
public class Product implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String name;
private double price;
private int stock;
public Product(Long id, String name, double price, int stock) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock;
}
// I getter e i setter sono omessi per brevità
@Override
public String toString() {
return "Product{id=" + id + ", name='" + name + "', price=" + price + "}";
}
}
Una volta definita la classe serializzabile, possiamo memorizzarla e recuperarla in modo trasparente:
package com.example.cache;
import net.spy.memcached.MemcachedClient;
import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;
public class ObjectCacheExample {
public static void main(String[] args) throws Exception {
MemcachedClient client = new MemcachedClient(
new InetSocketAddress("localhost", 11211)
);
// Creazione e memorizzazione di un oggetto
Product laptop = new Product(1L, "Laptop Pro", 1299.99, 15);
client.set("product:1", 3600, laptop);
// Recupero e cast dell'oggetto
Product cached = (Product) client.get("product:1");
if (cached != null) {
System.out.println("Prodotto in cache: " + cached);
} else {
System.out.println("Cache miss, caricamento dal database necessario");
}
client.shutdown(5, TimeUnit.SECONDS);
}
}
Pattern cache-aside
Il pattern più diffuso per integrare Memcached in un'applicazione è il cosiddetto cache-aside, noto anche come lazy loading. In questo schema, l'applicazione interroga prima la cache: se il valore è presente (cache hit), lo restituisce direttamente; in caso contrario (cache miss), recupera il dato dalla sorgente primaria (tipicamente un database), lo memorizza in cache per le richieste future e lo restituisce al chiamante.
package com.example.cache;
import net.spy.memcached.MemcachedClient;
public class ProductService {
private final MemcachedClient cache;
private final ProductRepository repository;
private static final int DEFAULT_TTL = 600;
public ProductService(MemcachedClient cache, ProductRepository repository) {
this.cache = cache;
this.repository = repository;
}
public Product findById(Long id) {
String key = buildKey(id);
// Tentativo di recupero dalla cache
Product product = (Product) cache.get(key);
if (product != null) {
return product;
}
// Cache miss: caricamento dal database
product = repository.findById(id);
if (product != null) {
// Memorizzazione in cache per le richieste successive
cache.set(key, DEFAULT_TTL, product);
}
return product;
}
public void update(Product product) {
repository.update(product);
// Invalidazione della cache dopo l'aggiornamento
cache.delete(buildKey(product.getId()));
}
private String buildKey(Long id) {
return "product:" + id;
}
}
La nomenclatura delle chiavi è un aspetto critico in qualsiasi sistema di caching. Una convenzione comune consiste nell'utilizzare un prefisso che identifica il tipo di entità, seguito da due punti e dall'identificativo univoco. Per chiavi più complesse si possono concatenare più segmenti, come user:1001:preferences o product:42:reviews:page:2. È importante che la chiave sia deterministica e ricostruibile a partire dai parametri della richiesta.
Gestione di cluster multi-nodo
In ambienti di produzione, Memcached viene tipicamente distribuito su più server per garantire alta disponibilità e maggiore capacità complessiva. Spymemcached supporta nativamente questa configurazione attraverso l'utilizzo di una lista di indirizzi. Internamente, il client utilizza un algoritmo di hashing consistente per distribuire le chiavi tra i nodi in modo che l'aggiunta o la rimozione di un server causi il minimo possibile di redistribuzione.
package com.example.cache;
import net.spy.memcached.MemcachedClient;
import net.spy.memcached.ConnectionFactoryBuilder;
import net.spy.memcached.ConnectionFactoryBuilder.Locator;
import java.net.InetSocketAddress;
import java.util.Arrays;
import java.util.List;
public class ClusterExample {
public static void main(String[] args) throws Exception {
// Definizione dei nodi del cluster
List<InetSocketAddress> nodes = Arrays.asList(
new InetSocketAddress("cache-01.example.com", 11211),
new InetSocketAddress("cache-02.example.com", 11211),
new InetSocketAddress("cache-03.example.com", 11211)
);
// Configurazione con hashing consistente Ketama
ConnectionFactoryBuilder builder = new ConnectionFactoryBuilder()
.setLocatorType(Locator.CONSISTENT)
.setOpTimeout(2000)
.setFailureMode(net.spy.memcached.FailureMode.Redistribute);
MemcachedClient client = new MemcachedClient(builder.build(), nodes);
// Le chiavi vengono distribuite automaticamente tra i nodi
for (int i = 0; i < 100; i++) {
client.set("item:" + i, 300, "Value " + i);
}
client.shutdown();
}
}
L'algoritmo Ketama, basato sull'hashing consistente, è generalmente preferito rispetto al modulo array hashing tradizionale perché minimizza il numero di chiavi che devono essere rimappate quando un nodo viene aggiunto o rimosso dal cluster. Il parametro FailureMode determina invece il comportamento del client in caso di indisponibilità di un nodo: Redistribute reindirizza le richieste agli altri nodi disponibili, Retry riprova periodicamente sullo stesso nodo, mentre Cancel fa fallire immediatamente l'operazione.
Operazioni atomiche e contatori
Memcached supporta operazioni atomiche su valori numerici tramite i comandi incr e decr. Queste operazioni sono particolarmente utili per implementare contatori, rate limiter o sistemi di accodamento leggeri senza dover ricorrere a transazioni distribuite. È importante notare che il valore iniziale deve essere memorizzato come stringa di cifre, dato che Memcached lo interpreta come un intero senza segno a 64 bit.
package com.example.cache;
import net.spy.memcached.MemcachedClient;
import java.net.InetSocketAddress;
public class CounterExample {
public static void main(String[] args) throws Exception {
MemcachedClient client = new MemcachedClient(
new InetSocketAddress("localhost", 11211)
);
String counterKey = "page:views:homepage";
// Inizializzazione del contatore se non esiste
client.add(counterKey, 0, "0");
// Incremento atomico di una unità
long currentViews = client.incr(counterKey, 1);
System.out.println("Visualizzazioni totali: " + currentViews);
// Incremento di valori multipli
long newTotal = client.incr(counterKey, 10);
System.out.println("Dopo incremento di 10: " + newTotal);
// Decremento atomico
long afterDecrement = client.decr(counterKey, 5);
System.out.println("Dopo decremento di 5: " + afterDecrement);
client.shutdown();
}
}
L'utilizzo del metodo add invece di set per l'inizializzazione garantisce che il contatore venga creato solo se la chiave non esiste già, evitando di sovrascrivere un valore esistente in scenari concorrenti. Una limitazione importante è che Memcached non permette di decrementare un contatore al di sotto dello zero: in tal caso il valore rimane fisso a zero.
Bulk operations e multi-get
Quando un'applicazione deve recuperare numerose chiavi contemporaneamente, effettuare una singola chiamata get per ciascuna di esse è estremamente inefficiente a causa del costo della latenza di rete. Memcached supporta il comando get_multi che permette di recuperare in un'unica operazione un insieme arbitrario di chiavi, riducendo drasticamente il numero di round trip.
package com.example.cache;
import net.spy.memcached.MemcachedClient;
import java.net.InetSocketAddress;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
public class BulkOperationsExample {
public static void main(String[] args) throws Exception {
MemcachedClient client = new MemcachedClient(
new InetSocketAddress("localhost", 11211)
);
// Popolamento iniziale della cache
for (int i = 1; i <= 10; i++) {
client.set("article:" + i, 600, "Contenuto articolo " + i);
}
// Costruzione della lista di chiavi da recuperare
List<String> keys = Arrays.asList(
"article:1", "article:3", "article:5",
"article:7", "article:9", "article:99"
);
// Recupero multiplo in una sola operazione
Map<String, Object> results = client.getBulk(keys);
// Le chiavi non trovate sono semplicemente assenti dalla mappa
for (String key : keys) {
if (results.containsKey(key)) {
System.out.println(key + " => " + results.get(key));
} else {
System.out.println(key + " => cache miss");
}
}
client.shutdown();
}
}
Il metodo getBulk restituisce una mappa che contiene solo le chiavi effettivamente presenti nella cache. Le chiavi mancanti, corrispondenti a cache miss, non generano entry con valore null ma sono semplicemente omesse. Questo comportamento permette al chiamante di identificare facilmente quali dati devono essere caricati dalla sorgente primaria.
Compare-and-swap per la concorrenza
Per scenari in cui più client potrebbero modificare contemporaneamente lo stesso valore, Memcached offre il meccanismo CAS (Compare And Swap). Ogni valore memorizzato è associato a un identificatore univoco che viene incrementato ad ogni modifica. Un'operazione CAS riesce solo se l'identificatore fornito dal client corrisponde a quello attualmente memorizzato sul server, garantendo così che nessun altro client abbia modificato il valore nel frattempo.
package com.example.cache;
import net.spy.memcached.MemcachedClient;
import net.spy.memcached.CASValue;
import net.spy.memcached.CASResponse;
import java.net.InetSocketAddress;
public class CasExample {
public static void main(String[] args) throws Exception {
MemcachedClient client = new MemcachedClient(
new InetSocketAddress("localhost", 11211)
);
String key = "shopping:cart:1001";
client.set(key, 0, "Carrello iniziale");
// Recupero del valore con il suo identificatore CAS
CASValue<Object> casValue = client.gets(key);
if (casValue != null) {
long casId = casValue.getCas();
Object currentValue = casValue.getValue();
System.out.println("Valore attuale: " + currentValue + " (CAS: " + casId + ")");
// Tentativo di aggiornamento atomico
CASResponse response = client.cas(key, casId, 300, "Carrello aggiornato");
switch (response) {
case OK:
System.out.println("Aggiornamento riuscito");
break;
case EXISTS:
System.out.println("Conflitto: il valore è stato modificato da un altro client");
break;
case NOT_FOUND:
System.out.println("Chiave non più presente in cache");
break;
default:
System.out.println("Risposta inattesa: " + response);
}
}
client.shutdown();
}
}
Il pattern tipico di utilizzo del CAS prevede un loop che ritenta l'operazione in caso di conflitto, recuperando il nuovo valore e ricalcolando la modifica. Questo approccio è noto come ottimistic locking ed è generalmente più performante del locking pessimistico in scenari con basso tasso di conflitto.
Gestione degli errori e resilienza
Un'applicazione che dipende da Memcached deve essere progettata per gestire correttamente le situazioni in cui il server di cache non è disponibile. Memcached è infatti pensato come un'ottimizzazione, non come una sorgente primaria di dati: ogni cache miss o errore di rete deve poter essere gestito ricadendo sulla sorgente autoritativa.
package com.example.cache;
import net.spy.memcached.MemcachedClient;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public class ResilientCacheService {
private final MemcachedClient cache;
private final ProductRepository repository;
private static final long OPERATION_TIMEOUT = 100;
public ResilientCacheService(MemcachedClient cache, ProductRepository repository) {
this.cache = cache;
this.repository = repository;
}
public Product findById(Long id) {
String key = "product:" + id;
Product product = null;
// Tentativo di lettura dalla cache con timeout esplicito
try {
product = (Product) cache.asyncGet(key)
.get(OPERATION_TIMEOUT, TimeUnit.MILLISECONDS);
} catch (TimeoutException ex) {
// Il server è lento ma non vogliamo bloccare la richiesta
System.err.println("Timeout sulla cache, fallback al database");
} catch (Exception ex) {
// Qualsiasi altro errore non deve compromettere la richiesta
System.err.println("Errore di cache: " + ex.getMessage());
}
if (product != null) {
return product;
}
// Fallback alla sorgente primaria
product = repository.findById(id);
// Tentativo di ripopolamento della cache senza bloccare in caso di errore
if (product != null) {
try {
cache.set(key, 600, product);
} catch (Exception ex) {
System.err.println("Impossibile aggiornare la cache: " + ex.getMessage());
}
}
return product;
}
}
L'imposizione di timeout aggressivi è una pratica fondamentale: se Memcached impiega più tempo di quanto impiegherebbe il database per rispondere, l'utilizzo della cache diventa controproducente. Un timeout di 50-100 millisecondi è solitamente appropriato in reti a bassa latenza, mentre in scenari con server remoti potrebbe essere necessario un valore leggermente più alto.
Considerazioni sul TTL e sull'invalidazione
La scelta del TTL per ciascuna chiave dipende dal compromesso tra freschezza dei dati e tasso di hit della cache. Valori molto bassi garantiscono che i dati siano sempre aggiornati ma riducono l'efficacia della cache, mentre valori molto alti aumentano il rischio di servire dati obsoleti. Una strategia comune consiste nell'impostare TTL relativamente lunghi (da minuti a ore) e invalidare esplicitamente le chiavi quando i dati sottostanti vengono modificati.
L'invalidazione esplicita presenta tuttavia le sue complessità, soprattutto in sistemi distribuiti dove più istanze applicative scrivono sugli stessi dati. In questi casi può essere utile combinare l'invalidazione con una strategia di versioning delle chiavi: invece di invalidare la chiave product:42, si può associare a ciascun prodotto un numero di versione e includere tale versione nella chiave di cache, ottenendo qualcosa come product:42:v17. Quando il prodotto viene modificato, viene incrementato il numero di versione e le vecchie chiavi vengono semplicemente abbandonate, finendo per essere eliminate dall'algoritmo LRU.
Conclusioni
Memcached rimane uno strumento estremamente valido nell'arsenale di qualsiasi sviluppatore Java che lavori su applicazioni ad alto traffico. La sua semplicità concettuale, le prestazioni eccellenti e la facilità di scalabilità orizzontale lo rendono ideale per ridurre il carico sui database e migliorare la responsività complessiva del sistema. L'integrazione tramite client come spymemcached è diretta e il modello di programmazione asincrona offerto permette di costruire applicazioni che sfruttano la cache senza compromettere il throughput.
È importante ricordare che Memcached non è una soluzione universale: per scenari che richiedono strutture dati complesse, persistenza o pubblicazione di eventi è preferibile valutare alternative come Redis. Tuttavia, quando il requisito è una cache distribuita pura, veloce e affidabile, Memcached continua a rappresentare una scelta eccellente, supportata da una community matura e da anni di utilizzo in produzione presso alcune delle più grandi piattaforme web del mondo.