Caching com singleflight e bigcache

1. Introdução ao Caching em Go

Sistemas concorrentes em Go enfrentam desafios clássicos de desempenho: latência elevada em consultas a bancos de dados, sobrecarga em APIs externas e contenção de recursos. O cache em memória surge como solução imediata, mas traz consigo o problema do cache stampede — quando múltiplas goroutines tentam reconstruir o mesmo dado simultaneamente.

Existem duas abordagens principais: cache local (in-memory) e cache distribuído (Redis, Memcached). Para cenários de alta performance com baixa latência, o cache local é preferível, desde que bem implementado.

Duas bibliotecas se destacam no ecossistema Go:
- singleflight (golang.org/x/sync/singleflight): coalesce chamadas concorrentes para o mesmo recurso
- bigcache (github.com/allegro/bigcache): cache local sem overhead de garbage collection

Este artigo demonstra como integrá-las para construir um sistema de caching robusto, evitando cache stampedes e mantendo alta throughput.

2. Bigcache: Cache Local de Alto Desempenho

Bigcache foi projetado para evitar pausas de GC. Sua arquitetura usa shards com mapas hash que armazenam apenas slices de bytes, sem ponteiros — o GC praticamente não varre o cache.

Configuração Básica

import (
    "github.com/allegro/bigcache/v3"
    "time"
)

func NewCache() (*bigcache.BigCache, error) {
    cfg := bigcache.Config{
        Shards:             1024,               // número de shards (potência de 2)
        LifeWindow:         10 * time.Minute,   // TTL dos itens
        CleanWindow:        1 * time.Minute,    // intervalo de limpeza
        MaxEntriesInWindow: 1000,               // estimativa de entradas por janela
        MaxEntrySize:       500,                // tamanho máximo por entrada (bytes)
        HardMaxCacheSize:   256,                // limite de memória (MB)
        Verbose:            false,
    }
    return bigcache.NewBigCache(cfg)
}

Exemplo Prático: Cache de Respostas de API

type UserService struct {
    cache *bigcache.BigCache
}

func (s *UserService) GetUser(id string) (*User, error) {
    // Tenta obter do cache
    data, err := s.cache.Get("user:" + id)
    if err == nil {
        var user User
        json.Unmarshal(data, &user)
        return &user, nil
    }

    // Fallback para fonte original
    user, err := s.fetchFromDB(id)
    if err != nil {
        return nil, err
    }

    // Popula cache com TTL
    serialized, _ := json.Marshal(user)
    s.cache.Set("user:"+id, serialized)
    return user, nil
}

Parâmetros críticos:
- LifeWindow: tempo de vida dos dados
- CleanWindow: frequência de remoção de expirados
- HardMaxCacheSize: evita estouro de memória

3. Singleflight: Evitando Cache Stampedes

O problema clássico: 100 requisições simultâneas para o mesmo usuário. Sem singleflight, todas executam a query no banco. Com singleflight, apenas uma executa, as demais aguardam o mesmo resultado.

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

var requestGroup singleflight.Group

func fetchUser(id string) (*User, error) {
    result, err, _ := requestGroup.Do("user:"+id, func() (interface{}, error) {
        // Apenas uma goroutine executa este bloco
        return queryDatabase(id)
    })
    if err != nil {
        return nil, err
    }
    return result.(*User), nil
}

Métodos Essenciais

  • Do(key, fn): executa fn uma vez por key; retorna resultado compartilhado
  • DoChan(key, fn): retorna canal para patterns assíncronos
  • Forget(key): remove key do grupo, permitindo nova execução
// Uso com DoChan para timeout
func fetchWithTimeout(ctx context.Context, id string) (*User, error) {
    ch := requestGroup.DoChan("user:"+id, func() (interface{}, error) {
        return queryDatabase(id)
    })

    select {
    case result := <-ch:
        if result.Err != nil {
            return nil, result.Err
        }
        return result.Val.(*User), nil
    case <-ctx.Done():
        requestGroup.Forget("user:" + id)
        return nil, ctx.Err()
    }
}

4. Integração Singleflight + Bigcache

O padrão cache-aside combinado com singleflight cria uma camada de proteção contra cache stampede.

type CacheService struct {
    cache  *bigcache.BigCache
    group  singleflight.Group
    db     Database
    metrics MetricsCollector
}

func (s *CacheService) GetOrFetch(ctx context.Context, key string) ([]byte, error) {
    // 1. Tenta cache
    if data, err := s.cache.Get(key); err == nil {
        s.metrics.Hit(key)
        return data, nil
    }
    s.metrics.Miss(key)

    // 2. Singleflight: apenas uma goroutine busca
    result, err, shared := s.group.Do(key, func() (interface{}, error) {
        // Verifica cache novamente (pode ter sido populado)
        if data, err := s.cache.Get(key); err == nil {
            return data, nil
        }

        // Busca da fonte original
        data, err := s.db.Fetch(ctx, key)
        if err != nil {
            return nil, err
        }

        // Popula cache
        s.cache.Set(key, data)
        return data, nil
    })

    if err != nil {
        return nil, err
    }

    if shared {
        s.metrics.Dedup(key) // requisição coalescida
    }
    return result.([]byte), nil
}

Tratamento de Erros

Erros no callback são propagados para todas as goroutines que aguardam:

func (s *CacheService) GetOrFetchWithFallback(key string) ([]byte, error) {
    result, err, _ := s.group.Do(key, func() (interface{}, error) {
        // Se falhar, todas recebem o erro
        data, err := s.db.Fetch(key)
        if err != nil {
            return nil, fmt.Errorf("fetch failed: %w", err)
        }
        return data, nil
    })
    return result.([]byte), err
}

5. Métricas e Observabilidade

Monitorar cache hits, misses e duplicatas suprimidas é essencial para tuning.

type MetricsCollector struct {
    hits   *expvar.Int
    misses *expvar.Int
    dedups *expvar.Int
}

func NewMetrics() *MetricsCollector {
    return &MetricsCollector{
        hits:   expvar.NewInt("cache_hits"),
        misses: expvar.NewInt("cache_misses"),
        dedups: expvar.NewInt("singleflight_dedups"),
    }
}

func (m *MetricsCollector) Hit(key string)  { m.hits.Add(1) }
func (m *MetricsCollector) Miss(key string) { m.misses.Add(1) }
func (m *MetricsCollector) Dedup(key string) { m.dedups.Add(1) }

Middleware de Cache com Logging

func CacheMiddleware(next http.Handler, cacheService *CacheService) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        cacheKey := r.URL.Path + "?" + r.URL.RawQuery

        data, err := cacheService.GetOrFetch(r.Context(), cacheKey)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("X-Cache", "HIT")
        w.Write(data)
    })
}

6. Estratégias Avançadas e Boas Práticas

Cache Warming

Pré-popule dados quentes durante a inicialização:

func WarmCache(cache *bigcache.BigCache, popularKeys []string) {
    for _, key := range popularKeys {
        data, _ := fetchExpensiveData(key)
        cache.Set(key, data)
    }
}

Stale-While-Revalidate

Sirva dados expirados enquanto atualiza em background:

func (s *CacheService) StaleWhileRevalidate(key string) ([]byte, error) {
    data, err := s.cache.Get(key)
    if err == nil {
        // Cache hit: dispara atualização assíncrona se estiver próximo do TTL
        go s.group.Do("refresh:"+key, func() (interface{}, error) {
            newData, _ := s.db.Fetch(key)
            s.cache.Set(key, newData)
            return nil, nil
        })
        return data, nil
    }
    return s.GetOrFetch(context.Background(), key)
}

Limpeza Seletiva

// Invalida todos os caches de um usuário
func (s *CacheService) InvalidateUserCache(userID string) {
    s.cache.Delete("user:" + userID)
    s.cache.Delete("orders:" + userID)
    s.group.Forget("user:" + userID)
}

7. Comparação com Alternativas e Limitações

Bigcache vs Freecache vs Go-Cache

Biblioteca GC Impact Features Performance
Bigcache Mínimo Shards, TTL, eviction Excelente
Freecache Mínimo Ring buffer, TTL Excelente
Go-cache Alto Genérico, mutex Moderada

Singleflight vs Mutex vs Channel

  • Singleflight: ideal para operações I/O-bound com alta concorrência
  • Mutex: adequado para seções críticas curtas
  • Channel-based: flexível, mas mais verboso

Limitações

  • Cache local não compartilhado entre instâncias
  • Dados replicados em cada pod
  • Para consistência global, use Redis ou Memcached

8. Conclusão e Próximos Passos

A combinação singleflight + bigcache oferece uma solução eficiente para cenários de alta leitura com picos de concorrência. O padrão cache-aside com coalescência de requisições reduz drasticamente a carga no banco de dados e melhora a latência.

Caso de uso ideal: endpoints REST com alta taxa de leitura (perfis de usuário, configurações, catálogos) onde dados podem ser ligeiramente defasados.

Próximos passos:
- Implementar timeout propagation com context
- Explorar worker pools para background refresh
- Migrar para cache distribuído quando necessário


Referências