Resposta rápida: use golang.org/x/sync/singleflight quando várias requisições concorrentes tentam fazer o mesmo trabalho caro — reconstruir cache, buscar um recurso externo, carregar configuração ou calcular um valor derivado — e você quer que apenas uma execução aconteça por chave, com as demais reutilizando o resultado. É a ferramenta idiomática de Go para mitigar cache stampede (também chamado de thundering herd) dentro de um processo.
Imagine um endpoint que serve o perfil de um produto. O cache expira. Em 50 ms chegam 200 requisições. Sem proteção, cada uma consulta o PostgreSQL, multiplica a carga e pode derrubar o banco no pior momento. Com singleflight, a primeira goroutine reconstrói o valor; as outras 199 esperam e recebem a mesma resposta. O banco vê uma query, não duzentas.
Este guia mostra como usar singleflight em serviços reais, como integrá-lo a cache-aside, quais armadilhas evitar e como combinar com context, timeouts e observabilidade. Ele complementa o Valkey vs Redis em Go, o guia de errgroup, o context com timeout, o worker pool e o rate limiting em APIs.
Quando singleflight é a escolha certa
| Situação | Usar singleflight? |
|---|---|
| Muitas requisições batem na mesma chave de cache expirada | Sim |
| Várias goroutines pedem o mesmo token OAuth / JWKS | Sim |
| Hot key de configuração ou feature flag remota | Sim |
| Cada request tem chave única (userID + requestID) | Não — não há colisão |
| Precisa serializar mutações em um mapa local | Prefira sync.Mutex / RWMutex |
| Quer limitar paralelismo de N trabalhos distintos | Prefira errgroup + SetLimit ou worker pool |
| Deduplicação entre vários pods/processos | singleflight local + coordenação distribuída |
A regra prática: se a chave se repete sob carga e o trabalho é idempotente e compartilhável, singleflight ajuda. Se cada chamada é independente, ele só adiciona complexidade.
O pacote e a API mínima
singleflight vive em golang.org/x/sync/singleflight. A API é pequena de propósito:
go get golang.org/x/sync/singleflight
package main
import (
"fmt"
"log"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
func main() {
var g singleflight.Group
var chamadas int
var mu sync.Mutex
trabalho := func() (any, error) {
mu.Lock()
chamadas++
mu.Unlock()
time.Sleep(100 * time.Millisecond) // simula I/O
return "produto-42", nil
}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
v, err, shared := g.Do("produto:42", trabalho)
if err != nil {
log.Println("erro:", err)
return
}
fmt.Printf("valor=%v shared=%v\n", v, shared)
}()
}
wg.Wait()
mu.Lock()
fmt.Println("execuções reais:", chamadas) // 1
mu.Unlock()
}
Pontos importantes:
Do(key, fn)— executafnse não houver chamada em andamento parakey; caso contrário, espera o resultado da chamada em voo.shared—truequando o resultado foi compartilhado com outras goroutines (alguém esperou).- A chave é string — escolha chaves estáveis e granulares o suficiente para não misturar resultados diferentes.
- O erro também é compartilhado — se a execução falha, todas as waiters recebem o mesmo erro.
Cache-aside com singleflight
O padrão mais comum em produção é envolver a reconstrução do cache:
package catalogo
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
type Produto struct {
ID string `json:"id"`
Nome string `json:"nome"`
Preco int64 `json:"preco_centavos"`
}
type Repositorio struct {
db DB
cache *redis.Client
group singleflight.Group
ttl time.Duration
}
type DB interface {
BuscarProduto(ctx context.Context, id string) (Produto, error)
}
func (r *Repositorio) Produto(ctx context.Context, id string) (Produto, error) {
chave := "produto:" + id
// 1. Tenta o cache
if raw, err := r.cache.Get(ctx, chave).Bytes(); err == nil {
var p Produto
if err := json.Unmarshal(raw, &p); err == nil {
return p, nil
}
}
// 2. Miss: deduplica a reconstrução
v, err, _ := r.group.Do(chave, func() (any, error) {
// Revalida o cache dentro do Do — outra goroutine pode ter
// preenchido enquanto esperávamos o lock lógico da chave.
if raw, err := r.cache.Get(ctx, chave).Bytes(); err == nil {
var p Produto
if err := json.Unmarshal(raw, &p); err == nil {
return p, nil
}
}
p, err := r.db.BuscarProduto(ctx, id)
if err != nil {
return Produto{}, err
}
payload, err := json.Marshal(p)
if err != nil {
return p, nil // retorna o valor mesmo se o cache falhar
}
// TTL com jitter reduz expiração sincronizada (stampede no futuro).
ttl := r.ttl + time.Duration(id[len(id)-1]%5)*time.Second
_ = r.cache.Set(ctx, chave, payload, ttl).Err()
return p, nil
})
if err != nil {
return Produto{}, err
}
return v.(Produto), nil
}
Por que a revalidação dentro do Do importa: se a primeira goroutine preencheu o cache e terminou, a segunda que ainda estava enfileirada no Do pode, em alguns cenários de timing com Forget ou com grupos reiniciados, evitar uma segunda ida ao banco. Na prática, o padrão “check → Do → check de novo → DB” é o mais defensivo.
Para o lado do cliente de cache, veja também Go com Redis para cache e sessões e a comparação Valkey vs Redis.
DoChan: quando o caller precisa reagir a cancelamento
Do bloqueia até a função terminar. Se o context do caller for cancelado no meio, a goroutine que espera continua presa até o líder terminar — e o líder não é cancelado automaticamente pelo cancelamento de um waiter.
DoChan devolve um canal e permite que o caller decida o que fazer:
func (r *Repositorio) ProdutoComTimeout(ctx context.Context, id string) (Produto, error) {
chave := "produto:" + id
ch := r.group.DoChan(chave, func() (any, error) {
return r.carregarECachear(context.WithoutCancel(ctx), id)
})
select {
case <-ctx.Done():
// Caller desistiu. O líder continua (e preenche o cache para os outros).
return Produto{}, ctx.Err()
case res := <-ch:
if res.Err != nil {
return Produto{}, res.Err
}
return res.Val.(Produto), nil
}
}
Detalhes de produção:
- O trabalho do líder não herda o cancelamento de um waiter. Se cada request cancelasse a reconstrução ao abandonar, o stampede voltaria: todo mundo cancela, ninguém termina, a próxima leva recomeça.
- Por isso o exemplo usa
context.WithoutCancel(ctx)(Go 1.21+) ou um context com timeout próprio e deadline de negócio para o líder. - O caller que saiu cedo não recebe o valor, mas o cache ainda pode ser aquecido para os próximos.
Combine com o guia de context, timeout e cancelamento para não misturar cancelamento de request com cancelamento de trabalho compartilhado.
Forget: invalidar uma execução em andamento (com cuidado)
Group.Forget(key) remove a chave do mapa interno sem esperar o fim da função. Novas chamadas a Do com a mesma chave iniciam um novo trabalho, mesmo que o antigo ainda esteja rodando.
// Após um deploy de schema ou invalidação forçada:
r.group.Forget("produto:42")
Use Forget quando:
- o resultado em voo ficou obsoleto (config mudou, feature flag virou, schema migrou);
- você detectou um erro transitório e quer permitir retry imediato por outra goroutine;
- um admin forçou rebuild.
Não use Forget em loop a cada request — isso anula a deduplicação e recria o stampede.
singleflight vs Mutex vs errgroup vs worker pool
| Ferramenta | Problema que resolve |
|---|---|
sync.Mutex / RWMutex | Exclusão mútua em estado compartilhado |
sync/atomic | Contadores e flags simples sem lock |
singleflight | Uma execução por chave; resultado compartilhado |
errgroup | N trabalhos distintos com cancelamento e primeiro erro |
| worker pool | Limitar paralelismo de uma fila de jobs heterogêneos |
sync.Pool | Reutilizar objetos para reduzir alocações |
Exemplo de combinação saudável: um handler usa singleflight para reconstruir cache de produto e errgroup para buscar, em paralelo, recomendações e estoque — trabalhos diferentes, não a mesma chave.
func (s *Service) PaginaProduto(ctx context.Context, id string) (Pagina, error) {
produto, err := s.repo.Produto(ctx, id) // singleflight por dentro
if err != nil {
return Pagina{}, err
}
g, ctx := errgroup.WithContext(ctx)
var recs []Rec
var estoque int
g.Go(func() error {
var err error
recs, err = s.recomendacoes.Listar(ctx, id)
return err
})
g.Go(func() error {
var err error
estoque, err = s.estoque.Saldo(ctx, id)
return err
})
if err := g.Wait(); err != nil {
return Pagina{}, err
}
return Pagina{Produto: produto, Recs: recs, Estoque: estoque}, nil
}
Para orquestração de grupos, o guia de errgroup cobre SetLimit, TryGo e fan-out/fan-in. Para filas persistentes, veja o worker pool com jobs.
Cache stampede: o problema completo
singleflight ataca o stampede no processo. Em sistemas distribuídos o desenho completo costuma ter camadas:
- TTL com jitter — evita que mil chaves expirem no mesmo segundo.
- singleflight local — colapsa misses idênticos dentro do pod.
- Cache compartilhado (Redis/Valkey) — os outros pods leem o valor já reconstruído.
- Soft TTL / early refresh — um request renova a chave antes de expirar de verdade.
- Lock distribuído ou “only once” — só quando o custo de rebuild é extremo e o miss simultâneo entre pods ainda dói.
- Rate limit e circuit breaker — protegem o backend se o cache cair por completo (rate limiting, circuit breaker).
Não espere que singleflight sozinho salve um cluster de 50 pods se o banco não aguenta 50 rebuilds simultâneos da mesma chave quente. Meça com pprof e métricas de hit ratio antes de adicionar locks distribuídos.
Observabilidade: o que medir
Sem métricas, singleflight vira “magia” difícil de depurar. Instrumente pelo menos:
- execuções reais da função (
fnentrou); - espera compartilhada (
shared == true); - duração do líder;
- erros do líder (e se foram repassados);
- timeouts de caller em
DoChan(waiter desistiu).
Esboço mínimo com contadores da standard library ou do seu cliente de métricas:
type GrupoObservado struct {
g singleflight.Group
execucoes atomic.Int64
shared atomic.Int64
erros atomic.Int64
}
func (o *GrupoObservado) Do(key string, fn func() (any, error)) (any, error, bool) {
v, err, shared := o.g.Do(key, func() (any, error) {
o.execucoes.Add(1)
return fn()
})
if shared {
o.shared.Add(1)
}
if err != nil {
o.erros.Add(1)
}
return v, err, shared
}
Em OpenTelemetry, um span no líder com atributo singleflight.key e um contador singleflight.shared já bastam para correlacionar picos de latência com colapso de trabalho. Veja OpenTelemetry em Go.
Armadilhas comuns
1. Chave ampla demais
Do("tudo", fn) serializa trabalho que deveria ser paralelo. Prefira produto:{id}, jwks:{issuer}, cfg:{env}.
2. Chave instável
fmt.Sprintf("%v", filtro) com maps (ordem de chaves) ou ponteiros gera chaves diferentes para o mesmo trabalho — a deduplicação nunca acontece. Normalize a chave (IDs ordenados, canonical JSON, hash estável).
3. Resultado que não pode ser compartilhado
Se fn devolve um *bytes.Buffer ou um struct com campos mutáveis reutilizados, os waiters compartilham a mesma instância. Prefira valores imutáveis, cópias defensivas ou tipos que não serão mutados depois do Do.
4. Context do request no líder
Passar o ctx cancelável do primeiro request para o líder faz o trabalho morrer quando esse request abandona — e os waiters herdam o erro. Use deadline de negócio dedicado para o líder.
5. Esquecer que o erro também é compartilhado
Um timeout ou 500 do backend vira erro para todos os waiters. Avalie retry dentro de fn (com backoff curto) para erros transitórios, ou aceite o fan-out controlado após Forget em cenários específicos.
6. Esperar deduplicação entre processos
Documente no README do serviço: “singleflight é por processo”. Em Kubernetes, o fator de amplificação no pior caso é ≈ número de pods quentes naquela chave.
7. Usar singleflight como cache
singleflight não guarda o resultado depois que o Do termina. Na próxima janela sem voo, fn roda de novo. Para memoização entre requests, use cache (memória local, Redis/Valkey) e singleflight na reconstrução.
Exemplo: JWKS e tokens remotos
Outro caso clássico: validar JWT com chaves remotas. Sem deduplicação, um pico de login pode martelar o endpoint de JWKS.
type JWKSCache struct {
group singleflight.Group
client *http.Client
url string
// mu protege o snapshot em memória após o fetch
mu sync.RWMutex
keys map[string]any
expira time.Time
}
func (j *JWKSCache) Key(ctx context.Context, kid string) (any, error) {
j.mu.RLock()
if time.Now().Before(j.expira) {
k, ok := j.keys[kid]
j.mu.RUnlock()
if ok {
return k, nil
}
// kid desconhecido com cache ainda válido: force refresh abaixo
} else {
j.mu.RUnlock()
}
v, err, _ := j.group.Do(j.url, func() (any, error) {
return j.refresh(ctx)
})
if err != nil {
return nil, err
}
keys := v.(map[string]any)
k, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("kid %s não encontrado", kid)
}
return k, nil
}
O mesmo desenho serve para feature flags remotas, discovery de OIDC e configurações de terceiros. Para o lado de autenticação da API, combine com autenticação e autorização em APIs Go.
Testando singleflight
Testes devem provar duas coisas: colapso sob concorrência e propagação de erro.
func TestProdutoColapsaChamadas(t *testing.T) {
t.Parallel()
var hits atomic.Int64
db := DBFunc(func(ctx context.Context, id string) (Produto, error) {
hits.Add(1)
time.Sleep(50 * time.Millisecond)
return Produto{ID: id, Nome: "Camiseta"}, nil
})
repo := &Repositorio{
db: db,
cache: redisMinimo(t), // miniredis ou fake
ttl: time.Minute,
}
const n = 50
var wg sync.WaitGroup
errCh := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := repo.Produto(context.Background(), "42")
errCh <- err
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("erro inesperado: %v", err)
}
}
if hits.Load() != 1 {
t.Fatalf("hits no DB = %d, queria 1", hits.Load())
}
}
Rode com a race detector ligada:
go test -race ./...
Para padrões de teste em tabela e fakes HTTP, veja testes table-driven e mocks com testify, gomock e httptest.
Checklist de produção
Antes de considerar o padrão “pronto”:
- Chaves estáveis, granulares e documentadas.
- Resultado imutável (ou copiado) entre waiters.
- Context do líder com deadline próprio, não amarrado ao primeiro request.
- Revalidação de cache dentro de
Doquando aplicável. - TTL com jitter no cache compartilhado.
- Métricas de execuções, shared e erros.
- Teste de concorrência com
-race. - Expectativa clara: deduplicação por processo.
- Fallback se o backend de origem falhar (erro compartilhado vs retry controlado).
- Combinação com rate limit / circuit breaker no pior caso de cold cache.
Quando preferir outra abordagem
- Memoização de CPU pura e barata — calcule de novo; singleflight não vale o custo de coordenação.
- Trabalho que deve rodar uma vez no processo inteiro no boot —
sync.Onceé mais simples. - Fila de jobs com retry, DLQ e persistência — use filas (SQS, Redis Streams) em vez de colapsar na request path.
- Atualização proativa — um refresher em background (ticker + singleflight) pode eliminar a maioria dos misses quentes.
Próximos passos
Se você está montando a camada de cache e resiliência de um serviço Go:
- Implemente cache-aside com singleflight nas hot keys.
- Adicione TTL com jitter e métricas de hit ratio.
- Proteja o backend com rate limiting e circuit breaker.
- Revise graceful shutdown e health checks para deploys sem stampede artificial.
- Use pprof se a reconstrução ainda for cara — às vezes o ganho está em reduzir o custo de
fn, não só em colapsar chamadas.
singleflight não substitui cache, fila nem banco. Ele resolve um problema estreito e frequente: muitos callers, o mesmo trabalho, ao mesmo tempo. Usado com chaves corretas, context disciplinado e um cache compartilhado, elimina uma classe inteira de incidentes de pico que aparecem exatamente quando o sistema mais precisa de estabilidade.
Perguntas frequentes
O que é singleflight em Go?
singleflight é um pacote de golang.org/x/sync que deduplica trabalho concorrente pela mesma chave. Se várias goroutines pedem a mesma operação ao mesmo tempo, apenas uma executa a função e as demais recebem o mesmo resultado ou erro, sem repetir a chamada cara.
singleflight resolve cache stampede?
Sim, dentro de um único processo. Quando muitas requisições encontram a mesma chave expirada, singleflight faz com que só uma reconstrua o valor enquanto as outras esperam. Em múltiplas réplicas, combine com TTL aleatório, locks distribuídos ou coordenação no cache compartilhado.
Qual a diferença entre singleflight e sync.Mutex?
Mutex serializa o acesso a um recurso compartilhado. singleflight agrupa chamadas pela mesma chave: trabalhos com chaves diferentes rodam em paralelo, e apenas a primeira chamada de cada chave executa a função. É o padrão certo para fan-in de trabalho idêntico, não para proteger um mapa genérico.
singleflight funciona entre pods no Kubernetes?
Não. Cada processo tem sua própria Group. Se dez pods recebem o mesmo miss de cache, cada um pode executar a função uma vez. Para deduplicação entre réplicas, use coordenação distribuída, singleflight local em cada pod e TTL com jitter.
Quando não usar singleflight?
Evite quando o resultado não pode ser compartilhado entre requisições, quando a chave é única por usuário e não há colisão real, quando a operação já é barata, ou quando você precisa de cancelamento independente por caller sem compartilhar o resultado parcial.