Pular para o conteúdo

Design de um agendador de tarefas distribuído: cron em escala sem execuções duplicadas

Projete um agendador distribuído: armazenamento de agendas, SKIP LOCKED, leases, execuções idempotentes, retentativas, misfires e horário de verão.

· Fundador e desenvolvedor

Publicado em

Diagrama de arquitetura. Uma Job API grava no Postgres. Três workers agendadores em uma moldura pegam leases de partições do etcd e enviam execuções a uma fila. Workers executores chamam os destinos, registram o histórico e tentam de novo ou vão à dead-letter.

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:

  1. 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.
  2. 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.
  3. 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

Diagrama de arquitetura. Uma Job API grava no Postgres. Três workers agendadores em uma moldura pegam leases de partições do etcd e enviam execuções a uma fila. Workers executores chamam os destinos, registram o histórico e tentam de novo ou vão à dead-letter.

18 componentes · 9 conexões

Abrir no Archboard
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
Um agendador de tarefas distribuído. Agendadores donos de partições reivindicam tarefas vencidas no Postgres e publicam execuções; executores pegam um lease, chamam os destinos com chave de idempotência, tentam de novo com backoff e enviam as esgotadas a uma dead-letter queue.

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

  1. Entre com Google ou GitHub e crie um diagrama.
  2. Adicione um Contêiner do pacote Modelo C4 para a Job API e um nó Postgres do pacote Dados para o armazenamento de agendas.
  3. 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.
  4. 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.
  5. Desenhe arestas com rótulos e trace as linhas de retentativa e dead-letter.
  6. 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.

Comece grátis — um assento, sem cartão de crédito.

Artigos relacionados

← Voltar ao blog

Abra um canvas em um minuto

Entre com Google ou GitHub. Grátis para um assento. Faça upgrade quando a equipe entrar.