Idempotência em Go é o que separa um consumer que funciona no ambiente local de um sistema que aguenta produção. Em filas, eventos e webhooks, a pergunta não é “a mensagem pode chegar duas vezes?”. A pergunta correta é: o que acontece quando ela chegar duas vezes?
RabbitMQ, Kafka, NATS JetStream, SQS, Redis Streams, Pub/Sub e a maioria dos brokers sérios trabalham, na prática, com alguma variação de entrega “ao menos uma vez”. Isso é bom porque reduz perda de mensagens. Mas também significa que o mesmo evento pode ser entregue novamente depois de timeout, queda do processo, falha de rede, rebalanceamento de consumer group ou ack que não chegou ao broker.
Este guia mostra como desenhar um consumer Go idempotente, quando fazer retry, quando mandar para dead-letter queue e quais sinais monitorar. Se você ainda está escolhendo tecnologia, leia primeiro o comparativo de mensageria em Go. Se o problema ainda cabe dentro de um único processo, veja também worker pool em Go.
O que é idempotência?
Uma operação idempotente pode ser executada mais de uma vez sem mudar o resultado final além da primeira execução. Em HTTP, PUT costuma ser idempotente: atualizar o nome de um usuário para “Ana” uma vez ou cinco vezes deixa o mesmo estado. Em mensageria, a ideia é parecida, mas o risco é maior porque a duplicidade pode gerar efeito externo.
Exemplos de efeito perigoso:
- Cobrar o mesmo pedido duas vezes.
- Enviar dois e-mails de confirmação.
- Criar dois registros para a mesma nota fiscal.
- Aplicar duas vezes o mesmo crédito em carteira.
- Reprocessar um webhook e sobrescrever estado mais novo.
O objetivo não é impedir que o broker reenvie mensagem. O objetivo é fazer seu handler reconhecer que aquele evento já foi tratado ou que a transição de estado já aconteceu.
Use uma chave de idempotência estável
Todo evento precisa de um identificador estável. Pode ser event_id, message_id, payment_id, order_id + status, webhook_id ou uma chave natural do domínio. O ponto importante é que a chave venha do produtor ou do sistema externo, não seja gerada aleatoriamente pelo consumer a cada tentativa.
Um payload mínimo:
type PedidoPago struct {
EventID string `json:"event_id"`
PedidoID string `json:"pedido_id"`
Valor int64 `json:"valor_centavos"`
PaidAt time.Time `json:"paid_at"`
}
Se EventID muda a cada retry, ele não serve para idempotência. Se o provedor externo não envia um ID confiável, derive uma chave determinística com os campos que definem o evento, como provedor + pagamento_id + status.
Tabela de eventos processados
O padrão mais comum em APIs Go com PostgreSQL é registrar eventos processados em uma tabela com chave única. Essa tabela pode ser criada por migration, junto com o restante do schema da aplicação:
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Em produção, você também pode guardar payload_hash, source, attempts e last_error, mas comece simples. O essencial é ter uma constraint única que o banco consiga proteger mesmo com dois consumers concorrentes.
Handler idempotente com transação
O desenho seguro é: abrir transação, tentar registrar o evento, executar a regra de negócio e commitar tudo junto. Se o evento já existe, o handler retorna sucesso sem repetir o efeito.
func ProcessarPedidoPago(ctx context.Context, db *sql.DB, evento PedidoPago) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var inserted bool
err = tx.QueryRowContext(ctx, `
INSERT INTO processed_events (event_id, event_type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING
RETURNING true
`, evento.EventID, "pedido_pago").Scan(&inserted)
if errors.Is(err, sql.ErrNoRows) {
return tx.Commit()
}
if err != nil {
return err
}
if err := marcarPedidoComoPago(ctx, tx, evento.PedidoID, evento.Valor); err != nil {
return err
}
return tx.Commit()
}
Esse exemplo usa PostgreSQL porque ON CONFLICT DO NOTHING é simples e previsível. A mesma ideia existe em MySQL com INSERT IGNORE ou ON DUPLICATE KEY UPDATE, mas cuidado para não esconder erro real como se fosse duplicidade.
Ordem da escrita importa
Uma dúvida comum é se o consumer deve registrar o evento antes ou depois da regra de negócio. Na prática, o registro e a regra precisam estar na mesma transação sempre que possível. Se você marca o evento como processado e depois falha ao atualizar o pedido, a próxima entrega será ignorada indevidamente. Se você atualiza o pedido e falha antes de registrar o evento, a próxima entrega pode repetir o efeito.
Nem todo efeito externo cabe em transação. Enviar e-mail, chamar gateway de pagamento e publicar outro evento exigem desenho adicional. Nesses casos, prefira gravar uma intenção no banco e deixar outro worker executar o efeito externo com sua própria chave de idempotência. Esse padrão é conhecido como outbox e conversa bem com migrations em Go, porque a tabela de outbox vira parte explícita do schema.
Retry não é tratamento de erro
Retry é uma ferramenta para erro temporário. Ele não corrige payload inválido, contrato quebrado, ID inexistente ou bug de código. Antes de retentar, classifique o erro:
| Tipo de erro | Ação recomendada |
|---|---|
| Timeout de API externa | Retry com backoff |
| Banco indisponível | Retry com backoff |
| Rate limit 429 | Retry respeitando janela |
| JSON inválido | DLQ direto |
| Campo obrigatório ausente | DLQ direto |
| Pedido inexistente definitivo | DLQ ou ignorar com auditoria |
| Bug do handler | Pausar consumer, corrigir e reprocessar |
Um retry sem limite vira loop infinito. Um retry imediato em massa vira tempestade. Um retry sem idempotência vira duplicidade.
Backoff em Go
Para um worker simples, backoff exponencial com limite resolve bastante:
func retryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
delay := time.Duration(1<<min(attempt-1, 5)) * time.Second
jitter := time.Duration(rand.Int63n(int64(250 * time.Millisecond)))
return delay + jitter
}
Em um broker real, prefira recursos duráveis do próprio sistema quando disponíveis: RabbitMQ com exchange de retry e DLX, SQS com visibility timeout e DLQ, Kafka com tópico de retry, NATS JetStream com NakWithDelay. Dormir dentro do consumer funciona em baixo volume, mas prende worker e reduz throughput.
Quando mandar para DLQ
Dead-letter queue não é lixeira. É uma fila de investigação. Uma mensagem deve ir para DLQ quando o sistema decidiu que continuar tentando agora é pior do que separar o caso para análise.
Boas razões para DLQ:
- Payload inválido ou incompatível com schema atual.
- Erro permanente depois de validação de domínio.
- Número máximo de tentativas atingido.
- Dependência externa rejeitou definitivamente a operação.
- Handler detectou estado impossível e precisa de intervenção.
Cada mensagem em DLQ precisa carregar motivo, tentativa, horário, erro resumido e correlação com logs. Se a DLQ cresce e ninguém olha, ela virou perda silenciosa de dados com nome bonito.
Logs estruturados para consumers
Consumers precisam de logs pesquisáveis. Use slog com campos estáveis: event_id, message_id, attempt, queue, consumer, pedido_id, error_type e duration_ms. Texto livre não basta quando você precisa investigar por que 300 mensagens foram para DLQ.
logger.Warn("mensagem enviada para dlq",
slog.String("event_id", evento.EventID),
slog.String("queue", "payments"),
slog.Int("attempt", attempt),
slog.String("reason", "payload_invalido"),
slog.Any("err", err),
)
O guia de slog em Go mostra como padronizar JSON, níveis e campos comuns para APIs e workers.
Métricas mínimas
Sem métrica, retry parece sucesso até o atraso virar incidente. Monitore pelo menos:
- Mensagens processadas por segundo.
- Taxa de erro por tipo.
- Número de retries por janela.
- Tamanho da fila e idade da mensagem mais antiga.
- Tamanho da DLQ.
- Tempo p95 de processamento.
- Quantidade de eventos duplicados ignorados.
O contador de duplicados é especialmente útil. Se ele sobe muito, talvez o broker esteja reentregando por timeout curto demais, o consumer esteja demorando além do esperado ou o ack esteja acontecendo tarde.
Como testar idempotência
Teste idempotência como comportamento, não como detalhe de implementação. Rode o mesmo evento duas vezes e verifique que o estado final só mudou uma vez.
func TestProcessarPedidoPagoIdempotente(t *testing.T) {
ctx := context.Background()
db := newTestDB(t)
evento := PedidoPago{EventID: "evt_123", PedidoID: "ped_1", Valor: 9900}
require.NoError(t, ProcessarPedidoPago(ctx, db, evento))
require.NoError(t, ProcessarPedidoPago(ctx, db, evento))
pedido := buscarPedido(t, db, "ped_1")
assert.Equal(t, "pago", pedido.Status)
assert.Equal(t, int64(9900), pedido.ValorPago)
assert.Equal(t, 1, contarEventosProcessados(t, db, "evt_123"))
}
Também vale testar corrida com dois goroutines processando o mesmo evento. A constraint única do banco deve proteger o estado mesmo se ambos chegarem ao mesmo tempo.
Checklist para produção
Antes de colocar um consumer Go em produção, confirme:
- Todo evento tem chave de idempotência estável.
- Efeitos de banco e registro de processamento ficam na mesma transação quando possível.
- Retry tem limite, backoff e classificação de erro.
- DLQ tem dono, alerta e processo de reprocessamento.
- Logs carregam
event_id, tentativa, fila e motivo do erro. - Métricas mostram fila, lag, retries, duplicados e DLQ.
- Testes executam o mesmo evento duas vezes.
Esse checklist aparece em entrevistas justamente porque ele vem de incidentes reais. Muitas vagas Go no Brasil pedem Kafka, RabbitMQ, SQS, PostgreSQL e Kubernetes, mas o diferencial é explicar como você evita duplicidade, como reprocessa com segurança e como descobre que um consumer está atrasado.
Conclusão
Idempotência em Go não depende de uma biblioteca mágica. Ela nasce de uma chave estável, uma constraint única, transações bem colocadas, retry limitado, DLQ tratada como operação real e logs que permitem investigar. Depois que esse desenho existe, trocar RabbitMQ por SQS ou Kafka por NATS deixa de ser o centro da discussão.
Comece pelo contrato do evento e pelo estado que não pode duplicar. Depois implemente o handler idempotente. Só então ajuste broker, backoff e throughput. Esse caminho é menos glamouroso do que instalar mais uma ferramenta, mas é o que mantém sistemas Go corretos quando produção entrega a mesma mensagem de novo.