githublinkedinemail
← /articles

Idempotência em sistemas reais

At-least-once é a realidade. Unique constraint é a única garantia de verdade. Idempotency-Key, dedupe table — sobrevivência em sistemas distribuídos.

4 mindistributed

Idempotência em sistemas reais

Toda fila entrega pelo menos uma vez.

Toda chamada HTTP pode ser tentada de novo.

Todo webhook pode chegar em duplicata.

Se sua aplicação não foi desenhada pra isso, ela já está quebrada — você só ainda não viu o estrago.


At-least-once é a realidade, não a exceção

A literatura faz parecer que existe "exactly-once delivery".

Não existe.

O que existe é:

  • at-most-once: pode perder mensagem (rápido, mas pouco confiável)
  • at-least-once: pode duplicar (confiável, mas você lida com duplicatas)
  • exactly-once: marketing

Toda fila séria (SQS, RabbitMQ, Sidekiq, Kafka) entrega at-least-once por padrão.

Tradução: cedo ou tarde, seu job vai rodar duas vezes pra mesma mensagem.

Se cobrar do cartão duas vezes, o problema é seu, não da fila.


Idempotência em uma frase

Operação idempotente é aquela que, executada N vezes com os mesmos parâmetros, tem o mesmo efeito que executá-la uma vez.

Note: efeito, não resultado.

GET /users/42 é trivialmente idempotente — não muda nada.

POST /charges precisa ser feito idempotente com intenção.


Idempotente ≠ comutativo

Esse aqui confunde gente boa.

  • Idempotente: f(f(x)) = f(x) — chamar de novo não muda nada.
  • Comutativo: f(g(x)) = g(f(x)) — a ordem não importa.
SET status = 'paid'        ← idempotente (chamar 2x, mesmo resultado)
balance += 100             ← NÃO idempotente, mas comutativo com outro +N
balance = 500              ← idempotente

Quando você projeta uma operação, decide qual das duas propriedades quer.

Em sistema de pagamento? Idempotência, sempre. Comutatividade é bônus.


A armadilha do "check then write"

Esse é o pecado mais comum:

def create_charge(order_id:, amount:)
  return if Charge.exists?(order_id: order_id)
  Charge.create!(order_id: order_id, amount: amount)
end

Parece idempotente. Não é.

Entre o exists? e o create!, outra thread / outro processo / outra réplica do worker pode rodar exatamente a mesma checagem.

Resultado: duas charges para a mesma order.

A janela é pequena. Em produção com tráfego real, acontece.

A única garantia real é no banco:

CREATE UNIQUE INDEX charges_order_id_idx ON charges (order_id);
def create_charge(order_id:, amount:)
  Charge.create!(order_id: order_id, amount: amount)
rescue ActiveRecord::RecordNotUnique
  Charge.find_by!(order_id: order_id)
end

Agora sim. O banco é a única fonte de verdade que importa.


Unique constraint é o único contrato real

Repete comigo:

Idempotência em código de aplicação, sem unique constraint no banco, não existe.

Mutex em Ruby? Vale dentro de UM processo.

Lock em Redis? Tem TTL, tem clock skew, tem rede.

if not exists em código? Race condition garantida.

UNIQUE INDEX? Atômico, garantido pelo Postgres, sem chance de falhar.

Diferença entre dev senior e junior:

  • junior protege idempotência com Mutex.synchronize
  • senior coloca UNIQUE INDEX e dorme tranquilo

Idempotência na camada HTTP — modelo Stripe

Stripe popularizou o padrão:

POST /v1/charges
Idempotency-Key: 9f3a7e2b-1c4d-4e5f-8a9b-1234567890ab

{ "amount": 5000, "currency": "usd" }

O cliente gera um UUID. Se ele tentar de novo (timeout, retry), manda o mesmo key.

O servidor:

  1. recebe a request
  2. olha tabela idempotency_keys por aquele key
  3. se já existe → devolve a resposta gravada
  4. se não existe → processa, grava resposta, devolve
┌──────────────────────────────────────────────┐
│  idempotency_keys                            │
├──────────────────────────────────────────────┤
│ key            uuid           PRIMARY KEY    │
│ user_id        bigint                        │
│ request_hash   text                          │
│ response_body  jsonb                         │
│ response_code  integer                       │
│ created_at     timestamptz                   │
└──────────────────────────────────────────────┘

Detalhe importante: você guarda o hash do corpo da request. Se chegar o mesmo key com body diferente, retorna erro. Senão um cliente esperto pode "reciclar" key.

E mais um detalhe: created_at existe pra você poder expirar keys antigas (Stripe usa 24h).


Idempotência em workers

O caso clássico: job de cobrança.

class ChargeOrderJob
  def perform(order_id)
    order = Order.find(order_id)
    PaymentGateway.charge(order)
    order.update!(paid: true)
  end
end

Sidekiq pode rodar esse job duas vezes. SQS quase sempre rodará. Resultado: cobrança duplicada.

A correção não é "tentar não duplicar", é assumir que vai duplicar:

class ChargeOrderJob
  def perform(order_id)
    order = Order.find(order_id)

    Charge.create!(
      order_id: order_id,
      idempotency_key: "charge_order_#{order_id}"
    )

    PaymentGateway.charge(order, idempotency_key: "charge_order_#{order_id}")
    order.update!(paid: true)
  rescue ActiveRecord::RecordNotUnique
    # job rodou de novo, charge já existe, segue a vida
  end
end

Duas camadas de idempotência:

  • a sua, via UNIQUE INDEX em charges.idempotency_key
  • a do provider, via Idempotency-Key header

E aqui está o detalhe que separa quem dorme e quem é acordado de madrugada: o idempotency_key tem que vir antes do dinheiro sair. Se você gerar o key dentro do job, cada retry gera um novo, e a proteção não vale nada.


A tabela de dedupe — o padrão genérico

Funciona pra qualquer evento que precisa ser processado uma vez:

CREATE TABLE processed_events (
  event_id   text PRIMARY KEY,
  processed_at timestamptz NOT NULL DEFAULT now()
);
def handle_event(event)
  ProcessedEvent.create!(event_id: event.id)
  do_the_thing(event)
rescue ActiveRecord::RecordNotUnique
  # já processado
end

Ordem importa:

ERRADO                        CERTO
─────────                     ─────────
1. do_the_thing               1. INSERT processed_events
2. INSERT processed_events    2. do_the_thing

Por quê? Porque se do_the_thing falhar, no caminho errado você fez a ação mas não marcou, e na próxima tentativa faz de novo.

No caminho certo: se a inserção foi feita e a ação falhou, o retry vê o registro e pula — e aí você precisa de outra estratégia (ex: salvar o resultado junto). É um trade-off, mas explícito.

Para a maioria dos casos, o melhor é envolver tudo numa transação:

ActiveRecord::Base.transaction do
  ProcessedEvent.create!(event_id: event.id)
  do_the_thing(event)
end

Tudo ou nada. Postgres resolve.


Os erros mais comuns

1. Idempotência só em código de aplicação.

Sem unique constraint, não conta. Já falei.

2. Idempotency key gerado dentro do worker.

Cada retry gera um key novo. Equivale a não ter key.

3. Confiar em "id auto-incremento" como dedupe.

O id é gerado pelo banco. O cliente não sabe qual vai ser. Não dá pra dedupe nisso.

4. TTL curto demais na tabela de keys.

Cliente pode demorar 30 minutos pra fazer retry (push notification atrasada, app em background). Key expirou? Cobrança duplicada.

5. Não testar o caminho duplo.

Faz teste que chama o endpoint duas vezes com o mesmo key. Roda o job duas vezes seguidas. Garante que o efeito é o mesmo.

6. Webhook handler sem dedupe.

Stripe, GitHub, qualquer provedor sério reenvia webhook quando seu endpoint retorna != 2xx, ou demora demais. Vai chegar de novo. Trate.


Mapa mental rápido

Tem operação que muta estado?
   ↓
Tem chance de ser chamada 2x?  (resposta: sempre tem)
   ↓
Defina a CHAVE de idempotência (id do recurso, uuid do cliente, hash do evento)
   ↓
Crie UNIQUE INDEX nessa chave
   ↓
Trate RecordNotUnique como "já feito, ok"
   ↓
Se há side-effect externo, propague a chave (Idempotency-Key)

Cinco passos. Em qualquer linguagem, qualquer banco, qualquer fila.


Conclusão

Sistema distribuído sem idempotência é bomba-relógio.

Não é "se" vai duplicar. É "quando".

A diferença entre quem aguenta o tranco e quem é acordado de madrugada:

  • assume at-least-once como padrão
  • coloca unique constraint no banco
  • propaga idempotency key entre camadas
  • testa o caminho duplicado

Idempotência não é um padrão. É uma habilidade de sobrevivência em sistemas distribuídos.

O dia que sua fila duplicar — e ela vai — você vai estar grato por ter levado a sério.

Ou vai aprender, do jeito caro.

Idempotência em sistemas reais
Idempotência em sistemas reais