300 compras para 1 item em estoque: como evitar venda duplicada
Uma camiseta, 300 tentativas de compra ao mesmo tempo. A versão ingênua vendeu entre 2 e 300 vezes. A versão correta vendeu 1, em todas as execuções.
- Venda válida
- Venda duplicada
- Recusada por falta de estoque
Ilustração. A V1 sorteia um dos 10 resultados reais registrados no teste de carga. O teste de verdade roda no repositório.
- requisições simultâneas
- 300
- unidade em estoque
- 1
- pedidos aceitos pela V1, conforme a execução
- 2 a 300
- pedido aceito pela V2, em todas as execuções
- 1
Por que este material existe
Este conteúdo tem dois usos. Primeiro, é um guia para equipes que constroem ou mantêm lojas virtuais e precisam de um jeito claro de explicar e verificar o problema da venda duplicada. Segundo, é um case técnico executado por mim, com repositório, testes automatizados e integração contínua públicos, para que qualquer pessoa reproduza o resultado.
O critério foi simples: o resultado tem que ser um número, não uma opinião. Cada afirmação da página aponta para um teste ou para uma fonte. Onde algo não foi provado, está escrito.
O motivo: venda duplicada é dinheiro perdido
Quando duas pessoas compram a última unidade, uma delas recebe um cancelamento. Isso vira reembolso, atendimento, custo de operação e avaliação ruim. Em marketplaces, pode virar penalidade na conta da loja (veja a fonte da Omie ao final).
- 01Cancelamento do pedido
- 02Reembolso, atendimento e custo de operação
- 03Avaliação ruim e, em marketplaces, possível penalidade
O problema costuma passar despercebido porque funciona perfeitamente com uma pessoa por vez. Só aparece quando muita gente compra junto, que é justamente o dia de maior faturamento.
A lógica, em duas versões
Para lojistas e times de negócio
Imagine uma camiseta na prateleira e 300 pessoas chegando ao balcão no mesmo segundo.
No sistema ingênuo, o atendente faz três coisas separadas: olha a prateleira, confirma que tem camiseta e depois risca uma do caderno. Como as 300 olham a prateleira antes de qualquer uma riscar o caderno, todas veem a camiseta e todas passam. O sistema vende a mesma camiseta várias vezes.
No sistema correto, olhar e riscar viram um gesto só, feito um cliente por vez. O primeiro leva a camiseta. Para os outros, a prateleira já está vazia no momento de checar.
Mais duas proteções: a camiseta fica reservada por alguns minutos enquanto o cliente paga (se não pagar, volta para a prateleira), e o aviso do gateway de pagamento, mesmo repetido várias vezes, só vale uma vez.
Para devs
A V1 faz read, check, write em três consultas. Entre a leitura e a gravação existe uma janela: N requisições leem estoque = 1 e todas gravam 0, mas cada uma insere um pedido.
A V2 move a condição para dentro do UPDATE. No Read Committed, quem encontra a linha já em alteração por outra transação espera ela terminar e reavalia o WHERE sobre a versão atualizada. Só a primeira encontra estoque > 0. O rowCount informa se a venda aconteceu.
A V3 acrescenta reserva com expiração (job a cada 5 s, liberação condicionada a status = 'reservado') e webhook idempotente com evento_id como chave primária. O retry com backoff e jitter só é seguro porque o receptor é idempotente.
O código que muda tudo
// V1: três idas separadas ao bancoconst { rows } = await pool.query( 'SELECT estoque FROM produtos WHERE id = $1', [id]); // 1) lêif (rows[0].estoque <= 0) return res.status(409).json({ erro: 'sem estoque' }); // 2) confereawait pool.query( 'UPDATE produtos SET estoque = $1 WHERE id = $2', [rows[0].estoque - 1, id]); // 3) gravaawait pool.query('INSERT INTO pedidos (produto_id) VALUES ($1)', [id]); Entre ler e gravar existe uma janela. É por ela que as 300 requisições passam.
// V2: conferir e baixar numa única operação, dentro de uma transaçãoawait client.query('BEGIN');const baixa = await client.query( 'UPDATE produtos SET estoque = estoque - 1 WHERE id = $1 AND estoque > 0', [id]);if (baixa.rowCount === 0) { // 0 linhas alteradas = acabou await client.query('ROLLBACK'); return res.status(409).json({ erro: 'sem estoque' });}await client.query('INSERT INTO pedidos (produto_id) VALUES ($1)', [id]);await client.query('COMMIT'); Conferir e baixar viram uma única operação. rowCount igual a 0 significa que acabou.
Infraestrutura: o que escolhi e por que
A regra foi priorizar ferramentas gratuitas e reproduzíveis. Qualquer pessoa clona o repositório e roda docker compose up -d, npm install e npm test.
| Peça | Por quê | Alternativa paga ou avançada |
|---|---|---|
| Node.js 22 | Linguagem que quase todo time web já domina. O foco fica na lógica, não na ferramenta. | Não há necessidade. |
| PostgreSQL 17 (Docker Compose) | Controle de concorrência por trava de linha e ambiente idêntico em qualquer máquina, com um comando. | Banco gerenciado (por exemplo AWS RDS, Supabase, Neon): backup, alta disponibilidade e escala sem você administrar. Vários têm plano gratuito, confira as condições atuais. |
| autocannon | Dispara as 300 requisições simultâneas. É uma biblioteca Node e entra no próprio teste automatizado. | Grafana Cloud k6 ou similares: carga distribuída por várias regiões e relatórios prontos, úteis para volumes muito maiores. |
| node:test (runner nativo) | Sem dependência extra. O teste afirma a regra de negócio: no máximo 1 venda para 1 item. | Vitest e Jest são gratuitos e trazem mais recursos. Não há versão paga necessária. |
| GitHub Actions | Roda o teste numa máquina limpa a cada envio. Prova que não é "funciona na minha máquina". | Planos pagos do GitHub ampliam os minutos de execução. Confira os limites do seu plano. |
Credenciais do docker-compose.yml servem só para teste local. Nunca use em produção.
Como foi feito
- Ambiente. Docker Compose com PostgreSQL 17 e tabelas criadas por um
init.sql. Um produto, estoque 1. - V1 ingênua. O checkout como um tutorial comum o escreveria, sem nenhum atraso artificial. Se o bug aparece, aparece com o código normal.
- Teste de carga. 300 requisições simultâneas com autocannon. Conferência por dois medidores independentes: respostas 2xx e linhas na tabela de pedidos.
- V2 correta. UPDATE condicional atômico dentro de uma transação.
- V3. Reserva com expiração, liberação em falha de pagamento, webhook idempotente.
- Retry com backoff exponencial e jitter, demonstrado contra um gateway fictício que falha nas duas primeiras chamadas.
- Testes automatizados e CI.
npm testroda localmente e no GitHub Actions, em máquina limpa.
O CI ainda encontrou um defeito que a minha máquina escondia: o init.sql do repositório estava incompleto e só funcionava porque o meu banco local tinha sido criado antes. É o motivo de rodar o teste numa máquina limpa.
Reserva e webhook, em um desenho
// Webhook idempotente: o evento_id é chave primáriaconst reg = await client.query( `INSERT INTO eventos_pagamento (evento_id, pedido_id, tipo) VALUES ($1, $2, $3) ON CONFLICT (evento_id) DO NOTHING`, [evento_id, pedido_id, tipo]);if (reg.rowCount === 0) return res.json({ duplicado: true }); // já processado// ...aplica o efeito na MESMA transação do registro do evento O mesmo evento pode chegar várias vezes. O efeito acontece uma só vez.
Custo x resultado
docker compose up -d)Resultado (1 unidade em estoque, 300 requisições simultâneas)
| Versão | Pedidos aceitos | Observação |
|---|---|---|
| V1 ingênua | 300, 300, 300, 290, 300, 3, 300, 300, 300, 2 | 10 execuções registradas. Nunca foi 1. |
| V2 correta | 1 | Em todas as execuções. Respostas 2xx e pedidos no banco sempre iguais. |
Pedidos aceitos pela V1 em cada execução. A linha azul marca o único resultado correto.
O tamanho da venda duplicada muda a cada execução. É o esperado em uma condição de corrida, porque depende do momento exato em que as requisições chegam ao banco. O que não muda é que a V1 nunca vendeu apenas 1.
Não afirmo que a V2 é mais rápida. As amostras são pequenas. Afirmo apenas que a correção não deixou o sistema mais lento.
Em um ambiente real, o custo maior costuma ser o tempo de montar o ambiente, não o código. No meu caso, configurar Docker e WSL no Windows foi a etapa mais demorada.
Onde isso aparece no varejo
Este case não integra nenhuma das marcas abaixo e não afirma compatibilidade com elas. Ele mostra um mecanismo, e as marcas servem para situar onde esse mecanismo importa.
Perfis de varejista em que o risco é maior
Loja de roupas com coleção limitada
Poucas peças por tamanho e lançamentos em horário marcado. O pico de acessos coincide com o estoque mínimo.
Loja de vinhos ou produtos de nicho
Rótulos com poucas unidades e clientes que compram assim que recebem o aviso de reposição.
Loja de peças e acessórios
Itens únicos ou de baixa quantidade, vendidos na loja própria e em marketplaces ao mesmo tempo.
Pequena indústria vendendo direto
Lotes pequenos, pedidos por link de pagamento e estoque que também alimenta o ERP.
Plataformas, ERPs, gateways e marketplaces
ERPs
Concentram o estoque da operação. Se a loja online baixa o estoque sem cuidado, o saldo que o ERP mostra deixa de ser confiável.
- Bling
- Tiny (Olist)
- Omie
- TOTVS
- Sankhya
- Linx
Gateways de pagamento
Avisam a loja por webhook e podem repetir o aviso. O padrão de webhook idempotente se aplica a esse tipo de integração.
- Mercado Pago
- Pagar.me
- Asaas
- Stripe
- Cielo
- Stone
Marketplaces
Canais onde uma venda duplicada vira cancelamento, queda de reputação e, em casos extremos, penalidade na conta.
- Mercado Livre
- Amazon
- Shopee
- Magalu
- TikTok Shop
Plataformas de loja
Onde o estoque é baixado no checkout. A mesma corrida pode existir em código próprio, plugins e integrações.
- WooCommerce
- Shopify
- Nuvemshop
- VTEX
- Tray
Dois pontos com fonte: o Mercado Pago orienta usar o identificador único da notificação para controle de idempotência, e a Omie descreve a venda duplicada entre canais e seu efeito em marketplaces. Veja as fontes.
Marcas citadas pertencem aos respectivos proprietários. Citação informativa, sem vínculo, parceria ou endosso.
Limites e próximos passos
Um case sério diz onde ele para de valer.
Limites conhecidos
- Teste em uma única máquina (Windows 11, Ryzen 7 3700U, 9,9 GB de RAM) e uma única instância da aplicação. Várias instâncias não foram testadas.
- O job de expiração rodando em várias instâncias está protegido pela guarda de status, mas um desenho de produção usaria FOR UPDATE SKIP LOCKED ou um agendador.
- Pagamento aprovado depois de a reserva expirar não tem política implementada (reembolsar ou reativar se houver estoque).
- O webhook do gateway fictício não verifica assinatura. Gateways reais assinam as notificações.
- O retry com backoff está demonstrado à parte, não ligado às chamadas da API.
- O fluxo de expiração foi verificado manualmente, sem teste automatizado.
- O teste da V1 é probabilístico: afirma que ela vende mais de 1. Em todas as execuções registradas isso aconteceu.
- Uma execução inicial registrou 301 pedidos para 300 requisições. Não se repetiu em 10 execuções seguintes e a causa não foi provada.
Próximos passos
- Implementar verificação de assinatura no webhook.
- Definir e testar a política de pagamento aprovado após expiração.
- Adicionar CHECK (estoque >= 0) como segunda camada de defesa, com teste.
- Comparar com a abordagem de travar a linha antes de ler (SELECT ... FOR UPDATE).
- Testar com várias instâncias da aplicação.
- Rodar o mesmo teste em checkouts gerados por diferentes assistentes de IA e publicar o resultado reproduzível.
Perguntas frequentes
O que é venda duplicada de estoque?
É quando a loja aceita mais pedidos do que unidades existem. Acontece por uma condição de corrida: o checkout lê o saldo, confere e grava em passos separados, e várias requisições simultâneas leem o mesmo saldo antes de qualquer gravação.
Por que o checkout funciona no teste manual e falha no pico de vendas?
Com uma pessoa por vez não existe sobreposição entre leitura e gravação. Com muita gente comprando junto, as requisições se sobrepõem e todas enxergam o estoque antes de a primeira baixá-lo. Por isso o bug costuma aparecer justamente no dia de maior faturamento.
Como o UPDATE condicional evita a venda duplicada?
A condição vai para dentro do próprio UPDATE (WHERE estoque > 0). No Read Committed do PostgreSQL, quem encontra a linha em alteração por outra transação espera e reavalia o WHERE sobre a versão atualizada. Só a primeira encontra estoque disponível, e o rowCount informa se a venda aconteceu.
Preciso de Redis, fila ou lock distribuído?
No cenário testado, com uma instância da aplicação e um banco PostgreSQL, o UPDATE condicional dentro de uma transação bastou. Testar com várias instâncias da aplicação está na lista de próximos passos do case.
O que é um webhook idempotente e por que importa?
Gateways podem repetir a mesma notificação. Um webhook idempotente produz o mesmo efeito mesmo recebendo o evento várias vezes. No case, o evento_id é chave primária e o INSERT usa ON CONFLICT DO NOTHING, então o segundo aviso é reconhecido como duplicado e ignorado.
Posso reproduzir o teste na minha máquina?
Sim. Clone o repositório e rode docker compose up -d, npm install e npm test. O teste dispara 300 requisições simultâneas para 1 item em estoque e confere o resultado.
Fontes
Documentação oficial
Existe uma discussão pública na lista de documentação do PostgreSQL sobre como o trecho de Read Committed é lido em comandos com subselect. O UPDATE de linha única usado aqui não depende desse caso.