Alle Artikel
12. September 2026
10 mins

Von Cronjobs zu Queues

Von Tim Rutte, Cloud & Software ArchitectThemaBackend & Plattformen

Eine mechanische Eieruhr neben einem Abreißspender, aus dem ein blauer, unbedruckter Zettel hängt.

In jedem gewachsenen System gibt es eine Datei, die niemand gern öffnet: die Liste der geplanten Aufgaben. Dreißig Zeilen, entstanden über zehn Jahre, mit Kommentaren wie „nicht löschen, Rechnungen" und mindestens einem Eintrag, bei dem die Zeit so gewählt wurde, dass er nach einem anderen läuft.

Das Modell dahinter ist einfach und hat lange getragen: Alle fünf Minuten nachsehen, ob es etwas zu tun gibt. Es hört an einem bestimmten Punkt auf zu tragen, und dieser Punkt ist gut erkennbar.

Dieser Artikel beschreibt, wann der Punkt erreicht ist, welche vier Probleme das Cron-Modell hat, und wie die Umstellung auf Aufträge in einer Warteschlange abläuft, ohne dass dabei etwas verloren geht.

Wann der Cronjob nicht mehr reicht

Vorweg: Für viele Aufgaben ist ein Zeitplan genau richtig und bleibt es. Ein nächtlicher Bericht, eine Bereinigung alter Datensätze, ein Abgleich, der einmal am Tag laufen soll. Dafür braucht niemand eine Warteschlange.

Der Punkt ist erreicht, wenn mindestens zwei der folgenden vier Sätze stimmen.

„Der Job läuft alle fünf Minuten und macht meistens nichts." Das ist eine Warteschlange, die nur schlecht umgesetzt ist: Die Datenbank wird als Aufgabenliste benutzt, und zwar mit Abfragen im Leerlauf.

„Wenn der Job länger als sein Intervall läuft, wird es unangenehm." Der klassische überlappende Lauf. Meist mit einer Dateisperre notdürftig abgefangen, die auf zwei Maschinen nicht mehr wirkt.

„Der Nutzer wartet, bis der Job das nächste Mal läuft." Ein Vorgang, der beim Absenden hätte starten können, startet bis zu fünf Minuten später. Das ist kein technisches, sondern ein fachliches Problem.

„Wenn er einmal fehlschlägt, ist der Datensatz weg." Der teuerste der vier. Ein Cronjob hat keine Wiederholung; was beim Durchlauf scheitert, muss die nächste Ausführung zufällig wieder aufgreifen, und oft tut sie das nicht.

Die vier Probleme im Detail

Alle vier haben dieselbe Wurzel: Ein Zeitplan beschreibt, wann etwas laufen soll, aber nicht, was zu tun ist. Der Auftrag existiert nirgends als eigener Gegenstand.

Kein Zustand je Aufgabe. Es gibt keinen Ort, an dem steht: Diese Bestellung muss noch an das Warenwirtschaftssystem übergeben werden, drei Versuche sind gescheitert, der nächste ist um 14:20 Uhr. Stattdessen gibt es eine Spalte exportiert, und die kennt zwei Zustände statt fünf.

Keine Wiederholung mit Abstand. Wenn ein Fremdsystem kurz nicht antwortet, ist die richtige Antwort ein zweiter Versuch in einer Minute. Ein Cronjob kann das nicht ausdrücken; er kann nur beim nächsten Lauf wieder alles versuchen, oder nichts.

Keine Parallelität, die man steuern kann. Entweder ein Lauf, dann ist er langsam. Oder mehrere, dann arbeiten sie womöglich an derselben Sache. Das Mittelding, also acht gleichzeitig und nie zweimal dasselbe, ist im Cron-Modell nicht vorgesehen.

Keine Sichtbarkeit. Wie viele Aufgaben warten gerade? Wie lange dauert eine im Schnitt? Welche scheitern immer wieder? Diese Fragen sind im Cron-Modell nicht beantwortbar, weil es die Aufgabe als Gegenstand nicht gibt.

Der erste Schritt: aus dem Job einen Auftrag machen

Die Umstellung beginnt nicht mit einer Warteschlange, sondern mit einer Tabelle. Das ist wichtig, weil sie den ganzen Rest vorbereitet und weil sie ohne neue Infrastruktur auskommt.

CREATE TABLE auftrag (
  id            BIGINT AUTO_INCREMENT PRIMARY KEY,
  art           VARCHAR(64)  NOT NULL,   -- 'bestellung.exportieren'
  schluessel    VARCHAR(128) NOT NULL,   -- fachliche Kennung, z. B. Bestell-Nr
  nutzlast      JSON         NOT NULL,
  zustand       ENUM('offen','laeuft','erledigt','gescheitert') NOT NULL DEFAULT 'offen',
  versuche      SMALLINT     NOT NULL DEFAULT 0,
  naechster_lauf DATETIME    NOT NULL,
  letzter_fehler TEXT        NULL,
  erstellt_am   DATETIME     NOT NULL,

  -- Verhindert denselben Auftrag zweimal. Die wichtigste Zeile der Tabelle.
  UNIQUE KEY eindeutig (art, schluessel),
  KEY holen (zustand, naechster_lauf)
) ENGINE=InnoDB;

Der eindeutige Schlüssel ist der Kern. Er macht das Einstellen eines Auftrags wiederholbar: Wer denselben Auftrag zweimal einstellt, bekommt einen, nicht zwei. Damit ist der gefährlichste Fehler des ganzen Themas ausgeschlossen, bevor er entstehen kann.

Ab hier ändert sich die Rolle des Cronjobs: Er stellt nicht mehr fest, was zu tun ist, sondern arbeitet ab, was in der Tabelle steht. Das ist bereits die halbe Umstellung, und sie kommt ohne eine einzige neue Komponente aus.

Wiederholbarkeit ist die Eintrittskarte

Sobald es Wiederholungen gibt, gilt eine Bedingung ausnahmslos: Jeder Auftrag muss zweimal ausgeführt werden können, ohne Schaden anzurichten. Das ist Idempotenz, und es ist keine Feinheit, sondern die Voraussetzung.

Der Grund ist unangenehm: Es gibt keine Garantie, dass ein Auftrag genau einmal ausgeführt wird. Ein Arbeiter kann mitten in der Verarbeitung sterben, nachdem er die Rechnung verschickt und bevor er den Zustand gespeichert hat. Der nächste Versuch beginnt von vorn.

In der Praxis hilft eine Reihenfolge, die für die meisten Aufträge trägt.

  • Zuerst prüfen, ob die Wirkung schon eingetreten ist. Nicht ob der Auftrag gelaufen ist, sondern ob das Ergebnis existiert. Gibt es die Rechnung im Zielsystem bereits, ist der Auftrag fertig.
  • Fremdsystemen eine eigene Kennung mitgeben. Fast alle Zahlungsdienste und Schnittstellen unterstützen einen Schlüssel für Wiederholungen. Damit wird die Frage aus dem ersten Punkt zum Problem der Gegenseite, und dort ist sie besser aufgehoben.
  • Nebenwirkungen ans Ende. Wenn ein Auftrag rechnet und dann eine Mail verschickt, gehört der Versand nach hinten. Dann kostet ein Abbruch in der Mitte höchstens eine wiederholte Rechnung, nicht eine zweite Mail an den Kunden.

Aufträge holen, ohne dass zwei denselben nehmen

Wenn mehrere Arbeiter laufen, dürfen sie nicht denselben Auftrag greifen. In MySQL geht das seit 8.0 in einer Abfrage:

-- SKIP LOCKED: gesperrte Zeilen werden uebersprungen statt gewartet.
-- Ohne das stehen acht Arbeiter hintereinander in einer Schlange.
START TRANSACTION;

SELECT id, art, nutzlast
FROM auftrag
WHERE zustand = 'offen' AND naechster_lauf <= NOW()
ORDER BY naechster_lauf
LIMIT 10
FOR UPDATE SKIP LOCKED;

UPDATE auftrag SET zustand = 'laeuft', versuche = versuche + 1
WHERE id IN (...);

COMMIT;

Für eine ganze Reihe von Systemen ist das die Endstation, und das ist in Ordnung. Eine Auftragstabelle mit dieser Abfrage trägt Zehntausende Aufträge am Tag, ohne dass ein zusätzlicher Dienst betrieben werden muss. Eine eigene Message Queue lohnt sich, wenn die Last deutlich darüber liegt, wenn mehrere Systeme dieselben Aufträge erzeugen, oder wenn die Datenbank ohnehin der Engpass ist.

Fehler, Abstände und die Aufträge, die nie gelingen

Jetzt kommt der Teil, der im Cron-Modell fehlte. Ein gescheiterter Auftrag wird nicht vergessen, sondern bekommt einen Termin.

private const MAX_VERSUCHE = 8;

public function gescheitert(Auftrag $a, Throwable $e): void
{
    if ($a->versuche() >= self::MAX_VERSUCHE) {
        // Nicht loeschen, nicht ewig weiterversuchen: aus dem Weg raeumen
        // und sichtbar machen. Das ist die Ablage fuer Unzustellbares.
        $this->db->update($a->id(), [
            'zustand'        => 'gescheitert',
            'letzter_fehler' => $e->getMessage(),
        ]);
        $this->melder->auftragAufgegeben($a, $e);
        return;
    }

    // Wachsender Abstand mit Streuung. Ohne die Streuung kommen nach einem
    // Ausfall des Fremdsystems alle Wiederholungen im selben Moment zurueck
    // und legen es ein zweites Mal lahm.
    $sekunden = min(2 ** $a->versuche(), 3600);
    $sekunden += random_int(0, (int) ($sekunden * 0.2));

    $this->db->update($a->id(), [
        'zustand'         => 'offen',
        'naechster_lauf'  => new DateTimeImmutable("+${sekunden} seconds"),
        'letzter_fehler'  => $e->getMessage(),
    ]);
}

Zwei Dinge daran sind aus Erfahrung geschrieben. Die Streuung verhindert, dass ein wiederhergestelltes Fremdsystem sofort von allen Wiederholungen gleichzeitig getroffen wird. Und der Endzustand gescheitert ist kein Löschen: Diese Aufträge bleiben stehen, sind abfragbar und können nach einer Korrektur mit einem Befehl wieder auf offen gesetzt werden.

Die Liste dieser endgültig gescheiterten Aufträge ist übrigens die nützlichste Ansicht des ganzen Systems. Sie ersetzt das Gefühl, dass „manchmal etwas nicht ankommt", durch eine Liste mit Kennungen.

Eine Warteschlange ist auch das Werkzeug, mit dem eine synchrone Abhängigkeit zwischen zwei Diensten verschwindet: Der verteilte Monolith.

Was der Zeitplan behält

Eine Warteschlange ersetzt nicht alle Cronjobs, und der Versuch endet in einer komplizierten Nachbildung. Zwei Dinge bleiben beim Zeitplan.

Das Erzeugen von Aufträgen zu festen Zeiten. Der monatliche Rechnungslauf startet weiterhin nach einem Zeitplan. Er tut dann nur etwas anderes als vorher: Statt dreitausend Rechnungen zu erzeugen, stellt er dreitausend Aufträge ein und ist nach zwei Sekunden fertig. Der Unterschied ist erheblich, weil dieser eine Lauf jetzt nicht mehr scheitern kann.

Das Aufräumen. Erledigte Aufträge löschen, gescheiterte melden, hängengebliebene zurücksetzen. Der letzte Punkt ist wichtig: Ein Arbeiter, der stirbt, lässt Aufträge im Zustand laeuft zurück, und die muss jemand nach einer Weile wieder freigeben.

-- Haengengebliebene freigeben, einmal je Minute
UPDATE auftrag
SET zustand = 'offen', naechster_lauf = NOW()
WHERE zustand = 'laeuft' AND erstellt_am < NOW() - INTERVAL 15 MINUTE;

Die Reihenfolge der Umstellung

Dreißig Cronjobs werden nicht in einem Schritt umgestellt. Die Reihenfolge, die sich bewährt hat, geht nach Schmerz und nicht nach Aufwand.

  1. Die Auftragstabelle anlegen und einen einzigen Job umstellen. Und zwar den, bei dem verlorene Vorgänge am meisten wehtun, meist eine Übergabe an ein Fremdsystem. Nach diesem einen Job sind Wiederholung und Sichtbarkeit da, und der Rest wird zur Wiederholung derselben Arbeit.
  2. Die Jobs umstellen, die alle fünf Minuten leer laufen. Hier ist der Ertrag am größten, weil aus einer Abfrage im Leerlauf ein Auftrag beim Entstehen wird.
  3. Die langen Läufe zerlegen. Ein Lauf über zwei Stunden wird zu einem Erzeuger plus vielen kleinen Aufträgen. Damit endet das Problem der überlappenden Läufe von selbst.
  4. Den Rest stehen lassen. Ein nächtlicher Bericht, der seit sechs Jahren durchläuft, bleibt ein Cronjob. Ihn umzustellen kostet Zeit und bringt nichts.

Der vierte Punkt gehört ausdrücklich dazu. Das Ziel ist nicht, die Liste der geplanten Aufgaben zu leeren, sondern die Vorgänge zuverlässig zu machen, die es nicht sind.

Damit ein Auftrag im selben Trace steht wie die Anfrage, die ihn erzeugt hat, braucht es den Kontext in der Nutzlast: OpenTelemetry in PHP und Go einführen.

Was Sie ab jetzt sehen müssen

Mit der Umstellung entsteht eine neue Betriebsaufgabe, und sie kommt mit drei Messwerten aus.

Das Alter des ältesten offenen Auftrags. Die aussagekräftigste einzelne Zahl. Steigt sie, kommen die Arbeiter nicht hinterher, und zwar unabhängig davon, wie viele Aufträge es gerade sind.

Die Zahl der endgültig gescheiterten Aufträge. Ein Wert über null braucht einen Menschen. Nicht sofort, aber an diesem Tag.

Die Dauer je Auftragsart. Getrennt nach Art, weil ein Durchschnitt über alle Arten nichts sagt, wenn eine davon vierzig Sekunden und eine andere vierzig Millisekunden braucht.

Diese drei Werte gehören dorthin, wo das Team ohnehin hinsieht. Was daran auffällt, ist meist kein Fehler im Auftragssystem, sondern einer im Fremdsystem, und genau das ist der Gewinn: Aus „manchmal kommt etwas nicht an" wird eine Zahl mit einem Namen daneben.

Wie ich Hintergrundverarbeitung in bestehenden Systemen aufsetze, steht auf einer eigenen Seite. Die Auftragstabelle oben ist dabei in mehr Projekten die richtige Antwort gewesen als eine eigene Warteschlange.

Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.