Cache distribuído em Go: Acelerando aplicações com Redis, Memcached e alternativas nativas

Seu sistema Go voa – goroutines leves, binários rápidos, baixa latência. Mas, de repente, o banco de dados começa a gritar. A mesma consulta é repetida milhares de vezes por segundo, devolvendo os mesmos dados. O que fazer? A resposta é cache.

Porém, quando você tem múltiplas instâncias do seu serviço Go rodando (como em Kubernetes), um cache local em memória gera inconsistências. A solução é o cache distribuído – um armazenamento centralizado que todas as instâncias enxergam.

Neste post, vamos explorar os principais padrões de cache, como implementá-los em Go com Redis, Memcached e alternativas como Bigcache, além de evitar armadilhas clássicas como o “thundering herd”.

Por que cache distribuído?

AbordagemVantagensDesvantagens
Cache local (sync.Map, bigcache)Ultra-rápido (sem rede), simplesInconsistente entre instâncias, desperdício de memória
Cache distribuído (Redis, Memcached)Consistente, compartilhado, escalávelLatência de rede (microssegundos), custo adicional

Para a maioria dos sistemas com mais de uma réplica, cache distribuído é essencial.

Padrões de cache para Go

1. Cache Aside (Lazy Loading) – O mais comum

A aplicação verifica o cache. Se houver miss, busca na fonte (banco) e popula o cache.

type UserCache struct {
    redis *redis.Client
    db    *sql.DB
}

func (c *UserCache) GetUser(ctx context.Context, id string) (*User, error) {
    // 1. Tenta cache
    key := "user:" + id
    data, err := c.redis.Get(ctx, key).Bytes()
    if err == nil {
        var user User
        json.Unmarshal(data, &user)
        return &user, nil
    }
    if err != redis.Nil {
        // Erro de rede? Log mas segue para banco
        log.Printf("redis error: %v", err)
    }

    // 2. Busca no banco (cache miss)
    var user User
    err = c.db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id=$1", id).
        Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        return nil, err
    }

    // 3. Popula cache (com TTL)
    go func() {
        data, _ := json.Marshal(user)
        c.redis.Set(context.Background(), key, data, 5*time.Minute)
    }()

    return &user, nil
}

Vantagem: Dados que não são acessados nunca vão para o cache.
Cuidado: No primeiro acesso de um dado popular, muitas requisições simultâneas podem causar “thundering herd” (veremos solução adiante).

2. Write-Through (Escrita através do cache)

Ao atualizar um dado, a aplicação atualiza o banco e o cache ao mesmo tempo. Mantém o cache sempre quente.

func (c *UserCache) UpdateUser(ctx context.Context, user *User) error {
    tx, err := c.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // Atualiza banco
    _, err = tx.ExecContext(ctx, "UPDATE users SET name=$1, email=$2 WHERE id=$3",
        user.Name, user.Email, user.ID)
    if err != nil {
        return err
    }

    // Atualiza cache (antes de commitar, mas ok)
    data, _ := json.Marshal(user)
    if err := c.redis.Set(ctx, "user:"+user.ID, data, 5*time.Minute).Err(); err != nil {
        log.Printf("cache update failed: %v", err)
        // não falha a operação principal
    }

    return tx.Commit()
}

Vantagem: O cache nunca tem dados obsoletos (desde que todas as escritas passem por aqui).
Desvantagem: Toda escrita tem overhead extra (gravar em dois lugares).

3. Write-Behind (Escrita assíncrona)

Similar ao write-through, mas a escrita no cache é imediata e a escrita no banco é feita de forma assíncrona (via worker ou fila). Muito usado para alta ingestão.

4. Evitando o problema “Thundering Herd”

Quando uma chave expira, milhares de requisições podem bater no banco simultaneamente. A solução é o pacote singleflight (da biblioteca padrão golang.org/x/sync/singleflight).

import "golang.org/x/sync/singleflight"

var userGroup singleflight.Group

func (c *UserCache) GetUserWithGroup(ctx context.Context, id string) (*User, error) {
    key := "user:" + id
    val, err, shared := userGroup.Do(key, func() (interface{}, error) {
        // Apenas uma requisição executará isso
        return c.getUserFromDB(ctx, id)
    })
    if err != nil {
        return nil, err
    }
    // shared indica que o resultado foi compartilhado com outras requisições
    return val.(*User), nil
}

O singleflight coalesce chamadas concorrentes para a mesma chave: apenas uma executa a função, as outras esperam e recebem o mesmo resultado.

Escolhendo a tecnologia certa

FerramentaPrósContrasQuando usar
RedisEstruturas ricas (hash, set, sorted set), persistência, clusterizaçãoMais pesado, maior latência que memória localCache distribuído geral, rate limiting, pub/sub, leaderboards
MemcachedExtremamente simples, muito rápido, baixo overheadApenas key-value (string), sem persistência, sem cluster nativoCache de respostas de API, sessões básicas
Bigcache (local)Zero GC, armazena GB em heap, velocidade de RAMLocal a cada instância, inconsistente entre réplicasCache de dados que podem ser levemente inconsistentes (ex: catálogo de produtos)
Ristretto (local)Alto hit rate, suporte a custos por itemConfiguração mais complexaCache local com políticas avançadas de evicção

Recomendação Jacobus:

  • Para cache distribuído: Redis (por flexibilidade e maturidade).
  • Para cache local de alta performance e tolerante a inconsistência: Bigcache.

Exemplo avançado: Cache de catálogo de produtos com Redis e singleflight

package catalog

import (
    "context"
    "encoding/json"
    "time"
    "golang.org/x/sync/singleflight"
    "github.com/redis/go-redis/v9"
)

type Product struct {
    ID    string  `json:"id"`
    Name  string  `json:"name"`
    Price float64 `json:"price"`
}

type ProductCache struct {
    redis   *redis.Client
    sfGroup singleflight.Group
    db      *sql.DB
}

func NewProductCache(redisAddr string, db *sql.DB) *ProductCache {
    rdb := redis.NewClient(&redis.Options{Addr: redisAddr})
    return &ProductCache{redis: rdb, db: db}
}

func (p *ProductCache) GetProduct(ctx context.Context, id string) (*Product, error) {
    key := "product:" + id

    // Tenta cache
    cached, err := p.redis.Get(ctx, key).Bytes()
    if err == nil {
        var prod Product
        json.Unmarshal(cached, &prod)
        return &prod, nil
    }

    // Se não achou, usa singleflight para evitar stampede
    val, err, _ := p.sfGroup.Do(key, func() (interface{}, error) {
        // Busca no banco
        var prod Product
        err := p.db.QueryRowContext(ctx, 
            "SELECT id, name, price FROM products WHERE id=$1", id).
            Scan(&prod.ID, &prod.Name, &prod.Price)
        if err != nil {
            return nil, err
        }
        // Popula cache (assíncrono)
        go func() {
            data, _ := json.Marshal(prod)
            p.redis.Set(context.Background(), key, data, 10*time.Minute)
        }()
        return &prod, nil
    })
    if err != nil {
        return nil, err
    }
    return val.(*Product), nil
}

Cache negativo: evite o “cache de ausência”

Quando um ID não existe no banco, você pode cachear essa ausência por um tempo curto (ex: 1 minuto) para evitar ataques ou picos de tráfego para dados inexistentes.

// No lugar de retornar erro, armazena marcador
if err == sql.ErrNoRows {
    // Cache de negativo por 60s
    p.redis.Set(ctx, key, "null", 60*time.Second)
    return nil, nil
}

Monitoramento e boas práticas

No seu sistema Go, exporte métricas Prometheus:

  • cache_hits_total – quantas vezes acertou
  • cache_misses_total – quantas vezes foi ao banco
  • cache_hit_ratio – hit / (hit + miss)
  • cache_latency_seconds – tempo de resposta do Redis

Meta: hit ratio > 70% (para dados quentes).

Cuidados:

  • TTL muito curto → muitos misses
  • TTL muito longo → dados obsoletos
  • Chaves muito grandes (>1MB) → sobrecarrega rede e serialização
  • Ausência de timeout nos comandos Redis (use context.WithTimeout)

Conclusão: cache distribuído é multiplicador de performance

Implementar cache distribuído em Go com Redis é uma das formas mais eficazes de reduzir latência e custo de banco de dados. Combinado com padrões como cache aside, write-through e singleflight, seu sistema escala sem sustos.

Na Jacobus Software, projetamos estratégias de cache que equilibram consistência, performance e custo. Desde sessões de usuário até APIs de catálogo, sabemos onde e como aplicar cada padrão.


⚡ Sua aplicação Go sofre com banco lento?

Implementamos cache distribuído com Redis, singleflight e métricas de hit ratio. Reduza latência e custos de nuvem agora.

👉 Fale com a Jacobus Software

Rolar para cima