Blog

Estoque negativo: a condição de corrida entre duas compras do último item

A cafeteira em promoção tinha trinta unidades no estoque. Às nove horas da noite, o e-mail da campanha saiu para quatrocentos mil clientes e, em onze minutos, a loja registrou trinta e quatro pedidos pagos. O painel mostrava o saldo em menos quatro. O time conferiu o código e encontrou a verificação no lugar certo: antes de gravar o pedido, o sistema lia o saldo e só seguia se houvesse unidade disponível. Todos os testes passavam, a revisão de código tinha aprovado e, em meses de operação normal, o problema nunca tinha aparecido. Os quatro clientes excedentes receberam um e-mail de desculpas e o estorno, dois abriram reclamação pública e o marketplace parceiro suspendeu o anúncio por venda sem estoque. O defeito não estava na regra, estava no intervalo entre ler o saldo e gravar a baixa, um intervalo de poucos milissegundos que só importa quando duas pessoas compram a mesma coisa ao mesmo tempo. Este artigo mostra como essa corrida acontece, por que nem o nível de isolamento padrão nem o ORM protegem você dela, como fazer a baixa ser atômica no próprio banco, como reservar estoque durante o pagamento sem prender unidades para sempre, como tratar pedidos com vários itens e produtos muito disputados, e como provar com um teste que a corrida sumiu.

2026-09-30 / Arquitetura / 17 min

01

Como duas compras corretas vendem a mesma unidade

O fluxo de compra mais comum tem três passos: ler o saldo, decidir na aplicação se dá para vender e gravar a baixa. Cada passo está certo isoladamente. O problema é que, entre o primeiro e o terceiro, o banco não promete que o saldo continue o mesmo. Em uma requisição por vez, isso nunca aparece. Com duas requisições chegando no mesmo milissegundo, as duas leem o mesmo saldo, as duas passam na verificação e as duas gravam.

// Versao com a corrida: le o saldo, decide na aplicacao e grava depois
async function venderComCorrida(db, produtoId, quantidade) {
  const { rows } = await db.query(
    'SELECT disponivel FROM estoque WHERE produto_id = $1',
    [produtoId],
  );
  if (rows[0].disponivel < quantidade) throw new Error('sem estoque');

  // Entre o SELECT acima e o UPDATE abaixo, outra sessao pode ter vendido
  // a mesma unidade. As duas passaram pela verificacao com o mesmo saldo.
  await db.query(
    'UPDATE estoque SET disponivel = disponivel - $2 WHERE produto_id = $1',
    [produtoId, quantidade],
  );
}
Sessão 1 (pedido A)                      Sessão 2 (pedido B)
--------------------                      --------------------
SELECT disponivel      -> 1
                                          SELECT disponivel      -> 1
1 >= 1, pode vender
                                          1 >= 1, pode vender
UPDATE disponivel - 1
COMMIT                 (saldo = 0)
                                          UPDATE disponivel - 1
                                          COMMIT                 (saldo = -1)

A janela entre o SELECT e o UPDATE parece pequena, mas ela inclui tudo que a aplicação faz no meio: calcular frete, aplicar cupom, consultar antifraude, chamar o gateway de pagamento. No incidente da cafeteira, o código lia o saldo no início do checkout e só gravava a baixa depois da resposta do gateway, cerca de um segundo e meio depois. Com quarenta compradores por segundo disputando as últimas unidades, a chance de duas sessões caírem na mesma janela deixou de ser rara e virou certeza.

Existe uma variante ainda pior. Se o UPDATE grava o valor calculado pela aplicação, como SET disponivel = 0, em vez de subtrair no banco, as duas sessões gravam zero. O estoque não fica negativo, o painel mostra um saldo aparentemente correto e duas unidades foram vendidas com uma só na prateleira. É a atualização perdida, e ela é a forma mais comum quando o código usa um ORM que carrega a entidade, altera o campo em memória e chama save. O estoque negativo pelo menos avisa. A atualização perdida só aparece no inventário físico.

02

Por que o isolamento padrão e o ORM não protegem você

É comum imaginar que colocar tudo dentro de uma transação resolve. Não resolve. O PostgreSQL, o MySQL com InnoDB e a maioria dos bancos gerenciados usam por padrão o nível READ COMMITTED ou REPEATABLE READ, e nenhum dos dois transforma um SELECT comum em uma trava. A transação garante que as suas escritas entram juntas ou não entram, e não que o valor que você leu continua valendo quando você escreve.

AbordagemImpede a venda duplicada?Custo e armadilha
SELECT, verificação na aplicação e UPDATE com valor calculadoNão. Gera atualização perdida: saldo zero e duas vendasÉ o padrão de ORM com carregar, alterar e salvar
SELECT, verificação na aplicação e UPDATE com disponivel - 1Não. Gera estoque negativoParece seguro porque a subtração é no banco, mas a decisão foi tomada sobre um valor velho
SELECT ... FOR UPDATE e depois UPDATESimA trava fica presa durante tudo que a aplicação faz entre as duas instruções; se isso inclui o gateway, a fila para no produto disputado
Coluna de versão com UPDATE ... WHERE versao = $vSimSob disputa, quase todas as tentativas falham e precisam repetir a leitura; funciona para edição de cadastro, sofre em promoção
Transação SERIALIZABLESimO banco aborta uma das sessões com erro 40001 e a aplicação precisa repetir a transação inteira; sem o laço de retentativa, vira erro para o cliente
UPDATE condicional: WHERE disponivel >= $qSimUma instrução, trava pelo tempo mínimo, sem retentativa; é a base recomendada

O ponto comum das abordagens que funcionam é que a verificação e a escrita passam a ser uma coisa só para o banco. Ou a linha fica travada entre as duas, ou o banco detecta o conflito e manda repetir, ou a verificação vai para dentro do próprio UPDATE. A última é a mais barata porque não depende de a aplicação fazer nada certo depois: se a condição não vale mais, a linha simplesmente não é atualizada.

03

A baixa atômica: verificar e decrementar na mesma instrução

Em vez de perguntar ao banco quanto tem e depois mandar subtrair, a aplicação pede diretamente: subtraia se ainda houver o suficiente. O número de linhas afetadas é a resposta. Uma linha significa que a unidade é sua. Zero linhas significa que não havia saldo no momento da escrita, qualquer que tenha sido o saldo lido antes.

CREATE TABLE estoque (
  produto_id  bigint  PRIMARY KEY,
  disponivel  integer NOT NULL,
  reservado   integer NOT NULL DEFAULT 0,
  CONSTRAINT disponivel_nao_negativo CHECK (disponivel >= 0),
  CONSTRAINT reservado_nao_negativo  CHECK (reservado >= 0)
);

CREATE TABLE reservas (
  id          uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  pedido_id   uuid        NOT NULL,
  produto_id  bigint      NOT NULL REFERENCES estoque (produto_id),
  quantidade  integer     NOT NULL CHECK (quantidade > 0),
  status      text        NOT NULL DEFAULT 'ativa'
              CHECK (status IN ('ativa', 'confirmada', 'liberada')),
  expira_em   timestamptz NOT NULL,
  criada_em   timestamptz NOT NULL DEFAULT now(),
  UNIQUE (pedido_id, produto_id)
);

CREATE INDEX reservas_ativas_por_expiracao
  ON reservas (expira_em)
  WHERE status = 'ativa';

-- A baixa atomica: verificacao e decremento na mesma instrucao.
-- Em READ COMMITTED, a segunda sessao espera a trava da linha e, quando a
-- primeira confirma, reavalia o WHERE sobre a versao nova: 0 >= 1 e falso,
-- nenhuma linha e atualizada e a venda e recusada.
UPDATE estoque
   SET disponivel = disponivel - $2,
       reservado  = reservado + $2
 WHERE produto_id = $1
   AND disponivel >= $2
RETURNING disponivel;

O que torna isso correto é o comportamento do UPDATE sob concorrência. Quando a segunda sessão tenta atualizar a mesma linha, ela espera a trava da primeira. Assim que a primeira confirma, o PostgreSQL não usa a versão da linha que a segunda viu no início: ele relê a versão recém-confirmada e reavalia o WHERE sobre ela. O saldo agora é zero, a condição disponivel >= 1 é falsa e o UPDATE termina sem atualizar nada. No MySQL com InnoDB, o UPDATE faz uma leitura atual da linha travada e chega ao mesmo resultado. A trava dura só o tempo da instrução e da transação que a contém, e não o tempo de toda a lógica da aplicação.

As restrições CHECK (disponivel >= 0) e CHECK (reservado >= 0) são a última linha de defesa. Elas não substituem o UPDATE condicional, porque transformariam cada venda recusada em uma exceção de violação de restrição, mas garantem que nenhum caminho do sistema, seja um script de ajuste, um endpoint antigo que ninguém lembrava ou uma integração com o ERP, consiga gravar estoque negativo. Se alguém esquecer a condição no WHERE, o banco recusa a escrita em vez de aceitar em silêncio. Adicionar essas restrições a uma tabela que já tem saldos negativos exige corrigir os dados antes, e a própria falha ao criar a restrição é um bom inventário de quantos produtos já foram vendidos a mais.

04

Reserva com expiração: segurar a unidade durante o pagamento

Baixar o estoque só depois do pagamento aprovado reabre a corrida em outro lugar: o cliente preenche o cartão, o gateway aprova e só então o sistema descobre que a unidade acabou, com o dinheiro já capturado. Baixar antes, sem prazo, cria o problema oposto: carrinhos abandonados e pagamentos recusados prendem unidades que nunca serão vendidas, e a promoção termina com produto parado e página mostrando esgotado. A solução é separar o saldo em dois números, disponível e reservado, e dar prazo à reserva.

  1. Ao iniciar o pagamento, o UPDATE condicional move a quantidade de disponível para reservado e grava uma linha em reservas com expira_em. Essa é a única etapa que disputa a unidade.
  2. Com o pagamento aprovado, a reserva vira confirmada e a quantidade sai do reservado. Não há mais disputa, porque a unidade já é do pedido.
  3. Se o prazo vence sem pagamento, um job marca a reserva como liberada e devolve a quantidade ao disponível.
  4. Pagamento recusado ou carrinho cancelado pelo cliente liberam a reserva na hora, pelo mesmo caminho do job, sem esperar o prazo.
import pg from 'pg';

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 20 });

export class EstoqueInsuficiente extends Error {
  constructor(produtoId) {
    super('estoque insuficiente para o produto ' + produtoId);
    this.name = 'EstoqueInsuficiente';
    this.produtoId = produtoId;
  }
}

const RESERVA_TTL = '15 minutes';

// Soma itens repetidos e ordena por produto_id. Dois pedidos com os mesmos
// produtos travam as linhas de estoque na mesma ordem e nao entram em deadlock.
function normalizar(itens) {
  const soma = new Map();
  for (const { produtoId, quantidade } of itens) {
    if (!Number.isInteger(quantidade) || quantidade <= 0) {
      throw new RangeError('quantidade invalida para o produto ' + produtoId);
    }
    soma.set(produtoId, (soma.get(produtoId) || 0) + quantidade);
  }
  return [...soma.entries()]
    .map(([produtoId, quantidade]) => ({ produtoId, quantidade }))
    .sort((a, b) => a.produtoId - b.produtoId);
}

// Reserva todos os itens do pedido ou nenhum. A transacao nao chama nada
// externo: quanto menos tempo a trava da linha fica presa, mais vendas por
// segundo o produto mais disputado aguenta.
export async function reservarPedido(pedidoId, itens) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    for (const { produtoId, quantidade } of normalizar(itens)) {
      const { rowCount } = await client.query(
        'UPDATE estoque SET disponivel = disponivel - $2, reservado = reservado + $2 ' +
          'WHERE produto_id = $1 AND disponivel >= $2',
        [produtoId, quantidade],
      );
      if (rowCount === 0) throw new EstoqueInsuficiente(produtoId);
      await client.query(
        'INSERT INTO reservas (pedido_id, produto_id, quantidade, expira_em) ' +
          'VALUES ($1, $2, $3, now() + $4::interval)',
        [pedidoId, produtoId, quantidade, RESERVA_TTL],
      );
    }
    await client.query('COMMIT');
    return { jaReservado: false };
  } catch (err) {
    await client.query('ROLLBACK').catch(() => {});
    // Retentativa do mesmo pedido: a UNIQUE (pedido_id, produto_id) falha,
    // o ROLLBACK devolve o que esta tentativa baixou e a reserva original fica.
    if (err.code === '23505') return { jaReservado: true };
    throw err;
  } finally {
    client.release();
  }
}

// Pagamento aprovado: a reserva vira venda e a unidade sai do reservado.
// Devolve os itens confirmados; se vier menos do que o pedido tem, parte da
// reserva expirou antes do pagamento e o chamador precisa reservar de novo
// ou estornar.
export async function confirmarPedido(pedidoId) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rows } = await client.query(
      "UPDATE reservas SET status = 'confirmada' " +
        "WHERE pedido_id = $1 AND status = 'ativa' " +
        'RETURNING produto_id, quantidade',
      [pedidoId],
    );
    rows.sort((a, b) => Number(a.produto_id) - Number(b.produto_id));
    for (const r of rows) {
      await client.query(
        'UPDATE estoque SET reservado = reservado - $2 WHERE produto_id = $1',
        [r.produto_id, r.quantidade],
      );
    }
    await client.query('COMMIT');
    return rows;
  } catch (err) {
    await client.query('ROLLBACK').catch(() => {});
    throw err;
  } finally {
    client.release();
  }
}

Três detalhes fazem esse código aguentar produção. A restrição UNIQUE (pedido_id, produto_id) torna a reserva idempotente: se o cliente clica duas vezes ou o front repete a requisição por timeout, a segunda tentativa falha na inserção, o ROLLBACK devolve o que ela tinha baixado e a função responde que o pedido já estava reservado. A transação não faz nenhuma chamada externa, porque cada milissegundo com a linha travada é um milissegundo em que ninguém mais compra aquele produto. E confirmarPedido só confirma reservas ainda ativas, então a corrida entre o pagamento que chega e o job que expira tem um vencedor só, decidido pela trava da linha em reservas.

-- Job a cada 30 segundos: devolve ao disponivel o que foi reservado e nao pago.
-- SKIP LOCKED pula reservas que um confirmarPedido esta travando agora; a
-- condicao status = 'ativa' garante que so um dos dois vence.
-- Se o banco abortar a execucao por deadlock (40P01), o job repete no ciclo seguinte.
WITH expiradas AS (
  UPDATE reservas
     SET status = 'liberada'
   WHERE id IN (
     SELECT id
       FROM reservas
      WHERE status = 'ativa'
        AND expira_em < now()
      ORDER BY expira_em
      LIMIT 500
      FOR UPDATE SKIP LOCKED
   )
  RETURNING produto_id, quantidade
),
por_produto AS (
  SELECT produto_id, sum(quantidade)::int AS qtd
    FROM expiradas
   GROUP BY produto_id
)
UPDATE estoque e
   SET disponivel = e.disponivel + p.qtd,
       reservado  = e.reservado - p.qtd
  FROM por_produto p
 WHERE e.produto_id = p.produto_id;

O prazo da reserva é uma decisão de produto com consequência técnica. Quinze minutos cobrem o pagamento por cartão e a maior parte dos Pix. Boleto não cabe nesse modelo, porque o pagamento pode levar dias: nesse caso, ou a reserva tem prazo longo e o produto disputado fica preso, ou o boleto não reserva e o pedido é confirmado apenas se houver saldo na compensação, com estorno automático quando não houver. Quando confirmarPedido devolve menos itens do que o pedido tem, é esse o caminho: tentar reservar de novo o que expirou e, se não houver saldo, estornar e avisar o cliente antes que ele descubra pela falta de entrega.

05

Pedidos com vários itens e o produto que todo mundo quer

Um pedido com três produtos precisa reservar os três ou nenhum, e é por isso que reservarPedido faz tudo em uma transação e desfaz tudo se um único item falhar. Essa transação trava uma linha por produto, e duas transações que travam as mesmas linhas em ordens diferentes entram em deadlock: o pedido A trava a cafeteira e espera o moedor, o pedido B trava o moedor e espera a cafeteira. O banco detecta o ciclo, aborta um dos dois com erro e o cliente vê uma falha que não tem nada a ver com estoque. Ordenar os itens por produto_id antes de travar elimina o ciclo, porque todas as transações passam a pegar as travas na mesma sequência.

O segundo problema é a linha quente. Na promoção, milhares de sessões querem atualizar a mesma linha de estoque, e o banco só deixa uma por vez. A vazão máxima desse produto passa a ser o inverso do tempo que cada transação segura a trava. Com cinco milissegundos por reserva, o teto é de duzentas reservas por segundo naquele item, independentemente de quantos servidores de aplicação existam. Se a transação chama o gateway no meio e segura a trava por um segundo e meio, o teto cai para menos de uma venda por segundo, e o pool de conexões esgota com sessões esperando a mesma linha.

EstratégiaQuando usarO que custa
Transação curta com UPDATE condicionalQuase sempre; aguenta centenas de reservas por segundo por produtoNada além de manter chamadas externas fora da transação
Saldo fragmentado em N linhas por produtoLançamentos e promoções com milhares de reservas por segundo no mesmo itemA reserva tenta um fragmento aleatório e, se estiver vazio, os outros; o total passa a ser uma soma, e rebalancear fragmentos exige um job
Contador em memória como porta de entrada, como DECRBY no Redis com script atômicoQuando o volume de tentativas é muito maior que o estoque, como em uma venda de ingressosDuas fontes de verdade: o banco continua obrigatório para a reserva, e o contador precisa ser reconciliado quando reservas expiram
Fila de compra com um consumidor por produtoQuando é aceitável responder aguarde em vez de sim ou não na horaLatência para o cliente e uma fila para operar, em troca de zero disputa no banco

Na maioria das lojas, a primeira linha da tabela é suficiente. A cafeteira recebeu quarenta tentativas por segundo no pico, muito abaixo do teto de uma transação curta. O que derrubou o fluxo não foi o volume, foi a transação que segurava a trava enquanto esperava o gateway. As outras estratégias só valem a complexidade quando a medição mostra sessões esperando a mesma linha por tempo relevante, e essa espera aparece no PostgreSQL em pg_stat_activity com wait_event_type = Lock sobre a tabela de estoque.

06

Provar que a corrida sumiu e perceber quando ela volta

Um teste de unidade com banco em memória e uma chamada por vez nunca vai encontrar esse defeito, e foi exatamente por isso que ele chegou à produção. O teste que importa dispara dezenas de compras simultâneas contra um banco real, com o mesmo motor e o mesmo nível de isolamento de produção, e verifica os invariantes no final: exatamente uma venda para uma unidade, saldo zero e nenhum número negativo.

import { randomUUID } from 'node:crypto';
import { pool, reservarPedido, EstoqueInsuficiente } from './estoque.js';

const PRODUTO = 42;
const COMPRADORES = 50;

await pool.query('DELETE FROM reservas WHERE produto_id = $1', [PRODUTO]);
await pool.query(
  'INSERT INTO estoque (produto_id, disponivel) VALUES ($1, 1) ' +
    'ON CONFLICT (produto_id) DO UPDATE SET disponivel = 1, reservado = 0',
  [PRODUTO],
);

// Cinquenta compradores disputando a ultima unidade ao mesmo tempo.
const resultados = await Promise.all(
  Array.from({ length: COMPRADORES }, () =>
    reservarPedido(randomUUID(), [{ produtoId: PRODUTO, quantidade: 1 }])
      .then(() => 'ok')
      .catch((err) => {
        if (err instanceof EstoqueInsuficiente) return 'sem_estoque';
        throw err;
      }),
  ),
);

const vendidos = resultados.filter((r) => r === 'ok').length;
const { rows } = await pool.query(
  'SELECT disponivel, reservado FROM estoque WHERE produto_id = $1',
  [PRODUTO],
);
console.log({ vendidos, recusados: COMPRADORES - vendidos, ...rows[0] });

await pool.end();
if (vendidos !== 1 || rows[0].disponivel !== 0 || rows[0].reservado !== 1) {
  console.error('corrida detectada: o estoque vendeu mais do que tinha');
  process.exit(1);
}

Rodando esse script contra a versão com a corrida, o resultado muda a cada execução: às vezes duas vendas, às vezes cinco, às vezes uma, o que já diz muito sobre a confiabilidade de um teste que passa uma vez. Contra a baixa atômica, ele vende uma unidade e recusa quarenta e nove em todas as execuções. Vale mantê-lo no pipeline de integração, porque a corrida volta com facilidade: basta alguém criar um endpoint novo de ajuste de estoque, um fluxo de troca ou uma importação que use o padrão de ler, decidir e salvar.

Em produção, o sinal mais confiável é comparar o saldo com o histórico. O saldo atual mais o reservado precisa ser igual ao estoque inicial menos o que foi vendido desde a última contagem. Quando os dois divergem, algum caminho gravou sem passar pela baixa atômica.

-- Divergencia entre o saldo e o historico: roda todo dia, deve voltar vazio.
-- estoque_inicial vem da ultima contagem ou do cadastro do produto.
SELECT e.produto_id,
       e.disponivel + e.reservado                         AS saldo_atual,
       i.estoque_inicial - coalesce(sum(r.quantidade), 0)  AS saldo_esperado
  FROM estoque e
  JOIN estoque_inicial i USING (produto_id)
  LEFT JOIN reservas r
         ON r.produto_id = e.produto_id
        AND r.status = 'confirmada'
        AND r.criada_em >= i.contado_em
 GROUP BY e.produto_id, e.disponivel, e.reservado, i.estoque_inicial
HAVING e.disponivel + e.reservado <> i.estoque_inicial - coalesce(sum(r.quantidade), 0);
  • Violações das restrições CHECK de estoque, registradas como erro com o endpoint de origem. Toda violação é um caminho de código que tentou vender sem a condição no WHERE.
  • Taxa de reservas recusadas por falta de saldo, por produto. Um salto indica esgotamento real, e recusas em produtos com saldo alto indicam bug.
  • Reservas liberadas por expiração em relação às confirmadas. Se passam de um terço, o prazo está curto demais para o meio de pagamento ou o checkout está perdendo clientes no caminho.
  • Tempo de espera por trava nas linhas de estoque, em p99. Ele antecipa a linha quente antes de o pool de conexões esgotar.
  • Pedidos confirmados com itens a menos do que foram reservados, que são pagamentos aprovados depois da expiração e precisam de estorno ou nova reserva.

Depois da mudança, a campanha seguinte da mesma cafeteira vendeu as quarenta unidades em quatro minutos, recusou mil e duzentas tentativas com a mensagem de esgotado antes do pagamento e não gerou nenhum estorno por falta de estoque. A consulta de divergência voltou vazia em todos os dias desde então, e as restrições CHECK barraram duas vezes um script antigo de ajuste manual que ninguém lembrava que existia.

FAQ

Perguntas frequentes

Colocar o estoque no Redis não resolve de vez a corrida?

O Redis executa um comando por vez, então um DECRBY ou um script Lua que verifica e decrementa é atômico, e a corrida dentro dele desaparece. O problema muda de lugar: o pedido, o pagamento e a reserva continuam no banco relacional, e agora há duas fontes de verdade que precisam concordar. Se o processo cai entre decrementar no Redis e gravar a reserva no banco, a unidade some. Se a reserva expira no banco e ninguém devolve ao Redis, a loja mostra esgotado com produto na prateleira. Redis faz sentido como porta de entrada para cortar tentativas quando o volume é muito maior que o estoque, com o banco ainda decidindo a reserva e um job reconciliando os dois números.

E quando o estoque vem do ERP e não do banco da loja?

Então a loja precisa de uma cópia do saldo que ela controla, e o ERP passa a ser a fonte do estoque físico, não da decisão de vender. A loja faz a baixa atômica e a reserva na própria tabela e envia ao ERP os pedidos confirmados. O ERP manda de volta as entradas de mercadoria e as contagens, que ajustam o saldo local por uma operação de incremento, e não sobrescrevendo o número, porque sobrescrever apaga as reservas feitas entre a leitura e a gravação do ERP. Consultar o ERP a cada compra para decidir se há saldo reintroduz a mesma corrida, agora com a latência de uma chamada externa no meio.

Vale a pena permitir vender um pouco além do estoque de propósito?

Às vezes vale, e a decisão é de negócio, não de banco. Marketplaces e lojas com reposição rápida aceitam vender algumas unidades a mais porque o custo de um pedido com atraso é menor do que o de mostrar esgotado para quem ia comprar. Se for essa a escolha, faça isso de forma explícita: uma coluna de limite de venda além do saldo por produto, usada na condição do UPDATE, como disponivel + limite_extra >= $q, e um fluxo de atendimento pronto para os pedidos que caírem nessa faixa. O que não pode acontecer é vender a mais por acidente, sem limite e sem saber quantos pedidos foram afetados.

Estoque negativo não é um bug de regra, é uma decisão tomada sobre um valor velho

Ler o saldo, verificar na aplicação e gravar depois funciona em todos os testes e falha exatamente quando a loja mais vende. A correção é mover a verificação para dentro da escrita, com um UPDATE condicional que só baixa se ainda houver saldo, restrições CHECK como última linha de defesa, reserva com prazo para segurar a unidade durante o pagamento, itens travados sempre na mesma ordem e transações curtas sem chamadas externas. Um teste com compras simultâneas contra o banco real prova a correção, e a comparação diária entre saldo e histórico avisa quando algum caminho novo volta a vender sem passar por ela. Posso revisar o fluxo de checkout e de estoque da sua operação, implementar a baixa atômica e a reserva com expiração e montar os testes e o monitoramento que impedem a corrida de voltar.