DEV Community

Aleksei Aleinikov
Aleksei Aleinikov

Posted on Originally published at alekseialeinikov.com on

Das Outbox Pattern: Zuverlässige Events ohne verteilte Transaktionen

Das Outbox Pattern: Zuverlässige Events ohne verteilte Transaktionen

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 Outbox Pattern: Event mit den Daten committen, danach ausliefern.

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.

Das Dual-Write-Problem: Commit und dann Absturz verliert das Event, Senden und dann Rollback erzeugt ein Phantom-Event, Senden innerhalb der Transaktion hält Locks und endet bei Timeout in einem unbekannten Zustand.
Jede Reihenfolge scheitert anders, und Retries reparieren keine davon.

  1. 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.
  2. 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.
  3. 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:

  1. Das Event als Zeile schreiben. Eine outbox-Tabelle liegt in derselben Datenbank wie Ihre Fachdaten.
  2. 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.
  3. Danach weiterreichen. Ein separater Prozess liest committete Outbox-Zeilen und veröffentlicht sie an den Broker, mit Retries, bis der Broker bestätigt.

So funktioniert die Outbox: Die Bestellzeile und die Outbox-Zeile werden in einer Datenbanktransaktion geschrieben, ein Relay liest committete Outbox-Zeilen, veröffentlicht sie nach Aggregat-ID geschlüsselt an den Broker, und Consumer deduplizieren anhand der Event-ID.
Das Event teilt das Schicksal der Daten. Die Zustellung wird at-least-once, und Consumer deduplizieren.

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.

outbox.sql


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;

Enter fullscreen mode Exit fullscreen mode

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

place-order.sql


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;

Enter fullscreen mode Exit fullscreen mode

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.

Polling Publisher gegen Transaction Log Tailing: Polling fragt die Outbox-Tabelle mit SKIP LOCKED ab und markiert Zeilen als veröffentlicht; Log Tailing liest WAL oder Binlog mit Debezium und seinem EventRouter und veröffentlicht an ein Topic, geschlüsselt nach Aggregat-ID.
Mit Polling starten. Zu CDC wechseln, wenn Latenz, Last oder Reihenfolge es verlangen.

Variante A: Polling Publisher

Eine Schleife in Ihrem eigenen Service. Einen Batch beanspruchen, veröffentlichen, als erledigt markieren, alles in einer kurzen Transaktion:

relay-batch.sql


BEGIN;

SELECT id, aggregatetype, aggregateid, type, payload

FROM outbox

WHERE published_at IS NULL

ORDER BY created_at

LIMIT 100

FOR 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;

Enter fullscreen mode Exit fullscreen mode

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:

Achtung

Pollen Sie nie „alles nach der zuletzt gesehenen ID“. IDs und Zeitstempel werden beim Insert vergeben, sichtbar werden Zeilen aber erst beim Commit, und Transaktionen committen in beliebiger Reihenfolge. Eine langsame Transaktion kann Zeile 41 committen, nachdem Sie schon an 42 vorbei sind, und ein Poller mit Hochwassermarke überspringt sie für immer. Selektieren Sie stattdessen auf published_at IS NULL, wie oben.

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 :

outbox-connector.properties


connector.class=io.debezium.connector.postgresql.PostgresConnector

plugin.name=pgoutput

table.include.list=public.outbox

transforms=outbox

transforms.outbox.type=io.debezium.transforms.outbox.EventRouter

transforms.outbox.table.expand.json.payload=true

value.converter=org.apache.kafka.connect.json.JsonConverter

Enter fullscreen mode Exit fullscreen mode

Mit 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).

Tipp

Wenn die Unterschiede zwischen PostgreSQL und MySQL für Ihre Plattform zählen, zählen sie auch hier: Logical Decoding mit pgoutput auf der einen Seite, zeilenbasiertes Binlog auf der anderen. Debezium unterstützt beide.

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.

consume-order-placed.sql


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ätigen
Enter fullscreen mode Exit fullscreen mode

At-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

Ursprünglich veröffentlicht auf alekseialeinikov.com

Top comments (0)