Jeder Event-getriebene Service schreibt irgendwann Code wie diesen: Bestellung speichern, dann OrderPlaced veröffentlichen. Im Test funktioniert das. In Produktion stürzt der Prozess zwischen den beiden Zeilen ab, und die Bestellung existiert ohne Event. Oder das Veröffentlichen klappt, die Datenbanktransaktion wird zurückgerollt, und das Event kündigt eine Bestellung an, die es nicht gibt.
Das ist das Dual-Write-Problem, und das Transactional-Outbox-Pattern ist die Standardlösung. Dieser Artikel erklärt das Problem, das Pattern, die zwei Wege, das Relay zu bauen, und die Details, die entscheiden, ob es wirklich funktioniert: Reihenfolge, Duplikate und Aufräumen.
Das Dual-Write-Problem
Eine Datenbank und ein Message Broker sind zwei Systeme ohne gemeinsame Transaktion. Jeder Code, der in beide schreibt, hat ein Zeitfenster, in dem ein Schreibvorgang gelungen ist und der andere nicht:
| Reihenfolge | Was schiefgehen kann |
|---|---|
| Erst committen, dann veröffentlichen | Absturz nach dem Commit: Das Event ist für immer verloren |
| Erst veröffentlichen, dann committen | Der Commit schlägt fehl: Consumer handeln auf Basis einer Bestellung, die es nie gab |
| Innerhalb der Transaktion veröffentlichen | Der Broker-Aufruf gelingt, die Transaktion wird trotzdem zurückgerollt |
Retries schließen das Fenster nicht, sie verschieben es. Tricks mit der Reihenfolge, etwa das Veröffentlichen aus einem After-Commit-Hook, verkleinern es nur: Ein Absturz zwischen Commit und Hook verliert das Event trotzdem. Verteilte Transaktionen über Datenbank und Broker hinweg gibt es selten, und wenn, sind sie langsam.
Das Pattern
Schreib das Event in derselben Transaktion wie die Geschäftsdaten in die Datenbank und veröffentliche es später von dort:
- Der Service fügt die Bestellzeile und eine Outbox-Zeile (Event-ID, Aggregate-ID, Typ, Payload) in einer lokalen Transaktion ein. Beide werden committet oder keine.
- Ein separates Relay liest neue Outbox-Zeilen und veröffentlicht sie beim Broker.
- Das Relay markiert die Zeilen als veröffentlicht oder löscht sie, sobald der Broker bestätigt.
Die Garantie ist jetzt einfach: Wenn die Bestellung existiert, wird ihr Event veröffentlicht, mindestens einmal. Chris Richardsons Beschreibung des Patterns nutzt dieselbe Struktur.
CREATE TABLE outbox (
id uuid PRIMARY KEY,
aggregate_id bigint NOT NULL,
type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Zwei Wege, das Relay zu bauen
Polling Publisher. Eine Schleife wählt unveröffentlichte Zeilen in Batches aus, veröffentlicht sie und löscht sie. In Postgres lässt SELECT ... FOR UPDATE SKIP LOCKED mehrere Relay-Instanzen die Arbeit teilen, ohne sich gegenseitig zu blockieren; siehe die Dokumentation der Locking-Klausel. Das ist leicht zu bauen und leicht zu verstehen. Die Kosten sind Polling-Latenz und Last sowie eine Feinheit bei der Reihenfolge: Zeilen mit niedrigeren IDs können nach Zeilen mit höheren IDs committet werden, sodass ein nach ID sortierender Poller eine Zeile überspringen kann, die noch nicht sichtbar ist.
Log Tailing (Change Data Capture). Ein CDC-Tool liest das Transaktionslog der Datenbank und macht aus committeten Outbox-Inserts Nachrichten. Der Outbox Event Router von Debezium tut genau das für Outbox-Tabellen und leitet jede Zeile anhand ihrer Spalten an ein Topic weiter. Events kommen in Commit-Reihenfolge heraus, mit geringer Latenz und ohne Polling-Abfragen, um den Preis, die CDC-Pipeline zu betreiben.
| Polling | CDC | |
|---|---|---|
| Latenz | Polling-Intervall | Nahe am Commit-Zeitpunkt |
| Reihenfolge | Braucht Sorgfalt | Commit-Reihenfolge |
| Bewegliche Teile | Ein Worker | Ein Connector und die Log-Konfiguration |
| Passt gut zu | Mittleren Volumen, kleinen Teams | Hohem Volumen, vielen Outboxes |
At least once heißt Duplikate
Das Relay kann eine Zeile veröffentlichen und abstürzen, bevor es sie als veröffentlicht markiert. Nach dem Neustart veröffentlicht es die Zeile erneut. Das ist unvermeidlich, also müssen Consumer idempotent sein:
- Jedes Event trägt eine eindeutige ID (die ID der Outbox-Zeile).
- Consumer speichern die IDs, die sie verarbeitet haben, idealerweise in derselben Transaktion wie den Effekt der Verarbeitung (eine Inbox-Tabelle), und überspringen bekannte IDs.
- Oder mach den Effekt von Natur aus idempotent, etwa mit einem Upsert auf die Bestell-ID.
Reihenfolge
Consumer interessiert meist die Reihenfolge pro Aggregate: OrderPlaced vor OrderCancelled für dieselbe Bestellung. Nutze die Aggregate-ID als Message Key, damit alle Events einer Bestellung in dieselbe Partition gehen, und veröffentliche in Commit-Reihenfolge (CDC tut das; ein Poller sollte nach einer beim Commit vergebenen Sequenz sortieren oder die Events eines Aggregates seriell verarbeiten). Eine globale Reihenfolge über alle Bestellungen braucht man selten, und sie ist teuer.
Aufräumen
Eine Outbox-Tabelle, die nie geleert wird, wächst unbegrenzt und bremst den Poller. Lösche Zeilen nach dem Veröffentlichen oder behalte sie für ein kurzes Audit-Fenster und entferne alte Partitionen. Mit CDC können Zeilen direkt nach dem Einfügen in derselben Transaktion gelöscht werden, weil der Connector den Insert aus dem Log liest, nicht aus der Tabelle.
Die Fehlerfälle durchgehen
Prüf das Design gegen jeden Absturzpunkt:
- Absturz vor dem Commit. Weder Bestellung noch Event existieren. Der Client bekommt einen Fehler und kann es erneut versuchen.
- Absturz nach dem Commit, bevor das Relay läuft. Beide Zeilen existieren; das Relay veröffentlicht das Event, sobald es startet.
- Relay-Absturz nach dem Veröffentlichen, vor dem Löschen der Zeile. Das Event wird nach dem Neustart erneut veröffentlicht, und Consumer verwerfen das Duplikat anhand der ID.
- Broker ausgefallen. Zeilen sammeln sich in der Outbox; der Service nimmt weiter Bestellungen an, und das Relay holt auf, wenn der Broker zurück ist. Alarmiere auf das Alter der ältesten unveröffentlichten Zeile.
In jedem Fall bleibt die Datenbank die Quelle der Wahrheit, und genau diese Eigenschaft soll das Pattern liefern.
Das Architekturdiagramm
Diagramminhalt
- Order service
- Postgres: one transaction
- orders
- outbox
- Outbox relay
- order-events
- Shipping service
- Email service
- Processed events
- both rows
- new rows
- publish
- OrderPlaced
- seen it?
So liest du es:
- Links: Der Bestellservice schreibt zwei Zeilen, in die Tabellen orders und outbox, innerhalb einer Postgres-Transaktion. Der Rahmen markiert die Transaktionsgrenze.
- Mitte: Das Outbox-Relay liest per Polling oder CDC neue Zeilen und veröffentlicht sie im Topic order-events.
- Rechts: Versand- und E-Mail-Service konsumieren die Events. Der E-Mail-Service prüft vor dem Handeln eine Tabelle verarbeiteter Event-IDs; so werden Duplikate abgefangen.
- Der SQL-Block zeigt die Transaktion, damit niemand raten muss, was „atomar“ hier bedeutet.
Was ins Diagramm gehört
- Die Transaktionsgrenze. Zeichne beide Tabellen hinein; das ist das ganze Pattern.
- Das Relay und seine Art. Polling oder CDC ändert Latenz und Reihenfolge.
- Die Zustellsemantik. Schreib „at least once“ an das Relay und „idempotent“ an jeden Consumer.
- Der Message Key. Vermerke, dass die Aggregate-ID als Key der Events dient.
Der Artikel zur Event-Driven-Architektur behandelt die Consumer-Seite genauer, und das Saga-Pattern-Diagramm zeigt, wo in jedem Schritt eines Workflows über mehrere Services eine Outbox nötig ist. Der Artikel zur Datenbankreplikation erklärt das Write-Ahead-Log, aus dem CDC liest.
In Archboard nachbauen
- Melde dich mit Google oder GitHub an und leg ein Diagramm an.
- Füge aus dem C4-Modell-Pack einen Container für den Bestellservice und einen weiteren für das Outbox-Relay hinzu.
- Füge aus dem Basis-Pack einen Rahmen hinzu, beschriftet mit Datenbank und Transaktion, und setz zwei Entität-Nodes aus dem Entity-Relationship-Pack für die Tabellen orders und outbox hinein.
- Füge Kafka aus dem Daten-Pack für das Topic hinzu, Container-Nodes für die Consumer und eine Datenbank für verarbeitete Event-IDs.
- Füge einen Markdown-Block aus dem Basis-Pack mit dem SQL der Transaktion hinzu.
Das Beispiel nutzt 10 Diagramm-Nodes und bleibt damit im Free-Plan.
FAQ
Welches Problem löst die Transactional Outbox? Verlorene oder erfundene Events, wenn ein Service in seine Datenbank schreibt und eine Nachricht in zwei getrennten Schritten veröffentlicht. Die Outbox macht das Event zum Teil derselben Datenbanktransaktion.
Liefert das Outbox-Pattern exactly once? Nein. Es garantiert At-least-once-Zustellung. Consumer müssen mit Duplikaten umgehen, meist indem sie verarbeitete Event-IDs speichern.
Polling oder CDC: Welches Relay soll ich nehmen? Polling ist einfacher und reicht für mittlere Volumen. CDC bietet geringere Latenz und Zustellung in Commit-Reihenfolge und lohnt sich bei hohem Volumen oder wenn viele Services Outboxes nutzen.
Brauche ich die Outbox noch, wenn ich Event Sourcing nutze? Nicht, wenn der Event Store selbst die Quelle ist, die Consumer abonnieren. Du brauchst sie immer dann, wenn ein Service Zustand in Tabellen speichert und zusätzlich Events veröffentlichen muss.
Wie groß wird die Outbox-Tabelle? So groß, wie du sie werden lässt. Lösche veröffentlichte Zeilen zügig oder partitioniere die Tabelle nach Zeit und entferne alte Partitionen.