Saltar al contenido

Diseño de un planificador de tareas distribuido: cron a escala sin ejecuciones dobles

Diseña un planificador distribuido: almacén de planificaciones, SKIP LOCKED, leases, ejecuciones idempotentes, reintentos, misfires y horario de verano.

· Fundador y desarrollador

Publicado el

Diagrama de arquitectura. Una Job API escribe en Postgres. Tres workers planificadores en un marco toman leases de particiones de etcd y envían ejecuciones a una cola. Workers ejecutores llaman a los destinos, registran el historial y reintentan o van a dead-letter.

Tu producto envía a cada cliente un informe de uso por correo a las 09:00 en su zona horaria, sincroniza cuentas de CRM cada 15 minutos y caduca las pruebas a medianoche. En un solo servidor, cron lo resuelve. Añade un segundo servidor y el informe sale dos veces. Añade un lock, deja que quien lo tiene se cuelgue durante un despliegue, y una mañana el informe sale cero veces.

Este artículo diseña un planificador de tareas distribuido que arranca millones de tareas a tiempo sin dejar que un duplicado cause daño, desde el almacén de planificaciones y los leases hasta el horario de verano, los misfires, el escalado y las herramientas existentes.

Requisitos y los números que importan

Supón un producto SaaS multi-tenant:

  • 10 millones de planificaciones activas, la mayoría por hora o por día.
  • 240 millones de ejecuciones al día si la planificación media se dispara 24 veces: unas 2 800 por segundo (240 000 000 / 86 400).
  • Las ejecuciones arrancan a los pocos segundos de su vencimiento y duran hasta 30 minutos.
  • 30 días de historial: 240 millones de filas al día a unos 200 bytes son 48 GB al día, 1,4 TB al mes.

La gente escribe 0 * * * *, así que los vencimientos se amontonan en los minutos redondos. Si 3 millones de planificaciones horarias usan el minuto 0, 3 millones de ejecuciones vencen a la vez, y despacharlas en un minuto exige 50 000 por segundo, 18 veces la media. Jenkins añadió el símbolo cron H para esto: aplica un hash al nombre del trabajo para obtener un minuto estable. Reparte tus tareas internas igual.

Modelo de datos: tareas y ejecuciones

jobs guarda cada definición y su próximo instante de vencimiento; job_runs guarda una fila por ocurrencia.

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) es la clave de ejecución: una ocurrencia y el ancla de todas las salvaguardas posteriores. Las particiones diarias convierten la retención en un drop de partición; PostgreSQL permite la clave primaria porque incluye la columna de partición.

Encontrar las tareas vencidas

Cada planificador consulta las particiones que posee:

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;

En la misma transacción inserta una fila job_runs por tarea y adelanta next_run_at. SKIP LOCKED salta las filas que retiene otra transacción en lugar de esperar, un uso que PostgreSQL documenta para tablas tipo cola. Tras el commit, los pollers posteriores ya no ven la fila como vencida: cada ocurrencia se despacha una vez.

Publicar en la cola tras el commit es una doble escritura, así que haz que la nueva fila de ejecución sirva también de registro outbox, como en el patrón transactional outbox. El polling tiene alternativas:

Enfoque Encuentra el trabajo vencido mediante Punto débil
Polling de la tabla jobs Un índice sobre next_run_at con SKIP LOCKED Cada disparo actualiza una columna indexada: sin actualizaciones HOT, vacuum constante
Particiones por cubos de tiempo Leer el cubo del minuto actual Las inserciones tardías en un cubo ya leído necesitan un barrido
Cola con retardo Un broker o sorted set que libera las ejecuciones al vencer Límites de retardo (Amazon SQS: 15 minutos); el almacén sigue siendo la fuente de verdad

Leases, heartbeats y ejecuciones idempotentes

Desde la cola, la entrega es at-least-once: un consumidor puede caerse antes de confirmar y un relay puede publicar dos veces. Por eso el ejecutor reclama primero la ejecución:

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;

Si no vuelve ninguna fila, hay un lease activo en otro sitio o la ejecución ya terminó: descarta el mensaje. El ejecutor envía un heartbeat cada 10 segundos, de modo que un lease de 30 segundos sobrevive a un heartbeat perdido; si muere, el lease expira y el planificador dueño vuelve a publicar la ejecución.

Un ejecutor pausado puede despertar tras expirar su lease y seguir escribiendo. El attempt devuelto es un fencing token, como en el análisis de locks distribuidos de Martin Kleppmann: los resultados se escriben con WHERE attempt = $3, así que un intento obsoleto no puede sobrescribir uno más nuevo. Los destinos fuera de tu base de datos reciben la clave de ejecución como clave de idempotencia.

Esa es la garantía honesta: despacho una vez por clave de ejecución, ejecución al menos una vez, efectos una sola vez solo en tareas idempotentes. La documentación de CronJob de Kubernetes dice lo mismo, porque un CronJob puede crear dos Jobs o ninguno; su controlador incluso nombra cada Job según la hora planificada, de modo que una creación duplicada falla.

Reintentos con backoff y un estado dead-letter

  • Clasifica los errores. Los timeouts, 5xx y 429 se pueden reintentar; los errores de validación van directos a dead.
  • Aplica backoff con full jitter: delay = random(0, min(cap, base * 2^attempt)). Con una base de 10 segundos, cuatro reintentos esperan hasta 20, 40, 80 y 160 segundos, y el quinto fallo es definitivo. Si cae un destino entero, los presupuestos de reintentos y los circuit breakers evitan que los reintentos se acumulen.
  • Mantén útil el estado dead: la carga útil, el último error, una alerta y un replay que reutilice la clave de ejecución.
  • Limita la concurrencia por tenant, como hace un rate limiter, para que la ráfaga de un tenant no deje sin recursos al resto.

Si la ejecución de las 09:00 sigue reintentándose a las 10:00, una política de solapamiento decide: omitir, encolar, ejecutar ambas o cancelar la antigua. Kubernetes la llama concurrencyPolicy (por defecto Allow); las planificaciones de Temporal usan Skip por defecto.

Semántica de cron: zonas horarias, horario de verano y misfires

Guarda cada expresión cron con una zona IANA como America/New_York, nunca con un desfase fijo, y calcula la próxima ejecución en hora local antes de convertirla a UTC; sumar 24 horas se desvía dos veces al año. Usa UTC para las tareas de infraestructura y la hora local donde la gente espera una hora de reloj, como en las horas de silencio de un sistema de notificaciones.

El horario de verano rompe la hora local: en Nueva York, de 2:00 a 2:59 no existió el 8 de marzo de 2026, y de 1:00 a 1:59 ocurrió dos veces el 1 de noviembre de 2026. Los planificadores no coinciden en ninguno de los dos casos:

Planificador Hora inexistente Hora repetida
Linux cron (cronie) Se ejecuta justo después del salto Se ejecuta una vez
Amazon EventBridge Scheduler Omite la ejecución Se ejecuta una vez
Kubernetes CronJob con timeZone Omite la ejecución Se ejecuta dos veces

La fila de Kubernetes sale de su biblioteca cron, que documenta que las tareas del adelanto de hora no se ejecutan, y del issue 121594, una tarea de las 02:00 en Berlín que se ejecutó dos veces. Elige una regla y pruébala.

Un misfire es una ejecución que empezó tarde porque el planificador estaba caído o sobrecargado. Una política por tarea se aplica cuando un planificador reclama una fila atrasada:

Política Tarea horaria tras 3 horas de caída Úsala para Equivalentes
Ejecutar una vez Se ejecuta una vez y luego se reanuda Sincronizaciones, limpiezas Quartz smart policy, Airflow con catchup desactivado (por defecto)
Omitir Espera al siguiente hueco Recordatorios Quartz DO_NOTHING, una ventana corta de catchup de Temporal
Recuperar Ejecuta las 3, la más antigua primero Facturación por hora Quartz IGNORE_MISFIRE_POLICY, Airflow catchup=True

Quartz cuenta una ejecución como misfire a los 60 segundos por defecto. Kubernetes tiene dos trampas: un startingDeadlineSeconds inferior a 10 puede omitir ejecuciones porque el controlador comprueba cada 10 segundos, y tras una caída solo lanza la ejecución perdida más reciente, así que las anteriores se pierden (con más de 100 planificaciones perdidas, además registra un aviso TooManyMissedTimes).

Escalar el planificador

Tres formas de ejecutar varios planificadores:

  1. Un único líder. Una instancia mantiene un lease (etcd, ZooKeeper, un Lease de Kubernetes) y consulta todo. Es simple pero limitado a un nodo, y el failover espera a que venza el lease: 15 segundos por defecto en kube-controller-manager, donde vive el controlador de CronJob.
  2. Pollers competidores sobre una tabla con SKIP LOCKED: sin coordinador, pero con un índice caliente. Quartz forma clúster mediante un lock de base de datos y avisa de que se degrada a partir de unos tres nodos.
  3. Propiedad de particiones. Divide las tareas en 256 particiones lógicas, muchas más que planificadores; cada planificador toma en etcd el lease de sus particiones y consulta solo esas. El hashing consistente mueve solo una parte cuando un nodo entra o muere, y etcd mantiene los leases consistentes mediante Raft.

La propiedad es una optimización, no la garantía: si un antiguo dueño pausado consulta una vez más tras el traspaso, el bloqueo de fila, el next_run_at adelantado y la clave de ejecución lo detienen. Las particiones también se convierten en la unidad de sharding de la base de datos.

Herramientas existentes y en qué destaca cada una

Herramienta Buena para Ojo con
Kubernetes CronJob Contenedores batch en un clúster que operas tú Un objeto por planificación, temporización aproximada
Quartz Planificación dentro de una app JVM, guardada en tu base de datos El lock de clúster limita el scale-out
Temporal Schedules Tareas de varios pasos con reintentos duraderos Operar Temporal o pagar su nube
Apache Airflow Pipelines de datos con dependencias y backfills No está pensado para millones de tareas pequeñas de tenants
Amazon EventBridge Scheduler Disparadores gestionados en AWS Cuotas por defecto en el momento de escribir (noviembre de 2026): 10 millones de planificaciones por Región, 1 000 invocaciones por segundo en las Regiones principales, ambas ajustables

Construye el tuyo solo para millones de planificaciones definidas por tenants.

El diagrama del planificador de tareas distribuido

Diagrama de arquitectura. Una Job API escribe en Postgres. Tres workers planificadores en un marco toman leases de particiones de etcd y envían ejecuciones a una cola. Workers ejecutores llaman a los destinos, registran el historial y reintentan o van a dead-letter.

18 componentes · 9 conexiones

Abrir en Archboard
Contenido del 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
Un planificador de tareas distribuido. Los planificadores de particiones reclaman tareas vencidas en Postgres y publican ejecuciones; los ejecutores toman un lease, llaman a los destinos con clave de idempotencia, reintentan y envían las agotadas a dead-letter.

Cómo leerlo:

  • De izquierda a derecha, una ejecución avanza hacia su procesamiento, desde la Job API y el almacén de planificaciones.
  • El marco es la capa de planificadores: tres workers se reparten 256 particiones con leases de etcd y publican las ejecuciones vencidas.
  • Los workers ejecutores toman un lease por ejecución en el historial, llaman a los destinos con una clave de idempotencia y registran el resultado.
  • Las aristas discontinuas son rutas de fallo: reintentos con backoff y la dead-letter queue tras el quinto fallo.
  • El bloque Markdown lista las políticas de misfire y las cifras de dimensionamiento.

Dibujar con claridad diagramas de planificadores de tareas

  • Escribe la clave de ejecución en el lienzo. (job_id, scheduled_at) explica la protección contra duplicados mejor que cualquier flecha.
  • Muestra la propiedad: las particiones y de dónde vienen los leases. Una caja rotulada planificador oculta la parte más difícil.
  • Dibuja discontinuas las rutas de fallo y etiqueta sus disparadores.
  • Pon los tiempos en el lienzo: lease, heartbeat y tope de reintentos deciden el tiempo de recuperación.

El artículo sobre cómo pasar de los requisitos a un diagrama de diseño aplica los mismos hábitos a un acortador de URL.

Constrúyelo en Archboard

  1. Inicia sesión con Google o GitHub y crea un diagrama.
  2. Añade un Contenedor del paquete Modelo C4 para la Job API y un nodo Postgres del paquete Datos para el almacén de planificaciones.
  3. Añade un Marco del paquete Básico con tres nodos Contenedor para los planificadores, y un Contenedor encima para el coordinador etcd.
  4. Añade una Cola del paquete Datos para las ejecuciones, un Contenedor para los ejecutores, un segundo Postgres para el historial, una Cola para las dead letters y un Sistema externo del paquete Modelo C4 para los servicios destino.
  5. Dibuja aristas con etiquetas y haz discontinuas las rutas de reintento y dead-letter.
  6. Añade un bloque Markdown del paquete Básico con las políticas de misfire, y notas breves de Texto como la consulta de reclamo y la clave de ejecución.

Un boceto Mermaid del pipeline se convierte en un primer borrador con la herramienta de Mermaid a diagrama editable.

El ejemplo usa 13 nodos de diagrama, dentro del plan Free.

Preguntas frecuentes

¿Cómo se evita que una tarea programada se ejecute dos veces? Usa el ID de la tarea más la hora planificada como clave de ejecución única. Reclama las tareas vencidas y adelanta su próxima hora de ejecución en una sola transacción, toma un lease de las ejecuciones antes de lanzarlas y pasa la clave de ejecución a los destinos como clave de idempotencia.

¿Qué hace FOR UPDATE SKIP LOCKED? Bloquea las filas devueltas y salta las que retienen otras transacciones, de modo que varios workers pueden consultar una tabla sin esperarse entre sí. PostgreSQL lo tiene desde la versión 9.5.

¿Cómo debe gestionar un planificador de tareas el horario de verano? Guarda una zona horaria IANA con cada planificación, calcula las próximas ejecuciones en UTC y decide qué ocurre en la hora inexistente y en la repetida, porque las herramientas difieren: un CronJob de Kubernetes incluso puede ejecutarse dos veces cuando se retrasan los relojes.

¿Qué es un misfire en la planificación de tareas? Una ejecución que perdió su hora de inicio, normalmente porque el planificador estaba caído o sobrecargado. La política de misfire decide si ejecutar una vez ahora, saltar al siguiente hueco o recuperar cada ejecución perdida.

¿Basta Kubernetes CronJob para un planificador distribuido? Para unos cientos de tareas batch en un clúster que ya operas, sí. Para millones de planificaciones de tenants, no: cada planificación es un objeto de Kubernetes, cada ejecución un Job con Pods, y la temporización es aproximada.

Empieza gratis — un puesto, sin tarjeta de crédito.

Artículos relacionados

← Volver al blog

Abre un lienzo en un minuto

Inicia sesión con Google o GitHub. Gratis para un asiento. Mejora el plan cuando se una el equipo.