Blog

Deadlock em produção: quando duas transações corretas se travam uma na outra

Uma carteira digital que faz repasses a vendedores começou a receber um chamado intermitente: a transferência falhou, tente de novo. Os logs da API mostravam ERROR: deadlock detected em cerca de 3% das transferências nos horários de pico, e no fechamento do mês, quando o job de repasses rodava junto com os pagamentos dos clientes, chegava a 11%. O código estava correto: cada transferência abria uma transação, debitava uma conta, creditava outra e confirmava. Testada sozinha, nunca falhava. O problema só existe quando duas transações corretas, cada uma isoladamente, precisam das mesmas linhas em ordens opostas. Este artigo mostra como o banco detecta e resolve esse impasse, como reproduzir o problema, por que ordenar a aquisição dos locks o elimina na raiz, quais outras construções comuns também o provocam, como repetir a transação com segurança quando mesmo assim ela for escolhida como vítima e como enxergar e provar tudo isso.

2026-10-04 / Arquitetura / 15 min

01

O que é um deadlock e o que o banco faz quando encontra um

Um deadlock é uma espera circular: a transação A segura um lock que B precisa e B segura um lock que A precisa. Nenhuma das duas pode avançar, e esperar mais não resolve nada. Não é lentidão nem falta de recurso, é um impasse lógico entre transações que fazem exatamente o que deveriam fazer. Por isso ele não aparece em teste unitário, em ambiente de desenvolvimento nem com tráfego baixo: depende de duas transações sobrepostas no tempo, tocando as mesmas linhas em ordens diferentes.

Tempo  Transacao A (Ana paga Bia)           Transacao B (Bia paga Ana)
t1     UPDATE contas ... WHERE id = 1
       (trava a linha da Ana)
t2                                          UPDATE contas ... WHERE id = 2
                                            (trava a linha da Bia)
t3     UPDATE contas ... WHERE id = 2
       (espera B soltar a linha da Bia)
t4                                          UPDATE contas ... WHERE id = 1
                                            (espera A soltar a linha da Ana)
t5     Ninguem avanca: A espera B e B espera A.
       Apos deadlock_timeout (1s) o Postgres aborta uma delas:
       ERROR: deadlock detected (SQLSTATE 40P01)
       A outra transacao termina normalmente.

O PostgreSQL não impede o deadlock, ele o detecta. Quando uma transação espera um lock por mais de deadlock_timeout, que por padrão é 1 segundo, o banco percorre o grafo de quem espera quem. Se encontra um ciclo, aborta uma das transações do ciclo com o erro 40P01 e deixa as outras seguirem. Duas consequências práticas: o usuário da transação escolhida como vítima espera pelo menos um segundo antes de receber o erro, e a transação vítima desfaz todo o trabalho já feito, inclusive o que não tinha relação com o conflito. O MySQL com InnoDB faz o equivalente, detecta o ciclo e devolve o erro 1213, e o SQL Server escolhe uma vítima e devolve o erro 1205.

O que você observaO que está acontecendoOnde olhar
ERROR: deadlock detected, SQLSTATE 40P01Ciclo de espera detectado, esta transação foi a vítimaLog do banco, que lista os processos, as consultas e os locks envolvidos
Requisições que demoram exatamente 1 segundo a mais e depois falhamA vítima esperou deadlock_timeout antes de ser abortadaLatência p99 com degraus em torno de 1 s
Requisições lentas sem erro e sem cicloEspera longa por lock, não deadlock: alguém segura a linha por tempo demaispg_blocking_pids e idade da transação
Falha só em horário de pico ou em job em loteA janela de sobreposição só fica grande com carga ou com loteCorrelacionar os erros com a agenda dos jobs

02

Reproduzindo: duas transferências corretas em sentidos opostos

O código abaixo é o que a maioria das equipes escreve, e ele está certo para qualquer execução isolada. Ana paga Bia e, no mesmo instante, Bia paga Ana. A transação A trava a linha da Ana e depois pede a da Bia. A transação B trava a linha da Bia e depois pede a da Ana. É exatamente a linha do tempo do diagrama.

// Versao que trava: cada transacao bloqueia as contas na ordem em que chegam
export async function transferir(pool, de, para, valor) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query('UPDATE contas SET saldo = saldo - $1 WHERE id = $2', [valor, de]);
    await client.query('UPDATE contas SET saldo = saldo + $1 WHERE id = $2', [valor, para]);
    await client.query('COMMIT');
  } catch (erro) {
    await client.query('ROLLBACK');
    throw erro; // com duas transferencias cruzadas, uma chega aqui com erro.code === '40P01'
  } finally {
    client.release();
  }
}

A ordem em que os locks são adquiridos é decidida pelos parâmetros da chamada, ou seja, pelo usuário. Cada transferência impõe uma ordem diferente, e o banco não tem como saber que as duas deveriam concordar. É a mesma natureza da condição de corrida do estoque negativo: o defeito é de coordenação, não de lógica de negócio, e só se manifesta com concorrência real.

03

A correção na raiz: toda transação trava as mesmas linhas na mesma ordem

Se todas as transações que precisam das contas 1 e 2 travarem primeiro a 1 e depois a 2, uma espera circular é impossível: quem chega depois simplesmente espera quem chegou antes. O critério de ordenação pode ser qualquer um, desde que seja total e igual para todos, e o id da linha é o mais simples. O código a seguir faz isso com um único SELECT ... ORDER BY id FOR UPDATE, que adquire os locks na ordem do ORDER BY, e depois aplica as alterações com as duas linhas já protegidas.

const RETENTAVEIS = new Set(['40P01', '40001']); // deadlock_detected, serialization_failure

// Executa a funcao inteira em uma transacao e repete a transacao INTEIRA
// quando o banco a escolhe como vitima. Nada com efeito externo roda aqui dentro.
export async function comTransacao(pool, trabalho, { tentativas = 4 } = {}) {
  for (let n = 1; ; n++) {
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      const resultado = await trabalho(client);
      await client.query('COMMIT');
      return resultado;
    } catch (erro) {
      await client.query('ROLLBACK').catch(() => {});
      if (!RETENTAVEIS.has(erro.code) || n >= tentativas) throw erro;
    } finally {
      client.release(); // devolve a conexao antes de esperar
    }
    // backoff exponencial com jitter: as vitimas nao voltam todas no mesmo instante
    await new Promise((resolver) => setTimeout(resolver, Math.random() * 25 * 2 ** n));
  }
}

export function transferir(pool, de, para, valor) {
  if (de === para) throw new Error('contas_iguais');
  return comTransacao(pool, async (client) => {
    // Regra de ouro: toda transacao trava as mesmas linhas na mesma ordem (id crescente),
    // nao importa quem paga quem. Um laco de espera circular deixa de ser possivel.
    const { rows } = await client.query(
      'SELECT id, saldo FROM contas WHERE id = ANY($1) ORDER BY id FOR UPDATE',
      [[de, para]],
    );
    if (rows.length !== 2) throw new Error('conta_inexistente');
    const origem = rows.find((linha) => linha.id === de);
    if (Number(origem.saldo) < valor) throw new Error('saldo_insuficiente');

    // As duas linhas ja estao travadas por esta transacao: a ordem dos UPDATEs nao importa mais.
    await client.query('UPDATE contas SET saldo = saldo - $1 WHERE id = $2', [valor, de]);
    await client.query('UPDATE contas SET saldo = saldo + $1 WHERE id = $2', [valor, para]);
  });
}
  • O ORDER BY no SELECT FOR UPDATE é o que define a ordem dos locks. Um UPDATE de várias linhas sem esse cuidado trava na ordem em que o plano de execução varre a tabela, que não é garantida e pode mudar com um novo índice ou com estatísticas atualizadas.
  • Travar todas as linhas necessárias no início da transação, antes de qualquer decisão, reduz a janela em que outra transação consegue entrar no meio, e o saldo lido já é o saldo travado, o que também elimina a corrida de leitura seguida de escrita.
  • A ordenação precisa valer para todos os caminhos de código que tocam essas tabelas: a API, o job de repasses, o estorno, o script de correção. Um único caminho que trave em outra ordem reabre o problema.
  • Quando a transação toca tabelas diferentes, a regra se estende: escolha uma ordem global entre tabelas, por exemplo sempre pedidos antes de itens e itens antes de estoque, e documente.

04

Outras causas comuns além da ordem das contas

O exemplo da transferência é o mais didático, mas em sistemas reais o ciclo costuma estar escondido em construções que parecem inocentes. As quatro abaixo explicam a maior parte dos deadlocks que chegam ao log.

ConstruçãoPor que forma um cicloCorreção
UPDATE ou DELETE em lote sem ordem definidaDois lotes que cobrem linhas em comum percorrem a tabela em ordens diferentes e travam linhas cruzadasSELECT id ... ORDER BY id FOR UPDATE antes, ou processar em pedaços pequenos sempre em ordem de id
INSERT ... ON CONFLICT DO UPDATE em loteDuas requisições inserem os mesmos conjuntos de chaves em ordens diferentes e cada uma trava as chaves da outraOrdenar as linhas pela chave única antes de enviar o lote
Chave estrangeira com atualização no paiUm INSERT no filho trava a linha do pai com FOR KEY SHARE, e um UPDATE de coluna chave ou SELECT FOR UPDATE no pai conflita com eleTravar o pai primeiro em toda transação que mexe em pai e filho, e criar índice nas colunas de chave estrangeira para encurtar a janela de lock
Transação longa com chamada externa dentroOs locks ficam seguros enquanto a transação espera uma API de terceiros, e a janela de sobreposição cresce de milissegundos para segundosFazer a chamada externa antes ou depois da transação, nunca dentro

A última linha da tabela é a mais traiçoeira porque não é um problema de ordem, é de duração. Todo deadlock precisa que duas transações coexistam segurando locks, e quanto mais tempo cada uma segura, maior a probabilidade. Encurtar a transação, fazendo só o que precisa ser atômico dentro dela, reduz a frequência de todos os tipos de deadlock ao mesmo tempo, inclusive os que você ainda não descobriu.

05

Quando mesmo assim acontece: repetir a transação inteira, com limite

Mesmo com a ordem correta, ainda é possível ter um deadlock raro vindo de um caminho que você não controla, como um gatilho, uma extensão ou uma consulta de relatório. Os erros 40P01 e 40001 têm a propriedade de que a transação inteira foi desfeita e não deixou efeito algum, então repeti-la é seguro. O ponto essencial é repetir a transação inteira, desde o BEGIN, e não apenas o comando que falhou: depois do erro, a transação está em estado abortado e as leituras anteriores podem já não ser válidas. A função comTransacao do código acima faz exatamente isso, com no máximo quatro tentativas, backoff exponencial e jitter para que as vítimas não voltem todas no mesmo instante e formem um novo ciclo.

  • Nada com efeito externo dentro da função repetida: e-mail, chamada de API, publicação em fila ou escrita em cache rodam depois do COMMIT. Uma transação repetida três vezes que enviou três e-mails não é segura de repetir.
  • Repita apenas os códigos que significam conflito de concorrência, 40P01 e 40001. Violação de unicidade, saldo insuficiente e erro de sintaxe voltariam a falhar do mesmo jeito e só escondem o defeito.
  • Limite as tentativas e deixe o erro subir depois delas. Retry sem limite transforma um problema de ordem de locks em uma tempestade de repetições que piora a carga que o causou.
  • Meça a taxa de retry como métrica. Um retry que funciona em silêncio esconde um deadlock que deveria ter sido corrigido: o número de repetições por minuto precisa ser baixo e estável, e um aumento é um alerta.

06

Enxergar e provar: logs, métricas e um teste de concorrência

Deadlock sem observabilidade vira um erro 500 sem explicação. Com log_lock_waits ligado, o Postgres registra no log quem esperou lock além do deadlock_timeout, e a mensagem de deadlock lista os processos, as consultas e os locks do ciclo, o que normalmente aponta direto para as duas linhas de código culpadas. O contador deadlocks de pg_stat_database é um acumulado por banco: exporte-o como métrica e alerte sobre a derivada, não sobre o valor absoluto. O lock_timeout por transação complementa a defesa, porque limita a espera por uma linha mesmo quando não há ciclo, o caso de uma transação esquecida aberta.

-- postgresql.conf (ou ALTER SYSTEM): grava no log quem esperou lock por mais de deadlock_timeout
log_lock_waits = on
deadlock_timeout = 1s

-- Por transacao: falha rapido em vez de esperar indefinidamente por uma linha
SET LOCAL lock_timeout = '5s';

-- Agora: quem esta bloqueado, por quem e ha quanto tempo
SELECT a.pid,
       pg_blocking_pids(a.pid)          AS bloqueado_por,
       a.wait_event_type,
       now() - a.xact_start             AS idade_da_transacao,
       left(a.query, 80)                AS consulta
FROM pg_stat_activity a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0;

-- Acumulado: quantos deadlocks este banco ja detectou (exponha como metrica e alerte na derivada)
SELECT datname, deadlocks FROM pg_stat_database WHERE datname = current_database();

Para provar que a correção funciona, o teste precisa produzir a concorrência que o ambiente de desenvolvimento nunca produz. O teste abaixo dispara 200 transferências cruzadas ao mesmo tempo contra um banco real e exige que nenhuma falhe e que a soma dos saldos continue igual. Com a versão ingênua do início do artigo, ele falha de forma consistente com o código 40P01, o que também serve para provar que o teste realmente reproduz o defeito. Rode-o contra um Postgres de verdade, em contêiner, e não contra um banco em memória: o comportamento de locks é justamente o que muda entre eles.

import test from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { transferir } from './transferir.js';

test('transferencias cruzadas em paralelo terminam sem erro e sem perder dinheiro', async () => {
  const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 20 });
  await pool.query('TRUNCATE contas');
  await pool.query('INSERT INTO contas (id, saldo) VALUES (1, 100000), (2, 100000)');

  // 200 transferencias, metade em cada sentido, todas disparadas ao mesmo tempo
  const disparos = Array.from({ length: 200 }, (_, i) =>
    i % 2 === 0 ? transferir(pool, 1, 2, 10) : transferir(pool, 2, 1, 10),
  );
  const resultados = await Promise.allSettled(disparos);

  const falhas = resultados.filter((r) => r.status === 'rejected');
  assert.equal(falhas.length, 0, falhas.map((f) => f.reason.code).join(','));

  const { rows } = await pool.query('SELECT sum(saldo)::int AS total FROM contas');
  assert.equal(rows[0].total, 200000); // o total nunca muda em transferencia entre contas
  await pool.end();
});

FAQ

Perguntas frequentes

Aumentar o deadlock_timeout resolve?

Não. Esse parâmetro só controla quanto tempo o banco espera antes de procurar um ciclo, então aumentá-lo apenas atrasa a detecção: cada deadlock passa a segurar locks e conexões por mais tempo antes de ser resolvido. Reduzi-lo muito também não ajuda, porque a verificação de ciclo tem custo. O valor padrão de 1 segundo é razoável para quase todos os casos, e a solução está na ordem dos locks e na duração das transações.

Posso tratar tudo com retry e ignorar a causa?

Não é recomendável. O retry torna o deadlock invisível para o usuário, mas cada ocorrência custa pelo menos um segundo de espera, desfaz trabalho e consome conexões. Com carga alta, a taxa de deadlocks cresce mais rápido que a de requisições e o retry passa a gerar a própria tempestade. Use o retry como rede de segurança para o deadlock raro, e a ordenação dos locks como correção da causa.

Isso vale para outros bancos e para ORMs?

Sim. MySQL com InnoDB, SQL Server e Oracle também têm deadlocks por espera circular e também resolvem escolhendo uma vítima, mudando apenas o código do erro e os detalhes de quais comandos travam o quê. Um ORM não muda o problema: ele apenas esconde a ordem em que as linhas são tocadas. Se o ORM salva vários objetos em uma transação, ordene os objetos pela chave antes de salvar e use a opção de bloqueio pessimista do ORM com a mesma regra de ordem.

Deadlock é um defeito de coordenação, não de lógica

Duas transações corretas podem se travar uma na outra porque o banco não sabe que elas deveriam concordar sobre a ordem. A correção é concordar por ele: toda transação trava as mesmas linhas na mesma ordem, faz só o necessário enquanto segura os locks e, para o caso raro que sobrar, é repetida inteira com limite e métrica. Com log de esperas, contador de deadlocks e um teste de concorrência no CI, o erro intermitente de pico deixa de ser um mistério e passa a ser algo que se previne e se mede.