Usare Memcached in Node.js
Memcached è un sistema di caching distribuito ad alte prestazioni, progettato per accelerare applicazioni web dinamiche riducendo il carico sui database. Si tratta di un key-value store in memoria che mantiene i dati in RAM, garantendo tempi di accesso nell'ordine dei microsecondi. In questo articolo vedremo come integrare Memcached in un'applicazione Node.js, partendo dall'installazione del server fino alla realizzazione di pattern di caching avanzati.
Cos'è Memcached e quando usarlo
Memcached è stato sviluppato originariamente da Brad Fitzpatrick per LiveJournal nel 2003 ed è oggi utilizzato da grandi piattaforme come Facebook, Twitter, YouTube e Wikipedia. La sua architettura è semplice ma estremamente efficiente: un demone in memoria che espone un protocollo testuale (o binario) su TCP, accessibile da qualsiasi client compatibile.
Le caratteristiche principali di Memcached includono:
- Storage esclusivamente in RAM, con eviction automatica quando la memoria si esaurisce
- Algoritmo LRU (Least Recently Used) per la rimozione delle chiavi obsolete
- Supporto nativo per il clustering tramite consistent hashing lato client
- Protocollo semplice e ben documentato
- Nessuna persistenza su disco (tutti i dati vengono persi al riavvio)
Memcached è particolarmente adatto per memorizzare risultati di query SQL costose, sessioni utente, fragment di HTML renderizzato, risposte di API esterne e qualsiasi dato che possa essere ricalcolato se necessario. Non è invece adatto come database primario, poiché non offre garanzie di persistenza, e non supporta strutture dati complesse come liste, set o hash (a differenza di Redis).
Installazione del server Memcached
Prima di poter utilizzare Memcached dal codice Node.js, è necessario installare il server. Su sistemi Debian o Ubuntu il comando è il seguente:
# Installazione del pacchetto Memcached
sudo apt update
sudo apt install memcached -y
# Verifica dello stato del servizio
sudo systemctl status memcached
Su macOS, utilizzando Homebrew:
# Installazione tramite Homebrew
brew install memcached
# Avvio del servizio
brew services start memcached
Per ambienti di sviluppo o test, è spesso preferibile utilizzare Docker, che garantisce isolamento e riproducibilità:
# Avvio di un container Memcached
docker run --name memcached-server -p 11211:11211 -d memcached:latest
# Verifica della connessione
echo "stats" | nc localhost 11211
Il file di configurazione principale su Linux si trova in /etc/memcached.conf. I parametri più importanti sono -m per la quantità di RAM allocata (in megabyte), -p per la porta (default 11211), -l per l'indirizzo di binding e -c per il numero massimo di connessioni simultanee.
Scelta del client Node.js
Esistono diversi client Memcached per Node.js, ognuno con caratteristiche specifiche. I più utilizzati e mantenuti attualmente sono:
- memcached: il client più popolare, supporta clustering, failover automatico e operazioni in batch
- memjs: client moderno con supporto per Promise e async/await nativi
- memcache-pp: alternativa leggera con API basata su Promise
In questo articolo utilizzeremo principalmente memjs per la sua API moderna e memcached per le funzionalità più avanzate. Iniziamo creando un nuovo progetto:
# Inizializzazione del progetto Node.js
mkdir memcached-demo
cd memcached-demo
npm init -y
# Installazione delle dipendenze
npm install memjs memcached express
npm install --save-dev nodemon
Connessione di base con memjs
La libreria memjs offre un'API basata su Promise che si integra perfettamente con il moderno JavaScript asincrono. Vediamo come stabilire una connessione e eseguire le operazioni fondamentali:
// Importazione del client memjs
import memjs from 'memjs';
// Creazione della connessione al server Memcached
const cacheClient = memjs.Client.create('localhost:11211', {
retries: 2,
expires: 3600,
timeout: 1,
keepAlive: true
});
// Funzione asincrona per testare la connessione
async function testConnection() {
try {
// Operazione di scrittura
await cacheClient.set('greeting', 'Ciao, mondo!', { expires: 60 });
console.log('Valore scritto correttamente nella cache');
// Operazione di lettura
const { value } = await cacheClient.get('greeting');
console.log('Valore letto:', value.toString());
// Operazione di cancellazione
await cacheClient.delete('greeting');
console.log('Chiave rimossa dalla cache');
} catch (error) {
console.error('Errore nella comunicazione con Memcached:', error.message);
}
}
testConnection();
Il metodo create accetta come primo argomento una stringa di connessione che può contenere uno o più server separati da virgola. Il secondo argomento è un oggetto di configurazione che permette di specificare il numero di tentativi in caso di errore, la durata di default delle chiavi in secondi, il timeout delle operazioni e l'uso del keep-alive TCP.
Va notato che memjs restituisce sempre i valori come Buffer, quindi è necessario chiamare toString() per ottenere una stringa leggibile, oppure utilizzare JSON.parse se il valore originale era un oggetto serializzato.
Serializzazione di oggetti complessi
Memcached memorizza esclusivamente stringhe o blob binari, quindi per salvare oggetti JavaScript complessi è necessario serializzarli in JSON. Ecco un wrapper riutilizzabile che gestisce automaticamente questa logica:
import memjs from 'memjs';
// Classe wrapper per la gestione semplificata della cache
class CacheService {
constructor(servers = 'localhost:11211', options = {}) {
this.client = memjs.Client.create(servers, options);
this.defaultTtl = options.expires || 3600;
}
// Salvataggio di un valore con serializzazione automatica
async set(key, value, ttl = this.defaultTtl) {
const serialized = JSON.stringify(value);
return this.client.set(key, serialized, { expires: ttl });
}
// Lettura con deserializzazione automatica
async get(key) {
const { value } = await this.client.get(key);
if (!value) return null;
try {
return JSON.parse(value.toString());
} catch (error) {
// Se non è JSON valido, restituisce la stringa grezza
return value.toString();
}
}
// Rimozione di una chiave specifica
async delete(key) {
return this.client.delete(key);
}
// Svuotamento completo della cache
async flush() {
return this.client.flush();
}
// Chiusura della connessione
close() {
this.client.close();
}
}
export default CacheService;
Questo wrapper semplifica notevolmente l'uso quotidiano della cache, evitando di dover gestire manualmente la serializzazione in ogni punto del codice. L'uso è immediato:
import CacheService from './cache-service.js';
const cache = new CacheService('localhost:11211');
// Salvataggio di un oggetto complesso
const userProfile = {
id: 42,
username: 'mario.rossi',
email: 'mario@example.com',
roles: ['editor', 'reviewer'],
preferences: {
language: 'it',
theme: 'dark'
}
};
await cache.set('user:42', userProfile, 1800);
// Recupero dell'oggetto deserializzato
const cachedUser = await cache.get('user:42');
console.log(cachedUser.username); // mario.rossi
Pattern Cache-Aside
Il pattern Cache-Aside (chiamato anche Lazy Loading) è il più diffuso quando si lavora con Memcached. La logica è semplice: prima di interrogare il database, l'applicazione verifica se il dato è già presente in cache. Se lo trova, lo restituisce immediatamente; altrimenti lo carica dal database, lo salva in cache e poi lo restituisce.
import CacheService from './cache-service.js';
import db from './database.js';
const cache = new CacheService();
// Funzione che implementa il pattern Cache-Aside
async function getProductById(productId) {
const cacheKey = `product:${productId}`;
// Tentativo di lettura dalla cache
const cached = await cache.get(cacheKey);
if (cached !== null) {
console.log(`Cache HIT per la chiave ${cacheKey}`);
return cached;
}
console.log(`Cache MISS per la chiave ${cacheKey}`);
// Caricamento dal database
const product = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
);
if (!product) {
return null;
}
// Salvataggio in cache per richieste future
await cache.set(cacheKey, product, 600);
return product;
}
Questo pattern presenta alcuni vantaggi importanti: la cache contiene solo i dati effettivamente richiesti, riducendo lo spreco di memoria; in caso di guasto della cache l'applicazione continua a funzionare leggendo direttamente dal database; il TTL garantisce che i dati vengano periodicamente rinfrescati.
Lo svantaggio principale è il rischio di cache stampede: quando una chiave molto richiesta scade, decine o centinaia di richieste possono colpire contemporaneamente il database. Vedremo più avanti come mitigare questo problema.
Invalidazione della cache
Uno degli aspetti più delicati del caching è l'invalidazione, ovvero la rimozione o l'aggiornamento dei dati cached quando i valori sottostanti cambiano. Esistono diverse strategie:
// Invalidazione esplicita dopo un update
async function updateProduct(productId, data) {
// Aggiornamento del database
await db.query(
'UPDATE products SET name = ?, price = ? WHERE id = ?',
[data.name, data.price, productId]
);
// Invalidazione della chiave specifica
await cache.delete(`product:${productId}`);
// Invalidazione di chiavi correlate
await cache.delete(`product:list:category:${data.categoryId}`);
await cache.delete('product:featured');
}
// Write-through: aggiornamento simultaneo di database e cache
async function updateProductWriteThrough(productId, data) {
await db.query(
'UPDATE products SET name = ?, price = ? WHERE id = ?',
[data.name, data.price, productId]
);
// Lettura del valore aggiornato e scrittura in cache
const updated = await db.query(
'SELECT * FROM products WHERE id = ?',
[productId]
);
await cache.set(`product:${productId}`, updated, 600);
}
La strategia write-through mantiene cache e database sempre sincronizzati, ma aumenta la latenza delle operazioni di scrittura. La strategia di invalidazione esplicita è più veloce ma può causare brevi finestre temporali in cui altre richieste leggono ancora il valore obsoleto.
Operazioni atomiche: increment e decrement
Memcached supporta operazioni atomiche di incremento e decremento, particolarmente utili per implementare contatori, rate limiter o sistemi di voting. Queste operazioni sono garantite thread-safe a livello di server:
import memjs from 'memjs';
const client = memjs.Client.create('localhost:11211');
// Implementazione di un contatore di visualizzazioni
async function trackPageView(pageId) {
const key = `views:page:${pageId}`;
// Inizializzazione del contatore se non esiste
await client.set(key, '0', { expires: 86400 });
// Incremento atomico
const { value } = await client.increment(key, 1);
return value;
}
// Rate limiter basato su sliding window
async function checkRateLimit(userId, maxRequests = 100) {
const minute = Math.floor(Date.now() / 60000);
const key = `ratelimit:${userId}:${minute}`;
// Inizializzazione se necessario
const { value: current } = await client.get(key);
if (!current) {
await client.set(key, '0', { expires: 60 });
}
// Incremento e verifica del limite
const { value: count } = await client.increment(key, 1);
if (count > maxRequests) {
throw new Error('Rate limit superato per questo utente');
}
return {
current: count,
remaining: maxRequests - count,
resetIn: 60 - (Math.floor(Date.now() / 1000) % 60)
};
}
Va sottolineato che increment e decrement richiedono che il valore esistente sia rappresentabile come un intero a 64 bit senza segno. Tentare di incrementare una chiave contenente una stringa non numerica produrrà un errore.
Operazioni multi-key
Quando è necessario recuperare o salvare più valori contemporaneamente, le operazioni multi-key (chiamate anche batch operations) offrono prestazioni significativamente migliori rispetto a chiamate singole ripetute. Il client memcached supporta queste operazioni in modo elegante:
import Memcached from 'memcached';
const client = new Memcached('localhost:11211', {
retries: 3,
retry: 10000,
remove: true
});
// Recupero multiplo di chiavi
function getMultiple(keys) {
return new Promise((resolve, reject) => {
client.getMulti(keys, (err, data) => {
if (err) return reject(err);
resolve(data);
});
});
}
// Esempio di utilizzo
async function loadUserDashboard(userId) {
const keys = [
`user:${userId}`,
`user:${userId}:notifications`,
`user:${userId}:messages:count`,
`user:${userId}:activities`
];
const data = await getMultiple(keys);
return {
profile: data[`user:${userId}`],
notifications: data[`user:${userId}:notifications`] || [],
messageCount: data[`user:${userId}:messages:count`] || 0,
activities: data[`user:${userId}:activities`] || []
};
}
Le operazioni multi-key sfruttano il pipelining del protocollo Memcached, inviando tutte le richieste in un singolo round-trip TCP. Questo è particolarmente vantaggioso quando il server Memcached è su una macchina remota, dove la latenza di rete diventa significativa.
Integrazione con Express
Vediamo ora come integrare Memcached in un'applicazione Express realistica, implementando un middleware di caching automatico per le risposte HTTP:
import express from 'express';
import CacheService from './cache-service.js';
const app = express();
const cache = new CacheService('localhost:11211');
// Middleware di caching per risposte HTTP
function cacheMiddleware(ttl = 300) {
return async (req, res, next) => {
// Solo le richieste GET vengono cached
if (req.method !== 'GET') return next();
const cacheKey = `http:${req.originalUrl}`;
try {
const cached = await cache.get(cacheKey);
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(cached);
}
// Intercettazione del metodo res.json originale
const originalJson = res.json.bind(res);
res.json = (body) => {
// Salvataggio in cache prima dell'invio
if (res.statusCode === 200) {
cache.set(cacheKey, body, ttl).catch(console.error);
}
res.set('X-Cache', 'MISS');
return originalJson(body);
};
next();
} catch (error) {
// In caso di errore della cache, prosegue normalmente
console.error('Errore nel middleware di cache:', error);
next();
}
};
}
// Applicazione del middleware a una route specifica
app.get('/api/products', cacheMiddleware(600), async (req, res) => {
const products = await db.query('SELECT * FROM products LIMIT 100');
res.json(products);
});
// Endpoint per la rimozione manuale della cache
app.delete('/api/cache/:key', async (req, res) => {
await cache.delete(req.params.key);
res.json({ success: true, key: req.params.key });
});
app.listen(3000, () => {
console.log('Server avviato sulla porta 3000');
});
Questo middleware intercetta tutte le risposte JSON di una route, le salva in cache con la URL completa come chiave e le restituisce dalla cache per le richieste successive. L'header X-Cache permette di verificare facilmente dal client se la risposta proviene dalla cache o è stata generata dal server.
Gestione del cache stampede
Il cache stampede (chiamato anche dogpile effect) si verifica quando una chiave molto popolare scade e numerose richieste concorrenti tentano simultaneamente di rigenerarla, sovraccaricando il database. Una tecnica efficace per mitigare il problema è il probabilistic early expiration, che rinnova la cache in modo probabilistico prima della scadenza effettiva:
import CacheService from './cache-service.js';
const cache = new CacheService();
// Implementazione di probabilistic early expiration
async function getWithEarlyRefresh(key, ttl, fetchFunction, beta = 1.0) {
const wrapper = await cache.get(key);
if (!wrapper) {
// Cache miss, ricalcolo immediato
const start = Date.now();
const value = await fetchFunction();
const delta = (Date.now() - start) / 1000;
const cached = {
value,
delta,
expiry: Math.floor(Date.now() / 1000) + ttl
};
await cache.set(key, cached, ttl);
return value;
}
const now = Math.floor(Date.now() / 1000);
const xfetch = wrapper.delta * beta * Math.log(Math.random());
// Decisione probabilistica di rinnovo anticipato
if (now - xfetch >= wrapper.expiry) {
const start = Date.now();
const value = await fetchFunction();
const delta = (Date.now() - start) / 1000;
const refreshed = {
value,
delta,
expiry: Math.floor(Date.now() / 1000) + ttl
};
await cache.set(key, refreshed, ttl);
return value;
}
return wrapper.value;
}
// Esempio di utilizzo per una query costosa
async function getTopProducts() {
return getWithEarlyRefresh(
'products:top',
300,
async () => db.query('SELECT * FROM products ORDER BY sales DESC LIMIT 50'),
2.0
);
}
Questo algoritmo, descritto nel paper "Optimal Probabilistic Cache Stampede Prevention", calcola una probabilità di rinnovo che cresce man mano che la chiave si avvicina alla scadenza. Il parametro beta controlla l'aggressività del rinnovo anticipato: valori più alti producono rinnovi più frequenti e quindi una migliore protezione contro lo stampede.
Configurazione di un cluster Memcached
Per applicazioni ad alto traffico, un singolo nodo Memcached può non essere sufficiente. Fortunatamente Memcached supporta il clustering attraverso il consistent hashing lato client, dove la responsabilità di distribuire le chiavi tra i server è demandata interamente al client:
import Memcached from 'memcached';
// Configurazione di un cluster con tre nodi
const client = new Memcached(
[
'cache1.internal:11211',
'cache2.internal:11211',
'cache3.internal:11211'
],
{
retries: 3,
retry: 10000,
remove: true,
failOverServers: ['cache-backup.internal:11211'],
timeout: 5000,
idle: 5000,
poolSize: 10
}
);
// Wrapper per operazioni sul cluster
class ClusterCache {
constructor(client) {
this.client = client;
}
set(key, value, ttl = 3600) {
return new Promise((resolve, reject) => {
const serialized = JSON.stringify(value);
this.client.set(key, serialized, ttl, (err) => {
if (err) return reject(err);
resolve(true);
});
});
}
get(key) {
return new Promise((resolve, reject) => {
this.client.get(key, (err, data) => {
if (err) return reject(err);
if (!data) return resolve(null);
try {
resolve(JSON.parse(data));
} catch {
resolve(data);
}
});
});
}
// Statistiche per ogni nodo del cluster
stats() {
return new Promise((resolve, reject) => {
this.client.stats((err, stats) => {
if (err) return reject(err);
resolve(stats);
});
});
}
}
const cluster = new ClusterCache(client);
// Verifica dello stato del cluster
const stats = await cluster.stats();
console.log('Stato dei nodi del cluster:', stats);
Il consistent hashing garantisce che, quando si aggiunge o rimuove un nodo, solo una piccola frazione delle chiavi viene rimappata, evitando di invalidare l'intera cache. La libreria memcached implementa questo algoritmo utilizzando il ketama hashing, lo stesso utilizzato dai client di altri linguaggi, garantendo interoperabilità.
Monitoraggio e debugging
In produzione è fondamentale monitorare lo stato di Memcached per identificare tempestivamente eventuali problemi. Le statistiche del server forniscono metriche dettagliate su hit rate, evizioni e utilizzo della memoria:
import Memcached from 'memcached';
const client = new Memcached('localhost:11211');
// Estrazione e analisi delle metriche
function getMetrics() {
return new Promise((resolve, reject) => {
client.stats((err, results) => {
if (err) return reject(err);
const server = results[0];
const hits = parseInt(server.get_hits, 10);
const misses = parseInt(server.get_misses, 10);
const total = hits + misses;
resolve({
uptime: parseInt(server.uptime, 10),
version: server.version,
currentConnections: parseInt(server.curr_connections, 10),
currentItems: parseInt(server.curr_items, 10),
bytesUsed: parseInt(server.bytes, 10),
bytesLimit: parseInt(server.limit_maxbytes, 10),
hitRate: total > 0 ? (hits / total * 100).toFixed(2) : 0,
evictions: parseInt(server.evictions, 10),
cmdGet: parseInt(server.cmd_get, 10),
cmdSet: parseInt(server.cmd_set, 10)
});
});
});
}
// Endpoint di monitoraggio per Express
app.get('/admin/cache/metrics', async (req, res) => {
try {
const metrics = await getMetrics();
res.json(metrics);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Un hit rate inferiore al 70-80% suggerisce che il TTL è troppo basso o che la memoria allocata è insufficiente. Un numero elevato di evizioni indica che Memcached sta rimuovendo chiavi attive per fare spazio a nuove, segnalando che è il momento di aumentare la RAM allocata al servizio.
Considerazioni di sicurezza
Memcached, per scelta architetturale, non implementa autenticazione né cifratura nativa nel protocollo standard. Questo lo rende vulnerabile se esposto direttamente su Internet. Le best practice di sicurezza includono:
- Binding del servizio esclusivamente su
127.0.0.1o su una rete privata interna - Utilizzo di firewall per limitare l'accesso solo agli host applicativi autorizzati
- Configurazione del protocollo SASL per ambienti che richiedono autenticazione
- Disabilitazione del protocollo UDP, storicamente sfruttato per attacchi DDoS amplificati
- Esecuzione del demone con un utente non privilegiato
Per disabilitare UDP e abilitare SASL, è sufficiente modificare la configurazione:
# In /etc/memcached.conf
-U 0
-S
-l 127.0.0.1
-m 512
-c 1024
Conclusioni
Memcached rimane una delle soluzioni di caching più collaudate e performanti per applicazioni web, con un'integrazione semplice e diretta in Node.js. La sua architettura essenziale e l'API minimale lo rendono particolarmente facile da apprendere e da operare in produzione, anche se richiede attenzione nella progettazione delle strategie di invalidazione e nella gestione dei pattern di accesso concorrente.
Quando si valuta l'adozione di Memcached, è importante considerare le alternative moderne come Redis, che offre strutture dati più ricche, persistenza opzionale, replicazione master-replica e funzionalità avanzate come pub/sub e streams. Memcached resta tuttavia la scelta preferibile quando si necessita di un caching puro, semplice da scalare orizzontalmente e con il minimo overhead operativo possibile.