18 de dez. de 2025·7 min de leitura

Agendamento de tarefas em segundo plano sem as dores de cabeça do cron: padrões

Aprenda padrões para agendar tarefas em segundo plano usando workflows e uma tabela de jobs para enviar lembretes, resumos diários e limpeza de forma confiável.

Agendamento de tarefas em segundo plano sem as dores de cabeça do cron: padrões

Por que o cron parece simples até não ser\n\nO cron é ótimo no primeiro dia: escreve-se uma linha, escolhe-se um horário e esquece. Para um servidor e uma tarefa, muitas vezes funciona.\n\nOs problemas aparecem quando você depende do agendamento para comportamento real do produto: lembretes, resumos diários, limpeza ou jobs de sincronização. A maioria das histórias de “execução perdida” não é culpa do cron. É tudo ao redor: um reboot do servidor, um deploy que sobrescreveu o crontab, uma tarefa que demorou mais que o esperado ou um descompasso de relógio/fuso horário. E quando você executa várias instâncias da aplicação, pode ter o modo de falha oposto: duplicatas, porque duas máquinas acham que devem rodar a mesma tarefa.\n\nTestes são outro ponto fraco. Uma linha de cron não te dá um jeito limpo de rodar “o que aconteceria às 9:00 AM amanhã” num teste reproduzível. Então o agendamento vira checagens manuais, surpresas em produção e caça a logs.\n\nAntes de escolher uma abordagem, seja claro sobre o que você está agendando. A maior parte do trabalho em segundo plano cai em alguns grupos: \n\n- Lembretes (enviar em um horário específico, apenas uma vez)\n- Resumos diários (agregar dados e depois enviar)\n- Tarefas de limpeza (deletar, arquivar, expirar)\n- Sincronizações periódicas (puxar ou empurrar atualizações)\n\nÀs vezes você pode pular o agendamento completamente. Se algo pode acontecer no momento em que um evento ocorre (um usuário se cadastra, um pagamento é confirmado, um ticket muda de status), trabalho orientado a eventos costuma ser mais simples e mais confiável que trabalho orientado por tempo.\n\nQuando você precisar de tempo, confiabilidade se resume a visibilidade e controle. Você quer um lugar para registrar o que deve rodar, o que rodou e o que falhou, além de um jeito seguro de tentar novamente sem criar duplicatas.\n\n## O padrão básico: scheduler, tabela de jobs, worker\n\nUma maneira simples de evitar dores de cabeça com cron é dividir responsabilidades: \n\n- Um scheduler decide o que deve rodar e quando.\n- Um worker faz o trabalho.\n\nManter esses papéis separados ajuda em duas frentes. Você pode mudar o tempo sem tocar na lógica de negócio, e pode mudar a lógica de negócio sem quebrar o agendamento.\n\nUma tabela de jobs vira a fonte da verdade. Em vez de esconder estado dentro de um processo do servidor ou numa linha de cron, cada unidade de trabalho é uma linha: o que fazer, para quem, quando deve rodar e o que aconteceu da última vez. Quando algo dá errado, você pode inspecionar, reexecutar ou cancelar sem adivinhar.\n\nUm fluxo típico fica assim: \n\n- O scheduler varre jobs elegíveis (por exemplo, run_at \u003c= now e status = queued).\n- Ele reivindica um job para que apenas um worker o pegue.\n- Um worker lê os detalhes do job e executa a ação.\n- O worker registra o resultado de volta na mesma linha.\n\nA ideia-chave é fazer o trabalho retomável, não mágico. Se um worker cair no meio, a linha do job ainda deve dizer o que aconteceu e o que fazer a seguir.\n\n## Projetando uma tabela de jobs útil\n\nUma tabela de jobs deve responder rapidamente duas perguntas: o que precisa rodar a seguir, e o que aconteceu da última vez.\n\nComece com um conjunto pequeno de campos que cubram identidade, tempo e progresso: \n\n- id, type: um id único mais um tipo curto como send_reminder ou daily_summary.\n- payload: JSON validado com apenas o que o worker precisa (por exemplo user_id, não o objeto inteiro do usuário).\n- run_at: quando o job fica elegível para rodar.\n- status: queued, running, succeeded, failed, canceled.\n- attempts: incrementado a cada tentativa.\n\nDepois acrescente algumas colunas operacionais que tornam concorrência segura e incidentes mais fáceis de tratar. locked_at, locked_by e locked_until permitem que um worker reivindique um job para que você não o execute duas vezes. last_error deve ser uma mensagem curta (e opcionalmente um código de erro), não um dump de stack trace que inche as linhas.\n\nPor fim, mantenha timestamps que ajudem suporte e relatórios: created_at, updated_at e finished_at. Eles permitem responder perguntas como “Quantos lembretes falharam hoje?” sem cavar nos logs.\n\nÍndices importam porque seu sistema constantemente pergunta “o que vem a seguir?”. Dois que normalmente valem a pena: \n\n- (status, run_at) para buscar jobs vencidos rápido\n- (type, status) para inspecionar ou pausar uma família de jobs durante um incidente\n\nPara payloads, prefira JSON pequeno e focado e valide antes de inserir o job. Armazene identificadores e parâmetros, não snapshots de dados de negócio. Trate a forma do payload como um contrato de API para que jobs antigos ainda rodem depois que você mudar a aplicação.\n\n## Ciclo de vida do job: statuses, locking e idempotência\n\nUm job runner é confiável quando cada job segue um ciclo de vida pequeno e previsível. Esse ciclo é sua rede de segurança quando dois workers começam ao mesmo tempo, um servidor reinicia no meio da execução ou você precisa reexecutar sem criar duplicatas.\n\nUma máquina de estados simples costuma ser suficiente: \n\n- queued: pronto para rodar a partir de run_at\n- running: reivindicado por um worker\n- succeeded: finalizado e não deve rodar de novo\n- failed: finalizado com erro e precisa de atenção\n- canceled: parado intencionalmente (por exemplo, usuário optou por sair)\n\n### Reivindicando jobs sem trabalho duplicado\n\nPara evitar duplicatas, reivindicar um job precisa ser atômico. A abordagem comum é um lock com timeout (uma lease): um worker reivindica definindo status=running e escrevendo locked_by e locked_until. Se o worker cair, o lock expira e outro worker pode reassumir.\n\nUm conjunto prático de regras para reivindicar: \n\n- reivindique apenas jobs queued cujo run_at \u003c= now\n- defina status, locked_by e locked_until na mesma atualização\n- reassuma jobs running somente quando locked_until \u003c now\n- mantenha a lease curta e a estenda se o job for longo\n\n### Idempotência (o hábito que salva você)\n\nIdempotência significa: se o mesmo job rodar duas vezes, o resultado continua correto.\n\nA ferramenta mais simples é uma chave única. Por exemplo, para um resumo diário você pode forçar um job por usuário por dia com uma chave como summary:user123:2026-01-25. Se uma inserção duplicada ocorrer, ela aponta para o mesmo job em vez de criar outro.\n\nMarque sucesso apenas quando o efeito colateral estiver realmente concluído (e-mail enviado, registro atualizado). Se você reexecutar, o caminho de retry não deve criar um segundo e-mail ou uma escrita duplicada.\n\n## Retries e tratamento de falhas sem drama\n\nRetries são onde sistemas de jobs ou viram confiáveis ou viram ruído. O objetivo é direto: tente novamente quando a falha for provavelmente temporária, pare quando não for.\n\nUma política de retry padrão geralmente inclui: \n\n- tentativas máximas (por exemplo, 5 tentativas no total)\n- uma estratégia de atraso (delay fixo ou backoff exponencial)\n- condições de parada (não reexecutar erros de “entrada inválida”)\n- jitter (um pequeno deslocamento aleatório para evitar picos de retry)\n\nEm vez de inventar um novo status para retries, muitas vezes você pode reaproveitar queued: ajuste run_at para o próximo horário de tentativa e coloque o job de volta na fila. Isso mantém a máquina de estados pequena.\n\nQuando um job pode fazer progresso parcial, trate isso como normal. Armazene um checkpoint para que um retry possa continuar com segurança, seja no payload do job (como last_processed_id) ou em uma tabela relacionada.\n\nExemplo: um job de resumo diário gera mensagens para 500 usuários. Se falhar no usuário 320, armazene o último id bem-sucedido e retome a partir do 321. Se você também armazenar um registro summary_sent por usuário por dia, uma nova execução pode pular usuários já processados.\n\n### Logs que realmente ajudam\n\nLogue o suficiente para debugar em minutos: \n\n- id do job, tipo e número da tentativa\n- entradas-chave (user/team id, intervalo de datas)\n- tempos (started_at, finished_at, next run time)\n- resumo curto do erro (mais stack trace se houver)\n- contagem de efeitos colaterais (e-mails enviados, linhas atualizadas)\n\n## Passo a passo: construa um loop simples de scheduler\n\nUm loop de scheduler é um processo pequeno que acorda em um ritmo fixo, busca trabalhos vencidos e os entrega. O objetivo é confiabilidade entediante, não timing perfeito. Para muitas aplicações, “acordar a cada minuto” é suficiente.\n\nEscolha a frequência com base em quão sensíveis ao tempo os jobs são e quanto carga seu banco aguenta. Se lembretes precisam ser quase em tempo real, rode a cada 30 a 60 segundos. Se resumos diários podem variar um pouco, a cada 5 minutos está bom e é mais barato.\n\nUm loop simples: \n\n1. Acorde e pegue a hora atual (use UTC).\n2. Selecione jobs vencidos onde status = 'queued' e run_at \u003c= now.\n3. Reivindique jobs com segurança para que apenas um worker os pegue.\n4. Entregue cada job reivindicado a um worker.\n5. Durma até o próximo tick.\n\nA etapa de reivindicação é onde muitos sistemas quebram. Você quer marcar um job como running (e armazenar locked_by e locked_until) na mesma transação que o seleciona. Muitos bancos suportam leituras “skip locked” para que múltiplos schedulers possam rodar sem se atrapalhar.\n\nsql\n-- concept example\nBEGIN;\nSELECT id FROM jobs\nWHERE status='queued' AND run_at \u003c= NOW()\nORDER BY run_at\nLIMIT 100\nFOR UPDATE SKIP LOCKED;\nUPDATE jobs\nSET status='running', locked_until=NOW() + INTERVAL '5 minutes'\nWHERE id IN (...);\nCOMMIT;\n\n\nMantenha o tamanho do lote pequeno (como 50 a 200). Lotes maiores podem desacelerar o banco e tornar crashes mais dolorosos.\n\nSe o scheduler cair no meio de um lote, a lease te salva. Jobs presos em running ficam elegíveis de novo após locked_until. Seu worker deve ser idempotente para que um job reassumido não gere e-mails duplicados ou cobranças em dobro.\n\n## Padrões para lembretes, resumos diários e limpeza\n\nA maioria das equipes acaba com três tipos de trabalho em segundo plano: mensagens que precisam sair no horário, relatórios que rodam em agenda e limpeza que mantém armazenamento e performance saudáveis. A mesma tabela de jobs e loop de worker pode lidar com todos.\n\n### Lembretes\n\nPara lembretes, armazene tudo que é necessário para enviar a mensagem na linha do job: para quem é, qual canal (email, SMS, Telegram, in-app), qual template e o horário exato de envio. O worker deve conseguir rodar o job sem “procurar” contexto adicional.\n\nSe muitos lembretes vencem ao mesmo tempo, adicione rate limiting. Limite mensagens por minuto por canal e deixe jobs extras aguardarem a próxima execução.\n\n### Resumos diários\n\nResumos diários falham quando a janela de tempo é ambígua. Escolha um corte estável (por exemplo, 08:00 no horário local do usuário) e defina a janela claramente (por exemplo, “ontem 08:00 até hoje 08:00”). Armazene o horário de corte e o fuso do usuário com o job para que reexecuções produzam o mesmo resultado.\n\nMantenha cada job de resumo pequeno. Se precisar processar milhares de registros, divida em pedaços (por time, por conta ou por intervalo de IDs) e enfileire jobs de acompanhamento.\n\n### Tarefas de limpeza\n\nA limpeza é mais segura quando você separa “deletar” de “arquivar.” Decida o que pode ser removido para sempre (tokens temporários, sessões expiradas) e o que deve ser arquivado (logs de auditoria, faturas). Rode a limpeza em lotes previsíveis para evitar locks longos e picos de carga repentinos.\n\n## Tempo e fusos: a fonte oculta de bugs\n\nMuitas falhas são bugs de tempo: um lembrete sai uma hora cedo, um resumo diário pula a segunda-feira ou a limpeza roda duas vezes.\n\nUm bom padrão é armazenar timestamps de agendamento em UTC e guardar o fuso horário do usuário separadamente. Seu run_at deve ser um momento em UTC. Quando um usuário diz “9:00 AM no meu horário”, converta para UTC ao agendar.\n\nO horário de verão é onde configurações ingênuas quebram. “Todo dia às 9:00 AM” não é o mesmo que “a cada 24 horas”. Em mudanças de DST, 9:00 AM mapeia para um UTC diferente, e alguns horários locais não existem (spring forward) ou acontecem duas vezes (fall back). A abordagem mais segura é calcular a próxima ocorrência local cada vez que você reagenda e então convertê-la para UTC.\n\nPara um resumo diário, decida o que “um dia” significa antes de escrever código. Um dia calendário (meia-noite a meia-noite no fuso do usuário) corresponde à expectativa humana. “Últimas 24 horas” é mais simples, mas deriva e surpreende as pessoas.\n\nDados tardios são inevitáveis: um evento chega depois de um retry, ou uma nota é adicionada alguns minutos após a meia-noite. Decida se eventos tardios pertencem a “ontem” (com um período de carência) ou a “hoje” e mantenha essa regra consistente.\n\nUm buffer prático pode evitar faltas: \n\n- varrer jobs vencidos até 2 a 5 minutos atrás\n- tornar o job idempotente para que reexecuções sejam seguras\n- registrar o intervalo de tempo coberto no payload para que resumos sejam consistentes\n\n## Erros comuns que causam execuções perdidas ou duplicadas\n\nA maior parte da dor vem de algumas suposições previsíveis.\n\nA maior é presumir execução “exatamente uma vez”. Em sistemas reais, workers reiniciam, chamadas de rede estouram, locks podem ser perdidos. Tipicamente você obtém entrega “pelo menos uma vez”, o que significa que duplicatas são normais e seu código deve tolerá-las.\n\nOutro erro é fazer efeitos colaterais primeiro (enviar e-mail, cobrar cartão) sem uma checagem de dedupe. Uma defesa simples costuma resolver: um timestamp sent_at, uma chave única como (user_id, reminder_type, date) ou um token de dedupe armazenado.\n\nVisibilidade é a próxima lacuna. Se você não consegue responder “o que está preso, desde quando e por quê”, você vai acabar adivinhando. Os dados mínimos a manter perto são status, contagem de tentativas, próximo horário agendado, último erro e id do worker.\n\nOs erros que aparecem com mais frequência: \n\n- desenhar jobs como se rodassem exatamente uma vez e se surpreender com duplicatas\n- executar efeitos sem checagem de dedupe\n- rodar um job gigante que tenta fazer tudo e atinge timeout no meio\n- reexecutar para sempre sem limite\n- pular visibilidade básica da fila (sem visão clara de backlog, falhas e itens de longa execução)\n\nUm exemplo concreto: um job de resumo diário itera 50.000 usuários e dá timeout no usuário 20.000. No retry ele recomeça e envia resumos de novo para os primeiros 20.000, a menos que você rastreie conclusão por usuário ou divida em jobs por usuário.\n\n## Checklist rápido para um sistema de jobs confiável\n\nUm job runner só está “pronto” quando você pode confiar nele às 2 da manhã.\n\nCertifique-se de ter: \n\n- Visibilidade da fila: contagens de queued vs running vs failed, além do job queued mais antigo.\n- Idempotência por padrão: suponha que todo job pode rodar duas vezes; use chaves únicas ou marcadores de “já processado”.\n- Política de retry por tipo de job: retries, backoff e condição clara de parada.\n- Armazenamento de tempo consistente: mantenha run_at em UTC; converta apenas na entrada e na exibição.\n- Locks recuperáveis: uma lease para que crashes não deixem jobs rodando para sempre.\n\nTambém limite tamanho do lote (quantos jobs reivindicar por vez) e concorrência do worker (quantos rodam ao mesmo tempo). Sem limites, um pico pode sobrecarregar seu banco ou prejudicar outros trabalhos.\n\n## Um exemplo realista: lembretes e resumos para um time pequeno\n\nUma pequena ferramenta SaaS tem 30 contas de cliente. Cada conta quer duas coisas: um lembrete às 9:00 AM para quaisquer tarefas abertas e um resumo diário às 6:00 PM do que mudou no dia. Eles também precisam de limpeza semanal para que o banco não encha com logs e tokens expirados.\n\nEles usam uma tabela de jobs mais um worker que faz polling por jobs vencidos. Quando um novo cliente se cadastra, o backend agenda a primeira execução de lembrete e resumo baseado no fuso horário do cliente.\n\nJobs são criados em alguns momentos comuns: no cadastro (criar agendas recorrentes), em certos eventos (enfileirar notificações pontuais), no tick do agendador (inserir execuções futuras) e no dia de manutenção (enfileirar limpeza).\n\nNuma terça-feira, o provedor de e-mail tem uma queda temporária às 8:59 AM. O worker tenta enviar lembretes, recebe timeout e reagenda esses jobs ajustando run_at com backoff (por exemplo, 2 minutos, depois 10, depois 30), incrementando attempts a cada vez. Como cada job de lembrete tem uma chave de idempotência como account_id + date + job_type, retries não produzem duplicatas se o provedor se recuperar no meio.\n\nA limpeza roda semanalmente em pequenos lotes, então não bloqueia outros trabalhos. Em vez de deletar um milhão de linhas num job, deleta-se até N linhas por execução e o job se reagenda até terminar.\n\nQuando um cliente reclama “Não recebi meu resumo”, a equipe checa a tabela de jobs para aquela conta e dia: status do job, contagem de tentativas, campos de lock atuais e o último erro retornado pelo provedor. Isso transforma “deveria ter enviado” em “aqui está exatamente o que aconteceu”.\n\n## Próximos passos: implemente, observe e escale\n\nEscolha um tipo de job e construa ele de ponta a ponta antes de adicionar mais. Um job de lembrete é um bom começo pois toca em tudo: agendamento, reivindicação de trabalho, envio de mensagem e registro de resultados.\n\nComece com uma versão em que você confia: \n\n- crie a tabela de jobs e um worker que processe um tipo de job\n- adicione um loop scheduler que reivindique e execute jobs vencidos\n- armazene payload suficiente para rodar o job sem adivinhação\n- registre cada tentativa e resultado para que “rodou?” vire uma pergunta de 10 segundos\n- adicione um caminho manual para reexecutar jobs falhos para que recuperação não exija deploy\n\nQuando rodar, torne observável para humanos. Mesmo uma visão administrativa básica paga rápido: buscar jobs por status, filtrar por tempo, inspecionar payload, cancelar um job preso, reexecutar um id específico.\n\nSe preferir construir esse fluxo de scheduler e worker com lógica de backend visual, AppMaster (appmaster.io) pode modelar a tabela de jobs no PostgreSQL e implementar o loop claim-process-update como um Business Process, gerando ainda código-fonte real para deploy.

Fácil de começar
Criar algo espantoso

Experimente o AppMaster com plano gratuito.
Quando estiver pronto, você poderá escolher a assinatura adequada.

Comece
Agendamento de tarefas em segundo plano sem as dores de cabeça do cron: padrões | AppMaster