Zum Inhalt springen

Verteilter Job-Scheduler im System Design: Cron im großen Maßstab ohne Doppelläufe

Entwirf einen verteilten Job-Scheduler: Schedule-Speicher, fällige Jobs mit SKIP LOCKED beanspruchen, Leases, idempotente Läufe, Retries, Misfires und Sommerzeit.

· Gründer & Entwickler

Veröffentlicht am

Architekturdiagramm. Eine Job API schreibt in einen Postgres-Speicher. Drei Scheduler-Worker in einem Rahmen holen Partitions-Leases von etcd und senden Läufe in eine Queue. Executor-Worker rufen Ziele auf, speichern die Historie und wiederholen per Queue oder Dead Letter.

Dein Produkt schickt jedem Kunden um 09:00 Uhr in seiner Zeitzone einen Nutzungsbericht per E-Mail, synchronisiert CRM-Konten alle 15 Minuten und lässt Testphasen um Mitternacht auslaufen. Auf einem Server erledigt das cron. Mit einem zweiten Server geht der Bericht doppelt raus. Mit einem Lock, dessen Halter bei einem Deploy hängen bleibt, geht er eines Morgens gar nicht raus.

Dieser Beitrag entwirft einen verteilten Job-Scheduler, der Millionen Jobs pünktlich startet, ohne dass ein Duplikat Schaden anrichtet: vom Schedule-Speicher und den Leases über Sommerzeit und Misfires bis zu Skalierung und bestehenden Tools.

Anforderungen und die Zahlen, auf die es ankommt

Nimm ein mandantenfähiges SaaS-Produkt an:

  • 10 Millionen aktive Schedules, die meisten stündlich oder täglich.
  • 240 Millionen Läufe pro Tag, wenn der durchschnittliche Schedule 24-mal feuert: etwa 2.800 pro Sekunde (240.000.000 / 86.400).
  • Läufe starten innerhalb weniger Sekunden nach ihrer Fälligkeit und dauern bis zu 30 Minuten.
  • 30 Tage Laufhistorie: 240 Millionen Zeilen pro Tag bei etwa 200 Byte sind 48 GB pro Tag, 1,4 TB pro Monat.

Menschen schreiben 0 * * * *, deshalb häufen sich Fälligkeiten auf volle Minuten. Nutzen 3 Millionen stündliche Schedules die Minute 0, werden 3 Millionen Läufe gleichzeitig fällig, und sie innerhalb einer Minute zu verteilen erfordert 50.000 pro Sekunde, das 18-Fache des Durchschnitts. Jenkins hat dafür das cron-Symbol H eingeführt: Es hasht den Jobnamen auf eine stabile Minute. Verteile interne Jobs genauso.

Datenmodell: Jobs und Läufe

jobs enthält jede Definition und ihren nächsten Fälligkeitszeitpunkt; job_runs enthält eine Zeile pro Ausführung.

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) ist der Run Key: eine Ausführung und der Anker jeder weiteren Absicherung. Tagespartitionen machen die Aufbewahrung zu einem Partition-Drop; PostgreSQL erlaubt den Primärschlüssel, weil er die Partitionsspalte enthält.

Fällige Jobs finden

Jeder Scheduler pollt die Partitionen, die ihm gehören:

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;

In derselben Transaktion legt er pro Job eine job_runs-Zeile an und setzt next_run_at weiter. SKIP LOCKED überspringt Zeilen, die eine andere Transaktion hält, statt zu warten; PostgreSQL dokumentiert diesen Einsatz für queue-artige Tabellen. Nach dem Commit sehen spätere Poller die Zeile nicht mehr als fällig: Jede Ausführung wird einmal dispatcht.

Das Veröffentlichen in die Queue nach dem Commit ist ein Dual Write. Lass die neue Run-Zeile deshalb zugleich als Outbox-Eintrag dienen, wie im Transactional-Outbox-Pattern. Zum Polling gibt es Alternativen:

Ansatz Findet fällige Arbeit über Schwachstelle
Polling der jobs-Tabelle Einen Index auf next_run_at mit SKIP LOCKED Jedes Feuern aktualisiert eine indizierte Spalte: keine HOT-Updates, ständige Vacuum-Arbeit
Zeit-Bucket-Partitionen Das Lesen des Buckets der aktuellen Minute Späte Inserts in einen bereits gelesenen Bucket brauchen einen Sweep
Delay-Queue Einen Broker oder Sorted Set, der Läufe bei Fälligkeit freigibt Delay-Obergrenzen (Amazon SQS: 15 Minuten); der Speicher bleibt die Source of Truth

Leases, Heartbeats und idempotente Läufe

Ab der Queue ist die Zustellung at-least-once: Ein Consumer kann vor dem Acknowledge abstürzen, und ein Relay kann doppelt veröffentlichen. Deshalb beansprucht der Executor den Lauf zuerst:

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;

Kommt keine Zeile zurück, gibt es anderswo ein aktives Lease oder der Lauf ist fertig: Verwirf die Nachricht. Der Executor sendet alle 10 Sekunden einen Heartbeat, sodass ein 30-Sekunden-Lease einen verlorenen Heartbeat übersteht; stirbt er, läuft das Lease ab und der zuständige Scheduler veröffentlicht den Lauf erneut.

Ein pausierter Executor kann nach Ablauf seines Leases aufwachen und weiterschreiben. Das zurückgegebene attempt ist ein Fencing Token, wie in Martin Kleppmanns Analyse verteilter Locks: Ergebnisse werden mit WHERE attempt = $3 geschrieben, sodass ein veralteter Versuch keinen neueren überschreiben kann. Ziele außerhalb deiner Datenbank bekommen den Run Key als Idempotency-Key.

Das ist die ehrliche Garantie: einmal dispatcht pro Run Key, mindestens einmal ausgeführt, Wirkung nur einmal bei idempotenten Jobs. Die Kubernetes-CronJob-Dokumentation sagt dasselbe, denn ein CronJob kann zwei Jobs erzeugen oder keinen; sein Controller benennt jeden Job sogar nach der geplanten Zeit, sodass ein doppeltes Anlegen fehlschlägt.

Retries mit Backoff und ein Dead-Letter-Zustand

  • Klassifiziere Fehler. Timeouts, 5xx und 429 sind wiederholbar; Validierungsfehler gehen direkt auf dead.
  • Backoff mit Full Jitter: delay = random(0, min(cap, base * 2^attempt)). Bei 10 Sekunden Basis warten vier Retries bis zu 20, 40, 80 und 160 Sekunden, und der fünfte Fehlschlag ist endgültig. Fällt ein ganzes Ziel aus, verhindern Retry-Budgets und Circuit Breaker, dass sich Retries auftürmen.
  • Halte den Dead-Zustand nützlich: die Payload, der letzte Fehler, ein Alert und ein Replay, der den Run Key wiederverwendet.
  • Begrenze die Nebenläufigkeit pro Mandant, wie es ein Rate Limiter tut, damit der Burst eines Mandanten die übrigen nicht aushungert.

Läuft der 09:00-Lauf um 10:00 Uhr noch in Retries, entscheidet eine Overlap-Policy: überspringen, einreihen, beide ausführen oder den alten abbrechen. Kubernetes nennt sie concurrencyPolicy (Standard Allow); Temporal-Schedules nutzen standardmäßig Skip.

Cron-Semantik: Zeitzonen, Sommerzeit und Misfires

Speichere jeden Cron-Ausdruck mit einer IANA-Zone wie America/New_York, nie mit einem festen Offset, und berechne den nächsten Lauf in Ortszeit, bevor du ihn nach UTC umrechnest; 24 Stunden zu addieren driftet zweimal im Jahr. Nutze UTC für Infrastruktur-Jobs und Ortszeit dort, wo Menschen eine Uhrzeit erwarten, etwa bei Ruhezeiten in einem Benachrichtigungssystem.

Die Sommerzeit bricht die Ortszeit: In New York gab es am 8. März 2026 die Zeit von 2:00 bis 2:59 Uhr nicht, und am 1. November 2026 gab es 1:00 bis 1:59 Uhr zweimal. Scheduler gehen mit beidem unterschiedlich um:

Scheduler Fehlende Zeit Wiederholte Zeit
Linux cron (cronie) Läuft direkt nach dem Sprung Läuft einmal
Amazon EventBridge Scheduler Überspringt den Lauf Läuft einmal
Kubernetes CronJob mit timeZone Überspringt den Lauf Läuft zweimal

Die Kubernetes-Zeile stammt aus der cron-Bibliothek, die dokumentiert, dass Jobs beim Vorstellen der Uhr nicht laufen, und aus Issue 121594, einem 02:00-Uhr-Job in Berlin, der zweimal lief. Wähle eine Regel und teste sie.

Ein Misfire ist ein Lauf, der verspätet startete, weil der Scheduler ausgefallen oder überlastet war. Eine Policy pro Job greift, wenn ein Scheduler eine verspätete Zeile beansprucht:

Policy Stündlicher Job nach 3 Stunden Ausfall Geeignet für Entsprechungen
Einmal ausführen Läuft einmal, danach normal weiter Syncs, Aufräumjobs Quartz Smart Policy, Airflow mit catchup aus (Standard)
Überspringen Wartet auf den nächsten Slot Erinnerungen Quartz DO_NOTHING, ein kurzes Temporal-Catchup-Fenster
Nachholen Führt alle 3 aus, älteste zuerst Stündliche Abrechnung Quartz IGNORE_MISFIRE_POLICY, Airflow catchup=True

Quartz zählt einen Lauf standardmäßig nach 60 Sekunden als Misfire. Kubernetes hat zwei Fallen: Ein startingDeadlineSeconds unter 10 kann Läufe überspringen, weil der Controller alle 10 Sekunden prüft, und nach einem Ausfall startet er nur den jüngsten verpassten Lauf, ältere gehen verloren (bei mehr als 100 verpassten Schedules protokolliert er zusätzlich eine TooManyMissedTimes-Warnung).

Den Scheduler skalieren

Drei Wege, mehrere Scheduler zu betreiben:

  1. Ein einzelner Leader. Eine Instanz hält ein Lease (etcd, ZooKeeper, ein Kubernetes Lease) und pollt alles. Das ist einfach, aber auf einen Node begrenzt, und das Failover wartet das Lease ab: standardmäßig 15 Sekunden im kube-controller-manager, dem Zuhause des CronJob-Controllers.
  2. Konkurrierende Poller auf einer Tabelle mit SKIP LOCKED: kein Koordinator, aber ein heißer Index. Quartz clustert über einen Datenbank-Lock und warnt, dass die Leistung ab etwa drei Nodes nachlässt.
  3. Partitionsbesitz. Teile Jobs in 256 logische Partitionen, weit mehr als Scheduler; jeder Scheduler least seine Partitionen in etcd und pollt nur diese. Consistent Hashing verschiebt nur einen Anteil, wenn ein Node beitritt oder stirbt, und etcd hält Leases über Raft konsistent.

Der Besitz ist eine Optimierung, nicht die Garantie: Pollt ein pausierter alter Besitzer nach einer Übergabe noch einmal, stoppen ihn die Zeilensperre, das vorgerückte next_run_at und der Run Key. Partitionen werden außerdem zur Einheit des Datenbank-Shardings.

Bestehende Tools und wofür jedes gut ist

Tool Gut für Vorsicht bei
Kubernetes CronJob Batch-Container auf einem Cluster, den du betreibst Ein Objekt pro Schedule, ungefähre Timings
Quartz Scheduling in einer JVM-App, gespeichert in deiner Datenbank Der Cluster-Lock begrenzt das Scale-out
Temporal Schedules Mehrstufige Jobs mit dauerhaften Retries Temporal betreiben oder seine Cloud bezahlen
Apache Airflow Datenpipelines mit Abhängigkeiten und Backfills Nicht gebaut für Millionen kleiner Mandanten-Jobs
Amazon EventBridge Scheduler Verwaltete Trigger auf AWS Standard-Quotas zum Zeitpunkt des Schreibens (November 2026): 10 Millionen Schedules pro Region, 1.000 Aufrufe pro Sekunde in primären Regionen, beide anpassbar

Baue nur dann einen eigenen, wenn es um Millionen mandantendefinierter Schedules geht.

Das Diagramm des verteilten Job-Schedulers

Architekturdiagramm. Eine Job API schreibt in einen Postgres-Speicher. Drei Scheduler-Worker in einem Rahmen holen Partitions-Leases von etcd und senden Läufe in eine Queue. Executor-Worker rufen Ziele auf, speichern die Historie und wiederholen per Queue oder Dead Letter.

18 Komponenten · 9 Verbindungen

In Archboard öffnen
Diagramminhalt
  • 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
Ein verteilter Job-Scheduler. Scheduler, die Partitionen besitzen, beanspruchen fällige Jobs aus Postgres und veröffentlichen Läufe; Executor leasen jeden Lauf, rufen Ziele mit Idempotency-Key auf, wiederholen mit Backoff und schicken erschöpfte Läufe in eine Dead-Letter-Queue.

So liest du es:

  • Von links nach rechts bewegt sich ein Lauf auf die Ausführung zu, ausgehend von der Job API und dem Schedule-Speicher.
  • Der Rahmen ist die Scheduler-Ebene: Drei Worker teilen sich 256 Partitionen über Leases von etcd und veröffentlichen fällige Läufe.
  • Executor-Worker leasen jeden Lauf in der Laufhistorie, rufen Ziele mit einem Idempotency-Key auf und speichern das Ergebnis.
  • Gestrichelte Kanten sind Fehlerpfade: Retries mit Backoff und die Dead-Letter-Queue nach dem fünften Fehlschlag.
  • Der Markdown-Block listet die Misfire-Policies und die Dimensionierungszahlen.

Job-Scheduler-Diagramme klar zeichnen

  • Schreibe den Run Key auf die Canvas. (job_id, scheduled_at) erklärt den Schutz vor Duplikaten besser als jeder Pfeil.
  • Zeige den Besitz: Partitionen und woher die Leases kommen. Eine Box mit der Beschriftung Scheduler verbirgt den schwierigsten Teil.
  • Zeichne die Fehlerpfade gestrichelt und beschrifte ihre Auslöser.
  • Setze Zeiten auf die Canvas: Lease, Heartbeat und Retry-Obergrenze bestimmen die Wiederherstellungszeit.

Der Beitrag darüber, wie aus Anforderungen ein Designdiagramm wird, wendet dieselben Gewohnheiten auf einen URL-Shortener an.

In Archboard nachbauen

  1. Melde dich mit Google oder GitHub an und erstelle ein Diagramm.
  2. Füge einen Container aus dem Paket C4-Modell für die Job API hinzu und einen Postgres-Node aus dem Paket Daten für den Schedule-Speicher.
  3. Füge einen Rahmen aus dem Paket Basis mit drei Container-Nodes für die Scheduler hinzu und darüber einen Container für den etcd-Koordinator.
  4. Füge eine Warteschlange aus dem Paket Daten für die Läufe hinzu, einen Container für die Executor, ein zweites Postgres für die Laufhistorie, eine Warteschlange für Dead Letters und ein Externes System aus dem Paket C4-Modell für die Ziel-Services.
  5. Zeichne beschriftete Kanten und strichle die Retry- und Dead-Letter-Pfade.
  6. Füge einen Markdown-Block aus dem Paket Basis mit den Misfire-Policies hinzu und kurze Text-Notizen wie die Claim-Query und den Run Key.

Eine Mermaid-Skizze der Pipeline wird mit dem Tool Mermaid in editierbares Diagramm zu einem ersten Entwurf.

Das Beispiel nutzt 13 Diagramm-Nodes und bleibt damit im Free-Plan.

FAQ

Wie verhindert man, dass ein geplanter Job doppelt läuft? Nutze die Job-ID plus die geplante Zeit als eindeutigen Run Key. Beanspruche fällige Jobs und setze ihre nächste Laufzeit in einer Transaktion weiter, leasee Läufe vor der Ausführung und gib den Run Key als Idempotency-Key an die Ziele weiter.

Was macht FOR UPDATE SKIP LOCKED? Es sperrt die zurückgegebenen Zeilen und überspringt Zeilen, die andere Transaktionen halten, sodass mehrere Worker eine Tabelle pollen können, ohne aufeinander zu warten. PostgreSQL kennt es seit Version 9.5.

Wie sollte ein Job-Scheduler mit der Sommerzeit umgehen? Speichere mit jedem Schedule eine IANA-Zeitzone, berechne nächste Läufe in UTC und lege fest, was in der fehlenden und der wiederholten Stunde passiert, denn Tools unterscheiden sich: Ein Kubernetes CronJob kann sogar zweimal laufen, wenn die Uhr zurückgestellt wird.

Was ist ein Misfire beim Job-Scheduling? Ein Lauf, der seine Startzeit verpasst hat, meist weil der Scheduler ausgefallen oder überlastet war. Die Misfire-Policy entscheidet, ob einmal sofort ausgeführt, zum nächsten Slot übersprungen oder jeder verpasste Lauf nachgeholt wird.

Reicht Kubernetes CronJob für einen verteilten Scheduler? Für ein paar hundert Batch-Jobs auf einem Cluster, den du ohnehin betreibst, ja. Für Millionen Mandanten-Schedules nein: Jeder Schedule ist ein Kubernetes-Objekt, jeder Lauf ein Job mit Pods, und das Timing ist ungefähr.

Kostenlos starten — ein Seat, keine Kreditkarte.

Ähnliche Beiträge

← Zurück zum Blog

In einer Minute ein Canvas öffnen

Melden Sie sich mit Google oder GitHub an. Kostenlos für einen Sitzplatz. Upgraden Sie, wenn das Team dazukommt.