Memcached è un sistema di caching distribuito in memoria, progettato per ridurre la latenza e il carico su database e servizi backend. Il suo obiettivo è offrire una key–value store volatile, estremamente semplice e veloce, da usare come cache di primo livello nelle applicazioni scalabili.
Obiettivi e principi di progettazione
- Semplicità: API ridotte all’essenziale (get, set, add, replace, delete, incr/decr, touch, cas) e protocollo snello.
- Bassa latenza: operazioni tipicamente nell’ordine dei millisecondi o microsecondi su rete locale.
- Scalabilità orizzontale lato client: il cluster cresce aggiungendo nodi, con lo sharding gestito dal client.
- Volatilità intenzionale: nessuna persistenza nativa; i dati sono considerati rigenerabili dalla fonte autorevole.
- Concorrenza efficiente: modello multithread con I/O non bloccante e lock minimizzati.
Modello di deployment
Memcached non ha una vista “coordinata” del cluster. I client distribuiscono le chiavi tra più nodi secondo una funzione di hashing deterministica. In caso di aggiunta o rimozione di nodi, è il client a ricalcolare la mappa delle chiavi. Questa scelta elimina punti singoli di fallimento lato server e riduce la complessità.
Sharding lato client
- Hashing della chiave: la chiave è trasformata in un hash; il risultato determina il nodo di destinazione.
- Consistent hashing: molte librerie client usano anelli di hash e “virtual nodes” per ridurre la riallocazione di chiavi quando cambia la topologia.
- Assenza di replica nativa: eventuale ridondanza (es. scrivere su più nodi) è opzionale e implementata dal client.
Architettura interna del server
Il server è progettato per massimizzare throughput e prevedibilità della latenza tramite gestione della memoria a slab, tabelle hash in memoria, e un modello I/O basato su eventi.
Gestione della memoria: slab allocator
- Classi di slab: la memoria viene suddivisa in classi di dimensioni crescenti; ogni classe contiene chunks di grandezza fissa adatti a oggetti di una certa taglia.
- Eliminazione della frammentazione: assegnando ogni elemento alla classe più piccola che lo contiene si riduce la frammentazione interna e si evita quella esterna.
- Slab reassign: il server può riassegnare slab tra classi per adattarsi a cambiamenti del profilo di carico.
Strutture dati chiave
- Tabella hash: indicizza gli elementi per chiave, con ridimensionamento dinamico e politiche per contenere la contesa tra thread.
- LRU per classe: ogni classe di slab mantiene una lista Least Recently Used per l’eviction quando la memoria della classe è esaurita.
- Metadati per item: includono chiave, TTL (scadenza), dimensione valore, flag utente e, opzionalmente, token CAS.
Politiche di scadenza ed eviction
- TTL per chiave: ogni elemento può avere una scadenza assoluta o relativa; gli elementi scaduti sono trattati come assenti.
- Eviction LRU: quando una classe è piena, gli elementi meno recenti sono rimossi per far spazio ai nuovi.
- Lazy expiration: la rimozione di scaduti avviene al momento dell’accesso o durante la scansione delle LRU, evitando cicli di garbage collection pesanti.
Concorrenza e threading
- Event loop non bloccante: un thread principale accetta connessioni; più worker threads gestiscono I/O e comandi usando notifiche e code di lavoro.
- Lock sharding: la tabella hash e le LRU sono partizionate per ridurre la contesa sui lock in ambienti multicore.
- Batching e code: le operazioni di rete e gli aggiornamenti agli item sono processati in lotti quando possibile per efficienza.
Protocollo di rete
- Protocollo testuale e binario: il testuale è leggibile e diffuso; il binario offre parsing più efficiente e supporto strutturato per funzionalità come CAS e multi-get.
- Connessioni TCP: la modalità tradizionale e più affidabile; l’uso storico di UDP per get molto veloci è oggi meno comune.
- Operazioni principali: recupero e scrittura (singola o multipla), eliminazione, incrementi/decrementi atomici, aggiornamento del TTL, check-and-set ottimistico.
Coerenza e concorrenza sui dati
- Coerenza best-effort: non essendoci consenso tra nodi, non esiste coerenza forte globale.
- CAS (Check-And-Set): fornisce controllo ottimistico delle versioni per evitare condizioni di gara negli aggiornamenti concorrenti.
- Atomicità intra-nodo: operazioni come incr/decr su una chiave sono atomiche sul singolo server che la ospita.
Flusso di una richiesta
- Il client calcola l’hash della chiave e seleziona il nodo di destinazione.
- Il server riceve la richiesta, individua l’elemento nella tabella hash e verifica scadenza e stato.
- Se è un get e l’elemento è valido, il valore è restituito; in caso contrario si risponde con “miss”.
- Per un set, il server seleziona la classe di slab, alloca un chunk, scrive i metadati e inserisce in tabella e LRU.
- In caso di memoria esaurita nella classe, avviene l’eviction LRU prima dell’inserimento.
Scalabilità e prestazioni
- Multi-get: richiedere più chiavi in un’unica richiesta riduce overhead di rete.
- Batching lato client: pipeline e connessioni persistenti massimizzano throughput.
- Bilanciamento delle chiavi: una buona funzione di hashing e virtual nodes evitano hot-spot.
- Riduzione degli oggetti grandi: valori molto voluminosi possono saturare classi di slab alte, peggiorando l’eviction.
Monitoraggio e osservabilità
- Statistiche runtime: comandi per misurare tassi di hit/miss, utilizzo memoria per classe, dimensioni della tabella hash, tempi medi, connessioni attive.
- LRU crawler: meccanismi interni per analizzare e ripulire periodicamente le liste LRU.
- Allarmi operativi: attenzione a crescita di eviction, calo di hit rate, connessioni in backlog, eccesso di oggetti scaduti.
Limiti progettuali
- Nessuna persistenza: un riavvio o un guasto comporta perdita del contenuto della cache.
- Nessuna replica o failover nativi: l’alta disponibilità richiede logica a livello applicativo o di infrastruttura.
- Sicurezza minima: storicamente non include autenticazione forte; l’uso raccomandato è su reti fidate o dietro proxy sicuri.
- Coerenza best-effort: in ambienti distribuiti con scritture concorrenti, l’applicazione deve gestire la logica di validazione.
Pattern d’uso ricorrenti
- Cache-aside: l’applicazione legge prima da Memcached; in caso di miss, carica dalla fonte e aggiorna la cache con un TTL adeguato.
- Write-through o write-behind: meno comuni con Memcached, ma possibili lato applicativo per sincronizzare con il data store.
- Dogpile e thundering herd: mitigati con jitter sui TTL, soft TTL lato client e lock applicativi temporanei.
Buone pratiche di configurazione
- Dimensionamento memoria: scegliere una RAM totale che minimizzi l’eviction per il profilo di oggetti reale.
- Tuning delle classi di slab: configurare il fattore di crescita per ridurre sprechi nelle classi alte.
- Separazione del traffico: usare interfacce e porte dedicate, con limiti di connessione e timeouts ragionevoli.
- Client maturi: preferire librerie con consistent hashing, retry con backoff e controllo degli errori.
Confronto concettuale con altri cache
- Memcached vs cache embedded: Memcached offre condivisione tra processi e host, a differenza delle cache in-process.
- Memcached vs sistemi con persistenza: rispetto a soluzioni che offrono persistenza e replicazione, Memcached punta a massima semplicità e velocità sacrificando durabilità.
Quando adottarlo
- Alti volumi di letture ripetute su dati rigenerabili.
- Riduzione della latenza per pagine, API e microservizi che interrogano spesso la stessa fonte lente.
- Riduzione del carico su database relazionali o servizi esterni costosi.
Conclusioni
Memcached rimane una componente essenziale per architetture ad alte prestazioni grazie a un design minimalista: sharding lato client, memoria a slab, LRU per classe, I/O non bloccante e assenza di persistenza. Questa combinazione semplifica l’operatività, massimizza la velocità e demanda all’applicazione il controllo di coerenza, disponibilità e sicurezza. Usato con buone pratiche di chiavi, TTL e osservabilità, fornisce un miglioramento drastico di throughput e latenza in una vasta gamma di scenari.