Usare Memcached in Go

Memcached è un sistema di caching in memoria distribuito, ad alte prestazioni, progettato per ridurre il carico sui database e accelerare le applicazioni web dinamiche memorizzando coppie chiave-valore direttamente nella RAM. Nato nel 2003 per supportare LiveJournal, è oggi uno degli strumenti di caching più diffusi al mondo, utilizzato da aziende come Facebook, Twitter, Wikipedia e YouTube. In questo articolo vedremo come integrare Memcached in un'applicazione Go, esplorando i pattern d'uso più comuni, dalla configurazione di base fino a tecniche avanzate come il connection pooling, la gestione della concorrenza e l'implementazione del pattern cache-aside.

Installazione di Memcached

Prima di poter utilizzare Memcached da un'applicazione Go, è necessario installare il server. Su sistemi basati su Debian o Ubuntu, l'installazione avviene tramite il package manager apt:

sudo apt update
sudo apt install memcached libmemcached-tools

Su macOS, utilizzando Homebrew:

brew install memcached
brew services start memcached

Per ambienti containerizzati, è preferibile utilizzare Docker. Il seguente file docker-compose.yml avvia un'istanza Memcached configurata per ascoltare sulla porta standard 11211:

services:
  memcached:
    image: memcached:1.6-alpine
    container_name: memcached_server
    restart: unless-stopped
    ports:
      - "11211:11211"
    command: memcached -m 128 -c 1024 -v
    networks:
      - cache_network

networks:
  cache_network:
    driver: bridge

Il parametro -m 128 imposta la memoria massima a 128 MB, mentre -c 1024 definisce il numero massimo di connessioni simultanee. Per verificare che il servizio sia attivo, è possibile utilizzare il comando telnet e inviare il comando stats:

telnet localhost 11211
stats
quit

La libreria gomemcache

Il client Go più utilizzato per interagire con Memcached è gomemcache, sviluppato originariamente da Brad Fitzpatrick, autore stesso di Memcached. La libreria offre un'API semplice e idiomatica, supporta connessioni multiple e implementa nativamente il consistent hashing per cluster di più server.

L'installazione avviene tramite il comando go get:

go mod init memcached-example
go get github.com/bradfitz/gomemcache/memcache

Operazioni di base

L'API di gomemcache ruota attorno al tipo memcache.Client, ottenuto tramite la funzione memcache.New. Il client è thread-safe e può essere condiviso tra goroutine senza necessità di sincronizzazione esterna.

package main

import (
	"fmt"
	"log"

	"github.com/bradfitz/gomemcache/memcache"
)

func main() {
	// Inizializzazione del client con un singolo server
	client := memcache.New("localhost:11211")

	// Memorizzazione di un valore con scadenza di 30 secondi
	err := client.Set(&memcache.Item{
		Key:        "greeting",
		Value:      []byte("Hello from Memcached"),
		Expiration: 30,
	})
	if err != nil {
		log.Fatalf("errore durante la scrittura: %v", err)
	}

	// Lettura del valore appena memorizzato
	item, err := client.Get("greeting")
	if err != nil {
		log.Fatalf("errore durante la lettura: %v", err)
	}

	fmt.Printf("Chiave: %s, Valore: %s\n", item.Key, item.Value)
}

Il tipo memcache.Item rappresenta una singola voce nella cache e include tre campi principali: Key (stringa di massimo 250 byte), Value (slice di byte di massimo 1 MB per default) e Expiration (tempo di vita in secondi). Un valore di Expiration pari a zero indica che la voce non scade automaticamente, mentre valori superiori a 30 giorni vengono interpretati come timestamp Unix assoluti.

Gestione degli errori

La libreria definisce diversi errori sentinella che è importante distinguere per implementare logiche di fallback corrette. Il caso più comune è memcache.ErrCacheMiss, restituito quando una chiave non esiste o è scaduta.

package main

import (
	"errors"
	"fmt"
	"log"

	"github.com/bradfitz/gomemcache/memcache"
)

func fetchValue(client *memcache.Client, key string) (string, error) {
	item, err := client.Get(key)
	if err != nil {
		// Distinzione tra cache miss e errori reali
		if errors.Is(err, memcache.ErrCacheMiss) {
			return "", fmt.Errorf("chiave %q non trovata in cache", key)
		}
		return "", fmt.Errorf("errore di comunicazione: %w", err)
	}
	return string(item.Value), nil
}

func main() {
	client := memcache.New("localhost:11211")

	value, err := fetchValue(client, "non_existing_key")
	if err != nil {
		log.Println(err)
		return
	}
	fmt.Println("Valore recuperato:", value)
}

Altri errori notevoli sono memcache.ErrNotStored (restituito da Add quando la chiave esiste già o da Replace quando non esiste), memcache.ErrCASConflict (per operazioni CAS fallite) e memcache.ErrServerError (errori generici lato server).

Le operazioni Add, Replace e CompareAndSwap

Oltre a Set, che sovrascrive sempre il valore esistente, Memcached offre primitive più granulari per gestire scenari di concorrenza. L'operazione Add memorizza un valore solo se la chiave non esiste, mentre Replace aggiorna un valore solo se la chiave esiste già.

package main

import (
	"errors"
	"fmt"
	"log"

	"github.com/bradfitz/gomemcache/memcache"
)

func main() {
	client := memcache.New("localhost:11211")

	// Add fallisce se la chiave esiste già
	err := client.Add(&memcache.Item{
		Key:        "user:1001",
		Value:      []byte("Mario Rossi"),
		Expiration: 3600,
	})
	if err != nil && !errors.Is(err, memcache.ErrNotStored) {
		log.Fatalf("errore inatteso: %v", err)
	}

	// Replace aggiorna solo se la chiave esiste
	err = client.Replace(&memcache.Item{
		Key:        "user:1001",
		Value:      []byte("Mario Bianchi"),
		Expiration: 3600,
	})
	if err != nil {
		log.Fatalf("errore durante replace: %v", err)
	}

	item, _ := client.Get("user:1001")
	fmt.Printf("Valore aggiornato: %s\n", item.Value)
}

L'operazione CompareAndSwap (CAS) implementa un meccanismo di controllo della concorrenza ottimistico. Ogni voce in Memcached possiede un identificatore univoco (CAS ID) che cambia ad ogni modifica. CAS aggiorna la voce solo se il CAS ID corrente corrisponde a quello che il client ha letto in precedenza, garantendo che nessun altro client abbia modificato il valore nel frattempo.

package main

import (
	"errors"
	"fmt"
	"log"
	"strconv"

	"github.com/bradfitz/gomemcache/memcache"
)

func incrementCounter(client *memcache.Client, key string) error {
	// Lettura con CAS ID
	item, err := client.Get(key)
	if err != nil {
		return err
	}

	current, err := strconv.Atoi(string(item.Value))
	if err != nil {
		return err
	}

	// Modifica del valore mantenendo il CAS ID
	item.Value = []byte(strconv.Itoa(current + 1))

	// CompareAndSwap fallisce se un altro client ha modificato la voce
	err = client.CompareAndSwap(item)
	if err != nil {
		if errors.Is(err, memcache.ErrCASConflict) {
			return fmt.Errorf("conflitto di concorrenza rilevato")
		}
		return err
	}
	return nil
}

func main() {
	client := memcache.New("localhost:11211")

	// Inizializzazione del contatore
	client.Set(&memcache.Item{Key: "counter", Value: []byte("0")})

	if err := incrementCounter(client, "counter"); err != nil {
		log.Fatal(err)
	}

	item, _ := client.Get("counter")
	fmt.Printf("Valore corrente: %s\n", item.Value)
}

Contatori atomici con Increment e Decrement

Per scenari che richiedono contatori numerici, Memcached fornisce le operazioni Increment e Decrement, che modificano il valore in modo atomico lato server. Questa è la soluzione preferibile rispetto al pattern CAS quando si lavora esclusivamente con valori interi.

package main

import (
	"fmt"
	"log"

	"github.com/bradfitz/gomemcache/memcache"
)

func main() {
	client := memcache.New("localhost:11211")

	// Il valore iniziale deve essere una stringa numerica
	err := client.Set(&memcache.Item{
		Key:        "page_views",
		Value:      []byte("0"),
		Expiration: 86400,
	})
	if err != nil {
		log.Fatal(err)
	}

	// Incremento atomico di 5 unità
	newValue, err := client.Increment("page_views", 5)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Visite totali: %d\n", newValue)

	// Decremento atomico
	newValue, err = client.Decrement("page_views", 2)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Dopo decremento: %d\n", newValue)
}

Serializzazione di strutture complesse

Memcached memorizza esclusivamente sequenze di byte, quindi per persistere strutture Go complesse è necessario serializzarle. I formati più comuni sono JSON, per la sua leggibilità e interoperabilità, e gob, il formato binario nativo di Go, più compatto e veloce ma utilizzabile solo tra applicazioni Go.

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"time"

	"github.com/bradfitz/gomemcache/memcache"
)

type Product struct {
	ID          int       `json:"id"`
	Name        string    `json:"name"`
	Price       float64   `json:"price"`
	Description string    `json:"description"`
	CreatedAt   time.Time `json:"created_at"`
}

type ProductCache struct {
	client *memcache.Client
}

func NewProductCache(servers ...string) *ProductCache {
	return &ProductCache{
		client: memcache.New(servers...),
	}
}

func (c *ProductCache) Save(product *Product, ttl int32) error {
	// Serializzazione in JSON
	data, err := json.Marshal(product)
	if err != nil {
		return fmt.Errorf("serializzazione fallita: %w", err)
	}

	key := fmt.Sprintf("product:%d", product.ID)
	return c.client.Set(&memcache.Item{
		Key:        key,
		Value:      data,
		Expiration: ttl,
	})
}

func (c *ProductCache) Load(id int) (*Product, error) {
	key := fmt.Sprintf("product:%d", id)
	item, err := c.client.Get(key)
	if err != nil {
		return nil, err
	}

	// Deserializzazione del JSON
	var product Product
	if err := json.Unmarshal(item.Value, &product); err != nil {
		return nil, fmt.Errorf("deserializzazione fallita: %w", err)
	}
	return &product, nil
}

func main() {
	cache := NewProductCache("localhost:11211")

	original := &Product{
		ID:          42,
		Name:        "Laptop Pro",
		Price:       1299.99,
		Description: "Workstation professionale",
		CreatedAt:   time.Now(),
	}

	if err := cache.Save(original, 600); err != nil {
		log.Fatal(err)
	}

	retrieved, err := cache.Load(42)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("Prodotto recuperato: %+v\n", retrieved)
}

Il pattern cache-aside

Il pattern cache-aside (noto anche come lazy loading) è la strategia di caching più diffusa nelle applicazioni web. L'applicazione tenta prima di leggere il dato dalla cache; in caso di miss, lo recupera dalla sorgente primaria (tipicamente un database) e lo memorizza nella cache per le richieste successive.

package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log"

	"github.com/bradfitz/gomemcache/memcache"
	_ "github.com/go-sql-driver/mysql"
)

type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
}

type UserRepository struct {
	db    *sql.DB
	cache *memcache.Client
	ttl   int32
}

func NewUserRepository(db *sql.DB, cache *memcache.Client, ttl int32) *UserRepository {
	return &UserRepository{
		db:    db,
		cache: cache,
		ttl:   ttl,
	}
}

func (r *UserRepository) FindByID(ctx context.Context, id int) (*User, error) {
	cacheKey := fmt.Sprintf("user:%d", id)

	// Tentativo di lettura dalla cache
	if item, err := r.cache.Get(cacheKey); err == nil {
		var user User
		if err := json.Unmarshal(item.Value, &user); err == nil {
			return &user, nil
		}
	} else if !errors.Is(err, memcache.ErrCacheMiss) {
		// Log dell'errore ma proseguimento con il database
		log.Printf("errore cache: %v", err)
	}

	// Cache miss: lettura dal database
	user := &User{}
	query := "SELECT id, name, email FROM users WHERE id = ?"
	err := r.db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, fmt.Errorf("utente %d non trovato", id)
		}
		return nil, err
	}

	// Aggiornamento asincrono della cache
	go r.populateCache(cacheKey, user)

	return user, nil
}

func (r *UserRepository) populateCache(key string, user *User) {
	data, err := json.Marshal(user)
	if err != nil {
		log.Printf("serializzazione fallita: %v", err)
		return
	}

	err = r.cache.Set(&memcache.Item{
		Key:        key,
		Value:      data,
		Expiration: r.ttl,
	})
	if err != nil {
		log.Printf("aggiornamento cache fallito: %v", err)
	}
}

func (r *UserRepository) Invalidate(id int) error {
	cacheKey := fmt.Sprintf("user:%d", id)
	err := r.cache.Delete(cacheKey)
	if err != nil && !errors.Is(err, memcache.ErrCacheMiss) {
		return err
	}
	return nil
}

La popolazione asincrona della cache tramite goroutine evita di rallentare la risposta al client in caso di problemi temporanei con Memcached. Il metodo Invalidate è fondamentale per garantire la consistenza: ogni volta che un dato viene modificato nel database, la corrispondente voce di cache deve essere rimossa.

Operazioni multiple e batching

Quando è necessario recuperare molte chiavi simultaneamente, l'operazione GetMulti riduce significativamente la latenza rispetto a chiamate Get sequenziali, eseguendo una singola richiesta multi-key al server.

package main

import (
	"fmt"
	"log"

	"github.com/bradfitz/gomemcache/memcache"
)

func main() {
	client := memcache.New("localhost:11211")

	// Pre-popolamento di alcune voci
	for i := 1; i <= 5; i++ {
		client.Set(&memcache.Item{
			Key:        fmt.Sprintf("article:%d", i),
			Value:      []byte(fmt.Sprintf("Contenuto articolo %d", i)),
			Expiration: 300,
		})
	}

	// Recupero in batch
	keys := []string{"article:1", "article:2", "article:3", "article:99"}
	items, err := client.GetMulti(keys)
	if err != nil {
		log.Fatal(err)
	}

	// Le chiavi non trovate non sono presenti nella mappa restituita
	for _, key := range keys {
		if item, found := items[key]; found {
			fmt.Printf("%s = %s\n", key, item.Value)
		} else {
			fmt.Printf("%s: non in cache\n", key)
		}
	}
}

Connection pooling e configurazione avanzata

Il client gomemcache gestisce internamente un pool di connessioni per ogni server. Il numero massimo di connessioni inattive per server è configurabile tramite il campo MaxIdleConns. Per applicazioni ad alto throughput è utile aumentare questo valore rispetto al default di 2.

package main

import (
	"fmt"
	"time"

	"github.com/bradfitz/gomemcache/memcache"
)

func newConfiguredClient() *memcache.Client {
	client := memcache.New(
		"cache-01.internal:11211",
		"cache-02.internal:11211",
		"cache-03.internal:11211",
	)

	// Numero massimo di connessioni inattive per server
	client.MaxIdleConns = 100

	// Timeout per le operazioni di rete
	client.Timeout = 500 * time.Millisecond

	return client
}

func main() {
	client := newConfiguredClient()

	// Le chiavi vengono distribuite tra i server tramite consistent hashing
	for i := 0; i < 10; i++ {
		key := fmt.Sprintf("item:%d", i)
		client.Set(&memcache.Item{
			Key:        key,
			Value:      []byte(fmt.Sprintf("value-%d", i)),
			Expiration: 60,
		})
	}
}

Quando si passano più server al costruttore, il client utilizza un algoritmo di consistent hashing basato su crc32 per distribuire le chiavi. Questo significa che ogni chiave viene sempre assegnata allo stesso server, e l'aggiunta o la rimozione di un nodo invalida solo una frazione delle chiavi.

Wrapping con interfacce per la testabilità

Per facilitare il testing unitario delle componenti che utilizzano Memcached, è buona pratica definire un'interfaccia che esponga solo le operazioni effettivamente utilizzate, permettendo di sostituire il client reale con un mock durante i test.

package cache

import (
	"errors"
	"time"

	"github.com/bradfitz/gomemcache/memcache"
)

// ErrNotFound rappresenta un cache miss in modo astratto rispetto alla libreria sottostante
var ErrNotFound = errors.New("cache: chiave non trovata")

type Cache interface {
	Get(key string) ([]byte, error)
	Set(key string, value []byte, ttl time.Duration) error
	Delete(key string) error
}

type MemcachedCache struct {
	client *memcache.Client
}

func NewMemcachedCache(servers ...string) *MemcachedCache {
	return &MemcachedCache{
		client: memcache.New(servers...),
	}
}

func (c *MemcachedCache) Get(key string) ([]byte, error) {
	item, err := c.client.Get(key)
	if err != nil {
		if errors.Is(err, memcache.ErrCacheMiss) {
			return nil, ErrNotFound
		}
		return nil, err
	}
	return item.Value, nil
}

func (c *MemcachedCache) Set(key string, value []byte, ttl time.Duration) error {
	return c.client.Set(&memcache.Item{
		Key:        key,
		Value:      value,
		Expiration: int32(ttl.Seconds()),
	})
}

func (c *MemcachedCache) Delete(key string) error {
	err := c.client.Delete(key)
	if err != nil && !errors.Is(err, memcache.ErrCacheMiss) {
		return err
	}
	return nil
}

Un mock in-memory per i test può essere implementato in poche righe di codice:

package cache

import (
	"sync"
	"time"
)

type InMemoryCache struct {
	mu    sync.RWMutex
	store map[string]cacheEntry
}

type cacheEntry struct {
	value     []byte
	expiresAt time.Time
}

func NewInMemoryCache() *InMemoryCache {
	return &InMemoryCache{
		store: make(map[string]cacheEntry),
	}
}

func (c *InMemoryCache) Get(key string) ([]byte, error) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	entry, found := c.store[key]
	if !found || time.Now().After(entry.expiresAt) {
		return nil, ErrNotFound
	}
	return entry.value, nil
}

func (c *InMemoryCache) Set(key string, value []byte, ttl time.Duration) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.store[key] = cacheEntry{
		value:     value,
		expiresAt: time.Now().Add(ttl),
	}
	return nil
}

func (c *InMemoryCache) Delete(key string) error {
	c.mu.Lock()
	defer c.mu.Unlock()

	delete(c.store, key)
	return nil
}

Considerazioni finali

Memcached resta una scelta eccellente per scenari in cui è necessario un caching distribuito semplice, veloce e affidabile. La sua architettura volutamente minimale, priva di persistenza e di strutture dati complesse, lo rende particolarmente adatto come livello di caching volatile davanti a database relazionali o servizi REST. La libreria gomemcache espone in modo idiomatico tutte le operazioni del protocollo binario di Memcached, integrandosi naturalmente con i pattern di concorrenza tipici di Go grazie alla sua natura thread-safe.

Tra le considerazioni operative più importanti vi sono la definizione di TTL appropriati per ciascuna tipologia di dato, l'implementazione di logiche di invalidazione coerenti con le scritture sul database e il monitoraggio delle statistiche del server (get_hits, get_misses, evictions) per dimensionare correttamente la memoria allocata. Per applicazioni che richiedono strutture dati più ricche o persistenza, Redis può rappresentare un'alternativa, ma per il puro caching ad alta velocità Memcached continua a offrire un rapporto prestazioni-complessità difficilmente eguagliabile.