Design e architettura di Memcached

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

  1. Il client calcola l’hash della chiave e seleziona il nodo di destinazione.
  2. Il server riceve la richiesta, individua l’elemento nella tabella hash e verifica scadenza e stato.
  3. Se è un get e l’elemento è valido, il valore è restituito; in caso contrario si risponde con “miss”.
  4. Per un set, il server seleziona la classe di slab, alloca un chunk, scrive i metadati e inserisce in tabella e LRU.
  5. 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.

Torna su