Jeder Service, der Daten besitzt, muss dem Rest des Systems irgendwann mitteilen, dass etwas passiert ist. Eine Bestellung geht ein, also müssen Billing, Versand und E-Mail davon erfahren. Der naheliegende Code sind zwei Zeilen: Bestellung speichern, dann OrderPlaced veröffentlichen.
Diese zwei Zeilen sind die häufigste Quelle für stillen Datenverlust in eventgetriebenen Systemen. Sie schreiben in zwei verschiedene Systeme, und nichts macht das Paar atomar. Das Outbox Pattern (Transactional Outbox) ist die Standardlösung: Schreiben Sie das Event in derselben Transaktion wie die Daten in Ihre eigene Datenbank und liefern Sie es danach aus. Dieser Leitfaden zeigt den Fehler, den es verhindert, das Schema, beide Wege der Auslieferung und die Teile, die die meisten Umsetzungen falsch machen: Duplikate, Reihenfolge und Aufräumen.

Das Dual-Write-Problem
In eine Datenbank speichern und an einen Message Broker senden ist ein Dual Write. Es gibt nur drei mögliche Reihenfolgen, und alle drei scheitern.

- Erst committen, dann senden. Der Prozess stürzt nach dem Commit ab, wird neu deployt oder verliert das Netzwerk. Die Bestellung existiert, das Event nicht. Billing bucht nie ab, der Versand verschickt nie, und nirgends steht ein Fehler im Log, denn genau der Code, der es wiederholt hätte, ist gestorben.
- Erst senden, dann committen. Das Event geht raus, dann scheitert die Transaktion an einem Constraint oder Deadlock und rollt zurück. Consumer handeln jetzt auf Basis einer Bestellung, die nie existiert hat: ein Phantom-Event.
- Innerhalb der Transaktion senden. Der Broker ist nicht Teil Ihrer Datenbanktransaktion, das wirkt also nur sicherer. Zeilensperren bleiben gehalten, während Sie auf das Netzwerk warten, und bei einem Timeout wissen Sie nicht, ob die Nachricht rausging.
Die Lehrbuchantwort ist eine verteilte Transaktion (Two-Phase Commit, XA) über beide Systeme. In der Praxis nehmen die meisten Broker nicht an XA teil, und 2PC koppelt die Verfügbarkeit beider Systeme aneinander. Kafkas eigene Transaktionen umfassen Schreibvorgänge in Kafka-Topics, nicht in Ihre Datenbank. Kafka hat zwar einen optionalen Weg angenommen, mit dem ein Producer an einem extern koordinierten Two-Phase Commit teilnehmen kann (KIP-939), aber auch das ist kein XA: Ihre Anwendung wird zum Koordinator und muss den Transaktionszustand selbst speichern und wiederherstellen.
Der Ausweg: aufhören, zwei Schreibvorgänge zu haben.
Was das Outbox Pattern ist
Das Outbox Pattern speichert ausgehende Events in einer Outbox-Tabelle, in derselben Datenbanktransaktion wie die fachliche Änderung, und veröffentlicht sie danach über ein separates Relay. So veröffentlichen Microservices Events zuverlässig und mindestens einmal, ohne verteilte Transaktion.
Das Pattern, von Chris Richardson auf microservices.io katalogisiert, macht aus zwei Schreibvorgängen einen:
- Das Event als Zeile schreiben. Eine
outbox-Tabelle liegt in derselben Datenbank wie Ihre Fachdaten. - Dieselbe Transaktion nutzen. Fachliche Änderung und Outbox-Insert werden gemeinsam committet oder gemeinsam zurückgerollt. Das Event existiert genau dann, wenn die Änderung committet wurde.
- Danach weiterreichen. Ein separater Prozess liest committete Outbox-Zeilen und veröffentlicht sie an den Broker, mit Retries, bis der Broker bestätigt.

Die Datenbanktransaktion, der Sie ohnehin vertrauen, ist jetzt der einzige atomare Schritt. Alles nach dem Commit ist eine Retry-Schleife, und Retry-Schleifen lassen sich leicht zuverlässig machen.
Es ist derselbe Baustein, der am Anfang einer zuverlässigen Webhook-Zustellung steht und hinter dem Versand von einer Million Benachrichtigungen ohne Absturz.
Schritt 1: die Outbox-Tabelle
Verwenden Sie die Spaltennamen, die Debezium standardmäßig erwartet. Sie kosten nichts und halten den Weg zu Change Data Capture offen.
CREATE TABLE outbox ( id uuid PRIMARY KEY, aggregatetype varchar(255) NOT NULL, -- bestimmt das Topic, z. B. "order" aggregateid varchar(255) NOT NULL, -- Nachrichtenschlüssel, hält die Reihenfolge pro Aggregat type varchar(255) NOT NULL, -- "OrderPlaced" payload jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), published_at timestamptz -- nur für ein Polling-Relay);
-- Das Polling-Relay betrachtet nur unveröffentlichte Zeilen.CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;Die id ist die Identität des Events für sein ganzes Leben. Consumer erkennen daran Duplikate, also erzeugen Sie sie in der Anwendung, nicht im Relay.
Schritt 2: das Event in derselben Transaktion schreiben
BEGIN;INSERT INTO orders (id, customer_id, total) VALUES (42, 7, 99.00);
INSERT INTO outbox (id, aggregatetype, aggregateid, type, payload)VALUES (gen_random_uuid(), 'order', '42', 'OrderPlaced', '{"eventVersion": 1, "orderId": 42, "customerId": 7, "total": 99.00}');COMMIT;Zwei Details zählen mehr, als es scheint:
- Der Payload ist ein Vertrag, kein Tabellen-Dump. Schreiben Sie das Event, das Consumer brauchen, mit expliziter Version, nicht
SELECT *der Bestelltabelle. Ihre Tabelle wird sich ändern; das Event-Schema muss sich kompatibel weiterentwickeln. - Halten Sie die Transaktion kurz. Die Outbox fügt einen Insert hinzu. Sie darf keinen Netzwerkaufruf hinzufügen.
Schritt 3: die Zeilen weiterreichen
Es gibt zwei Wege, committete Zeilen aus der Tabelle zu holen. Richardson nennt sie Polling Publisher und Transaction Log Tailing.

Variante A: Polling Publisher
Eine Schleife in Ihrem eigenen Service. Einen Batch beanspruchen, veröffentlichen, als erledigt markieren, alles in einer kurzen Transaktion:
BEGIN;SELECT id, aggregatetype, aggregateid, type, payloadFROM outboxWHERE published_at IS NULLORDER BY created_atLIMIT 100FOR UPDATE SKIP LOCKED;
-- jede Zeile an den Broker senden und auf das Ack warten, dann:UPDATE outbox SET published_at = now() WHERE id = ANY($published_ids);COMMIT;FOR UPDATE SKIP LOCKED (PostgreSQL ab 9.5, MySQL ab 8.0) lässt mehrere Relay-Instanzen laufen, ohne dass sie um dieselben Zeilen kämpfen. Damit kommen auch die zwei klassischen Fehler von Polling-Relays:
Der zweite Fehler ist die Reihenfolge. Mit mehreren Relays und SKIP LOCKED können zwei Events derselben Bestellung von verschiedenen Instanzen in beliebiger Reihenfolge veröffentlicht werden. Wenn das für Consumer zählt, und meistens tut es das, geben Sie jedem Aggregat genau einen Besitzer: ein einziges Relay oder Relays, die jeweils einen festen Bereich von hash(aggregateid) beanspruchen.
Polling bringt bis zu ein Intervall Latenz. In PostgreSQL senken Sie sie, ohne häufiger zu pollen: NOTIFY outbox in der schreibenden Transaktion, und das Relay lauscht per LISTEN. Benachrichtigungen werden erst beim Commit zugestellt, das Relay wacht also genau dann auf, wenn es etwas zu lesen gibt, und das Intervall wird zum Sicherheitsnetz.
Variante B: Transaction Log Tailing (CDC)
Statt die Tabelle abzufragen, lesen Sie das Transaktionslog der Datenbank: das WAL in PostgreSQL, das Binlog in MySQL. Inserts kommen in Commit-Reihenfolge an, ganz ohne Polling-Abfragen. Das übliche Werkzeug ist Debezium mit seinem Outbox Event Router:
connector.class=io.debezium.connector.postgresql.PostgresConnectorplugin.name=pgoutputtable.include.list=public.outboxtransforms=outboxtransforms.outbox.type=io.debezium.transforms.outbox.EventRoutertransforms.outbox.table.expand.json.payload=truevalue.converter=org.apache.kafka.connect.json.JsonConverterMit den Standardspalten aus Schritt 1 veröffentlicht der Router jede Zeile an das Topic outbox.event.<aggregatetype>, nutzt aggregateid als Nachrichtenschlüssel, sodass alle Events eines Aggregats geordnet in einer Partition landen, und legt die Event-id zur Deduplizierung in einen Nachrichten-Header.
Zwei Dinge machen Log Tailing im großen Maßstab attraktiv:
- Die Tabelle kann leer bleiben. Fügen Sie die Outbox-Zeile ein und löschen Sie sie in derselben Transaktion. Der Insert steht trotzdem im Log, Debezium veröffentlicht ihn, und der Router ignoriert Deletes. Kein Aufräumjob, kein Bloat.
- Keine Abfragelast. Der Connector liest das Log, das die Datenbank ohnehin schreibt.
Und eine Sache verdient Respekt: In PostgreSQL nutzt CDC einen Replication Slot, und die Datenbank hält WAL dafür vor, bis der Connector es bestätigt. Ein Connector, der ein Wochenende ausfällt, kann die Platte füllen. Alarmieren Sie auf Slot-Lag und begrenzen Sie ihn mit max_slot_wal_keep_size (ab PostgreSQL 13).
At-least-once: Consumer idempotent machen
Die Outbox garantiert, dass ein Event nie verloren geht. Sie garantiert nicht, dass es genau einmal ankommt. Das Relay kann einen Batch veröffentlichen und abstürzen, bevor es published_at setzt. Nach dem Neustart veröffentlicht es dieselben Events erneut. Eine erneute Zustellung durch den Broker oder ein Consumer-Absturz vor dem Ack bewirken dasselbe.
Jeder Consumer muss also deduplizieren, und der robuste Weg ist eine Inbox: Die Event-ID wird in derselben Transaktion gespeichert wie die eigentliche Änderung.
BEGIN;INSERT INTO processed_events (event_id) VALUES ($event_id)ON CONFLICT (event_id) DO NOTHING;-- 0 Zeilen eingefügt: Duplikat, committen und bestätigen, ohne etwas zu tun.
INSERT INTO invoices (order_id, amount) VALUES (42, 99.00);COMMIT;-- die Nachricht erst nach dem Commit bestätigenAt-least-once-Zustellung plus idempotente Consumer ist das, was in der Praxis mit „exactly-once“ gemeint ist. Dieselbe Technik schützt HTTP-APIs; die Details stehen in Idempotenz in der Praxis.
Reihenfolge: was Sie versprechen können und was nicht
- Pro Aggregat: ja. Schlüsseln Sie jede Nachricht nach
aggregateid. Kafka hält die Reihenfolge innerhalb einer Partition, Google Pub/Sub hat Ordering Keys für denselben Zweck. Alle Events zu Bestellung 42 kommen in Commit-Reihenfolge an, solange ein einziges Relay diesen Schlüssel besitzt. - Global: nein. Events zu Bestellung 42 und Bestellung 17 können in beliebiger relativer Reihenfolge ankommen. Bauen Sie Consumer so, dass das in Ordnung ist.
- Trotzdem absichern. Legen Sie eine Version oder Sequenznummer pro Aggregat in den Payload. Ein Consumer, der Version 5 nach Version 6 sieht, kann sie ignorieren, statt neueren Zustand zu überschreiben.
Die Outbox betreiben
Aufräumen. Ein Polling-Relay hinterlässt veröffentlichte Zeilen. Löschen Sie sie nach einem kurzen Audit-Fenster in Batches, oder partitionieren Sie die Tabelle bei hohem Volumen nach Tag und verwerfen alte Partitionen, was im Vergleich zu großen DELETE-Anweisungen und der Vacuum-Arbeit, die sie auslösen, nichts kostet. Mit CDC löschen Sie in derselben Transaktion, wie oben beschrieben.
Monitoring. Eine Metrik sagt fast alles: das Alter des ältesten unveröffentlichten Events. Mit Polling ist das now() - min(created_at) WHERE published_at IS NULL, mit CDC der Replikations-Lag. Alarmieren Sie, wenn es einige Minuten überschreitet; dieselbe Regel fängt ein totes Relay, einen Broker-Ausfall und ein Poison Event.
Poison Events. Ein Event, das der Broker ablehnt (zu groß, ungültiges Schema), blockiert die Warteschlange für seinen Schlüssel. Zählen Sie Versuche pro Zeile und verschieben Sie sie nach einem Limit in eine Dead-Letter-Tabelle mit Alarm, statt endlos zu wiederholen.
Tracing. Speichern Sie den Trace-Kontext in der Outbox-Zeile und stellen Sie ihn im Relay wieder her, damit ein Trace dem Request vom API-Aufruf über den Broker bis in jeden Consumer folgt. Debeziums Router unterstützt das über eine Tracing-Spalte.
Outbox-Implementierungen in Java, .NET, Go und MongoDB
Das Relay müssen Sie selten selbst schreiben. Das Pattern steckt in den Frameworks, die die meisten Teams ohnehin nutzen:
| Stack | Fertige Outbox | Hinweis |
|---|---|---|
| Java, Spring | Spring Modulith Event Publication Registry | Laut eigener Doku fehlen der nativen Externalisierung fortgeschrittene Outbox-Funktionen; 2.1 bringt Namastack Outbox und JobRunr als Backends |
| Java | Eventuate Tram | Von Chris Richardson, dem Autor des Pattern-Katalogs |
| .NET (C#) | MassTransit Transactional Outbox, NServiceBus Outbox, Wolverine, CAP | MassTransit und NServiceBus deduplizieren auch auf Consumer-Seite |
| Go | Watermill Forwarder | Veröffentlicht in Ihrer Transaktion in eine SQL-Tabelle und leitet an den echten Broker weiter |
| Jede Sprache | Debezium (Kafka Connect oder Debezium Server) | Debezium Server sendet ohne Kafka an Google Pub/Sub, Amazon Kinesis und andere |
MongoDB unterstützt Multi-Document-Transaktionen auf Replica Sets, die Outbox funktioniert also genauso: Bestellung und Outbox-Dokument in einer Session-Transaktion schreiben. Debezium hat einen eigenen MongoDB Outbox Event Router, weil der relationale nicht mit dem MongoDB-Connector funktioniert.
Cloud-Datenbanken haben oft ein Log, das Sie statt Polling lesen können: DynamoDB Streams nach einem TransactWriteItems, das Item und Event gemeinsam schreibt, oder Spanner Change Streams auf einer Outbox-Tabelle.
Outbox oder etwas anderes?
| Ansatz | Atomar mit Ihren Daten | Zusätzliche Infrastruktur | Trade-off |
|---|---|---|---|
| Erst committen, dann senden | Nein | Keine | Verliert Events bei Absturz |
| Two-Phase Commit (XA) | Ja | XA-fähiger Broker | Die meisten Broker unterstützen es nicht; koppelt Verfügbarkeit |
| Transactional Outbox | Ja | Ein Relay | At-least-once, Consumer müssen deduplizieren |
| CDC auf Fachtabellen | Ja | CDC-Pipeline | Ihr internes Schema wird zum öffentlichen Vertrag |
| Event Sourcing | Ja, das Event ist der Zustand | Event Store | Ändert Ihr gesamtes Persistenzmodell |
Die Outbox ist die Variante, die einen bewussten Event-Vertrag behält und nichts Exotischeres braucht als eine lokale Transaktion.
Wann Sie keine Outbox brauchen
- Niemand außerhalb der Datenbank muss davon erfahren. Ist die Folgearbeit ein Job in einer Queue-Tabelle in derselben Datenbank, wird die Job-Zeile bereits atomar mit Ihren Daten committet. Sie haben eine Outbox ohne den Namen.
- Sie nutzen bereits Event Sourcing. Das Anhängen an den Event Store ist die Zustandsänderung; veröffentlichen Sie aus dem Store.
- Ein verlorenes Event ist wirklich egal. Analytics-Pings und Best-Effort-Cache-Warming vertragen den seltenen Verlust. Fachliche Fakten wie Bestellungen, Zahlungen und Berechtigungen nicht.
Das Pattern passt auch direkt dazu, wie eine Microservices-Architektur funktioniert: Services besitzen ihre Daten, und asynchrone Events sind der Weg, über den der Rest des Systems von Änderungen erfährt, ohne die Datenbank eines anderen Service zu lesen.
Das Fazit
Der Fehler, den die Outbox behebt, ist im Test unsichtbar und in Produktion unvermeidlich: ein Absturz zwischen „gespeichert“ und „veröffentlicht“. Die Lösung ist, das Event nicht mehr als zweiten Schreibvorgang zu behandeln. Committen Sie es mit den Daten, in der Transaktion, der Sie ohnehin vertrauen, und lassen Sie ein Relay es mit Retries zustellen.
Fangen Sie einfach an: eine Outbox-Tabelle mit Debezium-kompatiblen Spalten, ein Polling-Relay mit SKIP LOCKED, das unveröffentlichte Zeilen selektiert statt an der letzten ID vorbeizulesen, Consumer, die anhand der Event-ID deduplizieren, und ein Alarm auf das Alter des ältesten unveröffentlichten Events. Wechseln Sie zu Log Tailing, wenn Latenz oder Last es verlangen. Ihr Schema ist schon bereit.
Weiterlesen: Webhooks erklärt: Signatur, Retries und Idempotenz · Eine Million Benachrichtigungen versenden, ohne abzustürzen






Aus der Community
Diskussion auf Mastodon & Bluesky
Antworten auf diesen Artikel aus dem offenen Netz, live geladen. Keine Werbung, keine Tracking-Skripte.
Antworte dort — es erscheint hier automatisch.