Chaves de Idempotência e Distributed Locks: Como Evitar Cobranças Duplas
O Pesadelo do Duplo Clique: Como a Rede Móvel Duplica Pagamentos
Mais cedo ou mais tarde, quem desenvolve lojas online ou plataformas SaaS recebe um alerta desesperado do suporte: “O cliente carregou apenas uma vez no botão de pagar, mas a conta do banco dele tem duas cobranças de 350 euros!”
Na maioria das vezes, o teu código não tem nenhum erro óbvio de programação. O problema acontece porque as redes móveis e os browsers não funcionam num mundo ideal.
Repara neste cenário real:
- O utilizador clica em “Pagar Agora” no telemóvel enquanto viaja de comboio.
- O browser dele envia um pedido
POST /api/pagamentoscom os dados da encomenda. - O teu servidor contacta o gateway de pagamentos (como o Stripe ou Easypay) e o dinheiro é retirado com sucesso.
- Quando o teu servidor tenta devolver o código
200 OK, o telemóvel do cliente perde cobertura durante dois segundos e a ligação TCP cai. - O browser deteta a falha de ligação e reenvia o pedido automaticamente. Ou então o cliente, impaciente por não ver confirmação, volta a clicar furiosamente no botão.
- A tua API recebe o segundo pedido, acha que é uma nova compra e debita o cartão de crédito do cliente pela segunda vez.
O ERRO DA COBRANÇA DUPLA NUMA API TRADICIONAL
- Telemóvel do Cliente (Rede Instável)
- Servidor API
- POST /api/pagamentos (1º Clique) → (Cobra no Stripe) (Cartão Cobrado 350€!)
- Ligação Cai: Mudança de Antena 4G: X — (Cliente não vê nada!)
- (O browser reenvia ou o utilizador clica de novo)
- POST /api/pagamentos (2º Pedido Duplicado) → (Cobra NOVAMENTE!)
- 200 OK (Pagamento Confirmado) — (Cobrado 350€ OUTRA VEZ)
- O cliente vê apenas uma encomenda, mas o banco retirou-lhe 700 euros!
Para desenhares web apps profissionais, tens de garantir que os teus endpoints críticos são estritamente idempotentes: uma operação que, mesmo executada dez vezes com os mesmos parâmetros, deixa o sistema exatamente no mesmo estado final que deixaria se tivesse sido chamada uma única vez.
Anatomia da Idempotência: Chaves Únicas, Estados e Bloqueios Atómicos
Um sistema de pagamentos robusto apoia-se em três pilares fundamentais:
- Chave de Idempotência do Cliente: Antes de disparar o pedido, o teu frontend gera um identificador único (UUIDv4) e envia-o num cabeçalho HTTP:
Idempotency-Key: a48f12cd-8891-4509-90b1-c89b702910a3. - Bloqueio Distribuído Atómico (Distributed Lock): Antes de cobrar o cartão, o servidor tenta reservar essa chave numa base de dados em memória ultrarrápida como o Redis. Se outro processo já estiver a correr com essa chave, o pedido repetido é travado de imediato com
409 Conflict. - Persistência de Resultados: Quando o pagamento é concluído, o código de resposta HTTP e os dados da transação são guardados no PostgreSQL durante 24 a 48 horas associados àquela chave.
FLUXO DE VIDA DE UM PEDIDO IDEMPOTENTE
- Pedido Recebido com Cabeçalho: Idempotency-Key:
- Verifica no PostgreSQL ou Redis se a chave já existe: Existe e Estado == “CONCLUIDO”?
- DEVOLVE A RESPOSTA EM CACHE (Zero cobranças adicionais!)
- Existe e Estado == “A_PROCESSAR”?
- DEVOLVE HTTP 409 CONFLICT (“Operação em curso”)
- Não Existe?
-
- ADQUIRE LOCK ATÓMICO (Redis SETNX)
-
- REGISTA Estado = “A_PROCESSAR”
-
- Executa a cobrança no fornecedor de pagamentos
-
- ATUALIZA Estado = “CONCLUIDO” e guarda a resposta JSON
-
- LIBERTA O LOCK ATÓMICO
-
- Devolve HTTP 200 OK original
Com este fluxo, aplicas o princípio de anti-over-engineering: evitas dores de cabeça e garantes segurança bancária com uma arquitetura elegante e fiável.
Como Implementar Locks Atómicos no Redis e Cache em PostgreSQL com Node.js
Vejamos como podes implementar esta lógica de forma concreta em Node.js com Redis e PostgreSQL. Usamos o comando atómico SETNX no Redis para o bloqueio de concorrência e o PostgreSQL para arquivo duradouro.
Primeiro, o módulo do bloqueio atómico:
// src/services/lockDistribuido.ts
import Redis from 'ioredis';
import crypto from 'node:crypto';
const redis = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');
export async function adquirirLock(
chaveRecurso: string,
ttlMs = 10000
): Promise<{ adquirido: boolean; libertar: () => Promise<void> }> {
const tokenLock = crypto.randomUUID();
const chaveRedis = `lock:idempotencia:${chaveRecurso}`;
// Operação atómica: grava APENAS SE NÃO EXISTIR com expiração em ms
const resultado = await redis.set(chaveRedis, tokenLock, 'PX', ttlMs, 'NX');
if (resultado !== 'OK') {
return { adquirido: false, libertar: async () => {} };
}
// Libertação segura através de script Lua para evitar apagar locks de outros
const libertar = async () => {
const scriptLua = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(scriptLua, 1, chaveRedis, tokenLock);
};
return { adquirido: true, libertar };
}
Em seguida, o middleware que processa as requisições na tua API:
// src/middleware/idempotencia.ts
import { Request, Response, NextFunction } from 'express';
import { pool } from '../db/postgres.js';
import { adquirirLock } from '../services/lockDistribuido.js';
export async function middlewareIdempotencia(req: Request, res: Response, next: NextFunction) {
const chave = req.headers['idempotency-key'] as string;
if (!chave) return next();
// Passo 1: Consulta o PostgreSQL para ver se a operação já terminou
const registo = await pool.query(
'SELECT estado, codigo_status, resposta_json FROM chaves_idempotencia WHERE chave = $1',
[chave]
);
if (registo.rows.length > 0) {
const dados = registo.rows[0];
if (dados.estado === 'CONCLUIDO') {
return res.status(dados.codigo_status).json(dados.resposta_json);
}
if (dados.estado === 'A_PROCESSAR') {
return res.status(409).json({ erro: 'Transação em processamento. Aguarda alguns segundos.' });
}
}
// Passo 2: Reserva o lock no Redis
const { adquirido, libertar } = await adquirirLock(chave);
if (!adquirido) {
return res.status(409).json({ erro: 'Pedido concorrente duplicado em execução.' });
}
await pool.query(
'INSERT INTO chaves_idempotencia (chave, estado, criado_em) VALUES ($1, $2, NOW()) ON CONFLICT (chave) DO NOTHING',
[chave, 'A_PROCESSAR']
);
// Captura a resposta final para arquivamento
const respostaOriginal = res.json.bind(res);
res.json = (corpo: any) => {
pool.query(
'UPDATE chaves_idempotencia SET estado = $1, codigo_status = $2, resposta_json = $3, concluido_em = NOW() WHERE chave = $4',
['CONCLUIDO', res.statusCode, corpo, chave]
).finally(() => libertar());
return respostaOriginal(corpo);
};
next();
}
Ao isolares estas dependências com Docker, todo o ambiente de testes reflete exatamente o comportamento dos teus servidores finais.
Estrutura de Base de Dados e Proteção Contra Alteração de Dados
No PostgreSQL, a tua tabela de idempotência deve validar não só a chave, mas também o conteúdo do pedido:
CREATE TABLE chaves_idempotencia (
chave VARCHAR(255) PRIMARY KEY,
utilizador_id UUID NOT NULL,
caminho_pedido VARCHAR(255) NOT NULL,
hash_parametros CHAR(64) NOT NULL, -- Hash SHA-256 dos dados enviados
estado VARCHAR(32) NOT NULL, -- 'A_PROCESSAR', 'CONCLUIDO', 'FALHOU'
codigo_status INTEGER,
resposta_json JSONB,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
concluido_em TIMESTAMPTZ
);
CREATE INDEX idx_chaves_idempotencia_criado_em ON chaves_idempotencia (criado_em);
Repara na importância do campo hash_parametros. Se um cliente com problemas de rede enviar a mesma Idempotency-Key, mas o valor da compra for alterado de 20€ para 500€, a tua API calcula o hash SHA-256 do corpo do pedido e deteta a divergência. Nesse caso, rejeitas a requisição com 422 Unprocessable Entity para impedir fraudes ou manipulação acidental. Podes analisar padrões complementares deste método no GitHub.
Boas Práticas Operacionais: Prazos de Expiração e Respostas a Erros
Para manteres a consistência financeira dos teus websites de alta performance segundo as regras operacionais da WebXtek, aplica estas recomendações:
- Define Prazos de Expiração Razoáveis: Não guardes chaves de idempotência para sempre. Um prazo entre 24 e 48 horas é suficiente para cobrir repetições de rede. Podes limpar registos antigos com uma tarefa agendada no cron.
- Usa Jitter Aleatório no Frontend: Se o frontend receber um
409 Conflict, não deve tentar de novo de imediato. Programa uma espera exponencial com variação aleatória (jitter) entre 300ms e 1500ms para evitar sobrecarregar a base de dados. - Guarda Também as Falhas de Saldo: Se o cartão do cliente for recusado por falta de saldo, isso é uma resposta definitiva. Deves arquivar o erro
402 Payment Requiredassociado à chave, para que cliques repetidos não continuem a bater na API do banco nem bloqueiem a conta do cliente por suspeita de fraude.
Com a combinação de chaves de idempotência e locks atómicos no Redis, eliminas em definitivo o receio de cobranças duplicadas e garantes a máxima fiabilidade financeira na tua aplicação.
Na prática, aplicas exatamente estes padrões operacionais em setores exigentes como saas empresarial b2b e especialistas em devops e arquitetura cloud. Quando alinhas a tua estrutura através de web apps e websites de alta performance, transformas boas práticas técnicas numa vantagem competitiva real e mensurável para o teu negócio.
[+] [ INDEX_DIRECTORY ] 13 tópicos ▼
- O Pesadelo do Duplo Clique: Como a Rede Móvel Duplica Pagamentos
- Anatomia da Idempotência: Chaves Únicas, Estados e Bloqueios Atómicos
- Como Implementar Locks Atómicos no Redis e Cache em PostgreSQL com Node.js
- Estrutura de Base de Dados e Proteção Contra Alteração de Dados
- Boas Práticas Operacionais: Prazos de Expiração e Respostas a Erros
- Autor
- Perguntas Frequentes
- O que é uma chave de idempotência numa API de pagamentos?
- Qual é a diferença entre uma chave de idempotência e um distributed lock?
- Como é que o comando SETNX do Redis garante atomicidade em concorrência?
- O que deve responder a tua API se chegar um pedido duplicado enquanto o primeiro ainda está a processar?
- Nós Relacionados
- Sequência do Caminho
Perguntas Frequentes
O que é uma chave de idempotência numa API de pagamentos?
Uma chave de idempotência é um identificador único gerado pelo cliente (normalmente um UUIDv4) enviado num cabeçalho HTTP (Idempotency-Key). O teu servidor usa essa chave para identificar repetições do mesmo pedido, garantindo que pagamentos repetidos devolvem o mesmo resultado sem voltar a cobrar o cartão.
Qual é a diferença entre uma chave de idempotência e um distributed lock?
A chave de idempotência guarda o histórico das operações e devolve a resposta gravada quando a operação já terminou. O distributed lock (como o SETNX no Redis) garante exclusão mútua em tempo real, impedindo que dois pedidos concorrentes com a mesma chave sejam executados em paralelo.
Como é que o comando SETNX do Redis garante atomicidade em concorrência?
O comando SET lock_key token NX PX 10000 grava o registo apenas se ele não existir previamente (NX) e define um prazo de expiração (PX). Como o Redis processa pedidos sequencialmente num ciclo único, a operação é matematicamente atómica e livre de race conditions.
O que deve responder a tua API se chegar um pedido duplicado enquanto o primeiro ainda está a processar?
Se chegar um segundo pedido idêntico com o lock ainda ocupado, a API deve responder com o código HTTP 409 Conflict indicando que a transação está em processamento, ou aguardar a conclusão para devolver o resultado final sem cobrar novamente.
[ RELATED_NODES ]
> INICIAR_PROJETO
Precisa de um website que transmita confiança, apareça na pesquisa e dê mais força à sua presença digital? Comece a conversa aqui.