Zum Inhalt springen

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.

6 Min. Lesezeit

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.

Verwandte Fragen

Verschwindet das, wenn wir tries auf 1 setzen?
Nein. Das entfernt die Wiederholung, nicht das Duplikat. Ein Worker, der abgeschossen wird, nachdem der Job seine Arbeit getan, aber die Nachricht noch nicht bestätigt hat, sieht diese Nachricht wieder, egal was tries sagt - und jetzt haben Sie auch keine Wiederholung mehr für die vorübergehenden Fehler, die eine verdient hätten.
Reicht eine Datenbanktransaktion?
Sie macht Ihre eigenen Schreibvorgänge atomar, was notwendig und nicht hinreichend ist. Eine Transaktion kann keine gesendete E-Mail und keine beim Zahlungsdienstleister ausgeführte Belastung zurückrollen, und genau das sind die Wirkungen, bei denen ein Duplikat am meisten wehtut.
Und ShouldBeUnique?
Es verhindert, dass ein zweiter Job eingereiht wird, während der erste noch aussteht, und stoppt damit doppelte Dispatches. Es verhindert nicht, dass ein einzelner eingereihter Job zweimal ausgeführt wird, denn die Sperre wird gelöst, sobald der Job zu laufen beginnt. Es ist nützlich und es ist ein anderes Problem.
Woran merken wir, ob uns das schon passiert?
Suchen Sie nach den Symptomen statt in den Logs: doppelte E-Mails, die Kunden melden, Webhook-Zustellungen, die Ihr Partner zweimal erhalten hat, Zeilen, die eine Eindeutigkeitsregel verletzen, von der Sie dachten, sie werde durchgesetzt. Wenn in Ihrer failed_jobs-Tabelle Einträge stehen, die niemand gelesen hat, gehen Sie davon aus, dass es passiert.

← Zurück zu allen Artikeln

Anrufen+1 848 272 7583WhatsApp+90 850 308 5436E-Mailinfo@codefacture.comKontaktseite