ArquiteturaIntermediário7 min de leitura6 seçõesanos 1950, Data aproximada

Race condition

Duas execuções leem o mesmo estado, decidem com base nele e gravam — e o resultado passa a depender de quem chega primeiro. É o bug que nunca aparece no teste e vende o mesmo ingresso duas vezes na sexta à noite.

Em 10 segundos

Quando duas execuções leem o mesmo estado e decidem juntas, o resultado depende da ordem de chegada — e a checagem 'if saldo >= valor' vira decoração. A cura nunca é checar com mais cuidado: é tornar checagem e escrita uma única operação atômica.

Antes de ter nome

Em circuitos, a corrida é física: dois sinais disputam qual chega primeiro. No software, a disputa é entre threads ou requisições pela mesma memória ou linha.

  • #concorrencia
  • #atomicidade
  • #locks
  • #transacoes
  • #double-spend
Analogia

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.

01Conceito

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.

Sem o padrão × com o padrão

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.

A correção não é checar melhor — é eliminar a distância entre a checagem e a escrita, movendo a decisão para onde a atomicidade existe.
02Anatomia

Onde a corrida nasce — e onde morre

Camada 1/3Aplicação

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.

03Implementaçã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 }>;
}
04No mundo real

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

05Cuidado

Armadilhas

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

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

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

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

06Decisão

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 = $v

O 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