Blog

Job agendado que roda duas vezes: exclusão mútua distribuída sem trava eterna

O fechamento de comissões rodava todo dia às 00:05 e nunca tinha dado problema, até a semana em que o serviço passou de uma para três réplicas para aguentar o tráfego de uma campanha. Na manhã seguinte, cento e doze vendedores receberam três e-mails de comissão, e o financeiro encontrou três lançamentos por loja. O agendador estava embutido na aplicação, e cada réplica disparou o job no mesmo minuto. A correção feita às pressas foi uma trava no Redis com SET NX, sem expiração, liberada no final do job. Funcionou por três semanas, até que um pod foi morto por falta de memória no meio do fechamento. A trava nunca foi liberada, as réplicas seguintes encontraram a chave ocupada e desistiram em silêncio, e o fechamento ficou nove dias sem rodar até alguém perguntar por que as comissões não tinham chegado. Os dois incidentes são faces do mesmo problema: exclusão mútua entre processos que não compartilham memória, podem morrer a qualquer momento e podem ficar parados sem saber. Este artigo mostra de onde vêm as execuções duplicadas, por que a trava sem prazo e a trava com prazo ingênuo falham de formas opostas, como implementar um lease no PostgreSQL com renovação e número de geração, como usar esse número como cerca para rejeitar a escrita de quem perdeu a trava, por que ainda é preciso registrar a execução por janela agendada, e como operar tudo isso com métricas que avisam antes do cliente.

2026-09-26 / Arquitetura / 18 min

01

Por que um job agendado roda duas vezes

Um agendador dentro da aplicação, seja node-cron, um @Scheduled do Spring ou um setInterval, só sabe do processo em que está. Enquanto o serviço tem uma instância, "rodar às 00:05" e "rodar uma vez às 00:05" significam a mesma coisa. No dia em que a infraestrutura escala horizontalmente, a segunda frase deixa de ser verdade sem que nenhuma linha de código mude. E réplicas são só a causa mais óbvia: mesmo com uma instância, existem pelo menos quatro outros caminhos para a mesma ocorrência executar mais de uma vez.

CausaComo aconteceSinal nos logs
Várias réplicas com agendador embutidoCada instância carrega o mesmo cron; escalar de uma para três réplicas triplica as execuçõesMesmo horário de início em hosts diferentes
Deploy com sobreposiçãoO rolling update mantém o pod antigo e o novo vivos ao mesmo tempo; se o horário cai nessa janela, os dois disparamDuas execuções com versões diferentes da aplicação
Execução mais longa que o intervaloO job de cinco em cinco minutos passa a levar sete, e a próxima ocorrência começa com a anterior ainda rodandoDuração maior que o intervalo e execuções sobrepostas no mesmo host
Reentrega do agendador externoCronJob do Kubernetes, EventBridge e Cloud Scheduler entregam pelo menos uma vez; um timeout gera nova tentativa de algo que já executouDois inícios com segundos de diferença para a mesma ocorrência
Recuperação de disparo perdidoO agendador executa ao reiniciar as ocorrências que "perdeu" enquanto estava fora, inclusive as que outra instância já cobriuExecução fora do horário logo depois de um deploy ou reinício

A consequência de rodar duas vezes depende do que o job faz. Um job que recalcula um cache desperdiça CPU e ninguém percebe. Um job que envia e-mails, gera cobranças, lança comissões ou chama uma API externa com efeito colateral produz dano visível ao cliente. É por isso que a pergunta certa não é "como garantir que roda uma vez", que nenhum sistema distribuído garante sozinho, e sim "o que acontece se rodar duas vezes, e quais camadas impedem que isso vire dano".

02

A trava sem prazo e a trava com prazo ingênuo

A primeira ideia de quase todo time é uma trava compartilhada: antes de rodar, grava uma chave; se a chave já existe, outra instância está rodando e esta desiste. O problema está em quem apaga a chave. Se só o próprio job apaga no final, qualquer morte abrupta entre o início e o fim, como falta de memória, deploy que mata o processo, nó que some ou um kill -9, deixa a trava para sempre. O job não falha, simplesmente para de rodar, e o único sinal é a ausência de algo que deveria ter acontecido.

// Trava sem prazo: correta enquanto nada morre no meio do caminho.
const ok = await redis.set('trava:fechamento', 'ocupado', 'NX');
if (!ok) return; // outra instancia rodando... ou uma instancia que morreu ha nove dias
try {
  await fecharComissoes();
} finally {
  await redis.del('trava:fechamento'); // nunca executa se o processo for morto
}

// Trava com prazo: expira sozinha, mas pode expirar com o dono ainda vivo.
const ok2 = await redis.set('trava:fechamento', idDoProcesso, 'NX', 'PX', 60000);

A correção natural é dar prazo à trava, e ela resolve a trava eterna, mas cria o problema oposto. O prazo é uma aposta sobre quanto tempo o dono vai precisar dela, e o dono não controla o próprio tempo. Uma pausa longa de coleta de lixo, uma CPU estrangulada pelo limite do contêiner, uma VM migrada a quente ou uma consulta lenta podem fazer o processo ficar parado mais que o prazo. Quando ele volta, não tem como saber que a trava venceu, e continua de onde parou.

t=0s    A adquire a trava (prazo de 60s) e começa o fechamento
t=20s   A fica parado: pausa de GC, CPU estrangulada ou VM migrada
t=60s   a trava vence; ninguém liberou, ela apenas expirou
t=61s   B adquire a trava e começa o mesmo fechamento
t=75s   A volta, sem saber que perdeu a trava, e grava as comissões
t=80s   B grava as comissões
        -> duas escritas, as duas feitas "com a trava"

Há ainda um terceiro defeito, mais sutil, no DEL do exemplo: se A volta depois que B adquiriu a trava e executa o finally, apaga a trava de B, e uma terceira instância pode entrar. Liberar exige conferir o dono, e conferir e apagar precisam ser uma única operação atômica. Os três defeitos juntos mostram o que uma trava distribuída precisa ter: prazo para sobreviver à morte do dono, renovação para que o prazo não precise adivinhar a duração do job, liberação condicionada ao dono, e uma forma de o recurso protegido recusar a escrita de quem perdeu a trava sem saber.

03

Lease no PostgreSQL com dono, renovação e geração

Se o job já escreve no PostgreSQL, a forma mais simples de ter essas quatro propriedades é uma tabela de leases no próprio banco. Cada linha representa uma trava nomeada, com o identificador do dono, o instante em que vence e um número de geração que só cresce. Toda tomada de posse incrementa a geração, e esse número passa a identificar a posse de forma única, algo que o nome do host ou o PID não fazem, porque o mesmo processo pode perder e retomar a trava.

CREATE TABLE travas_job (
  nome      text PRIMARY KEY,
  dono      text NOT NULL,
  geracao   bigint NOT NULL,
  expira_em timestamptz NOT NULL
);

-- Adquirir: cria a linha ou toma posse de uma trava vencida.
-- Devolve a nova geracao, ou nenhuma linha se outro dono ainda esta valido.
INSERT INTO travas_job AS t (nome, dono, geracao, expira_em)
VALUES ($1, $2, 1, now() + make_interval(secs => $3))
ON CONFLICT (nome) DO UPDATE
   SET dono      = EXCLUDED.dono,
       geracao   = t.geracao + 1,
       expira_em = EXCLUDED.expira_em
 WHERE t.expira_em <= now()
RETURNING geracao;

-- Renovar: so o dono atual, na mesma geracao e antes de vencer.
UPDATE travas_job
   SET expira_em = now() + make_interval(secs => $3)
 WHERE nome = $1 AND dono = $2 AND geracao = $4 AND expira_em > now();

-- Liberar: vence a trava sem apagar a linha, para a geracao nunca voltar a 1.
UPDATE travas_job
   SET expira_em = now()
 WHERE nome = $1 AND dono = $2 AND geracao = $3;

A aquisição é uma única instrução: o INSERT com ON CONFLICT DO UPDATE e a cláusula WHERE só toma posse se a trava atual estiver vencida, e o RETURNING só devolve linha quando a posse mudou. Duas instâncias disputando ao mesmo tempo se serializam no índice da chave primária, e a segunda reavalia a condição sobre a versão já atualizada pela primeira, então apenas uma recebe a geração. Não existe janela entre ler e escrever, que é o erro clássico de quem implementa isso com um SELECT seguido de UPDATE.

  • Todos os prazos usam now() do banco, e não o relógio de cada réplica. Hosts com relógios diferentes discordariam sobre quando a trava venceu, e um host adiantado tomaria posse de uma trava que para o dono ainda é válida.
  • A liberação não apaga a linha. Se apagasse, a próxima aquisição criaria a trava de novo com geração 1, e um dono antigo com geração 7 pareceria mais recente. A geração precisa ser monotônica para servir de cerca.
  • A renovação exige dono, geração e prazo ainda válido. Quem já perdeu a trava recebe zero linhas afetadas e sabe que deve parar, em vez de estender silenciosamente uma posse que já é de outro.
  • O prazo deixa de ser uma estimativa da duração do job. Com renovação a cada terço do prazo, um job de duas horas funciona com prazo de noventa segundos, e uma instância morta é substituída em no máximo noventa segundos.

04

O executor em Node.js e a cerca na escrita

O executor encapsula o ciclo completo: tenta adquirir, desiste sem erro se outro dono estiver ativo, renova em segundo plano enquanto a tarefa roda, sinaliza a tarefa para parar se a renovação falhar e libera no final. A tarefa recebe a geração e um AbortSignal, e fica responsável por conferir o sinal entre unidades de trabalho.

// trava-job.js: lease no PostgreSQL com renovacao e geracao (node-postgres).
import os from 'node:os';
import { randomUUID } from 'node:crypto';

const DONO = os.hostname() + ':' + process.pid + ':' + randomUUID().slice(0, 8);

const SQL_ADQUIRIR =
  'INSERT INTO travas_job AS t (nome, dono, geracao, expira_em) ' +
  'VALUES ($1, $2, 1, now() + make_interval(secs => $3)) ' +
  'ON CONFLICT (nome) DO UPDATE SET dono = EXCLUDED.dono, ' +
  'geracao = t.geracao + 1, expira_em = EXCLUDED.expira_em ' +
  'WHERE t.expira_em <= now() RETURNING geracao';
const SQL_RENOVAR =
  'UPDATE travas_job SET expira_em = now() + make_interval(secs => $3) ' +
  'WHERE nome = $1 AND dono = $2 AND geracao = $4 AND expira_em > now()';
const SQL_LIBERAR =
  'UPDATE travas_job SET expira_em = now() ' +
  'WHERE nome = $1 AND dono = $2 AND geracao = $3';

export async function executarComTrava(pool, nome, ttlSegundos, tarefa) {
  const { rows } = await pool.query(SQL_ADQUIRIR, [nome, DONO, ttlSegundos]);
  if (rows.length === 0) return { executou: false };

  const geracao = rows[0].geracao; // bigint chega como string no node-postgres
  const controle = new AbortController();

  // Renova a cada terco do prazo. Na duvida (erro ou posse perdida), para a tarefa.
  const renovacao = setInterval(async () => {
    try {
      const r = await pool.query(SQL_RENOVAR, [nome, DONO, ttlSegundos, geracao]);
      if (r.rowCount === 0) controle.abort(new Error('trava perdida'));
    } catch (erro) {
      controle.abort(erro);
    }
  }, (ttlSegundos * 1000) / 3);

  try {
    const resultado = await tarefa({ geracao, sinal: controle.signal });
    return { executou: true, resultado };
  } finally {
    clearInterval(renovacao);
    await pool.query(SQL_LIBERAR, [nome, DONO, geracao]).catch(() => {});
  }
}

O AbortSignal reduz a janela de dano, mas não a elimina. Um processo pausado não executa o callback de renovação, não percebe o abort e não confere o sinal: ele simplesmente volta a rodar a próxima linha, que pode ser uma escrita. Para fechar essa janela, a escrita precisa conferir a posse no mesmo lugar em que acontece. Quando o recurso protegido está no mesmo banco, isso é uma consulta com FOR SHARE dentro da transação da escrita.

// fechamento-diario.js: cada loja e gravada em uma transacao que confere a posse.
import { executarComTrava } from './trava-job.js';

const SQL_CONFERIR_POSSE =
  'SELECT 1 FROM travas_job ' +
  'WHERE nome = $1 AND geracao = $2 AND expira_em > now() FOR SHARE';

export function fechamentoDiario(pool, dia) {
  return executarComTrava(pool, 'fechamento-diario', 90, async ({ geracao, sinal }) => {
    const { rows: lojas } = await pool.query('SELECT id FROM lojas WHERE ativa ORDER BY id');

    for (const loja of lojas) {
      sinal.throwIfAborted();
      const cliente = await pool.connect();
      try {
        await cliente.query('BEGIN');
        const posse = await cliente.query(SQL_CONFERIR_POSSE, ['fechamento-diario', geracao]);
        if (posse.rowCount === 0) throw new Error('trava perdida antes da escrita');

        await cliente.query(
          'INSERT INTO comissoes (loja_id, dia, valor) ' +
            'SELECT $1::bigint, $2::date, coalesce(sum(total), 0) * 0.05 FROM pedidos ' +
            'WHERE loja_id = $1::bigint AND criado_em >= $2::date AND criado_em < $2::date + 1 ' +
            'ON CONFLICT (loja_id, dia) DO NOTHING',
          [loja.id, dia],
        );
        await cliente.query('COMMIT');
      } catch (erro) {
        await cliente.query('ROLLBACK');
        throw erro;
      } finally {
        cliente.release();
      }
    }
  });
}

O FOR SHARE é o que transforma a conferência em cerca. Ele trava a linha da trava em modo compartilhado até o COMMIT, e a tomada de posse por outra instância precisa de um bloqueio de atualização sobre a mesma linha, que conflita com ele. Assim, ou a conferência vê a geração vencida e aborta, ou a escrita termina antes que qualquer outro dono possa assumir. FOR KEY SHARE não serviria, porque não conflita com uma atualização que não muda a chave primária. A restrição única em comissoes com ON CONFLICT DO NOTHING é a última camada: mesmo que tudo acima falhe, a segunda gravação da mesma loja no mesmo dia não produz um segundo lançamento.

Quando o efeito é externo, como uma API de pagamentos ou um envio de e-mail, a mesma ideia vale na forma de fencing token: a geração vai junto com a requisição e o destino recusa qualquer requisição com geração menor que a maior já vista para aquele recurso. Se o destino não oferece isso, o recurso disponível é a chave de idempotência derivada da ocorrência, como fechamento-2026-09-25-loja-42, que faz a segunda chamada devolver o resultado da primeira em vez de repetir o efeito.

05

Trava impede concorrência, não repetição

Com o lease funcionando, duas instâncias nunca executam o fechamento ao mesmo tempo. Isso não significa que ele executa uma vez por dia. Se a réplica A termina às 00:05:40 e libera a trava, e a réplica B tem o agendador atrasado por um reinício, um deploy ou um relógio dois minutos atrás, B adquire uma trava livre às 00:07 e fecha o dia de novo. A trava resolve sobreposição, e o problema aqui é sequência. Para ele, o job precisa registrar qual ocorrência já foi concluída.

CREATE TABLE execucoes_job (
  nome         text NOT NULL,
  janela       date NOT NULL,
  dono         text NOT NULL,
  tentativas   int  NOT NULL DEFAULT 1,
  iniciado_em  timestamptz NOT NULL DEFAULT now(),
  concluido_em timestamptz,
  PRIMARY KEY (nome, janela)
);

-- Reivindicar a janela, ja com a trava em maos: so segue se ela nunca foi concluida.
-- Uma execucao que morreu no meio pode ser retomada; uma concluida, nao.
INSERT INTO execucoes_job AS e (nome, janela, dono)
VALUES ($1, $2, $3)
ON CONFLICT (nome, janela) DO UPDATE
   SET dono        = EXCLUDED.dono,
       tentativas  = e.tentativas + 1,
       iniciado_em = now()
 WHERE e.concluido_em IS NULL
RETURNING tentativas;

-- Ao terminar com sucesso, marcar a janela como concluida.
UPDATE execucoes_job
   SET concluido_em = now()
 WHERE nome = $1 AND janela = $2 AND dono = $3;

O detalhe decisivo é como a janela é calculada. Ela precisa vir da ocorrência que o agendador pretendia executar, e não do horário em que a execução começou. Para um fechamento diário, a janela é o dia sendo fechado, no fuso do negócio: tanto a execução pontual das 00:05 quanto uma atrasada das 03:40 chegam à mesma chave, e a segunda encontra a janela concluída. Se a janela fosse derivada do timestamp de início arredondado para o minuto, cada atraso produziria uma chave nova e a proteção desapareceria justamente nos casos em que ela é necessária.

CamadaO que impedeO que não impede
Lease com renovaçãoDuas execuções simultâneas do mesmo jobUma segunda execução depois que a primeira terminou
Cerca pela geração na escritaEscrita de um dono que ficou parado e perdeu a travaEfeitos em sistemas que não conferem a geração
Registro de execução por janelaRepetir uma ocorrência já concluídaDuplicidade dentro de uma execução que morreu no meio
Restrição única ou chave de idempotência no efeitoDuplicar o resultado de uma unidade de trabalhoCusto de processar de novo o que já foi feito

Nenhuma camada sozinha é suficiente, e a tabela mostra por quê. A última linha é a mais importante, porque é a única que protege o dado mesmo quando todas as outras falham, e é também a que torna seguro retomar uma execução interrompida: a tentativa seguinte refaz as lojas que faltaram e passa pelas já gravadas sem duplicar nada.

06

Operar sem surpresa: métricas, prazo e alternativas

A trava eterna do início durou nove dias porque o sistema só registrava o que acontecia, e o defeito era algo que deixou de acontecer. Jobs agendados precisam de monitoramento por ausência, além do monitoramento por erro.

  • Alerta de janela não concluída: se execucoes_job não tem linha concluída para ontem até as 02:00, alguém é avisado, independentemente do motivo.
  • Idade da trava: uma trava com expira_em no futuro e sem renovação recente, ou com a mesma geração há mais tempo que a maior duração conhecida do job, indica um dono travado.
  • Execuções puladas por trava ocupada, por job e por host. Algumas por dia são normais com várias réplicas; um aumento súbito indica uma execução que não termina.
  • Posses perdidas: toda renovação com zero linhas afetadas e toda escrita recusada pela cerca devem virar log estruturado e métrica, porque são a evidência de pausas longas ou de prazo curto demais.
  • Duração comparada ao intervalo: quando a duração de um job recorrente passa de metade do intervalo, a sobreposição deixou de ser hipótese.

O prazo deve ser várias vezes maior que o pior atraso esperado de uma renovação, somando latência do banco, pausas de GC e estrangulamento de CPU, e pequeno o bastante para que a substituição de uma instância morta não atrase o job de forma relevante. Entre sessenta e cento e vinte segundos, com renovação a cada terço, costuma funcionar para jobs de negócio. Prazos de poucos segundos transformam qualquer lentidão do banco em troca de dono, e prazos de meia hora reintroduzem a trava eterna em escala menor.

MecanismoSobrevive à morte do donoProtege contra dono pausadoObservação
Redis SET NX sem prazoNãoNãoProduz a trava eterna
Redis SET NX PX com liberação condicionalSim, pelo prazoNão, sem geraçãoAdequado quando rodar duas vezes custa só processamento
pg_try_advisory_lock de sessãoSim, quando a conexão caiParcialmente, se toda escrita usar a mesma conexãoPrende uma conexão durante o job e não funciona com PgBouncer em modo transação
Tabela de lease com geraçãoSim, pelo prazoSim, com a cerca na escritaExige conferir a geração no recurso protegido
CronJob com concurrencyPolicy: ForbidSimNãoImpede Jobs sobrepostos, mas a própria documentação do Kubernetes admite disparos duplicados raros

Mover o agendamento para fora da aplicação, com um CronJob ou um agendador gerenciado, resolve a multiplicação por réplicas e costuma ser a decisão certa quando o job é pesado. Mas não resolve reentregas nem execuções sequenciais, e por isso o registro por janela e a idempotência no efeito continuam necessários. O que muda é onde fica o primeiro filtro, não a necessidade das outras camadas.

FAQ

Perguntas frequentes

Redlock com vários servidores Redis resolve o problema do dono pausado?

Não. O Redlock aumenta a disponibilidade do serviço de trava, porque a trava sobrevive à queda de um dos servidores Redis, mas não muda o que acontece do lado do cliente. Um processo que adquiriu a trava e ficou parado por mais tempo que o prazo continua acreditando que a possui quando volta, e nenhum número de servidores Redis impede essa escrita tardia. O que impede é o recurso protegido conferir um número monotônico a cada escrita e recusar números antigos, e o Redlock não fornece esse número. Para jobs em que a duplicidade custa só processamento, um único Redis com SET NX PX e liberação condicional é suficiente. Para jobs com efeito sobre dinheiro ou comunicação com clientes, a proteção precisa estar na escrita, com geração ou com chave de idempotência, e aí a escolha entre um ou vários Redis deixa de ser a decisão importante.

Por que não usar apenas pg_try_advisory_lock?

O advisory lock de sessão é barato e tem uma propriedade valiosa: é liberado automaticamente quando a conexão fecha, então não existe trava eterna enquanto o banco detectar a queda. Ele funciona bem quando o job inteiro roda na mesma conexão que segura o lock. Os problemas aparecem fora desse cenário. O job precisa manter uma conexão dedicada aberta durante toda a execução, o que pesa em pools pequenos. Com PgBouncer em modo transação, cada instrução pode ir para uma conexão diferente do servidor, e o lock pode ser adquirido em uma e ficar preso a outro cliente. E se o job escreve por outras conexões do pool, a perda da conexão do lock não impede essas escritas, então não há cerca. A variante de transação, pg_try_advisory_xact_lock, funciona com PgBouncer quando o job cabe em uma única transação, o que raramente vale para fechamentos longos. A tabela de lease custa algumas linhas a mais e resolve todos esses casos com a mesma lógica.

Qual prazo usar para a trava e de quanto em quanto tempo renovar?

O prazo não é a duração esperada do job, e esse é o erro mais comum. Ele é o tempo máximo que o sistema aceita esperar para outra instância assumir depois que o dono morreu, e precisa ser várias vezes maior que o pior atraso de uma renovação. Renovar a cada terço do prazo dá ao dono duas tentativas antes de perder a posse por uma lentidão pontual. Para a maioria dos jobs de negócio, um prazo de sessenta a cento e vinte segundos com renovação a cada vinte a quarenta segundos equilibra as duas pontas. Prazos muito curtos transformam picos de latência do banco em troca de dono e em execuções abortadas; prazos muito longos atrasam a recuperação e escondem instâncias travadas. A métrica que diz se o prazo está certo é a de posses perdidas: se ela aparece sem instâncias mortas, o prazo está curto demais para o ambiente.

Rodar uma vez é uma propriedade construída em camadas, não um horário no cron

Um job agendado roda duas vezes porque réplicas, deploys, reentregas e atrasos criam ocorrências extras sem que nenhum código mude, e a trava ingênua troca esse problema por outro: sem prazo, ela prende o job para sempre quando o dono morre; com prazo e sem cerca, ela deixa um dono pausado escrever depois de perder a posse. Um lease no banco, com dono, renovação e geração monotônica, resolve a concorrência; a conferência da geração dentro da transação da escrita fecha a janela do dono pausado; o registro de execução por janela impede a repetição sequencial; e a restrição única no efeito protege o dado quando todo o resto falha. Com alertas por ausência, a trava eterna deixa de durar nove dias. Posso revisar os jobs agendados do seu sistema, mapear onde uma execução duplicada vira dano ao cliente e implementar as camadas que faltam sem parar a operação.