Zum Inhalt springen
DedicatedPHP Kontakt

Eine transaktionale Outbox in PHP entwerfen

Das Transactional-Outbox-Muster in PHP verhindert, dass Ereignisse zwischen einer Geschäftstransaktion und einer Queue verloren gehen. Erfahre, wie du sie verarbeitest, erneut zustellst und überwachst.

Konzeptionelles Diagramm einer PHP-Transaktion, die eine Geschäftsänderung und ein Ereignis in einer Outbox-Tabelle für die asynchrone Veröffentlichung speichert

Eine Anwendung kann eine Bestellung erfolgreich speichern und trotzdem das Zahlungssystem, den Inventardienst oder eine Job-Queue nicht benachrichtigen. Ebenso kann sie die Nachricht veröffentlichen und scheitern, bevor die Bestellung bestätigt wird. In beiden Fällen geraten die Systeme aus dem Gleichlauf. Das Transactional-Outbox-Muster in PHP löst dieses Problem der doppelten Schreiboperation, indem die Geschäftsänderung und der Eintrag des Ereignisses gemeinsam in derselben Datenbanktransaktion bestätigt werden.

Die Outbox sorgt weder für eine sofortige Zustellung noch garantiert sie allein, dass ein Consumer ein Ereignis genau einmal verarbeitet. Ihr Zweck ist, dass ein bestätigtes Ereignis nicht von einer zweiten Schreiboperation abhängt, die unabhängig davon fehlschlagen könnte. Für eine korrekte Umsetzung müssen auch die nebenläufige Verarbeitung, Wiederholungsversuche, Idempotenz und der laufende Betrieb berücksichtigt werden.

Das Problem beim Schreiben in die Datenbank und Veröffentlichen eines Ereignisses

Das Problem beim Schreiben in die Datenbank und Veröffentlichen eines Ereignisses — guía visual de DedicatedPHP

Angenommen, ein Vorgang bestätigt einen Kauf und veröffentlicht danach OrderPlaced in einer Queue. Wird der PHP-Prozess zwischen diesen beiden Aktionen beendet, ist der Kauf gespeichert, aber niemand erhält das Ereignis. Wird zuerst veröffentlicht und anschließend die Datenbanktransaktion zurückgerollt, kann der Consumer einen nicht existierenden Kauf verarbeiten.

Eine SQL-Transaktion umfasst in der Regel nicht zugleich einen Aufruf an eine Queue, eine externe API oder einen Broker. Der Versuch, beide Vorgänge manuell zu koordinieren, beseitigt das Fehlerfenster nicht, sondern ändert nur die Reihenfolge, in der es auftritt. Das Transactional-Outbox-Muster vermeidet die nicht atomare doppelte Schreiboperation, indem es die Geschäftsänderung und eine Zeile, die das zu veröffentlichende Ereignis repräsentiert, in eine einzige Transaktion aufnimmt.

Minimaler Entwurf der Outbox-Tabelle

Die Tabelle muss genügend Informationen speichern, damit ein anderer Prozess das Ereignis identifizieren, zustellen und nachverfolgen kann. Ein erster Entwurf kann Folgendes enthalten:

  • Eindeutige Kennung: stabil und als Idempotenzschlüssel verwendbar, zum Beispiel eine UUID.
  • Ereignistyp: etwa OrderPlaced, idealerweise mit einer Schema-Version, falls sich das Format weiterentwickelt.
  • Nutzdaten: serialisierte Daten, die der Consumer benötigt; unnötige sensible Informationen sollten nicht enthalten sein.
  • Erstellungszeitpunkt: hilfreich für die Sortierung, die Messung des Alters und die Diagnose von Verzögerungen.
  • Status und Nachverfolgung: zum Beispiel ausstehend, beansprucht oder veröffentlicht; Anzahl der Versuche, letzter Fehler und Zeitpunkt des letzten Versuchs.
  • Beanspruchungsdaten: Kennung des Workers und Ablaufzeit der Berechtigung, falls ein Lease zur Koordination von Consumern verwendet wird.

Das konkrete Schema hängt von der Datenbank-Engine und dem Volumen ab. Füge Indizes hinzu, die Abfragen nach ausstehenden Ereignissen unterstützen, und lade nicht die gesamte Tabelle, um Arbeit zu finden. Lege außerdem fest, wie lange veröffentlichte Zeilen und Fehler aufbewahrt werden; lasse die Outbox nicht standardmäßig zu einem unbegrenzt wachsenden Archiv werden.

Geschäftsdaten und Ereignis in einer Transaktion speichern

In PHP muss der Code, der den Geschäftszustand ändert und das Ereignis hinzufügt, dieselbe Verbindung und Transaktion verwenden. Das folgende Beispiel zeigt die Abfolge, kein universell gültiges SQL-Schema:

$pdo->beginTransaction();

try {
    // Die Bestellung über diese Verbindung einfügen oder aktualisieren.
    $orderId = saveOrder($pdo, $orderData);

    $eventId = generateUuid();
    $payload = json_encode([
        'event_id' => $eventId,
        'order_id' => $orderId,
    ], JSON_THROW_ON_ERROR);

    $statement = $pdo->prepare(
        'INSERT INTO outbox_events (id, event_type, payload, status, created_at)
         VALUES (:id, :type, :payload, :status, CURRENT_TIMESTAMP)'
    );
    $statement->execute([
        'id' => $eventId,
        'type' => 'OrderPlaced',
        'payload' => $payload,
        'status' => 'pending',
    ]);

    $pdo->commit();
} catch (Throwable $error) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $error;
}

Wird die Transaktion bestätigt, sind beide Datensätze gespeichert; schlägt sie fehl, darf keiner von ihnen als bestätigt gelten. Veröffentliche die Nachricht nicht innerhalb der Transaktion beim Broker in der Erwartung, dadurch werde der Vorgang atomar: Der externe Aufruf nimmt nicht am SQL-Commit teil.

Die UUID im Beispiel identifiziert das in dieser Transaktion erstellte Ereignis, verhindert aber für sich genommen keine Duplikate, wenn die gesamte Geschäftsanfrage wiederholt wird. Ist dieses Risiko relevant – etwa weil eine Anfrage nach einem Timeout erneut gesendet werden kann –, verwende einen Idempotenzschlüssel für die Anfrage oder eine Eindeutigkeitsbedingung für den Geschäftsvorgang. Erkenne bei einer Wiederholung das Ergebnis und das Ereignis wieder, die bereits dem ursprünglichen Vorgang zugeordnet sind, statt ein weiteres Ereignis mit einer anderen UUID anzulegen.

Ausstehende Ereignisse nebenläufig und ausfallsicher verarbeiten

Ein Worker fragt eine begrenzte Anzahl geeigneter Ereignisse ab, beansprucht sie und veröffentlicht sie am Ziel. Die Beanspruchung muss verhindern, dass mehrere Worker dasselbe Ereignis gleichzeitig verarbeiten, oder zumindest dafür sorgen, dass sich eine solche Doppelverarbeitung sicher handhaben lässt. Die verfügbaren Optionen hängen von der Datenbank-Engine ab: Zeilensperren, bedingte Statusaktualisierungen oder Leases mit Ablaufzeit. Es gibt keine portable Abfrage, die alle Fälle korrekt löst; prüfe die Sperr- und Isolationssemantik der verwendeten Datenbank.

Eine gängige Strategie besteht darin, Zeilen für einen begrenzten Zeitraum zu beanspruchen, sie außerhalb einer langen Transaktion zu veröffentlichen und sie nach einer Bestätigung des Ziels als veröffentlicht zu markieren. Stürzt der Prozess nach der Veröffentlichung, aber vor der Statusaktualisierung ab, läuft das Lease ab und ein anderer Worker kann das Ereignis erneut veröffentlichen. Wird das Ereignis vor der Veröffentlichung als veröffentlicht markiert und stürzt der Prozess ab, kann es verloren gehen. Veröffentlichungsversuche können daher mindestens einmal erfolgen, aber nicht genau einmal: Es kann zu Duplikaten kommen, zugleich ist nicht garantiert, dass das Ziel das Ereignis tatsächlich erhält. Eine tatsächliche Zustellung hängt von der Wiederholungsrichtlinie und davon ab, dass das Ziel wieder verfügbar wird, bevor die Versuche ausgeschöpft sind, oder dass zur Wiederherstellung des Ereignisses eingegriffen wird.

Verwende kleine oder konfigurierbare Batches und begrenze die Laufzeit jeder Ausführung. SQL-Sperren aufrechtzuerhalten, während auf ein externes Netzwerk gewartet wird, kann die Datenbank beeinträchtigen. Batch-Größe, Lease-Dauer und Abfragehäufigkeit sollten anhand von Messwerten angepasst werden: Ein großer Batch verbessert die Leistung nicht zwangsläufig und kann die Latenz erhöhen oder die Wiederherstellung erschweren.

Idempotenz, Wiederholungsversuche und festgefahrene Ereignisse

Der Consumer muss tolerieren, dass dasselbe Ereignis mehr als einmal eintrifft. Für eine Geschäftswirkung in derselben Datenbank kann er die Ereigniskennung in einer Tabelle verarbeiteter Ereignisse mit einer Eindeutigkeitsbedingung speichern und die Wirkung in derselben Transaktion anwenden, in der die Kennung gespeichert wird. Wird die Transaktion bestätigt, sind Kennung und Wirkung gemeinsam gespeichert; schlägt sie fehl, kann der Vorgang wiederholt werden, ohne dass nur eines von beiden bestätigt bleibt.

Dieser Mechanismus macht externe, nicht transaktionale Wirkungen nicht automatisch idempotent. Ruft der Consumer eine API auf, sendet eine E-Mail oder führt eine andere Aktion außerhalb seiner Datenbank aus, kann ein Absturz zwischen der externen Wirkung und dem lokalen Eintrag zu einer Wiederholung führen. Verwende einen vom Ziel unterstützten Idempotenzschlüssel, eine spezifische Koordination oder einen geeigneten Kompensationsablauf. Gibt es keine dieser Möglichkeiten, erkenne das Duplikationsrisiko ausdrücklich an und gehe entsprechend damit um.

Setze Wiederholungsversuche mit zunehmenden Wartezeiten und angemessenen Grenzen ein und unterscheide vorübergehende Fehler – etwa einen Timeout – von dauerhaften Fehlern wie ungültigen Nutzdaten. Speichere den letzten Fehler so, dass er sich sinnvoll untersuchen lässt, aber schreibe keine Geheimnisse oder unnötigen personenbezogenen Daten in die Logs. Bewahre das Ereignis nach ausgeschöpften Versuchen in einem sichtbaren Fehlerstatus auf; lösche es nicht stillschweigend.

Die Wiederherstellung muss ausdrücklich erfolgen: Ursache ermitteln, sie beheben und entscheiden, ob dasselbe Ereignis erneut versucht werden soll oder ein Eingriff erforderlich ist. Vergewissere dich vor der Wiederaufnahme, dass der Consumer idempotent ist. Eine Schaltfläche zum „erneuten Verarbeiten“ ohne Kontrollen kann externe Wirkungen wiederholen oder die erwartete Reihenfolge verändern. Dokumentiere, wer dies tun darf und was dabei protokolliert wird.

Überwachung, Aufbewahrung und Datenschutz

Überwache die Anzahl ausstehender Ereignisse, das Alter des ältesten ausstehenden Ereignisses, fehlgeschlagene Versuche, die Veröffentlichungsrate und die Zeit zwischen Erstellung und Zustellung. Eine wachsende Queue kann auf einen Ausfall des Ziels, angehaltene Consumer, anhaltende Fehler oder unzureichende Kapazität hinweisen. Ein Alarm, der nur auf der Gesamtanzahl basiert, kann Verzögerungen verdecken; das Alter hilft zu erkennen, ob die Verarbeitung voranschreitet.

Mit jedem Ereignis wächst die Tabelle. Lege eine Aufbewahrungsrichtlinie für veröffentlichte und fehlgeschlagene Ereignisse fest und lösche Datensätze stapelweise, damit Wartungsvorgänge nicht zu aufwendig werden. Bewahre vor dem Bereinigen alles auf, was je nach Geschäftsanforderungen für Audits, Support oder Untersuchungen benötigt wird. Beschränke den Zugriff auf die Nutzdaten, verschlüssele oder minimiere sensible Daten, sofern angemessen, und lege eine mit der Aufbewahrungsdauer vereinbare Löschrichtlinie fest.

Wann die Outbox sinnvoll ist und wann ihre Komplexität vermieden werden sollte

Wann die Outbox sinnvoll ist und wann ihre Komplexität vermieden werden sollte — guía visual de DedicatedPHP

Die Outbox ist sinnvoll, wenn eine Geschäftsbestätigung eine zuverlässige asynchrone Aktion auslösen muss, Fehler zwischen Systemen auftreten können und der Verlust der Benachrichtigung Folgen hätte. Sie ist besonders nützlich, wenn die Anwendung bereits über eine transaktionale Datenbank verfügt und sich in eine Queue oder externe Dienste integrieren muss, ohne deren Verfügbarkeit an jede Anfrage zu koppeln.

Sie kann unnötig sein, wenn Aufgaben entbehrlich oder rekonstruierbar sind, eine regelmäßige Abfrage des Geschäftszustands ausreicht oder Queue und Persistenz für diesen Ablauf eine geeignete integrierte Transaktion bieten. Bewerte diese Alternative anhand ihrer tatsächlichen Grenzen: Nimm nicht an, dass zwei Dienste atomar sind, nur weil sie zum selben Produkt gehören.

Das Muster verursacht auch Kosten: eine zusätzliche Tabelle, einen Worker, Wiederholungsversuche, Statusverwaltung, Aufbewahrung und betrieblichen Support. Vermeide es, ein komplexes Statussystem einzuführen, bevor die Anforderungen bekannt sind. Beginne mit Kennungen, Status, Zeitstempeln, Versuchen und einer sicheren Beanspruchung; erweitere den Entwurf nur bei konkreten Anforderungen an Reihenfolge, Volumen, Audit oder Wiederherstellung. Richtig ist die einfachste Option, die die erforderliche Konsistenz wahrt und Fehler erkennbar und behebbar macht.

Möchten Sie diese Ideen in Ihrem Projekt anwenden?Lass uns über deine PHP-Plattform sprechen.
Verwandten Dienst anzeigen