Condição de corrida: o bug que só acontece às vezes
Entenda o que é uma race condition com um exemplo prático de duas requisições decrementando estoque ao mesmo tempo, e como resolver com transação, lock ou operação atômica no banco de dados.
Tem uma categoria de bug que é especialmente frustrante de investigar: aquele que funciona perfeitamente na maioria das vezes, mas de vez em quando, sem padrão aparente, produz um resultado errado. Estoque que fica negativo, cupom usado duas vezes, saldo que não bate. Boa parte desses casos tem o mesmo culpado por trás: condição de corrida.
O que é uma condição de corrida
Uma condição de corrida (race condition) acontece quando duas ou mais operações acessam o mesmo dado ao mesmo tempo, e o resultado final depende da ordem exata em que cada uma termina, uma ordem que muda de execução pra execução, de forma imprevisível.
O exemplo mais direto é um decremento de estoque. Imagina esse código, que parece perfeitamente razoável à primeira vista:
async function comprarProduto(produtoId) {
const produto = await db.query("SELECT estoque FROM produtos WHERE id = $1", [produtoId]);
if (produto.estoque > 0) {
await db.query("UPDATE produtos SET estoque = estoque - 1 WHERE id = $1", [produtoId]);
return { sucesso: true };
}
return { sucesso: false, erro: "sem estoque" };
}Rodando sozinho, uma requisição de cada vez, esse código nunca falha. O problema aparece quando duas requisições chegam quase ao mesmo tempo, com o estoque em 1:
- Requisição A lê o estoque: 1.
- Requisição B lê o estoque, antes de A terminar de atualizar: também 1.
- Requisição A confirma que 1 > 0, atualiza o estoque pra 0.
- Requisição B, que já tinha lido 1 antes da atualização de A, também confirma que 1 > 0, e atualiza o estoque pra 0 de novo.
Resultado: duas vendas confirmadas, com estoque que só tinha uma unidade. O estoque final ainda mostra 0 (parece certo), mas na verdade duas pessoas pagaram por um produto que só existia em uma unidade, um erro que só o histórico de vendas revela, não o estoque final sozinho.
Por que isso é tão difícil de reproduzir
O que torna esse tipo de bug traiçoeiro é justamente a dependência de timing. Localmente, testando sozinho, uma requisição sempre termina bem antes da próxima começar, então o bug nunca aparece. Ele só se manifesta quando duas requisições realmente coincidem no tempo, o que em produção, com tráfego real e picos de acesso, acontece com frequência suficiente pra causar prejuízo real, mesmo sendo raro o suficiente pra nunca aparecer num teste manual.
Resolvendo com transação e lock
A solução mais direta é garantir que, entre ler o estoque e atualizar ele, nenhuma outra operação consiga mexer no mesmo registro. Isso se chama lock, e a forma mais comum de aplicar é dentro de uma transação de banco, usando SELECT ... FOR UPDATE:
async function comprarProduto(produtoId) {
return db.transaction(async (trx) => {
const produto = await trx.query(
"SELECT estoque FROM produtos WHERE id = $1 FOR UPDATE",
[produtoId],
);
if (produto.estoque <= 0) {
return { sucesso: false, erro: "sem estoque" };
}
await trx.query("UPDATE produtos SET estoque = estoque - 1 WHERE id = $1", [produtoId]);
return { sucesso: true };
});
}O FOR UPDATE trava a linha lida até a transação terminar. Se a requisição B tentar ler o mesmo produto enquanto a transação de A ainda está em andamento, ela fica esperando até A terminar (seja confirmando ou desfazendo a mudança), garantindo que B sempre leia o valor já atualizado, nunca o valor antigo que A já estava prestes a mudar.
Resolvendo com operação atômica
Uma alternativa, muitas vezes mais simples, é evitar o padrão "ler, decidir, escrever" inteiramente, e deixar o próprio banco fazer a checagem e a atualização numa única operação atômica:
UPDATE produtos
SET estoque = estoque - 1
WHERE id = $1 AND estoque > 0
RETURNING estoque;Aqui não existe uma leitura separada seguida de uma decisão no código da aplicação. A condição estoque > 0 e a atualização acontecem como uma coisa só, garantida pelo próprio banco de dados. Se a query não retornar nenhuma linha, significa que o estoque já estava zerado no momento exato da atualização, sem brecha nenhuma pra duas requisições passarem pela mesma condição ao mesmo tempo.
Onde mais isso aparece
Estoque é o exemplo mais didático, mas o mesmo padrão de "ler, decidir, escrever" espalhado sem proteção aparece em qualquer lugar que envolve contagem ou estado compartilhado: cupom de desconto com limite de uso, vaga limitada num evento, saldo de carteira digital, até em controle de like duplicado numa postagem. Qualquer situação onde duas requisições concorrentes poderiam ler o mesmo estado "antigo" antes de qualquer uma escrever o novo é candidata a esse tipo de bug.
Fechando
Condição de corrida não é sobre escrever código errado de forma óbvia, o código funciona perfeitamente sozinho. O problema aparece só quando duas execuções coincidem no tempo, o que é exatamente o motivo de ser tão difícil de notar em desenvolvimento e tão caro quando aparece em produção. A defesa não é testar mais vezes na mão, é estruturar a operação crítica como atômica desde o início, seja com lock explícito numa transação, seja deixando a checagem e a escrita acontecerem como uma coisa só direto no banco.
Direto na sua
caixa de entrada.
Um aviso por e-mail sempre que eu publicar um post novo. Sem spam, sem newsletter chata, só isso.
Leia também
- Do zero ao ar: como criar e publicar um site completo28 de setembro de 2026 · 3 min
- Otimização de imagem no Next.js com next/image27 de setembro de 2026 · 4 min
- Load balancing: como distribuir requisição entre várias instâncias26 de setembro de 2026 · 5 min
- PWA: transforme seu site num app instalável24 de setembro de 2026 · 5 min