---
title: "Das Outbox Pattern: Zuverlässige Events ohne verteilte Transaktionen"
description: "Daten speichern und ein Event veröffentlichen sind zwei Schreibvorgänge in zwei Systemen, und kein Retry macht sie atomar. Die Transactional Outbox schreibt das Event in derselben Datenbanktransaktion und liefert es danach aus. Schema, Polling vs. CDC mit Debezium, idempotente Consumer, Reihenfolge und Betrieb."
author: Aleksei Aleinikov
date: 2026-10-19
lang: de
tags: [outbox-pattern, transactional-outbox, event-driven-architecture, microservices, debezium, change-data-capture, idempotenz]
canonical: https://www.alekseialeinikov.com/de/blog/topics/architecture/outbox-pattern-erklaert
source: alekseialeinikov.com
---

# 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.](https://www.alekseialeinikov.com/blog/outbox-pattern.webp)

## 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.](https://www.alekseialeinikov.com/blog/outbox-dual-write-problem.webp "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.](https://www.alekseialeinikov.com/blog/outbox-how-it-works.webp "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](https://www.alekseialeinikov.com/de/blog/topics/architecture/webhooks-erklaert-signatur-retries-idempotenz) steht und hinter dem [Versand von einer Million Benachrichtigungen ohne Absturz](https://www.alekseialeinikov.com/de/blog/topics/architecture/eine-million-benachrichtigungen-versenden-ohne-abzustuerzen-2026).

## 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.

```sql title="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;
```

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

```sql title="place-order.sql" {4-6}
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**.

![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.](https://www.alekseialeinikov.com/blog/outbox-polling-vs-cdc.webp "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:

```sql title="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;
```

`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:

> [!WARNING]
> **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**:

```properties title="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
```

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

> [!TIP]
> Wenn die [Unterschiede zwischen PostgreSQL und MySQL](https://www.alekseialeinikov.com/de/blog/topics/data/postgres-vs-mysql-2026-vergleich) 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.

```sql title="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
```

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](https://www.alekseialeinikov.com/de/blog/topics/architecture/idempotenz-in-der-praxis-api-retries-2026).

## 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](https://www.alekseialeinikov.com/de/blog/topics/architecture/monolith-vs-microservices-2026-ehrliche-abwaegung): 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](https://www.alekseialeinikov.com/de/blog/topics/architecture/webhooks-erklaert-signatur-retries-idempotenz) · [Eine Million Benachrichtigungen versenden, ohne abzustürzen](https://www.alekseialeinikov.com/de/blog/topics/architecture/eine-million-benachrichtigungen-versenden-ohne-abzustuerzen-2026)
