Ir para conteúdo
Case técnico // Guia para equipes

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.

Por Gustavo Assis 8 min de leitura Atualizado em

Requisições300
Em estoque1
Pedidos aceitos

  • 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
01

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.

02

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).

  1. 01Cancelamento do pedido
  2. 02Reembolso, atendimento e custo de operação
  3. 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.

03

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 ingênua checkout-v1.js
// 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 correta checkout-v2.js
// 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.

Comparação entre a V1 e a V2: na V1, as requisições leem o estoque antes de qualquer gravação e todas passam; na V2, a conferência e a baixa acontecem juntas, uma requisição por vez, e só a primeira vende.
V1 contra V2: a mesma corrida, dois desfechos.
04

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çaPor 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.

05

Como foi feito

  1. Ambiente. Docker Compose com PostgreSQL 17 e tabelas criadas por um init.sql. Um produto, estoque 1.
  2. 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.
  3. Teste de carga. 300 requisições simultâneas com autocannon. Conferência por dois medidores independentes: respostas 2xx e linhas na tabela de pedidos.
  4. V2 correta. UPDATE condicional atômico dentro de uma transação.
  5. V3. Reserva com expiração, liberação em falha de pagamento, webhook idempotente.
  6. Retry com backoff exponencial e jitter, demonstrado contra um gateway fictício que falha nas duas primeiras chamadas.
  7. Testes automatizados e CI. npm test roda 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

Fluxo da V3: o pedido reserva a unidade por alguns minutos; a confirmação do pagamento chega por webhook com evento_id único e só é processada uma vez; sem pagamento, a reserva expira e a unidade volta ao estoque.
Reserva com expiração e webhook idempotente.
Webhook idempotente (V3) webhook.js
// 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.

06

Custo x resultado

R$ 0em licenças e infraestrutura para reproduzir o case
1 comandopara subir o banco (docker compose up -d)
4 testespassando local e no GitHub Actions

Resultado (1 unidade em estoque, 300 requisições simultâneas)

VersãoPedidos aceitosObservação
V1 ingênua300, 300, 300, 290, 300, 3, 300, 300, 300, 210 execuções registradas. Nunca foi 1.
V2 correta1Em todas as execuções. Respostas 2xx e pedidos no banco sempre iguais.

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.

Gráfico antes e depois: a V1 aceitou entre 2 e 300 pedidos nas 10 execuções, enquanto a V2 aceitou exatamente 1 pedido em todas.
Antes e depois: pedidos aceitos para 1 item em estoque.

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.

07

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.

08

Limites e próximos passos

Um case sério diz onde ele para de valer.

Limites conhecidos

  1. 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.
  2. 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.
  3. Pagamento aprovado depois de a reserva expirar não tem política implementada (reembolsar ou reativar se houver estoque).
  4. O webhook do gateway fictício não verifica assinatura. Gateways reais assinam as notificações.
  5. O retry com backoff está demonstrado à parte, não ligado às chamadas da API.
  6. O fluxo de expiração foi verificado manualmente, sem teste automatizado.
  7. O teste da V1 é probabilístico: afirma que ela vende mais de 1. Em todas as execuções registradas isso aconteceu.
  8. 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

  1. Implementar verificação de assinatura no webhook.
  2. Definir e testar a política de pagamento aprovado após expiração.
  3. Adicionar CHECK (estoque >= 0) como segunda camada de defesa, com teste.
  4. Comparar com a abordagem de travar a linha antes de ler (SELECT ... FOR UPDATE).
  5. Testar com várias instâncias da aplicação.
  6. Rodar o mesmo teste em checkouts gerados por diferentes assistentes de IA e publicar o resultado reproduzível.
09

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.

10

Fontes

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.