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/v9La cosa piu’ importante da fare e’ configurare il connection pool. I valori predefiniti sono conservativi; in qualsiasi servizio con carico reale e’ necessario regolarli.
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
}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.
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()
}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.
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:
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.
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.
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
}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.
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.
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.
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
}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)
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
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
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
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.