Salta al contenuto principale

Redis in Go con go-redis/v9: Caching, Pub/Sub e Pattern di Produzione

·10 minuti
Indice dei contenuti
Redis non e’ solo una cache. E’ un server di strutture dati che parla TCP, persiste su disco, replica tra nodi e gestisce il fan-out pub/sub in un unico binario. Questo post spiega come usarlo correttamente da Go con go-redis/v9: pool di connessioni, gestione TTL, cache-aside pattern, sorted set per il rate limiting, pub/sub e pipeline.

La maggior parte dei team ricorre a Redis quando ha bisogno di una cache chiave-valore veloce, lo usa con SET/GET e si ferma li’. Questo significa perdere quasi tutto quello che Redis sa fare. Questo post copre le strutture dati e i pattern operativi che contano in produzione: configurazione corretta del connection pool, disciplina sui TTL, cache-aside pattern con i generics, liste come code, sorted set per il rate limiting a finestra scorrevole, pub/sub per il fan-out e pipeline atomiche.

Tutti gli esempi usano go-redis/v9, che e’ il client Go standard. Non usare redigo o radix per nuovi progetti; go-redis ha un’API piu’ ricca, supporto nativo per i context, ed e’ mantenuto attivamente dal team di Redis.

Panoramica dell’Architettura
#

flowchart LR
    App[Go Service] -->|Pool| Pool[Connection Pool\nPoolSize / MinIdleConns]
    Pool --> Redis[(Redis Server)]
    Redis -->|Pub/Sub| Sub1[Subscriber 1]
    Redis -->|Pub/Sub| Sub2[Subscriber 2]
    Redis -->|Sorted Set| RL[Rate Limiter]
    Redis -->|Hash| Cache[Structured Cache]
    Redis -->|List| Queue[Work Queue]

Setup e Configurazione del Connection Pool
#

Installa la libreria:

go get github.com/redis/go-redis/v9

La cosa piu’ importante da fare e’ configurare il connection pool. I valori predefiniti sono conservativi; in qualsiasi servizio con carico reale e’ necessario regolarli.

internal/cache/client.go
package cache

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

func NewClient(addr, password string, db int) *redis.Client {
    rdb := redis.NewClient(&redis.Options{
        Addr:     addr,
        Password: password,
        DB:       db,

        // Impostazioni pool -- regola in base al tuo profilo di carico.
        PoolSize:     20,            // massimo connessioni aperte per processo
        MinIdleConns: 5,             // mantienile attive anche a basso traffico
        MaxIdleTime:  5 * time.Minute,

        // Timeout di connessione e di comando.
        DialTimeout:  3 * time.Second,
        ReadTimeout:  2 * time.Second,
        WriteTimeout: 2 * time.Second,

        // Riprova errori transitori fino a 3 volte con backoff.
        MaxRetries:      3,
        MinRetryBackoff: 8 * time.Millisecond,
        MaxRetryBackoff: 512 * time.Millisecond,
    })
    return rdb
}

func Ping(ctx context.Context, rdb *redis.Client) error {
    status := rdb.Ping(ctx)
    if err := status.Err(); err != nil {
        return fmt.Errorf("redis ping: %w", err)
    }
    return nil
}
Importante

PoolSize e’ per processo. Se esegui 5 repliche di un servizio con PoolSize: 20, hai fino a 100 connessioni aperte su Redis. Controlla l’impostazione maxclients di Redis (default 10000 – va bene per la maggior parte dei casi, ma vale la pena monitorarlo).

Stringhe e TTL
#

Ogni chiave che scrivi dovrebbe avere un TTL a meno che tu non abbia una ragione esplicita per non farlo. Senza TTL, le chiavi si accumulano per sempre e alla fine si raggiungono i limiti di memoria.

internal/cache/strings.go
package cache

import (
    "context"
    "time"

    "github.com/redis/go-redis/v9"
)

const defaultTTL = 15 * time.Minute

// Set memorizza un valore con un TTL.
func Set(ctx context.Context, rdb *redis.Client, key, value string, ttl time.Duration) error {
    return rdb.Set(ctx, key, value, ttl).Err()
}

// Get recupera un valore. Restituisce redis.Nil se la chiave non esiste.
func Get(ctx context.Context, rdb *redis.Client, key string) (string, error) {
    return rdb.Get(ctx, key).Result()
}

// SetNX imposta un valore solo se la chiave non esiste gia'.
// E' il blocco costruttivo per i distributed lock.
func SetNX(ctx context.Context, rdb *redis.Client, key, value string, ttl time.Duration) (bool, error) {
    return rdb.SetNX(ctx, key, value, ttl).Result()
}

// GetEX recupera un valore e resetta il suo TTL (scadenza scorrevole).
func GetEX(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (string, error) {
    return rdb.GetEx(ctx, key, ttl).Result()
}
Nota

SetNX (SET if Not eXists) e’ il primitivo per i distributed lock. Acquisisci il lock chiamando SetNX("lock:resource", workerID, 30*time.Second). Rilascialo con uno script Lua che controlla il valore prima di eliminarlo (per evitare di rilasciare il lock di un altro worker). Per uso in produzione, considera l’algoritmo Redlock o la libreria redsync.

Il Cache-Aside Pattern
#

Il cache-aside pattern e’: prima controlla la cache, in caso di miss recupera dalla fonte di verita’, poi popola la cache. Incapsularlo in una funzione generica mantiene il codice chiamante pulito.

internal/cache/getorset.go
package cache

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

// GetOrSet tenta di recuperare un valore in cache di tipo T.
// In caso di cache miss chiama fetch(), memorizza il risultato e lo restituisce.
func GetOrSet[T any](
    ctx context.Context,
    rdb *redis.Client,
    key string,
    ttl time.Duration,
    fetch func(ctx context.Context) (T, error),
) (T, error) {
    var zero T

    raw, err := rdb.Get(ctx, key).Result()
    if err == nil {
        // Cache hit.
        var value T
        if jsonErr := json.Unmarshal([]byte(raw), &value); jsonErr != nil {
            return zero, fmt.Errorf("cache unmarshal %q: %w", key, jsonErr)
        }
        return value, nil
    }

    if !errors.Is(err, redis.Nil) {
        // Errore reale di Redis -- fail open: vai direttamente alla fonte.
        return fetch(ctx)
    }

    // Cache miss: recupera dalla fonte di verita'.
    value, err := fetch(ctx)
    if err != nil {
        return zero, err
    }

    data, err := json.Marshal(value)
    if err != nil {
        return zero, fmt.Errorf("cache marshal %q: %w", key, err)
    }

    // Scrittura best-effort; non fallire la richiesta se la cache fallisce.
    _ = rdb.Set(ctx, key, data, ttl).Err()
    return value, nil
}

Utilizzo dal layer di servizio:

internal/service/user.go
type User struct {
    ID    string `json:"id"`
    Email string `json:"email"`
    Plan  string `json:"plan"`
}

func (s *UserService) GetUser(ctx context.Context, id string) (User, error) {
    key := fmt.Sprintf("user:%s", id)
    return cache.GetOrSet(ctx, s.redis, key, 10*time.Minute, func(ctx context.Context) (User, error) {
        return s.db.FindUserByID(ctx, id)
    })
}

Liste come Code
#

Le liste Redis sono doppiamente concatenate. LPUSH + RPOP fornisce una coda FIFO. BLPOP si blocca finche’ non e’ disponibile un elemento, evitando loop di polling nei processi worker.

internal/queue/queue.go
package queue

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

    "github.com/redis/go-redis/v9"
)

type Queue[T any] struct {
    rdb  *redis.Client
    name string
}

func New[T any](rdb *redis.Client, name string) *Queue[T] {
    return &Queue[T]{rdb: rdb, name: name}
}

func (q *Queue[T]) Push(ctx context.Context, item T) error {
    data, err := json.Marshal(item)
    if err != nil {
        return fmt.Errorf("queue marshal: %w", err)
    }
    return q.rdb.LPush(ctx, q.name, data).Err()
}

// Pop si blocca per al massimo timeout in attesa di un elemento.
// Restituisce (zero, false, nil) in caso di timeout.
func (q *Queue[T]) Pop(ctx context.Context, timeout time.Duration) (T, bool, error) {
    var zero T
    res, err := q.rdb.BRPop(ctx, timeout, q.name).Result()
    if err != nil {
        if err == redis.Nil {
            return zero, false, nil // timeout
        }
        return zero, false, fmt.Errorf("queue pop: %w", err)
    }
    var item T
    if err := json.Unmarshal([]byte(res[1]), &item); err != nil {
        return zero, false, fmt.Errorf("queue unmarshal: %w", err)
    }
    return item, true, nil
}

Sorted Set: Rate Limiting a Finestra Scorrevole
#

Un sorted set in cui ogni membro e’ un timestamp di richiesta (score = Unix nanosecondi) e’ un’implementazione pulita di un rate limiter a finestra scorrevole. Il pattern: aggiungi la richiesta corrente, rimuovi le voci piu’ vecchie della finestra, conta quelle rimaste.

internal/ratelimit/slidingwindow.go
package ratelimit

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

// Allow restituisce true se il chiamante e' entro il suo rate limit.
// key e' tipicamente qualcosa come "rl:user:<id>:endpoint".
func Allow(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
    now := time.Now()
    windowStart := now.Add(-window)

    pipe := rdb.Pipeline()
    pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart.UnixNano()))
    pipe.ZAdd(ctx, key, redis.Z{Score: float64(now.UnixNano()), Member: now.UnixNano()})
    countCmd := pipe.ZCard(ctx, key)
    pipe.Expire(ctx, key, window)

    if _, err := pipe.Exec(ctx); err != nil {
        return false, fmt.Errorf("rate limit pipeline: %w", err)
    }

    return countCmd.Val() <= limit, nil
}
Suggerimento

Per il rate limiting ad alto throughput (migliaia di controlli al secondo), considera uno script Lua che raggruppa tutti e quattro i comandi in una singola chiamata atomica lato server. Questo elimina il round-trip della pipeline e la piccola finestra di race tra ZRemRangeByScore e ZAdd.

Pub/Sub: Fan-Out
#

NATS o Kafka sono scelte migliori per la messaggistica duratura. Ma per il fan-out leggero in cui perdere un messaggio durante un riavvio e’ accettabile (broadcast di invalidazione cache, aggiornamenti di dashboard in tempo reale), Redis pub/sub e’ veloce e semplice.

internal/pubsub/pubsub.go
package pubsub

import (
    "context"
    "fmt"
    "log/slog"

    "github.com/redis/go-redis/v9"
)

// Publish invia un messaggio a un canale.
func Publish(ctx context.Context, rdb *redis.Client, channel, message string) error {
    if err := rdb.Publish(ctx, channel, message).Err(); err != nil {
        return fmt.Errorf("publish to %q: %w", channel, err)
    }
    return nil
}

// Subscribe esegue un subscriber bloccante. Chiamare da una goroutine.
// Il handler viene chiamato per ogni messaggio ricevuto.
func Subscribe(ctx context.Context, rdb *redis.Client, channel string, handler func(msg string)) {
    sub := rdb.Subscribe(ctx, channel)
    defer sub.Close()

    ch := sub.Channel()
    for {
        select {
        case msg, ok := <-ch:
            if !ok {
                return
            }
            handler(msg.Payload)
        case <-ctx.Done():
            slog.Info("pubsub subscriber in shutdown", "channel", channel)
            return
        }
    }
}

Hash Map: Oggetti Strutturati
#

Gli hash memorizzano coppie campo-valore sotto una singola chiave. Sono efficienti in memoria per oggetti con molti campi e permettono di aggiornare campi individuali senza ri-serializzare l’intera struct.

internal/cache/hash.go
package cache

import (
    "context"
    "fmt"

    "github.com/redis/go-redis/v9"
)

func HSet(ctx context.Context, rdb *redis.Client, key string, value interface{}) error {
    if err := rdb.HSet(ctx, key, value).Err(); err != nil {
        return fmt.Errorf("hset %q: %w", key, err)
    }
    return nil
}

func HGetAll[T any](ctx context.Context, rdb *redis.Client, key string) (T, error) {
    var result T
    if err := rdb.HGetAll(ctx, key).Scan(&result); err != nil {
        return result, fmt.Errorf("hgetall %q: %w", key, err)
    }
    return result, nil
}

Esempio di struct:

type Session struct {
    UserID    string `redis:"user_id"`
    Role      string `redis:"role"`
    CreatedAt int64  `redis:"created_at"`
}

// Salva
_ = cache.HSet(ctx, rdb, "session:abc123", &Session{UserID: "u1", Role: "admin", CreatedAt: time.Now().Unix()})
rdb.Expire(ctx, "session:abc123", 24*time.Hour)

// Recupera
sess, _ := cache.HGetAll[Session](ctx, rdb, "session:abc123")

Pipeline e Transazioni
#

Una pipeline raggruppa piu’ comandi in un unico round-trip. Una transazione (MULTI/EXEC) rende quei comandi atomici.

internal/cache/pipeline.go
package cache

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

// IncrWithExpire incrementa un contatore e imposta il suo TTL in modo atomico.
// Senza una transazione, un crash tra INCR ed EXPIRE lascia una chiave per sempre.
func IncrWithExpire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (int64, error) {
    var incr *redis.IntCmd

    _, err := rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
        incr = pipe.Incr(ctx, key)
        pipe.Expire(ctx, key, ttl)
        return nil
    })
    if err != nil {
        return 0, fmt.Errorf("incr with expire %q: %w", key, err)
    }
    return incr.Val(), nil
}
Avviso

TxPipelined usa MULTI/EXEC. Non riprova in caso di contesa. Se hai bisogno di locking ottimistico (controlla-poi-agisci), usa WATCH con un loop di retry manuale. Per la maggior parte dei contatori e delle sequenze atomiche brevi, TxPipelined e’ lo strumento giusto.

Errori Comuni
#

Nessun TTL sulle chiavi (memory leak)
Ogni chiave scritta su Redis senza TTL vive finche’ il server esaurisce la memoria e inizia a fare eviction con qualsiasi maxmemory-policy impostata. Il default piu’ sicuro e’ allkeys-lru, ma la correzione corretta e’ impostare sempre un TTL al momento della scrittura. Fai un audit con redis-cli --scan --pattern "*" | xargs redis-cli object encoding e controlla le keyspace stats nell’output di INFO.
Impostazioni predefinite del connection pool sotto carico
Il PoolSize predefinito in go-redis e’ runtime.GOMAXPROCS * 10, che spesso e’ troppo basso per servizi con alta concorrenza su Redis. Monitora pool_stats tramite rdb.PoolStats() e osserva se Timeouts aumenta. Imposta PoolSize e MinIdleConns esplicitamente in base alla concorrenza misurata.
Usare GET/SET per oggetti strutturati
Serializzare una struct in JSON e memorizzarla come stringa significa che ogni aggiornamento ri-scrive l’intero oggetto. Usa un Hash quando hai una struct con piu’ campi indipendenti che potresti aggiornare separatamente. E’ piu’ efficiente in memoria e supporta HINCRBY per i campi numerici.
Non gestire redis.Nil
rdb.Get(ctx, key).Result() restituisce redis.Nil quando la chiave non esiste. Non e’ un errore nel senso tradizionale. Controlla sempre con errors.Is(err, redis.Nil) prima di trattarlo come un vero fallimento.
Memorizzare l'intera stringa del token nella blocklist
Quando si implementa la revoca JWT, memorizzare la stringa completa del token come chiave Redis e’ uno spreco. Memorizza invece il claim jti (JWT ID). E’ breve, univoco per token, e non contiene dati sensibili.

Se vuoi approfondire uno di questi argomenti, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati