---
title: "singleflight em Go: Cache Stampede e Deduplicação de Trabalho"
url: "https://golang.com.br/blog/singleflight-go-cache-stampede-deduplicacao/"
markdown_url: "https://golang.com.br/blog/singleflight-go-cache-stampede-deduplicacao.MD"
description: "Aprenda singleflight em Go para evitar cache stampede, deduplicar chamadas caras e proteger banco e APIs sob alta concorrência com exemplos de produção."
date: "2026-08-10"
author: "Golang Brasil"
---

# singleflight em Go: Cache Stampede e Deduplicação de Trabalho

Aprenda singleflight em Go para evitar cache stampede, deduplicar chamadas caras e proteger banco e APIs sob alta concorrência com exemplos de produção.


**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](/blog/valkey-vs-redis-go-cache-producao/), o [guia de errgroup](/blog/errgroup-go-concorrencia-cancelamento-erros/), o [context com timeout](/blog/context-timeout-cancelamento-go/), o [worker pool](/blog/worker-pool-go-fila-jobs/) e o [rate limiting em APIs](/blog/rate-limiting-go-api-producao/).

## 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:

```bash
go get golang.org/x/sync/singleflight
```

```go
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)`** — executa `fn` se não houver chamada em andamento para `key`; caso contrário, espera o resultado da chamada em voo.
- **`shared`** — `true` quando 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:

```go
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](/tutoriais/go-redis-cache/) e a comparação [Valkey vs Redis](/blog/valkey-vs-redis-go-cache-producao/).

## 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:

```go
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](/blog/context-timeout-cancelamento-go/) 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.

```go
// 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.

```go
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](/blog/errgroup-go-concorrencia-cancelamento-erros/) cobre `SetLimit`, `TryGo` e fan-out/fan-in. Para filas persistentes, veja o [worker pool com jobs](/blog/worker-pool-go-fila-jobs/).

## Cache stampede: o problema completo

`singleflight` ataca o stampede **no processo**. Em sistemas distribuídos o desenho completo costuma ter camadas:

1. **TTL com jitter** — evita que mil chaves expirem no mesmo segundo.
2. **singleflight local** — colapsa misses idênticos dentro do pod.
3. **Cache compartilhado** (Redis/Valkey) — os outros pods leem o valor já reconstruído.
4. **Soft TTL / early refresh** — um request renova a chave antes de expirar de verdade.
5. **Lock distribuído ou “only once”** — só quando o custo de rebuild é extremo e o miss simultâneo entre pods ainda dói.
6. **Rate limit e circuit breaker** — protegem o backend se o cache cair por completo ([rate limiting](/blog/rate-limiting-go-api-producao/), [circuit breaker](/blog/circuit-breaker-go-http-resiliencia/)).

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](/blog/pprof-go-producao/) 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 (`fn` entrou);
- **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:

```go
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](/blog/go-opentelemetry-observabilidade-tracing-metricas/).

## 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.

```go
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](/blog/autenticacao-autorizacao-go-apis/).

## Testando singleflight

Testes devem provar duas coisas: **colapso sob concorrência** e **propagação de erro**.

```go
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:

```bash
go test -race ./...
```

Para padrões de teste em tabela e fakes HTTP, veja [testes table-driven](/blog/testes-tabela-go-guia-table-driven-tests/) e [mocks com testify, gomock e httptest](/blog/mocks-go-testify-gomock-fakes-httptest/).

## Checklist de produção

Antes de considerar o padrão “pronto”:

1. Chaves estáveis, granulares e documentadas.
2. Resultado imutável (ou copiado) entre waiters.
3. Context do líder com deadline próprio, não amarrado ao primeiro request.
4. Revalidação de cache dentro de `Do` quando aplicável.
5. TTL com jitter no cache compartilhado.
6. Métricas de execuções, shared e erros.
7. Teste de concorrência com `-race`.
8. Expectativa clara: deduplicação **por processo**.
9. Fallback se o backend de origem falhar (erro compartilhado vs retry controlado).
10. 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](/blog/sqs-go-filas-workers-aws/), [Redis Streams](/blog/redis-streams-go-filas-consumer-groups/)) 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:

1. Implemente cache-aside com singleflight nas hot keys.
2. Adicione TTL com jitter e métricas de hit ratio.
3. Proteja o backend com [rate limiting](/blog/rate-limiting-go-api-producao/) e [circuit breaker](/blog/circuit-breaker-go-http-resiliencia/).
4. Revise [graceful shutdown](/blog/graceful-shutdown-go-producao/) e [health checks](/blog/health-checks-go-liveness-readiness-startup/) para deploys sem stampede artificial.
5. Use [pprof](/blog/pprof-go-producao/) 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.
