
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?
| Abordagem | Vantagens | Desvantagens |
|---|---|---|
| Cache local (sync.Map, bigcache) | Ultra-rápido (sem rede), simples | Inconsistente entre instâncias, desperdício de memória |
| Cache distribuído (Redis, Memcached) | Consistente, compartilhado, escalável | Latê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
| Ferramenta | Prós | Contras | Quando usar |
|---|---|---|---|
| Redis | Estruturas ricas (hash, set, sorted set), persistência, clusterização | Mais pesado, maior latência que memória local | Cache distribuído geral, rate limiting, pub/sub, leaderboards |
| Memcached | Extremamente simples, muito rápido, baixo overhead | Apenas key-value (string), sem persistência, sem cluster nativo | Cache de respostas de API, sessões básicas |
| Bigcache (local) | Zero GC, armazena GB em heap, velocidade de RAM | Local a cada instância, inconsistente entre réplicas | Cache de dados que podem ser levemente inconsistentes (ex: catálogo de produtos) |
| Ristretto (local) | Alto hit rate, suporte a custos por item | Configuração mais complexa | Cache 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 acertoucache_misses_total– quantas vezes foi ao bancocache_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.
