Ihre Laravel-Jobs werden zweimal laufen. Bauen Sie dafür.
At-least-once heißt, dass jeder Job irgendwann mehr als einmal läuft. Was das bricht, warum Wiederholungen nicht die einzige Ursache sind, und wie man ihn ohne Wettlauf absichert.
Laravels Queue gibt Ihnen At-least-once-Zustellung. Fast jedes Team liest das als „sie wiederholt bei Fehlern" und geht weiter, was die Hälfte davon ist. Die andere Hälfte ist, dass ein Job, der erfolgreich war, trotzdem erneut laufen kann, und keine noch so sorgfältige Fehlerbehandlung verhindert das.
Vier Wege, auf denen ein Job zweimal läuft
Die Wiederholung, die Sie konfiguriert haben. Ein Job wirft, die Queue wiederholt ihn. Offensichtlich, und die eine, mit der alle rechnen.
Das Timeout, das falsch war. Der Job dauert länger als das konfigurierte Timeout. Der Worker wird abgeschossen, die Nachricht nicht bestätigt, ein anderer Worker nimmt sie auf – während der erste seine Arbeit womöglich noch beendet.
Das Deployment. Ein Worker hält einen Job, während der Code unter ihm ausgetauscht wird. Er wird neu gestartet; die Nachricht geht zurück in die Queue. Der Job hat den größten Teil seiner Arbeit vor dem Neustart getan, und alles davon wird noch einmal getan.
Die Infrastruktur. Eine Verbindung bricht zwischen „der Job ist fertig" und „die Bestätigung hat den Broker erreicht". Die Arbeit ist passiert. Die Queue weiß das nicht.
Nur die erste liegt in Ihrer Hand. Die anderen drei sind der Grund, warum eine Queue at-least-once ist und nicht exactly-once, und warum Exactly-once-Zustellung nichts ist, das man konfigurieren kann.
Was das tatsächlich kostet
Eine zweimal gesendete E-Mail ist peinlich. Alles hier drunter ist schlimmer:
- Eine zweimal eingezogene Zahlung, also eine Rückerstattung, ein Support-Vorgang und ein Kunde, der nicht wiederkommt.
- Ein Webhook, zweimal an einen Partner zugestellt, dessen eigenes System ebenfalls nicht idempotent ist.
- Ein Lagerbestand, zweimal verringert, also ein überverkauftes Produkt.
- Eine Rechnungsnummer, zweimal vergeben, also ein Buchhaltungsproblem, an dem in manchen Rechtsordnungen eine Behörde hängt.
Der letzte Punkt hört auf, hypothetisch zu sein, sobald eine Queue Datensätze in das Hauptbuch eines anderen schreibt. Ein doppelter Job gegen eine Buchhaltungs-API ist eine doppelte Rechnung in einer echten Buchhaltung, und wer sie wieder auseinandernimmt, arbeitet nicht bei Ihnen.
Das Muster ist bei allen dasselbe: Die Wirkung ist außerhalb Ihrer Datenbank eingetreten, eine Transaktion konnte sie also nicht rückgängig machen.
Die Lösung, die nicht funktioniert
Das ist der Code, den wir am häufigsten finden, und er ist ein Wettlauf:
public function handle(): void
{
if ($this->order->refresh()->paid_at !== null) {
return; // schon erledigt, überspringen
}
$charge = $this->gateway->charge($this->order); // ← beide Worker kommen hierhin
$this->order->update(['paid_at' => now()]);
}Zwei Worker lesen die Zeile, bevor einer von beiden schreibt. Beide sehen
null, beide belasten. Prüfen-dann-Handeln ist keine Absicherung, es ist ein
schmaleres Zeitfenster – und Queue-Duplikate kommen genau unter den
Bedingungen an, die Fenster schmal machen.
Absicherungen, die halten
Die Regel lautet: Die Eindeutigkeit muss von etwas durchgesetzt werden, gegen das man nicht anrennen kann – der Datenbank oder dem Dritten.
Eine Unique-Constraint, die die Arbeit macht. Lassen Sie das Einfügen scheitern, statt vorher zu fragen:
public function handle(): void
{
try {
$payment = Payment::create([
'order_id' => $this->order->id,
'idempotency_key' => $this->key, // Unique-Index
]);
} catch (UniqueConstraintViolationException) {
return; // ein anderer Worker besitzt diesen hier
}
$this->gateway->charge($this->order, $this->key);
}Ein Idempotenzschlüssel, den der Anbieter durchsetzt. Jede ernstzunehmende Zahlungs-API akzeptiert einen. Zwei identische Anfragen mit demselben Schlüssel ergeben eine Belastung und zwei identische Antworten. Das ist die stärkste verfügbare Zusicherung, denn sie hält auch dann, wenn Ihre eigene Datenbank das war, was ausgefallen ist.
Ein bedingtes Update. Machen Sie den Zustandswechsel selbst zur Sperre:
$claimed = Order::where('id', $this->order->id)
->whereNull('paid_at')
->update(['paid_at' => now()]); // liefert betroffene Zeilen
if ($claimed === 0) {
return; // jemand anders hat den Wechsel gemacht
}Eine Anweisung, entschieden von der Datenbank. Der Worker, der 1 bekommt,
besitzt die Arbeit.
Ein Schlüssel, der beim Dispatch erzeugt wird, nicht im Job. Das zählt und wird leicht übersehen: Berechnet der Job seinen Idempotenzschlüssel aus der aktuellen Zeit oder einem Zufallswert, erzeugen zwei Ausführungen desselben Jobs zwei Schlüssel und die Absicherung greift nie. Der Schlüssel ist Teil der Nutzlast des Jobs, einmal erzeugt, beim Einreihen.
Wenn Sie schon dabei sind
Zwei Einstellungen und eine Gewohnheit, alle billig:
Setzen Sie das Timeout unter die Wiederholungsverzögerung. Darf ein Job 90 Sekunden laufen und wird nach 60 wiederholt, haben Sie sich gleichzeitige Ausführungen desselben Jobs garantiert.
Nutzen Sie Backoff, keine feste Verzögerung. public $backoff = [10, 60, 300]; – ein Ausfall bei einem Dritten wird nicht besser, wenn man ihn dreimal
in dreißig Sekunden anfasst.
Wiederholen Sie nicht, was nicht gelingen kann. Ein fünfmal wiederholter Validierungsfehler sind fünf identische Fehlschläge und eine Verzögerung für alles, was in der Queue dahinter steht. Werfen Sie etwas, das der Job als fatal behandelt, und lassen Sie ihn beim ersten Versuch scheitern.
Die letzte Meile: jemand muss hinsehen
Jede Behebung oben ist sinnlos, wenn Fehler unsichtbar sind. Der mit Abstand
häufigste Produktionsfehler, zu dem wir gerufen werden, ist eine
failed_jobs-Tabelle mit tausenden Zeilen, die nie jemand gelesen hat.
Das ist kein Queue-Fehler. Der Job ist gescheitert, das Framework hat es genau wie dokumentiert aufgezeichnet, und an der Aufzeichnung hing nichts. Hängen Sie Fehler an das, was Ihr Team ohnehin beobachtet, alarmieren Sie auf die Rate statt auf das Ereignis, und setzen Sie einen Heartbeat auf die Queue selbst, damit ein Worker, der stehenbleibt, von einer schlicht leeren Queue unterscheidbar ist.
Eine Queue, die man in Ruhe lassen kann, ist nicht eine, die nie scheitert. Sie ist eine, die es Ihnen sagt, wenn sie es tut, und keinen Schaden anrichtet, wenn sie sich wiederholt.
Wenn Ihre gerade von der anderen Sorte ist: das ist die Arbeit.
