Aprenda o padrão cron + banco de dados para executar trabalhos agendados em segundo plano com tentativas, bloqueio e idempotência — sem montar um sistema de filas completo.

A maioria dos apps precisa que algo aconteça mais tarde ou em um horário definido: enviar e-mails de acompanhamento, rodar uma checagem de cobrança noturna, limpar registros antigos, reconstruir um relatório ou atualizar um cache.
No começo, é tentador adicionar um sistema de filas completo porque parece a forma “certa” de fazer jobs em segundo plano. Mas filas adicionam peças móveis: mais um serviço para rodar, monitorar, deployar e depurar. Para uma equipe pequena (ou um fundador solo), esse peso extra pode te atrapalhar.
Então a pergunta real é: como executar trabalhos agendados de forma confiável sem montar mais infraestrutura?
Uma tentativa comum é simples: adicionar uma entrada no cron que chama um endpoint, e deixar esse endpoint fazer o trabalho. Funciona até não funcionar. Quando você tem mais de um servidor, um deploy no momento errado, ou um job que demora mais que o esperado, começam a aparecer falhas confusas.
Trabalhos agendados costumam quebrar de algumas formas previsíveis:
O padrão cron + banco de dados é um caminho intermediário. Você continua usando cron para “acordar” no horário, mas armazena a intenção do job e o estado no banco de dados para que o sistema coordene, faça retries e registre o que aconteceu.
É uma boa opção quando você já tem um banco (frequentemente PostgreSQL), poucos tipos de jobs e quer comportamento previsível com mínimo trabalho de ops. Também é natural para apps construídos rapidamente em stacks modernos (por exemplo, React + Go + PostgreSQL).
Não é indicado quando você precisa de altíssimo throughput, jobs de longa execução que precisam transmitir progresso, ordenação estrita entre muitos tipos de jobs, ou fan-out pesado (milhares de subtarefas por minuto). Nesses casos, uma fila real e workers dedicados geralmente compensam.
O padrão cron + banco de dados executa trabalho em segundo plano agendado sem rodar um sistema de filas completo. Você ainda usa cron (ou qualquer agendador), mas o cron não decide o que rodar. Ele apenas acorda um worker com frequência (uma vez por minuto é comum). O banco decide qual trabalho está devido e garante que apenas um worker pegue cada job.
Pense nisso como um checklist compartilhado em um quadro branco. O cron é a pessoa que entra na sala a cada minuto e pergunta: “Alguém precisa fazer algo agora?” O banco é o quadro branco que mostra o que está devido, o que já foi pego e o que foi feito.
Os componentes são diretos:
Exemplo: você quer enviar lembretes de faturas todas as manhãs, atualizar um cache a cada 10 minutos e limpar sessões antigas à noite. Em vez de três comandos cron separados (cada um com seus modos de sobreposição e falha), você armazena entradas de job em um só lugar. O cron inicia o mesmo processo de worker. O worker pergunta ao Postgres “o que está devido agora?” e o Postgres responde permitindo que o worker reclame exatamente um job por vez.
Isso escala gradualmente. Você pode começar com um worker em um servidor. Mais tarde, rodar cinco workers em vários servidores. O contrato continua o mesmo: a tabela é o contrato.
A mudança de mentalidade é simples: o cron é só o despertador. O banco é o controlador de tráfego que decide o que pode rodar, registra o que aconteceu e te dá um histórico claro quando algo dá errado.
Esse padrão funciona melhor quando seu banco vira a fonte da verdade sobre o que deve rodar, quando deve rodar e o que aconteceu da última vez. O esquema não é sofisticado, mas detalhes pequenos (campos de lock e índices certos) fazem grande diferença conforme a carga cresce.
Duas abordagens comuns:
Se você espera depurar falhas com frequência, mantenha histórico. Se quer a configuração mínima possível, comece com uma tabela e adicione histórico depois.
Aqui está um layout amigável ao PostgreSQL. Se você estiver construindo em Go com PostgreSQL, essas colunas mapeiam bem para structs.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Alguns detalhes que salvam dores depois:
send_invoice_emails).jsonb para poder evoluí-lo sem migrations.Sem índices, os workers acabam escaneando demais. Comece com:
(status, run_at)(locked_until)queued e failed)Isso mantém a consulta “encontrar próximo job executável” rápida mesmo quando a tabela cresce.
O objetivo é simples: muitos workers podem rodar, mas apenas um deve pegar um job específico. Se dois workers processarem a mesma linha, você terá e-mails duplicados, cobranças em duplicidade ou dados confusos.
Uma abordagem segura é tratar a reclamação do job como uma “licença” (lease). O worker marca o job como bloqueado por uma janela curta. Se o worker travar, a licença expira e outro worker pode pegá-lo. É isso que locked_until faz.
Sem licença, um worker pode travar após bloquear um job e nunca desbloqueá-lo (processo morto, servidor reiniciado, deploy com problema). Com locked_until, o job fica disponível de novo quando o tempo passar.
Uma regra típica: um job pode ser reclamado quando locked_until é NULL ou locked_until <= now().
O detalhe chave é reivindicar o job em uma única instrução (ou numa transação). Você quer que o banco seja o árbitro.
Aqui está um padrão comum do PostgreSQL: pegue um job devido, trave-o e retorne ao worker. (Esse exemplo usa uma única tabela jobs; a mesma ideia vale para job_runs.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
AND (locked_until IS NULL OR locked_until <= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
Por que funciona:
FOR UPDATE SKIP LOCKED permite que múltiplos workers compitam sem travar uns aos outros.RETURNING entrega a linha ao worker que venceu a disputa.Defina a licença maior que uma execução normal, mas curta o suficiente para que um crash recupere rápido. Se a maioria dos jobs termina em 10 segundos, uma licença de 2 minutos é suficiente.
Para tarefas longas, renove a licença enquanto você trabalha (heartbeat). Uma abordagem simples: a cada 30 segundos, estenda locked_until se você ainda for o dono do job.
WHERE id = $job_id AND locked_by = $worker_idEssa última condição importa. Ela impede que um worker estenda a licença de um job que não possui mais.
Retry é onde esse padrão ou parece tranquilo ou vira uma bagunça barulhenta. O objetivo é simples: quando um job falha, tente de novo mais tarde de um modo que você consiga explicar, medir e parar.
Comece tornando o estado do job explícito e finito: queued, running, succeeded, failed, dead. Na prática, a maioria das equipes usa failed para “falhou mas vai tentar de novo” e dead para “falhou e desistimos”. Essa distinção evita loops infinitos.
Contar tentativas é a segunda proteção. Armazene attempts (quantas vezes tentou) e max_attempts (quantas vezes permite). Quando um worker captura um erro, ele deve:
attemptsfailed se attempts < max_attempts, caso contrário deadrun_at para a próxima tentativa (apenas para failed)Backoff é só a regra que decide o próximo run_at. Escolha uma, documente-a e mantenha-a consistente:
Jitter importa quando uma dependência cai e volta. Sem ele, centenas de jobs podem tentar ao mesmo tempo e falhar de novo.
Armazene detalhe de erro suficiente para tornar falhas visíveis e depuráveis. Você não precisa de um sistema de logs completo, mas precisa do básico:
last_error (mensagem curta, segura para mostrar numa tela de admin)error_code ou error_type (ajuda a agrupar)failed_at e next_run_atlast_stack (apenas se você controlar o tamanho)Uma regra concreta que funciona bem: marque jobs como dead após 10 tentativas e use backoff exponencial com jitter. Isso mantém falhas transitórias sendo re-tentadas, mas evita que jobs quebrados consumam CPU para sempre.
Idempotência significa que seu job pode rodar duas vezes e ainda produzir o mesmo resultado final. Nesse padrão, importa porque a mesma linha pode ser pega novamente após um crash, timeout ou retry. Se seu job é “enviar um e-mail de fatura”, executá-lo duas vezes não é inofensivo.
Uma forma prática de pensar: divida cada job em (1) fazer o trabalho e (2) aplicar um efeito. Você quer que o efeito aconteça uma vez, mesmo que o trabalho seja tentado várias vezes.
Uma chave de idempotência deve vir do que o job representa, não da tentativa do worker. Boas chaves são estáveis e fáceis de explicar, como invoice_id, user_id + day ou report_name + report_date. Se duas tentativas do job se referem ao mesmo evento do mundo real, elas devem compartilhar a mesma chave.
Exemplo: “Gerar relatório diário de vendas para 2026-01-14” pode usar sales_report:2026-01-14. “Cobrar fatura 812” pode usar invoice_charge:812.
A proteção mais simples é deixar o PostgreSQL rejeitar duplicatas. Armazene a chave de idempotência em um campo indexável e adicione uma restrição única.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
Isso impede que duas linhas com a mesma chave existam ao mesmo tempo. Se seu design permite múltiplas linhas (para histórico), coloque a unicidade em uma tabela de “efeitos”, como sent_emails(idempotency_key) ou payments(idempotency_key).
Efeitos comuns para proteger:
sent_emails com chave única antes de enviar, ou registre o id da mensagem do provedor quando enviado.delivered_webhooks(event_id) e pule se já existir.file_generated chaveado por (type, date).Se você está em uma stack com Postgres (por exemplo, backend Go + PostgreSQL), essas checagens de unicidade são rápidas e fáceis de manter próximas aos dados. A ideia-chave é simples: retries são normais, duplicatas são opcionais.
Escolha um runtime simples e fique com ele. O ponto do padrão cron + banco é menos peças móveis, então um processo pequeno em Go, Node ou Python que fale com PostgreSQL costuma ser suficiente.
Crie as tabelas e índices. Adicione uma tabela jobs (mais quaisquer tabelas de referência que quiser), indexe run_at e adicione um índice que ajude seu worker a encontrar jobs disponíveis rápido (por exemplo em (status, run_at)).
Escreva uma função de enqueue mínima. Sua app deve inserir uma linha com run_at definido como “agora” ou um horário futuro. Mantenha o payload pequeno e previsível (IDs e um job type, não blobs enormes).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running na mesma transação.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
Processe e finalize. Para cada job reclamado, faça o trabalho e depois atualize para done com finished_at. Se falhar, registre uma mensagem de erro e mova de volta para queued com um novo run_at (backoff). Mantenha as atualizações de finalização pequenas e sempre as execute, mesmo se o processo estiver encerrando.
Adicione regras de retry que você possa explicar. Use uma fórmula simples como run_at = now() + (attempts^2) * interval '10 seconds', e pare após max_attempts definindo status = 'dead'.
Você não precisa de um dashboard completo no dia um, mas precisa do suficiente para perceber problemas.
Se você já está numa stack Go + PostgreSQL, isso mapeia bem para um único binário de worker mais cron.
Imagine um SaaS pequeno com dois trabalhos agendados:
Mantenha simples: uma tabela PostgreSQL para conter jobs e um worker que roda a cada minuto (acionado por cron). O worker reclama jobs devidos, executa e registra sucesso ou falha.
Você pode enfileirar jobs de alguns lugares:
cleanup_nightly para “hoje”.send_weekly_report para a próxima segunda do usuário.send_weekly_report que roda imediatamente para um intervalo de datas específico.O payload é só o mínimo que o worker precisa. Mantenha pequeno para facilitar retry.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Um worker pode travar no pior momento: logo após enviar o e-mail, mas antes de marcar o job como “feito”. Ao reiniciar, ele pode pegar o mesmo job outra vez.
Para evitar envios duplicados, dê ao trabalho uma chave natural de deduplicação e armazene-a onde o banco possa aplicar uma restrição. Para relatórios semanais, uma boa chave é (user_id, week_start_date). Antes de enviar, o worker registra “vou enviar o relatório X”. Se esse registro já existir, pula o envio.
Isso pode ser tão simples quanto uma tabela sent_reports com uma constraint única em (user_id, week_start_date), ou uma idempotency_key única no job.
Suponha que o provedor de e-mail dê timeout. O job falha, então o worker:
attemptsSe continuar falhando além do limite (por exemplo, 10 tentativas), marque como “dead” e pare de tentar. O job ou tem sucesso uma vez, ou ele tenta de forma explícita e segura até o limite, e a idempotência torna o retry seguro.
O padrão cron + banco é simples, mas pequenos erros podem gerar duplicatas, trabalho preso ou carga surpresa. A maioria dos problemas aparece depois do primeiro crash, deploy ou pico de tráfego.
A maioria dos incidentes reais vem de algumas armadilhas:
locked_until. Se um worker travar depois de reclamar um job, aquela linha pode ficar “em progresso” para sempre. Um timestamp de lease permite que outro worker a pegue depois.user_id, invoice_id ou uma chave de arquivo) e busque o resto na execução.Exemplo: você envia um e-mail semanal de fatura. Se o worker der timeout após enviar mas antes de marcar o job como feito, o mesmo job pode ser re-tentado e enviar um e-mail duplicado. Isso é normal nesse padrão, a menos que você adicione uma proteção (por exemplo, registrar um evento único “e-mail enviado” chaveado pelo id da fatura).
Evite misturar agendamento e execução numa mesma transação longa. Se você segurar uma transação aberta enquanto faz chamadas de rede, mantém locks por mais tempo que o necessário e bloqueia outros workers.
Fique atento a diferenças de relógio entre máquinas. Use o tempo do banco (NOW() no PostgreSQL) como fonte de verdade para run_at e locked_until, não o relógio do app server.
Defina um tempo máximo de execução claro. Se um job pode levar 30 minutos, faça a licença maior que isso e renove se necessário. Caso contrário outro worker pode pegá-lo no meio da execução.
Mantenha a tabela de jobs saudável. Se jobs completados se acumularem para sempre, consultas ficam lentas e a contenção aumenta. Escolha uma regra simples de retenção (arquivar ou deletar linhas antigas) antes que a tabela vire um problema.
Antes de colocar esse padrão em produção, verifique o básico. Uma pequena omissão aqui costuma virar jobs presos, duplicatas surpresas ou workers dando hammer no banco.
run_at, status, attempts, locked_until e max_attempts (além de last_error ou similar para ver o que aconteceu).invoice_id).max_attempts.Se isso estiver certo, o padrão cron + banco de dados costuma ser estável para cargas reais.
Depois que o checklist estiver ok, foque na operação do dia a dia.
run_at = now() e limpa o lock) e “cancel” (move para um status terminal). Isso economiza tempo em incidentes.status, run_at).Se quiser montar isso rapidamente, Koder.ai (koder.ai) pode ajudar você a ir do esquema a um app Go + PostgreSQL implantado com menos trabalho manual, enquanto você foca em regras de lock, retries e idempotência.
Se depois você ultrapassar essa configuração, você terá aprendido o ciclo de vida do job claramente, e essas mesmas ideias se mapeiam bem para um sistema de filas completo.