Aller au contenu

Conception d'un ordonnanceur de tâches distribué : cron à grande échelle sans double exécution

Concevez un ordonnanceur distribué : stockage des planifications, SKIP LOCKED, leases, exécutions idempotentes, retries, misfires et heure d'été.

· Fondateur et développeur

Publié le

Architecture. Une Job API écrit dans Postgres. Trois workers d'ordonnancement dans un cadre prennent des leases de partitions d'etcd et envoient les exécutions dans une file. Des workers d'exécution appellent les cibles, gardent l'historique, retentent ou vont en dead-letter.

Votre produit envoie à chaque client un rapport d'usage par e-mail à 09:00 dans son fuseau horaire, synchronise les comptes CRM toutes les 15 minutes et fait expirer les essais à minuit. Sur un seul serveur, cron s'en charge. Ajoutez un deuxième serveur et le rapport part deux fois. Ajoutez un verrou, laissez son détenteur se bloquer pendant un déploiement, et un matin le rapport ne part pas du tout.

Cet article conçoit un ordonnanceur de tâches distribué qui démarre des millions de tâches à l'heure sans laisser un doublon faire des dégâts, du stockage des planifications et des leases à l'heure d'été, aux misfires, à la montée en charge et aux outils existants.

Exigences et chiffres qui comptent

Supposez un produit SaaS multi-tenant :

  • 10 millions de planifications actives, la plupart horaires ou quotidiennes.
  • 240 millions d'exécutions par jour si la planification moyenne se déclenche 24 fois : environ 2 800 par seconde (240 000 000 / 86 400).
  • Les exécutions démarrent dans les secondes qui suivent leur échéance et durent jusqu'à 30 minutes.
  • 30 jours d'historique : 240 millions de lignes par jour à environ 200 octets, soit 48 Go par jour et 1,4 To par mois.

Les gens écrivent 0 * * * *, donc les échéances s'accumulent sur les minutes rondes. Si 3 millions de planifications horaires utilisent la minute 0, 3 millions d'exécutions arrivent à échéance en même temps, et les répartir en une minute demande 50 000 par seconde, soit 18 fois la moyenne. Jenkins a ajouté le symbole cron H pour cela : il hache le nom de la tâche vers une minute stable. Répartissez vos tâches internes de la même façon.

Modèle de données : tâches et exécutions

jobs contient chaque définition et son prochain instant d'échéance ; job_runs contient une ligne par occurrence.

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) est la clé d'exécution : une occurrence, et l'ancrage de toutes les protections suivantes. Les partitions quotidiennes font de la rétention un simple drop de partition ; PostgreSQL autorise la clé primaire parce qu'elle inclut la colonne de partition.

Trouver les tâches dues

Chaque ordonnanceur interroge les partitions dont il est propriétaire :

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;

Dans la même transaction, il insère une ligne job_runs par tâche et avance next_run_at. SKIP LOCKED saute les lignes qu'une autre transaction détient au lieu d'attendre, un usage que PostgreSQL documente pour les tables de type file. Après le commit, les pollers suivants ne voient plus la ligne comme due : chaque occurrence est distribuée une seule fois.

Publier dans la file après le commit est une double écriture ; laissez donc la nouvelle ligne d'exécution servir aussi d'enregistrement outbox, comme dans le pattern transactional outbox. Le polling a des alternatives :

Approche Trouve le travail dû via Point faible
Polling de la table jobs Un index sur next_run_at avec SKIP LOCKED Chaque déclenchement met à jour une colonne indexée : pas de mises à jour HOT, vacuum constant
Partitions par tranche de temps La lecture de la tranche de la minute courante Les insertions tardives dans une tranche déjà lue exigent un balayage
File à délai Un broker ou un sorted set qui libère les exécutions à l'échéance Plafonds de délai (Amazon SQS : 15 minutes) ; le stockage reste la source de vérité

Leases, heartbeats et exécutions idempotentes

À partir de la file, la livraison est at-least-once : un consommateur peut planter avant l'acquittement, et un relais peut publier deux fois. L'exécuteur réserve donc d'abord l'exécution :

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;

Aucune ligne renvoyée signifie qu'un lease actif existe ailleurs ou que l'exécution est terminée : abandonnez le message. L'exécuteur envoie un heartbeat toutes les 10 secondes, donc un lease de 30 secondes survit à un heartbeat perdu ; s'il meurt, le lease expire et l'ordonnanceur propriétaire republie l'exécution.

Un exécuteur en pause peut se réveiller après l'expiration de son lease et continuer à écrire. Le attempt renvoyé est un fencing token, comme dans l'analyse des verrous distribués de Martin Kleppmann : les résultats sont écrits avec WHERE attempt = $3, si bien qu'une tentative périmée ne peut pas écraser une plus récente. Les cibles hors de votre base reçoivent la clé d'exécution comme clé d'idempotence.

C'est la garantie honnête : distribution une fois par clé d'exécution, exécution au moins une fois, effets une seule fois uniquement pour les tâches idempotentes. La documentation des CronJob Kubernetes dit la même chose, car un CronJob peut créer deux Jobs ou aucun ; son contrôleur nomme même chaque Job d'après l'heure planifiée, de sorte qu'une création en double échoue.

Retries avec backoff et un état dead-letter

  • Classez les erreurs. Les timeouts, les 5xx et les 429 sont retentables ; les erreurs de validation vont directement à dead.
  • Appliquez un backoff avec full jitter : delay = random(0, min(cap, base * 2^attempt)). Avec une base de 10 secondes, quatre retries attendent jusqu'à 20, 40, 80 et 160 secondes, et le cinquième échec est définitif. Si une cible entière est en panne, les budgets de retry et les circuit breakers évitent que les retries s'accumulent.
  • Gardez l'état dead utile : la charge utile, la dernière erreur, une alerte et un replay qui réutilise la clé d'exécution.
  • Plafonnez la concurrence par tenant, comme le fait un rate limiter, pour que la rafale d'un tenant n'affame pas les autres.

Si l'exécution de 09:00 est encore en retry à 10:00, une politique de chevauchement tranche : ignorer, mettre en file, lancer les deux ou annuler l'ancienne. Kubernetes l'appelle concurrencyPolicy (Allow par défaut) ; les planifications Temporal utilisent Skip par défaut.

Sémantique de cron : fuseaux horaires, heure d'été et misfires

Stockez chaque expression cron avec une zone IANA comme America/New_York, jamais avec un décalage fixe, et calculez la prochaine exécution en heure locale avant de la convertir en UTC ; ajouter 24 heures dérive deux fois par an. Utilisez UTC pour les tâches d'infrastructure et l'heure locale là où les gens attendent une heure d'horloge, comme pour les heures calmes d'un système de notifications.

L'heure d'été casse l'heure locale : à New York, la tranche de 2:00 à 2:59 n'a pas existé le 8 mars 2026, et celle de 1:00 à 1:59 s'est produite deux fois le 1er novembre 2026. Les ordonnanceurs ne s'accordent sur aucun des deux cas :

Ordonnanceur Heure manquante Heure répétée
Linux cron (cronie) S'exécute juste après le saut S'exécute une fois
Amazon EventBridge Scheduler Ignore l'exécution S'exécute une fois
Kubernetes CronJob avec timeZone Ignore l'exécution S'exécute deux fois

La ligne Kubernetes vient de sa bibliothèque cron, qui documente que les tâches du passage à l'heure d'été ne s'exécutent pas, et de l'issue 121594, une tâche de 02:00 à Berlin exécutée deux fois. Choisissez une règle et testez-la.

Un misfire est une exécution qui a démarré en retard parce que l'ordonnanceur était arrêté ou surchargé. Une politique par tâche s'applique quand un ordonnanceur réserve une ligne en retard :

Politique Tâche horaire après 3 heures de panne À utiliser pour Équivalents
Exécuter une fois S'exécute une fois, puis reprend Synchronisations, nettoyages Quartz smart policy, Airflow avec catchup désactivé (par défaut)
Ignorer Attend le créneau suivant Rappels Quartz DO_NOTHING, une courte fenêtre de catchup Temporal
Rattraper Exécute les 3, la plus ancienne d'abord Facturation horaire Quartz IGNORE_MISFIRE_POLICY, Airflow catchup=True

Quartz compte une exécution comme misfire après 60 secondes par défaut. Kubernetes a deux pièges : un startingDeadlineSeconds inférieur à 10 peut faire sauter des exécutions parce que le contrôleur vérifie toutes les 10 secondes, et après une panne il ne lance que la dernière exécution manquée, les précédentes sont perdues (au-delà de 100 planifications manquées, il enregistre en plus un avertissement TooManyMissedTimes).

Faire évoluer l'ordonnanceur

Trois façons d'exécuter plusieurs ordonnanceurs :

  1. Un leader unique. Une instance détient un lease (etcd, ZooKeeper, un Lease Kubernetes) et interroge tout. C'est simple mais limité à un nœud, et le failover attend l'expiration du lease : 15 secondes par défaut dans kube-controller-manager, qui héberge le contrôleur CronJob.
  2. Des pollers concurrents sur une table avec SKIP LOCKED : pas de coordinateur, mais un index chaud. Quartz fait du clustering via un verrou de base de données et prévient que les performances se dégradent au-delà d'environ trois nœuds.
  3. Propriété des partitions. Répartissez les tâches en 256 partitions logiques, bien plus que d'ordonnanceurs ; chaque ordonnanceur prend un lease sur ses partitions dans etcd et n'interroge que celles-ci. Le hachage cohérent ne déplace qu'une part quand un nœud arrive ou meurt, et etcd garde les leases cohérents grâce à Raft.

La propriété est une optimisation, pas la garantie : si un ancien propriétaire en pause interroge encore une fois après un transfert, le verrou de ligne, le next_run_at avancé et la clé d'exécution l'arrêtent. Les partitions deviennent aussi l'unité de sharding de la base.

Outils existants et ce que chacun fait de mieux

Outil Excelle pour Attention à
Kubernetes CronJob Conteneurs batch sur un cluster que vous exploitez Un objet par planification, timing approximatif
Quartz Ordonnancement dans une application JVM, stocké dans votre base Le verrou de cluster limite le scale-out
Temporal Schedules Tâches à plusieurs étapes avec retries durables Exploiter Temporal ou payer son cloud
Apache Airflow Pipelines de données avec dépendances et backfills Non conçu pour des millions de petites tâches de tenants
Amazon EventBridge Scheduler Déclencheurs managés sur AWS Quotas par défaut à la date de rédaction (novembre 2026) : 10 millions de planifications par Région, 1 000 invocations par seconde dans les Régions principales, tous deux ajustables

Ne construisez le vôtre que pour des millions de planifications définies par les tenants.

Le diagramme de l'ordonnanceur de tâches distribué

Architecture. Une Job API écrit dans Postgres. Trois workers d'ordonnancement dans un cadre prennent des leases de partitions d'etcd et envoient les exécutions dans une file. Des workers d'exécution appellent les cibles, gardent l'historique, retentent ou vont en dead-letter.

18 composants · 9 connexions

Ouvrir dans Archboard
Contenu du diagramme
  • 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 ordonnanceur de tâches distribué. Les ordonnanceurs de partitions réservent les tâches dues dans Postgres et publient des exécutions ; les exécuteurs prennent un lease, appellent les cibles avec une clé d'idempotence, retentent et envoient les échecs en dead-letter.

Comment le lire :

  • De gauche à droite, une exécution avance vers son traitement, depuis la Job API et le stockage des planifications.
  • Le cadre est la couche d'ordonnancement : trois workers se partagent 256 partitions grâce aux leases d'etcd et publient les exécutions dues.
  • Les workers d'exécution prennent un lease par exécution dans l'historique, appellent les cibles avec une clé d'idempotence et enregistrent le résultat.
  • Les arêtes en pointillés sont les chemins d'échec : retries avec backoff, et la dead-letter queue après le cinquième échec.
  • Le bloc Markdown liste les politiques de misfire et les chiffres de dimensionnement.

Dessiner clairement des diagrammes d'ordonnanceur de tâches

  • Écrivez la clé d'exécution sur le canevas. (job_id, scheduled_at) explique la protection contre les doublons mieux que n'importe quelle flèche.
  • Montrez la propriété : les partitions et l'origine des leases. Une boîte intitulée ordonnanceur cache la partie la plus difficile.
  • Tracez les chemins d'échec en pointillés et étiquetez leurs déclencheurs.
  • Mettez les durées sur le canevas : lease, heartbeat et plafond de retry déterminent le temps de reprise.

L'article sur le passage des exigences à un diagramme de conception applique les mêmes habitudes à un raccourcisseur d'URL.

Le construire dans Archboard

  1. Connectez-vous avec Google ou GitHub et créez un diagramme.
  2. Ajoutez un Conteneur du pack Modèle C4 pour la Job API et un nœud Postgres du pack Données pour le stockage des planifications.
  3. Ajoutez un Cadre du pack Base contenant trois nœuds Conteneur pour les ordonnanceurs, et un Conteneur au-dessus pour le coordinateur etcd.
  4. Ajoutez une File d'attente du pack Données pour les exécutions, un Conteneur pour les exécuteurs, un deuxième Postgres pour l'historique, une File d'attente pour les dead letters et un Système externe du pack Modèle C4 pour les services cibles.
  5. Tracez des arêtes étiquetées et mettez en pointillés les chemins de retry et de dead-letter.
  6. Ajoutez un bloc Markdown du pack Base avec les politiques de misfire, et de courtes notes Texte comme la requête de réservation et la clé d'exécution.

Une esquisse Mermaid du pipeline devient un premier brouillon avec l'outil de conversion Mermaid vers diagramme modifiable.

L'exemple utilise 13 nœuds de diagramme et reste dans les limites de l'offre Free.

FAQ

Comment empêcher une tâche planifiée de s'exécuter deux fois ? Utilisez l'ID de la tâche plus l'heure planifiée comme clé d'exécution unique. Réservez les tâches dues et avancez leur prochaine heure d'exécution dans une seule transaction, prenez un lease sur les exécutions avant de les lancer et transmettez la clé d'exécution aux cibles comme clé d'idempotence.

À quoi sert FOR UPDATE SKIP LOCKED ? Il verrouille les lignes renvoyées et saute celles que d'autres transactions détiennent, si bien que plusieurs workers peuvent interroger une table sans s'attendre. PostgreSQL l'a depuis la version 9.5.

Comment un ordonnanceur de tâches doit-il gérer l'heure d'été ? Stockez un fuseau IANA avec chaque planification, calculez les prochaines exécutions en UTC et décidez de ce qui se passe pendant l'heure manquante et l'heure répétée, car les outils diffèrent : un CronJob Kubernetes peut même s'exécuter deux fois quand les horloges reculent.

Qu'est-ce qu'un misfire dans l'ordonnancement de tâches ? Une exécution qui a manqué son heure de démarrage, en général parce que l'ordonnanceur était arrêté ou surchargé. La politique de misfire décide s'il faut exécuter une fois maintenant, sauter au créneau suivant ou rattraper chaque exécution manquée.

Kubernetes CronJob suffit-il pour un ordonnanceur distribué ? Pour quelques centaines de tâches batch sur un cluster que vous exploitez déjà, oui. Pour des millions de planifications de tenants, non : chaque planification est un objet Kubernetes, chaque exécution un Job avec des Pods, et le timing est approximatif.

Commencez gratuitement — un siège, sans carte bancaire.

Articles associés

← Retour au blog

Ouvrez un canevas en une minute

Connectez-vous avec Google ou GitHub. Gratuit pour un siège. Passez à un forfait supérieur quand l’équipe vous rejoint.