A última poltrona do cinema
Duas pessoas abrem o app ao mesmo tempo e veem a poltrona F7 livre. As duas tocam em 'comprar'. Cada uma consultou um mundo que era verdade no momento da consulta — mas a decisão foi tomada depois, quando o mundo já podia ter mudado. Se o sistema não fizer 'verificar e reservar' num gesto só, alguém vai assistir ao filme no colo de um estranho.
O problema: a janela entre ler e gravar
O formato é sempre o mesmo: SELECT para ler, if para decidir, UPDATE para gravar. Parece sequencial no seu código, mas o servidor atende dezenas de requisições ao mesmo tempo — e duas delas podem cruzar a mesma janela com a mesma leitura desatualizada.
É o bug mais traiçoeiro de produção porque é probabilístico: passa no teste, passa no staging, passa meses em produção — e explode exatamente no pico, quando a concorrência é máxima e o estrago é maior.
O nome técnico do estrago mais comum é lost update: a segunda escrita sobrescreve a primeira sem nunca tê-la visto. Em bancos, o nível de isolamento padrão (read committed) não protege contra isso — transação aberta não é sinônimo de segurança. As armas reais: UPDATE condicional (a decisão vai no WHERE), SELECT ... FOR UPDATE (lock pessimista), coluna de versão (lock otimista) e UNIQUE constraint (o banco arbitra quem chegou primeiro).
Quando o conflito é raro, lock otimista ganha: ninguém espera, e o perdedor ocasional apenas relê e repete. Quando a disputa é intensa (drop de ingressos, saldo de conta quente), lock pessimista ou serialização por fila evita a tempestade de retries. E note a relação com idempotência: retry sem chave é você criando uma corrida contra si mesmo.
Decidir dentro da escrita
- UPDATE ... SET saldo = saldo − 100
- WHERE id = conta AND saldo >= 100
- 0 linhas alteradas → saldo insuficiente
Checagem e escrita viram uma operação só, arbitrada pelo banco: o segundo concorrente encontra a condição falsa e não grava nada.
Onde a corrida nasce — e onde morre
Camada 1/3Aplicação
use ← →Camada 1
Aplicação
onde a janela é aberta
É no código de aplicação que o ler-decidir-gravar se espalha em três chamadas. Réplicas pioram: um lock em memória (mutex, Map de 'em processamento') só protege dentro de um processo — com duas instâncias atrás do load balancer, cada uma tem o seu.
Se violar: confiar em lock de memória num serviço com mais de uma réplica é não ter lock — a corrida só muda de endereço.
Código
// BUGADO: ler -> decidir -> gravar em três passos separados.
// Entre o SELECT e o UPDATE existe uma janela — e produção mora nela.
async function sacarBugado(db: Db, conta: string, valor: number) {
const { saldo } = await db.one("SELECT saldo FROM contas WHERE id = $1", [conta]);
if (saldo < valor) throw new Error("saldo insuficiente"); // os dois passam aqui
await db.none("UPDATE contas SET saldo = saldo - $1 WHERE id = $2", [valor, conta]);
// dois saques simultâneos de 100 numa conta com 100: ambos leem 100,
// ambos passam na checagem, ambos gravam. Saldo final: -100.
}
// CORRETO: a decisão acontece DENTRO da escrita, onde o banco é atômico.
async function sacar(db: Db, conta: string, valor: number) {
const alteradas = await db.result(
`UPDATE contas
SET saldo = saldo - $1
WHERE id = $2
AND saldo >= $1`, // checagem e escrita na mesma operação
[valor, conta]
);
if (alteradas.rowCount === 0) throw new Error("saldo insuficiente");
// o segundo saque concorrente encontra saldo < valor e altera 0 linhas.
}
// Alternativa quando o conflito é raro: lock otimista por versão.
// UPDATE ... SET saldo = $novo, versao = versao + 1
// WHERE id = $conta AND versao = $versaoLida
// -> 0 linhas alteradas = alguém mexeu antes; releia e tente de novo.
interface Db {
one(sql: string, p: unknown[]): Promise<{ saldo: number }>;
none(sql: string, p: unknown[]): Promise<void>;
result(sql: string, p: unknown[]): Promise<{ rowCount: number }>;
}Casos de uso
Double spend em carteira digital
Cenário
Um cliente com R$ 100 de saldo dispara dois saques de R$ 100 no mesmo segundo — às vezes por má fé, às vezes porque o app fez retry sozinho após um timeout.
Como o padrão entra
O saque vira UPDATE condicional com saldo >= valor no WHERE, dentro da mesma transação que registra o lançamento no ledger. O segundo saque altera zero linhas e recebe 'saldo insuficiente' — não importa quantas réplicas do serviço existam.
Trade-off: Contas muito quentes (a mesma linha disputada por centenas de operações/s) viram ponto de contenção no banco — aí a serialização por fila ou o particionamento de saldo entram na conversa.
Overselling de vagas em evento
Cenário
Um curso com 50 vagas abre inscrições às 10h. Às 10h00m03s, 300 pessoas submetem o formulário; o código checava COUNT(*) < 50 antes de inserir.
Como o padrão entra
A contagem sai do código e vira estrutura: 50 linhas de 'vaga' pré-criadas, e inscrever-se é UPDATE vagas SET dono = $1 WHERE dono IS NULL LIMIT 1 — ou uma UNIQUE em (evento, posicao). O banco arbitra; a 51ª pessoa recebe 'esgotado' em vez de um assento fantasma.
Trade-off: Modelar o recurso como linhas disputáveis exige repensar o schema — e o pico de contenção nos primeiros segundos continua exigindo atenção (fila de espera é o passo seguinte).
Armadilhas
Resolver checando 'com mais cuidado'
Checar duas vezes, adicionar um sleep, revalidar antes do UPDATE: nada disso fecha a janela — só a encurta. Janela menor é bug mais raro e mais difícil de reproduzir, o que é pior. A única correção é atomicidade: decisão e escrita na mesma operação.
Lock em memória com múltiplas réplicas
O mutex protege um processo; produção tem três. Cada réplica segura o próprio lock e a corrida continua entre elas. Coordenação entre processos exige um árbitro compartilhado: o banco, um lock distribuído, ou uma fila particionada por chave.
Confiar que 'transação' resolve sozinha
BEGIN/COMMIT não impede lost update no isolamento padrão: duas transações read committed leem o mesmo saldo e gravam em cima uma da outra sem erro algum. Transação protege atomicidade do seu conjunto de escritas — não a validade da sua leitura. Para isso: FOR UPDATE, versão ou WHERE condicional.
Criar corrida contra si mesmo com retry
Timeout + retry automático = duas execuções da mesma operação disputando o mesmo recurso. Sem chave de idempotência, o seu próprio cliente é o concorrente — e a dupla cobrança não precisa de dois usuários para acontecer.
Quando usar × quando evitar
Use quando
3- Proteja com atomicidade todo recurso finito disputado: saldo, estoque, vagas, cupom de uso único.
- Prefira lock otimista quando o conflito é raro; pessimista ou fila quando a disputa é intensa.
- Trate retry como concorrência: chave de idempotência anda junto.
Evite quando
3- Lock pesado onde não há disputa real — contenção gratuita é lentidão gratuita.
- Serialização global quando dava para serializar por chave.
- Isolamento SERIALIZABLE no banco inteiro como 'garantia' — o custo aparece antes do benefício.
Testar o que ficou
4 armadilhas de Race condition viram perguntas, com o nome do padrão escondido, misturadas com padrões parecidos.
Onde isto aparece de verdade
Você já usa isto, talvez sem saber o nome.
useEffect sem cleanupDuas requisições em voo e a resposta antiga chega por último, sobrescrevendo a nova na tela — a corrida clássica do React sem função de limpeza.duplo clique em PagarDois cliques rápidos disparam duas cobranças antes de a primeira marcar o pedido como pago — a janela entre checar e agir.if (!existe) cria() concorrenteDois processos checam que o registro não existe ao mesmo tempo e ambos o criam: o check-then-act sem lock nem restrição de unicidade.
O padrão em uma linha
Sem nome de domínio, sem cerimônia. Só a ideia.
// Duas escritas; a última ganha sem erro.
// Use versão/lock: UPDATE ... WHERE version = $vO que ele cobra
Nenhum salto a mais entre a chamada e o efeito.
- Proteger a seção crítica com lock, versão ou escrita atômica adiciona código e pontos de contenção
- Bugs de corrida são intermitentes e difíceis de reproduzir, o que encarece o diagnóstico
Não vale se: as execuções nunca tocam o mesmo estado ao mesmo tempo — sem dado disputado, não há corrida a proteger.
Onde isto aparece
Continue por aqui
- IdempotênciaExecutar a mesma operação duas vezes tem o mesmo efeito de executar uma: é o que torna o retry seguro. Em rede, onde 'não sei se foi' é resposta comum, é a diferença entre reenviar e cobrar em dobro.
- Ledger (livro-razão)Em vez de um saldo que muda, um histórico imutável de lançamentos — e o saldo é a soma. Com partidas dobradas, todo valor sai de uma conta e entra em outra, e a soma do sistema é sempre zero: dinheiro não aparece nem some sem rastro.
- Máquina de estadosDeclare os estados possíveis e as transições permitidas numa tabela explícita: tudo que não está nela é impossível por construção. É a diferença entre 'status é uma string que a gente atualiza' e um ciclo de vida com regras.