Seu produto envia a cada cliente um relatório de uso por e-mail às 09:00 no fuso dele, sincroniza contas de CRM a cada 15 minutos e expira testes à meia-noite. Em um servidor só, o cron resolve. Adicione um segundo servidor e o relatório sai duas vezes. Adicione um lock, deixe quem o segura travar durante um deploy, e numa manhã o relatório sai zero vezes.
Este artigo projeta um agendador de tarefas distribuído que inicia milhões de tarefas no horário sem deixar um duplicado causar dano, do armazenamento de agendas e dos leases ao horário de verão, aos misfires, à escala e às ferramentas existentes.
Requisitos e os números que importam
Suponha um produto SaaS multi-tenant:
- 10 milhões de agendas ativas, a maioria por hora ou por dia.
- 240 milhões de execuções por dia se a agenda média disparar 24 vezes: cerca de 2.800 por segundo (240.000.000 / 86.400).
- As execuções começam em poucos segundos após o vencimento e duram até 30 minutos.
- 30 dias de histórico: 240 milhões de linhas por dia a cerca de 200 bytes são 48 GB por dia, 1,4 TB por mês.
As pessoas escrevem 0 * * * *, então os vencimentos se acumulam nos minutos redondos. Se 3 milhões de agendas horárias usam o minuto 0, 3 milhões de execuções vencem ao mesmo tempo, e despachá-las em um minuto exige 50.000 por segundo, 18 vezes a média. O Jenkins adicionou o símbolo cron H para isso: ele aplica hash ao nome do job para obter um minuto estável. Distribua as tarefas internas do mesmo jeito.
Modelo de dados: tarefas e execuções
jobs guarda cada definição e seu próximo instante de vencimento; job_runs guarda uma linha por ocorrência.
CREATE TABLE jobs (
job_id uuid PRIMARY KEY,
cron_expr text NOT NULL, -- '0 9 * * *'
time_zone text NOT NULL, -- 'America/New_York'
misfire text NOT NULL DEFAULT 'run_once',
partition_id smallint NOT NULL, -- hash(job_id) % 256
next_run_at timestamptz NOT NULL, -- a UTC instant
paused boolean NOT NULL DEFAULT false
);
CREATE INDEX jobs_due ON jobs (partition_id, next_run_at) WHERE NOT paused;
CREATE TABLE job_runs (
job_id uuid NOT NULL,
scheduled_at timestamptz NOT NULL,
attempt int NOT NULL DEFAULT 0,
status text NOT NULL, -- queued, running, retrying, succeeded, dead
lease_until timestamptz,
PRIMARY KEY (job_id, scheduled_at) -- the run key
) PARTITION BY RANGE (scheduled_at);
(job_id, scheduled_at) é a chave de execução: uma ocorrência e a âncora de toda salvaguarda posterior. Partições diárias tornam a retenção um drop de partição; o PostgreSQL permite a chave primária porque ela inclui a coluna de partição.
Encontrando as tarefas vencidas
Cada agendador consulta as partições que possui:
SELECT job_id, next_run_at
FROM jobs
WHERE partition_id = ANY($1) AND NOT paused
AND next_run_at <= now()
ORDER BY next_run_at
LIMIT 500
FOR UPDATE SKIP LOCKED;
Na mesma transação, ele insere uma linha job_runs por tarefa e avança next_run_at. SKIP LOCKED pula as linhas que outra transação segura em vez de esperar, um uso que o PostgreSQL documenta para tabelas semelhantes a filas. Após o commit, os pollers seguintes não veem mais a linha como vencida: cada ocorrência é despachada uma vez.
Publicar na fila após o commit é uma escrita dupla, então faça a nova linha de execução servir também como registro de outbox, como no padrão transactional outbox. O polling tem alternativas:
| Abordagem | Encontra trabalho vencido por | Ponto fraco |
|---|---|---|
| Polling da tabela jobs | Um índice em next_run_at com SKIP LOCKED |
Cada disparo atualiza uma coluna indexada: sem atualizações HOT, vacuum constante |
| Partições por balde de tempo | Ler o balde do minuto atual | Inserções tardias em um balde já lido precisam de uma varredura |
| Fila com atraso | Um broker ou sorted set que libera execuções no vencimento | Limites de atraso (Amazon SQS: 15 minutos); o armazenamento continua sendo a fonte de verdade |
Leases, heartbeats e execuções idempotentes
A partir da fila, a entrega é at-least-once: um consumidor pode cair antes de confirmar e um relay pode publicar duas vezes. Por isso o executor reivindica a execução primeiro:
UPDATE job_runs
SET status = 'running', attempt = attempt + 1,
lease_until = now() + interval '30 seconds'
WHERE job_id = $1 AND scheduled_at = $2
AND (status IN ('queued', 'retrying')
OR (status = 'running' AND lease_until < now()))
RETURNING attempt;
Se nenhuma linha voltar, há um lease ativo em outro lugar ou a execução terminou: descarte a mensagem. O executor envia um heartbeat a cada 10 segundos, então um lease de 30 segundos sobrevive a um heartbeat perdido; se ele morrer, o lease expira e o agendador dono republica a execução.
Um executor pausado pode acordar depois que seu lease expirou e continuar escrevendo. O attempt retornado é um fencing token, como na análise de locks distribuídos de Martin Kleppmann: os resultados são gravados com WHERE attempt = $3, de modo que uma tentativa obsoleta não sobrescreve uma mais nova. Destinos fora do seu banco de dados recebem a chave de execução como chave de idempotência.
Essa é a garantia honesta: despacho uma vez por chave de execução, execução ao menos uma vez, efeitos uma única vez só para tarefas idempotentes. A documentação do CronJob do Kubernetes diz o mesmo, porque um CronJob pode criar dois Jobs ou nenhum; seu controlador até nomeia cada Job pelo horário agendado, de modo que uma criação duplicada falha.
Retentativas com backoff e um estado dead-letter
- Classifique os erros. Timeouts, 5xx e 429 podem ser repetidos; erros de validação vão direto para dead.
- Aplique backoff com full jitter:
delay = random(0, min(cap, base * 2^attempt)). Com base de 10 segundos, quatro retentativas esperam até 20, 40, 80 e 160 segundos, e a quinta falha é definitiva. Se um destino inteiro cair, orçamentos de retentativa e circuit breakers evitam que as retentativas se acumulem. - Mantenha o estado dead útil: o payload, o último erro, um alerta e um replay que reutilize a chave de execução.
- Limite a concorrência por tenant, como faz um rate limiter, para que o pico de um tenant não deixe os demais sem recursos.
Se a execução das 09:00 ainda estiver em retentativa às 10:00, uma política de sobreposição decide: pular, enfileirar, executar as duas ou cancelar a antiga. O Kubernetes a chama de concurrencyPolicy (padrão Allow); as agendas do Temporal usam Skip por padrão.
Semântica do cron: fusos horários, horário de verão e misfires
Guarde cada expressão cron com uma zona IANA como America/New_York, nunca com um deslocamento fixo, e calcule a próxima execução em horário local antes de convertê-la para UTC; somar 24 horas desvia duas vezes por ano. Use UTC para tarefas de infraestrutura e o horário local onde as pessoas esperam um horário de relógio, como nas horas de silêncio de um sistema de notificações.
O horário de verão quebra o horário local: em Nova York, das 2:00 às 2:59 não existiu em 8 de março de 2026, e das 1:00 às 1:59 aconteceu duas vezes em 1º de novembro de 2026. Os agendadores divergem nos dois casos:
| Agendador | Horário inexistente | Horário repetido |
|---|---|---|
| Linux cron (cronie) | Executa logo após o salto | Executa uma vez |
| Amazon EventBridge Scheduler | Pula a execução | Executa uma vez |
Kubernetes CronJob com timeZone |
Pula a execução | Executa duas vezes |
A linha do Kubernetes vem da sua biblioteca cron, que documenta que tarefas do adiantamento do relógio não são executadas, e do issue 121594, uma tarefa das 02:00 em Berlim que rodou duas vezes. Escolha uma regra e teste-a.
Um misfire é uma execução que começou tarde porque o agendador estava fora do ar ou sobrecarregado. Uma política por tarefa se aplica quando um agendador reivindica uma linha atrasada:
| Política | Tarefa horária após 3 horas de queda | Use para | Equivalentes |
|---|---|---|---|
| Executar uma vez | Executa uma vez e depois retoma | Sincronizações, limpezas | Quartz smart policy, Airflow com catchup desligado (padrão) |
| Pular | Espera o próximo horário | Lembretes | Quartz DO_NOTHING, uma janela curta de catchup do Temporal |
| Recuperar | Executa as 3, da mais antiga à mais nova | Cobrança por hora | Quartz IGNORE_MISFIRE_POLICY, Airflow catchup=True |
O Quartz conta uma execução como misfire após 60 segundos por padrão. O Kubernetes tem duas armadilhas: um startingDeadlineSeconds abaixo de 10 pode pular execuções porque o controlador verifica a cada 10 segundos, e depois de uma queda ele inicia só a execução perdida mais recente, então as anteriores se perdem (com mais de 100 agendas perdidas, ele também registra um aviso TooManyMissedTimes).
Escalando o agendador
Três formas de executar vários agendadores:
- Um único líder. Uma instância segura um lease (etcd, ZooKeeper, um Lease do Kubernetes) e consulta tudo. É simples, mas limitado a um nó, e o failover espera o lease vencer: 15 segundos por padrão no kube-controller-manager, onde mora o controlador do CronJob.
- Pollers concorrentes em uma tabela com
SKIP LOCKED: sem coordenador, mas com um índice quente. O Quartz forma cluster por um lock de banco de dados e avisa que degrada além de cerca de três nós. - Propriedade de partições. Divida as tarefas em 256 partições lógicas, muito mais que agendadores; cada agendador pega no etcd o lease de suas partições e consulta só essas. O hashing consistente move apenas uma fração quando um nó entra ou morre, e o etcd mantém os leases consistentes por meio do Raft.
A propriedade é uma otimização, não a garantia: se um antigo dono pausado consultar mais uma vez após a transferência, o bloqueio de linha, o next_run_at avançado e a chave de execução o detêm. As partições também viram a unidade de sharding do banco de dados.
Ferramentas existentes e no que cada uma é boa
| Ferramenta | Boa para | Cuidado com |
|---|---|---|
| Kubernetes CronJob | Contêineres batch em um cluster que você opera | Um objeto por agenda, temporização aproximada |
| Quartz | Agendamento dentro de um app JVM, guardado no seu banco de dados | O lock de cluster limita o scale-out |
| Temporal Schedules | Tarefas de vários passos com retentativas duráveis | Operar o Temporal ou pagar pela nuvem dele |
| Apache Airflow | Pipelines de dados com dependências e backfills | Não foi feito para milhões de tarefas pequenas de tenants |
| Amazon EventBridge Scheduler | Gatilhos gerenciados na AWS | Cotas padrão no momento da escrita (novembro de 2026): 10 milhões de agendas por Região, 1.000 invocações por segundo nas Regiões principais, ambas ajustáveis |
Construa o seu apenas para milhões de agendas definidas por tenants.
O diagrama do agendador de tarefas distribuído
Conteúdo do diagrama
- Job API
- Schedule store (jobs)
- Coordinator
- Scheduler workers · 256 partitions
- Scheduler 1
- Scheduler 2
- Scheduler 3
- Run queue
- Executor workers
- Target services
- Run history (job_runs)
- Dead letter
- POST /jobs
- due jobs
- partition leases
- runs
- deliver
- call + Idempotency-Key
- lease, result
- after 5 attempts
- retry: backoff + jitter
Como ler:
- Da esquerda para a direita, uma execução avança rumo ao processamento, a partir da Job API e do armazenamento de agendas.
- A moldura é a camada de agendadores: três workers dividem 256 partições com leases do etcd e publicam as execuções vencidas.
- Os workers executores pegam um lease por execução no histórico, chamam os destinos com uma chave de idempotência e registram o resultado.
- As arestas tracejadas são caminhos de falha: retentativas com backoff e a dead-letter queue após a quinta falha.
- O bloco Markdown lista as políticas de misfire e os números de dimensionamento.
Desenhando diagramas de agendador de tarefas com clareza
- Escreva a chave de execução no canvas.
(job_id, scheduled_at)explica a proteção contra duplicatas melhor que qualquer seta. - Mostre a propriedade: as partições e de onde vêm os leases. Uma caixa rotulada agendador esconde a parte mais difícil.
- Desenhe tracejados os caminhos de falha e rotule seus gatilhos.
- Coloque os tempos no canvas: lease, heartbeat e limite de retentativas decidem o tempo de recuperação.
O artigo sobre como transformar requisitos em um diagrama de design aplica os mesmos hábitos a um encurtador de URL.
Monte no Archboard
- Entre com Google ou GitHub e crie um diagrama.
- Adicione um Contêiner do pacote Modelo C4 para a Job API e um nó Postgres do pacote Dados para o armazenamento de agendas.
- Adicione uma Moldura do pacote Básico com três nós Contêiner para os agendadores, e um Contêiner acima dela para o coordenador etcd.
- Adicione uma Fila do pacote Dados para as execuções, um Contêiner para os executores, um segundo Postgres para o histórico, uma Fila para as dead letters e um Sistema externo do pacote Modelo C4 para os serviços de destino.
- Desenhe arestas com rótulos e trace as linhas de retentativa e dead-letter.
- Adicione um bloco Markdown do pacote Básico com as políticas de misfire, e notas curtas de Texto como a consulta de reivindicação e a chave de execução.
Um esboço Mermaid do pipeline vira um primeiro rascunho com a ferramenta de Mermaid para diagrama editável.
O exemplo usa 13 nós de diagrama e fica dentro do plano Free.
Perguntas frequentes
Como evitar que uma tarefa agendada execute duas vezes? Use o ID da tarefa mais o horário agendado como chave de execução única. Reivindique as tarefas vencidas e avance o próximo horário de execução delas em uma única transação, pegue um lease das execuções antes de rodá-las e passe a chave de execução aos destinos como chave de idempotência.
O que o FOR UPDATE SKIP LOCKED faz? Ele bloqueia as linhas retornadas e pula as linhas que outras transações seguram, de modo que vários workers podem consultar uma tabela sem esperar uns pelos outros. O PostgreSQL o tem desde a versão 9.5.
Como um agendador de tarefas deve tratar o horário de verão? Guarde um fuso IANA com cada agenda, calcule as próximas execuções em UTC e decida o que acontece na hora inexistente e na repetida, porque as ferramentas diferem: um CronJob do Kubernetes pode até executar duas vezes quando os relógios voltam.
O que é um misfire no agendamento de tarefas? Uma execução que perdeu seu horário de início, geralmente porque o agendador estava fora do ar ou sobrecarregado. A política de misfire decide se executa uma vez agora, pula para o próximo horário ou recupera cada execução perdida.
O Kubernetes CronJob basta para um agendador distribuído? Para algumas centenas de tarefas batch em um cluster que você já opera, sim. Para milhões de agendas de tenants, não: cada agenda é um objeto do Kubernetes, cada execução um Job com Pods, e a temporização é aproximada.