Laravel und Kafka ohne verlorene Events: Transactional Outbox und idempotente Consumer mit PostgreSQL

Ein Datenbank-Commit und eine Kafka-Veröffentlichung sind keine normale gemeinsame Transaktion. Outbox und Duplikatschutz schließen die wichtigsten Fehlerfenster.
Dieser Code wirkt zunächst plausibel:
$order = Order::create($data);
$kafka->publish('orders.placed.v1', $order->toArray());
Gelingt die Datenbankänderung und scheitert der Publish, existiert eine Bestellung ohne Inventar-Event. Vertauschst du die Aufrufe, kann ein Event über eine später zurückgerollte Bestellung erscheinen. Ein Kafka-Aufruf innerhalb von DB::transaction() macht Kafka nicht zum Teil der PostgreSQL-Transaktion.
Die Transactional Outbox schreibt Geschäftsdaten und Event-Datensatz in derselben Datenbanktransaktion. Ein separater Publisher bringt den Datensatz nach Kafka. So übersteht das Event einen Prozessabsturz nach dem Commit. Weil die Veröffentlichung wiederholt werden kann, muss der Consumer Seiteneffekte idempotent behandeln.
1. Der Event-Vertrag
{
"eventId": "evt_01JQ8X8F",
"eventType": "OrderPlaced",
"schemaVersion": 1,
"occurredAt": "2026-09-29T10:30:00Z",
"orderId": "ord_782",
"customerId": "cus_17",
"totalMinor": 12900,
"currency": "USD"
}
OrderPlaced bedeutet, dass die Bestellung tatsächlich bestätigt wurde. eventId bleibt über Wiederholungen unverändert und dient der Duplikaterkennung. Der Kafka-Key ist orderId, sodass Ereignisse derselben Bestellung bei stabiler Partitionierung zusammenbleiben. Veröffentliche kein vollständiges Eloquent-Modell: Tabellenfelder, Beziehungen und interne Daten sind kein stabiler öffentlicher Vertrag.
Dokumentiere Eigentümer, Key, Felder, Aufbewahrung, Kompatibilität und geplante Leser des Topics. Ein v1 im Namen ersetzt keine Schema-Governance.
2. Outbox-Tabelle und eine Transaktion
Ein vereinfachter PostgreSQL-Startpunkt:
CREATE TABLE outbox_events (
id uuid PRIMARY KEY,
aggregate_id text NOT NULL,
topic text NOT NULL,
event_key text NOT NULL,
payload jsonb NOT NULL,
occurred_at timestamptz NOT NULL,
published_at timestamptz NULL,
attempts integer NOT NULL DEFAULT 0
);
CREATE INDEX outbox_pending_idx ON outbox_events (occurred_at, id)
WHERE published_at IS NULL;
Erstelle Bestellung und Outbox-Zeile innerhalb derselben DB::transaction(). Bei Rollback verschwinden beide; nach Commit bleibt das Event selbst bei einem sofortigen Absturz erhalten. Halte die Transaktion kurz und warte nicht mit Datenbanksperren auf Kafka. Ein anpassbarer PHP-Entwurf:
DB::transaction(function () use ($data) {
$order = Order::create($data);
$eventId = (string) Str::uuid();
DB::table('outbox_events')->insert([
'id' => $eventId,
'aggregate_id' => (string) $order->id,
'topic' => 'orders.placed.v1',
'event_key' => (string) $order->id,
'payload' => json_encode([
'eventId' => $eventId,
'eventType' => 'OrderPlaced',
'schemaVersion' => 1,
'orderId' => (string) $order->id,
], JSON_THROW_ON_ERROR),
'occurred_at' => now(),
]);
});
Im echten Projekt gehören Validierung, Datentypen und Datenschutz dazu. Das Beispiel ist kein universeller Aufruf einer bestimmten Kafka-PHP-Bibliothek.
3. Publisher und Fehlerfenster
Ein Worker wählt kleine Mengen unveröffentlichter Zeilen, beansprucht sie sicher gegenüber anderen Workern, sendet mit gespeichertem Key nach Kafka, wartet auf passende Bestätigung und setzt published_at. Ein kurzer Claim oder ein ablaufender Lease kann abgestürzte Worker auffangen. Halte keine Datenbanksperre während einer langsamen Netzwerkoperation.
Kafka kann das Event akzeptieren, bevor der Worker published_at setzt und abstürzt. Beim nächsten Versuch wird es nochmals gesendet. Die Outbox bietet daher mindestens einmalige Veröffentlichung, kein globales „genau einmal“. Verwende dieselbe eventId bei jedem Versuch. Ein idempotenter Producer hilft gegen eigene Kafka-Retries, macht PostgreSQL-Update und Kafka-Bestätigung aber nicht atomar.
Der Publisher kann ein Laravel-Command, Worker oder CDC-Prozess sein. Client-Bibliotheken brauchen eine Prüfung auf Version, Sicherheit und Betrieb. Laravels eingebaute Queue-Backends umfassen etwa Redis, SQS und Datenbanken; Kafka wird durch QUEUE_CONNECTION allein nicht zum eingebauten Driver.
4. Der idempotente Inventar-Consumer
Ein Consumer reserviert Bestand und stürzt vor seinem Offset-Commit ab. Nach dem Neustart liest er das Event erneut. Eine eindeutige Tabelle schützt den Seiteneffekt:
CREATE TABLE processed_events (
consumer_name text NOT NULL,
event_id text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);
Füge (inventory-reservation, eventId) in derselben Inventar-Datenbanktransaktion ein, die die Reservierung ausführt. Ist der Eintrag bereits vorhanden, überspringe den wiederholten Effekt. Erst nach erfolgreichem Commit schreitet der Kafka-Offset fort. Ein Absturz zwischen Datenbank- und Offset-Commit führt zu sicherer Wiederholung. Ein Rollback darf nicht als erfolgreich verarbeitet gelten.
E-Mail und Zahlung liegen außerhalb dieser lokalen Transaktion. Nutze nach Möglichkeit Idempotency-Keys des Anbieters, eine weitere Outbox oder eine Kompensation. Formuliere Garantien für jeden Seiteneffekt getrennt.
5. Retry, Quarantäne und Schemaänderungen
Ein vorübergehender Datenbankausfall braucht Retry mit Backoff. Ein dauerhaft ungültiges Event braucht Untersuchung, Reparatur oder Quarantäne. Ein Dead-Letter-Topic kann helfen, doch dessen Publish und das Commit des ursprünglichen Offsets haben wieder ein Fehlerfenster. Definiere Alarm, Zugang, Retention und Replay. Schlucke keine Ausnahme, um bloß den Offset fortzuschreiben.
Logge eventId, Topic, Partition, Offset, Gruppe und sichere Fehlerkategorie, ohne vertrauliche Payloads vollständig zu speichern. Halte Duplikatschutz für die versprochene Retention- und Replay-Zeit. Teste neue Producer-Schemas gegen alte Events und Consumer. Ändere bestehende Feldbedeutungen nicht stillschweigend.
Teste Ausfälle statt nur Methodenaufrufe
Teste Bestell-Rollback, Commit bei gestopptem Publisher, Kafka-Erfolg mit Absturz vor published_at, doppelte Lieferung, Absturz nach Reservierung und vor Offset sowie parallele Worker. Beobachte Alter der ältesten Outbox-Zeile, ausstehende Menge, Publish-Fehler, Consumer-Lag und Quarantäne. Ein gesunder HTTP-Endpunkt sagt nichts über eine feststeckende Outbox.
Für eine einzelne Hintergrund-Mail reicht oft die normale Laravel-Queue. Wenn ein Geschäftsfakt mehrere unabhängige Systeme zuverlässig erreichen und später erneut gelesen werden muss, bilden Outbox, stabile Event-ID, idempotenter Consumer und sichtbare Fehlerbehandlung eine sinnvolle Einheit.
Quellen
Empfohlene Artikel

Kafka im Produktivbetrieb: Partitionen, Consumer Lag und sichere Wiederherstellung
Von der Wahl des Partition Keys bis zum Incident Runbook: Dieser Leitfaden verbindet Kafka Kennzahlen mit den tatsächlichen Geschäftsergebnissen.

Apache Kafka verstehen: Topics, Partitionen, Consumer Groups und deine erste Event-Pipeline
Verfolge ein Bestellereignis vom Producer zu mehreren Consumern und probiere Partitionen, Reihenfolge und Replay lokal aus.

APIs entwerfen, denen Clients vertrauen können: Ein praktischer Leitfaden vom Vertrag aus
Gute APIs machen den nächsten Schritt für Clients vorhersehbar. Ein Kursanmeldungs-Beispiel zeigt sichere Wiederholungen, verständliche Fehler und Änderungen am Vertrag.
Kommentare
0 KommentareNoch keine freigegebenen Kommentare sichtbar. Neue Antworten können moderiert werden.