Idempotência em sistemas distribuídos: garantindo que a operação não se repita

1. Fundamentos da Idempotência em Sistemas Distribuídos

Em sistemas distribuídos, a idempotência é a propriedade que garante que uma operação possa ser executada múltiplas vezes sem produzir efeitos colaterais diferentes da execução única. Formalmente, uma operação é idempotente se, para qualquer número n de execuções, o estado final do sistema é idêntico ao estado após a primeira execução.

A importância da idempotência cresce exponencialmente em ambientes onde falhas de rede, timeouts e retransmissões são comuns. Quando um cliente envia uma requisição e não recebe resposta devido a um timeout, ele naturalmente tentará novamente. Sem idempotência, essa retransmissão pode causar duplicação de dados, cobranças em dobro ou inconsistências graves.

A diferença fundamental entre métodos HTTP ilustra bem o conceito:

GET /api/users/123         → Idempotente: ler sempre retorna o mesmo recurso
PUT /api/users/123         → Idempotente: atualizar com os mesmos dados não altera o estado
DELETE /api/users/123      → Idempotente: deletar um recurso já deletado é seguro
POST /api/users            → NÃO idempotente: cada chamada cria um novo recurso
PATCH /api/users/123       → NÃO idempotente: depende do delta aplicado

O problema central surge quando sistemas de mensageria ou protocolos de transporte implementam retries automáticos. Uma mensagem pode ser entregue múltiplas vezes, e o receptor precisa estar preparado para lidar com duplicatas de forma segura.

2. Estratégias de Implementação de Idempotência no Backend

A estratégia mais comum e robusta para implementar idempotência é o uso de chaves de idempotência (idempotency keys). O cliente gera um token único (UUID v4, por exemplo) para cada requisição e o envia no cabeçalho. O servidor armazena essa chave junto com o resultado da operação.

Modelo de dados para tabela de idempotência:

CREATE TABLE idempotency_keys (
    id BIGSERIAL PRIMARY KEY,
    key VARCHAR(64) NOT NULL UNIQUE,
    response_status INTEGER NOT NULL,
    response_body TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);

CREATE UNIQUE INDEX idx_idempotency_key ON idempotency_keys(key);

O fluxo de processamento é:

  1. Cliente gera UUID e envia no cabeçalho Idempotency-Key: uuid-123
  2. Servidor verifica se a chave já existe no banco
  3. Se existe: retorna a resposta armazenada original
  4. Se não existe: processa a operação, armazena chave + resultado, retorna resposta
  5. A chave expira após um período configurável (geralmente 24 horas)

Implementação simplificada em pseudocódigo:

function processarRequisicao(request):
    chave = request.header("Idempotency-Key")

    if chave is null:
        return erro("Chave de idempotência obrigatória")

    registro = banco.buscarChave(chave)

    if registro exists:
        return registro.resposta

    transacao = banco.iniciarTransacao()

    try:
        resultado = executarOperacao(request)
        banco.salvarChave(chave, resultado.status, resultado.body)
        transacao.commit()
        return resultado
    except:
        transacao.rollback()
        throw

3. Idempotência em APIs REST: Padrões e Boas Práticas

Embora os métodos HTTP tenham semânticas de idempotência definidas, na prática é necessário implementar proteções adicionais, especialmente para POST em operações críticas.

Exemplo de endpoint com chave de idempotência:

POST /api/pagamentos
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
    "valor": 150.00,
    "moeda": "BRL",
    "destinatario": "joao@email.com"
}

O tratamento de respostas deve seguir estas regras:

Cenário 1: Primeira requisição
→ Servidor processa e retorna 201 Created
→ Armazena {chave: "550e...", status: 201, body: {"id": "pag_123"}}

Cenário 2: Requisição duplicada (mesma chave)
→ Servidor encontra chave existente
→ Retorna 200 OK com mesmo body: {"id": "pag_123"}
→ Indica no cabeçalho X-Idempotent-Replayed: true

Cenário 3: Requisição em andamento
→ Servidor bloqueia concorrência com lock na chave
→ Segunda requisição aguarda ou retorna 409 Conflict

4. Garantindo Idempotência em Operações Financeiras e Críticas

Operações financeiras exigem o mais alto nível de garantia de idempotência. Um pagamento duplicado pode causar prejuízos significativos e danos à reputação.

Exemplo prático: processamento de pagamento com idempotência

function processarPagamento(pagamentoId, chaveIdempotencia, transacao):
    // Lock pessimista na chave
    banco.executar("SELECT pg_advisory_xact_lock(hashtext(?))", chaveIdempotencia)

    // Verifica se já foi processado
    pagamento = banco.buscar("SELECT * FROM pagamentos WHERE chave_idempotencia = ?", chaveIdempotencia)

    if pagamento exists:
        return pagamento

    // Verifica saldo e processa
    saldo = gatewayFinanceiro.verificarSaldo(pagamentoId)

    if saldo < transacao.valor:
        throw new SaldoInsuficienteException()

    // Operação atômica: debita e registra
    banco.executar("""
        INSERT INTO pagamentos (id, chave_idempotencia, valor, status)
        VALUES (?, ?, ?, 'processado')
    """, pagamentoId, chaveIdempotencia, transacao.valor)

    gatewayFinanceiro.debitar(pagamentoId, transacao.valor)

    return {"status": "sucesso", "id": pagamentoId}

Estratégias de compensação quando a idempotência falha:

Cenário: Timeout durante processamento, cliente reenviou
→ Sistema detecta duplicata pela chave
→ Se pagamento foi debitado mas resposta não retornou:
    → Retorna resposta original armazenada
    → Cliente não tenta novamente

Cenário: Falha catastrófica (banco indisponível)
→ Chave de idempotência perdida
→ Cliente deve reenviar com nova chave
→ Sistema precisa detectar duplicidade por outros meios:
    → Combinação de dados únicos (número do pedido + valor + data)
    → Consulta ao gateway externo

5. Idempotência em Filas e Mensageria Distribuída

Em sistemas de mensageria, a garantia de entrega "exactly-once" é extremamente difícil de alcançar. A maioria dos sistemas oferece "at-least-once", exigindo que os consumidores implementem idempotência.

Exemplo com Apache Kafka:

// Consumidor Kafka com idempotência
function consumirMensagem(mensagem):
    idUnico = mensagem.headers["message-id"]

    // Verifica se mensagem já foi processada
    processado = redis.get("processed:" + idUnico)

    if processado is not null:
        log("Mensagem duplicada ignorada: " + idUnico)
        return  // Commit sem processar

    // Processa a mensagem
    resultado = processarNegocio(mensagem.body)

    // Marca como processada (com TTL)
    redis.setex("processed:" + idUnico, 86400, "1")

    // Commit manual
    consumer.commitSync()

Para RabbitMQ e SQS:

// Estratégia com banco de dados para deduplicação
function handlerSQS(event):
    for record in event.records:
        messageId = record.messageId
        body = json.parse(record.body)

        // Tenta inserir com UNIQUE constraint
        try:
            banco.executar("""
                INSERT INTO mensagens_processadas (message_id, dados, processado_em)
                VALUES (?, ?, NOW())
            """, messageId, body)

            processarMensagem(body)

        except UniqueViolation:
            log("Mensagem já processada: " + messageId)
            continue

6. Desafios e Armadilhas Comuns na Prática

Problemas de concorrência: Duas requisições simultâneas com a mesma chave de idempotência podem ambas passar pela verificação inicial antes de qualquer uma persistir o resultado. Soluções incluem:

// Uso de locks otimistas com versão
UPDATE idempotency_keys 
SET response_status = ?, response_body = ?
WHERE key = ? AND version = 1;

// Se 0 linhas afetadas, outra requisição já processou

Sincronização de relógios: Se chaves de idempotência expiram baseadas em timestamp, diferenças de relógio entre serviços podem causar expiração prematura. Solução: usar TTL baseado no tempo de criação do registro, não no relógio do cliente.

Casos extremos:

Falha no armazenamento da chave:
→ Banco cai após processar operação mas antes de salvar chave
→ Cliente reenvia → operação é processada novamente
→ Mitigação: usar transações distribuídas ou padrão Saga

Rede particionada:
→ Cliente recebe timeout, reenvia com nova chave
→ Primeira requisição chega atrasada e é processada
→ Duas operações executadas
→ Mitigação: timeout longo + chave única por recurso

7. Testes e Validação de Idempotência

Testes de integração para verificar idempotência:

// Teste: requisição duplicada deve retornar mesmo resultado
funcao testarIdempotencia():
    chave = gerarUUID()

    // Primeira chamada
    resposta1 = api.post("/pagamentos", {
        headers: {"Idempotency-Key": chave},
        body: {"valor": 100}
    })
    assert resposta1.status == 201

    // Segunda chamada com mesma chave
    resposta2 = api.post("/pagamentos", {
        headers: {"Idempotency-Key": chave},
        body: {"valor": 100}
    })
    assert resposta2.status == 200
    assert resposta2.body.id == resposta1.body.id
    assert resposta2.headers["X-Idempotent-Replayed"] == "true"

Simulação de falhas de rede:

// Teste: timeout na primeira tentativa
funcao testarTimeoutEIdempotencia():
    chave = gerarUUID()

    // Simula falha de rede
    mockServidor.ativarTimeout()

    try:
        api.post("/operacao", {headers: {"Idempotency-Key": chave}})
    catch TimeoutException:
        log("Timeout simulado")

    mockServidor.desativarTimeout()

    // Retry com mesma chave
    resposta = api.post("/operacao", {headers: {"Idempotency-Key": chave}})
    assert resposta.status in [200, 201]
    assert banco.contarOperacoes(chave) == 1

Monitoramento e métricas:

// Métricas essenciais para dashboard
idempotency_requests_total{status="first"}  // Primeiras requisições
idempotency_requests_total{status="replayed"}  // Requisições duplicadas
idempotency_key_conflicts_total  // Conflitos de concorrência
idempotency_key_expired_total  // Chaves expiradas antes do uso

// Logging estruturado
log.info("Requisição idempotente processada", {
    "idempotency_key": chave,
    "replayed": true,
    "original_timestamp": registro.created_at,
    "processing_time_ms": elapsed
})

Referências