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 compartilhadoDoChan(key, fn): retorna canal para patterns assíncronosForget(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
- Documentação oficial do singleflight — API completa com exemplos de Do, DoChan e Forget
- Bigcache: Fast, concurrent, evicting in-memory cache — Repositório oficial com benchmarks e guia de configuração
- Avoiding cache stampedes with singleflight — Artigo técnico detalhando padrões de coalescência em Go
- Caching patterns in Go — Tutorial prático sobre cache-aside, stale-while-revalidate e outras estratégias
- Bigcache vs Freecache: Performance comparison — Benchmarks comparativos entre bibliotecas de cache em Go