Usare Memcached in PHP
Memcached è un sistema di caching distribuito ad alte prestazioni, pensato per memorizzare in RAM coppie chiave/valore e ridurre drasticamente il carico su database e API. In ambito PHP, Memcached è una delle soluzioni più diffuse per accelerare applicazioni web, gestire sessioni condivise tra più server e implementare strategie di caching applicativo. In questo articolo vedremo come installare il server, configurare l'estensione PHP, eseguire le operazioni fondamentali, gestire la scadenza dei dati, lavorare con pool di server multipli e affrontare scenari avanzati come il caching di query database e la gestione delle race condition.
Memcached e Memcache: le due estensioni PHP
Prima di iniziare è importante chiarire una fonte ricorrente di confusione: per PHP esistono due estensioni distinte, memcache e memcached. La prima è più vecchia, scritta interamente in PHP/C e ormai poco mantenuta. La seconda, memcached, si basa sulla libreria nativa libmemcached, offre prestazioni superiori, supporto al protocollo binario, compressione, serializzazione configurabile e gestione avanzata dei pool di server. In tutti i progetti moderni è consigliabile utilizzare l'estensione memcached, che è quella che analizzeremo in questa guida.
Installazione del server e dell'estensione
Su sistemi Debian/Ubuntu il server Memcached e l'estensione PHP si installano tramite i pacchetti ufficiali:
sudo apt update
sudo apt install memcached libmemcached-tools php-memcached
sudo systemctl enable --now memcached
Per verificare che il servizio sia attivo e in ascolto sulla porta predefinita 11211 si può usare:
systemctl status memcached
ss -tlnp | grep 11211
L'installazione dell'estensione PHP può essere verificata con:
php -m | grep memcached
php --ri memcached
Configurazione di base del server
Il file di configurazione principale su Debian/Ubuntu si trova in /etc/memcached.conf. I parametri più rilevanti sono la memoria allocata, l'indirizzo di binding e il numero massimo di connessioni:
# Memoria massima utilizzabile dalla cache (in MB)
-m 256
# Porta di ascolto
-p 11211
# Bind solo su localhost per sicurezza
-l 127.0.0.1
# Numero massimo di connessioni simultanee
-c 1024
# Utente di esecuzione del demone
-u memcache
In contesti di produzione è fondamentale non esporre mai Memcached direttamente su Internet: il protocollo è privo di autenticazione nativa (se non si usa SASL) e un'istanza pubblica diventa rapidamente vettore di attacchi amplification.
Prima connessione e operazioni fondamentali
L'API dell'estensione è esposta attraverso la classe Memcached. La connessione avviene aggiungendo uno o più server al client tramite addServer():
<?php
$cache = new Memcached();
$cache->addServer('127.0.0.1', 11211);
// Scrittura di una chiave con TTL di 60 secondi
$cache->set('greeting', 'Hello, world!', 60);
// Lettura della chiave
$value = $cache->get('greeting');
if ($cache->getResultCode() === Memcached::RES_SUCCESS) {
echo $value;
} else {
echo 'Chiave non trovata';
}
Il metodo set() accetta una chiave, un valore e un tempo di scadenza espresso in secondi. Se il valore di TTL supera i 30 giorni (2.592.000 secondi), Memcached lo interpreta come un timestamp Unix assoluto: una distinzione importante da tenere a mente per evitare comportamenti inattesi.
Verifica del risultato delle operazioni
Un errore comune consiste nel confondere un valore mancante con un errore di connessione. Quando get() restituisce false bisogna sempre controllare il codice di risultato:
<?php
$result = $cache->get('missing_key');
switch ($cache->getResultCode()) {
case Memcached::RES_SUCCESS:
// Chiave trovata
break;
case Memcached::RES_NOTFOUND:
// Chiave inesistente o scaduta
break;
default:
// Errore di connessione o protocollo
error_log('Errore Memcached: ' . $cache->getResultMessage());
}
Distinguere tra RES_NOTFOUND e altri codici di errore è essenziale per non scambiare un guasto temporaneo del server per una semplice cache miss, soprattutto quando la cache viene usata come fonte di dati derivati.
Operazioni multiple e atomicità
Per ridurre il numero di round-trip di rete, l'estensione mette a disposizione metodi che operano su più chiavi in una sola chiamata:
<?php
// Scrittura multipla
$cache->setMulti([
'user:1:name' => 'Alice',
'user:2:name' => 'Bob',
'user:3:name' => 'Charlie',
], 300);
// Lettura multipla
$users = $cache->getMulti(['user:1:name', 'user:2:name', 'user:3:name']);
foreach ($users as $key => $name) {
echo "$key => $name\n";
}
Per garantire l'atomicità nella scrittura sono disponibili anche add(), che inserisce solo se la chiave non esiste, e replace(), che aggiorna solo se la chiave è già presente:
<?php
// Inserisce solo se la chiave non esiste
$inserted = $cache->add('lock:process', '1', 30);
if ($inserted) {
// Codice critico protetto da lock
} else {
// Un altro processo detiene già il lock
}
Contatori atomici
Memcached è particolarmente efficace come backend per contatori distribuiti grazie ai metodi increment() e decrement(), che operano in modo atomico lato server:
<?php
// Inizializza il contatore solo la prima volta
$cache->add('page:views', 0);
// Incrementa di 1 in modo atomico
$views = $cache->increment('page:views');
echo "Visualizzazioni: $views";
Va ricordato che increment() richiede che il valore sia già presente e numerico: se la chiave non esiste l'operazione fallisce. Per questo motivo conviene sempre inizializzarla con add(), oppure usare la firma estesa che accetta un valore iniziale.
Serializzazione e compressione
L'estensione serializza automaticamente array e oggetti PHP prima di inviarli al server. Il serializzatore predefinito è quello nativo di PHP, ma sono disponibili anche JSON e igbinary, quest'ultimo molto più compatto e veloce:
<?php
$cache = new Memcached();
$cache->setOption(Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY);
$cache->setOption(Memcached::OPT_COMPRESSION, true);
$cache->addServer('127.0.0.1', 11211);
// Memorizzazione di un oggetto complesso
$profile = [
'id' => 42,
'name' => 'Alice',
'roles' => ['editor', 'reviewer'],
];
$cache->set('profile:42', $profile, 600);
La compressione viene applicata automaticamente ai valori che superano una soglia configurabile (di default 2000 byte). Per dati molto ripetitivi può ridurre l'occupazione di memoria anche del 70-80%, al prezzo di un piccolo overhead in CPU.
Pool di server e hashing consistente
Una delle funzionalità più potenti dell'estensione è il supporto nativo a pool di server multipli, con distribuzione delle chiavi tramite hashing consistente. Questo permette di scalare orizzontalmente la cache mantenendo stabile la mappatura chiave-nodo anche quando un server viene aggiunto o rimosso:
<?php
$cache = new Memcached('app_pool');
// addServers viene chiamato solo se il pool è vuoto
if (count($cache->getServerList()) === 0) {
$cache->setOption(Memcached::OPT_LIBKETAMA_COMPATIBLE, true);
$cache->setOption(Memcached::OPT_DISTRIBUTION, Memcached::DISTRIBUTION_CONSISTENT);
$cache->setOption(Memcached::OPT_REMOVE_FAILED_SERVERS, true);
$cache->addServers([
['cache1.internal', 11211, 50],
['cache2.internal', 11211, 30],
['cache3.internal', 11211, 20],
]);
}
Il terzo parametro di ogni voce rappresenta il peso relativo del nodo. Il costruttore con argomento (qui 'app_pool') crea un'istanza persistente: la connessione e l'elenco dei server vengono riutilizzati tra le richieste, evitando di ricreare il pool ad ogni esecuzione dello script.
Pattern di caching applicativo
Il pattern più comune per il caching di dati derivati è il cosiddetto cache-aside, in cui l'applicazione legge prima dalla cache e solo in caso di miss interroga la sorgente autoritativa:
<?php
function getUserById(PDO $db, Memcached $cache, int $userId): ?array
{
$key = "user:$userId";
$user = $cache->get($key);
if ($cache->getResultCode() === Memcached::RES_SUCCESS) {
return $user;
}
// Cache miss: leggi dal database
$stmt = $db->prepare('SELECT id, name, email FROM users WHERE id = :id');
$stmt->execute(['id' => $userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
if ($user !== null) {
$cache->set($key, $user, 300);
}
return $user;
}
Quando i dati sottostanti cambiano, l'applicazione deve invalidare esplicitamente la cache con delete():
<?php
function updateUserEmail(PDO $db, Memcached $cache, int $userId, string $email): void
{
$stmt = $db->prepare('UPDATE users SET email = :email WHERE id = :id');
$stmt->execute(['email' => $email, 'id' => $userId]);
// Invalida la voce in cache
$cache->delete("user:$userId");
}
Cache stampede e il pattern CAS
Quando una chiave molto richiesta scade, decine o centinaia di richieste concorrenti possono tentare contemporaneamente di rigenerarla, sovraccaricando il database. Questo fenomeno è noto come cache stampede. Una mitigazione efficace consiste nell'usare un lock temporaneo tramite add():
<?php
function getExpensiveData(Memcached $cache, string $key, callable $loader, int $ttl): mixed
{
$value = $cache->get($key);
if ($cache->getResultCode() === Memcached::RES_SUCCESS) {
return $value;
}
$lockKey = "$key:lock";
// Solo un processo otterrà il lock
if ($cache->add($lockKey, 1, 10)) {
try {
$value = $loader();
$cache->set($key, $value, $ttl);
return $value;
} finally {
$cache->delete($lockKey);
}
}
// Gli altri processi attendono brevemente e ritentano la lettura
usleep(50_000);
return $cache->get($key);
}
Per aggiornamenti concorrenti su una stessa chiave, l'estensione supporta il pattern Check-And-Set tramite getByKey()/cas(), che permette di aggiornare un valore solo se il suo token di versione non è cambiato tra la lettura e la scrittura:
<?php
do {
$value = $cache->get('counter', null, $cas);
if ($cache->getResultCode() === Memcached::RES_NOTFOUND) {
$cache->add('counter', 1);
break;
}
$newValue = $value + 1;
$cache->cas($cas, 'counter', $newValue);
} while ($cache->getResultCode() !== Memcached::RES_SUCCESS);
Gestione delle sessioni PHP
Memcached può essere usato come storage delle sessioni PHP, scenario particolarmente utile quando le richieste vengono distribuite su più server applicativi dietro un load balancer. La configurazione si effettua in php.ini:
session.save_handler = memcached
session.save_path = "cache1.internal:11211,cache2.internal:11211"
memcached.sess_locking = On
memcached.sess_lock_wait_min = 1000
memcached.sess_lock_wait_max = 2000
Il locking di sessione previene scritture concorrenti sulla stessa sessione, ma su applicazioni con molte richieste AJAX parallele può introdurre latenza: in questi casi conviene disabilitarlo per gli endpoint che non modificano i dati di sessione, oppure adottare una strategia di lock più granulare.
Limiti e considerazioni operative
Memcached impone alcuni vincoli da tenere presenti in fase di progettazione. La dimensione massima di una singola chiave è 250 byte, mentre il valore non può superare 1 MB di default (limite alzabile con l'opzione -I del server, ma sconsigliato). La cache è volatile: al riavvio del demone tutti i dati vengono persi, e nessun meccanismo di persistenza su disco è previsto a livello nativo. Inoltre, l'algoritmo di eviction LRU può rimuovere chiavi anche prima della loro scadenza naturale quando la memoria allocata si satura.
Per monitorare lo stato del server è possibile usare il comando stats via telnet o, lato PHP, il metodo getStats():
<?php
$stats = $cache->getStats();
foreach ($stats as $server => $info) {
echo "Server: $server\n";
echo " Memoria usata: " . $info['bytes'] . " byte\n";
echo " Hit: " . $info['get_hits'] . "\n";
echo " Miss: " . $info['get_misses'] . "\n";
echo " Evictions: " . $info['evictions'] . "\n";
}
Il rapporto tra get_hits e get_misses è il principale indicatore dell'efficacia della cache: un hit ratio inferiore al 70-80% suggerisce TTL troppo brevi, chiavi mal progettate o memoria insufficiente.
Conclusioni
L'estensione memcached per PHP offre un'interfaccia completa e performante verso uno dei sistemi di caching più collaudati in ambito web. Le sue funzionalità chiave (pool di server con hashing consistente, operazioni atomiche, serializzazione configurabile, supporto CAS) la rendono adatta a scenari che vanno dal semplice caching di query database fino alla gestione di sessioni distribuite e contatori in tempo reale. La regola operativa fondamentale resta una sola: trattare la cache come un'ottimizzazione, mai come fonte di verità. Ogni dato memorizzato deve poter essere ricostruito a partire da una sorgente autoritativa, perché in Memcached la persistenza non è mai garantita.