Modul 13: Distributed Systems — Sagas, Outbox & Eventual Consistency
Verteilte Transaktionen ohne 2PC: Saga-Pattern mit Choreography und Orchestration, Transactional Outbox gegen das Dual-Write-Problem, idempotente Consumer, kompensierende Aktionen und Distributed Tracing mit OpenTelemetry.
Distributed Systems — Sagas, Outbox & Eventual Consistency
Lektion 63 von 1
Warum verteilte Transaktionen anders denken wollen
Modul 12 hat Aggregate mit Typestate und Event Sourcing konsistent gemacht — innerhalb eines Dienstes. Sobald eine Bestellung aber Payment, Lagerbestand und Versand in getrennten Services beruehrt, gibt es keine gemeinsame Transaktion mehr. Die klassische Antwort, Two-Phase Commit (2PC/XA), scheitert in Microservices: Der Koordinator haelt Locks ueber das Netz offen, ein einzelner langsamer Teilnehmer blockiert alle anderen, und faellt der Koordinator zwischen den Phasen aus, bleiben Teilnehmer in einem unbestimmten Zustand haengen. Cloud-Umgebungen machen das schlimmer: Netzwerk- Partitionen und Instanzausfaelle sind der Normalfall, nicht die Ausnahme.
Die pragmatische Antwort ist das Saga-Pattern: Statt einer grossen verteilten Transaktion fuehrt man eine Sequenz lokaler Transaktionen aus — jede mit ACID-Garantien in ihrem eigenen Service. Scheitert eine davon, laufen Kompensationen rueckwaerts durch die bereits abgeschlossenen Schritte. Die Konsistenz ist danach eventual, nicht immediate — und genau das ist der Preis, den man bewusst zahlt.
Lesson 1: Verteilte Transaktionen & das Saga-Pattern
Ein Saga-Schritt ist eine lokale Transaktion plus einer definierenden Frage: Wie macht man ihn rueckgaengig? Buchung stornieren, Lager wieder freigeben, E-Mail mit Widerruf nachsenden. Es gibt zwei Koordinationsstile:
- Choreography: Jeder Service reagiert auf Events des vorherigen. Kein
zentraler Kopf — der Ablauf emergiert aus dem Event-Fluss. Der Event Store
aus Modul 12 ist dafuer die ideale Basis:
OrderPlacedtriggert Payment,PaymentReceivedtriggert Lagerreservierung, jedes Event ist zugleich Audit-Beleg. - Orchestration: Ein Orchestrator kennt den Ablauf explizit und ruft die Schritte der Reihe nach auf. Bei komplexen Ablaufen mit Bedingungen und Schleifen ist das lesbarer als implizite Event-Ketten.
Den Orchestrator kann man wieder mit Typestate aus Modul 12 modellieren — der Saga-Zustand ist eine Zustandsmaschine, und ungueltige Uebergaenge (z.B. "bezahlen" nach "storniert") sind Compile-Fehler:
// Saga-Orchestrator als Typestate: Schritte nur in gueltiger Reihenfolge
type SagaStarted = { kind: "started"; orderId: string };
type Reserved = { kind: "reserved"; orderId: string; reservationId: string };
type Paid = { kind: "paid"; orderId: string; paymentId: string };
function reserveStock(s: SagaStarted, qty: number): Reserved { /* ... */ }
function chargeCard(s: Reserved, token: string): Paid { /* ... */ }
function releaseStock(s: Reserved): SagaStarted { /* Kompensation */ }
// chargeCard auf SagaStarted ist ein Compile-Error — Reihenfolge im Typ
Faustregel: Wenige Schritte, einfacher Pfad → Choreography mit Events. Viele Schritte, Verzweigungen, Timeouts → Orchestration. Beides kann dieselben lokalen Transaktionen und Kompensationen verwenden.
Lesson 2: Outbox-Pattern — zuverlaessiges Event-Publishing
Das grundlegendste Problem verteilter Systeme ist das Dual-Write-Problem: Service A committet die Bestellung in die Datenbank und published danach ein Event auf den Broker. Zwischen beiden Operationen kann alles scheitern — Crash, Netzwerkfehler, Deploy. Ergebnis: Die Bestellung existiert, aber kein anderer Service erfaehrt davon. Oder umgekehrt: Das Event ist draussen, der Commit rollte zurueck. Beide Operationen in einer Transaktion zu kapseln geht nicht, weil der Message Broker kein ACID-Teilnehmer der Datenbank ist.
Das Outbox-Pattern loest das, indem es die zweite Operation in dieselbe Datenbank-Transaktion zieht: Das Event wird in eine Outbox-Tabelle geschrieben — atomar mit der Fachlichkeit. Ein separater Publisher versendet die Outbox-Eintraege danach an den Broker:
-- In derselben Transaktion wie der Fachlichkeit-Commit
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = $1;
INSERT INTO outbox (id, aggregate_id, type, payload, created_at)
VALUES (gen_random_uuid(), $1, 'PaymentReceived', $2, now());
COMMIT;
-- Eintraege bleiben bis zur Bestaetigung des Publishers stehen
Bei Event Sourcing (Modul 12) ist das Outbox-Pattern fast gratis: Die Events sind bereits die Source of Truth, der Event Store ist die Outbox. Der Publisher muss sie nur hinausreichbar machen. Dafuer gibt es zwei Stile:
- Polling Publisher: Ein Prozess fragt periodisch
SELECT ... WHERE published_at IS NULL. Einfach, aber Polling-Intervall = Latenz, und die DB traegt die Abfrage-Last. - Change-Data-Capture (CDC): Debezium liest das Transaktionslog (z.B. WAL bei Postgres) und streamt Aenderungen in Echtzeit auf den Broker. Nahe an Event-Streaming, aber betrieblich deutlich aufwendiger.
Wichtig in beiden Faellen: Der Publisher kann Eintraege mindestens einmal versenden (at least once) — genau dann, wenn er nach dem Broker-Write aber vor dem Markieren abstuerzt. Consumer muessen deshalb idempotent sein.
Lesson 3: Eventual Consistency & idempotente Consumer
Strong Consistency heisst: Nach dem Commit sieht jeder Leser denselben Zustand. Eventual Consistency heisst: Replikate und Read Models konvergieren irgendwann, kurzzeitig koennen sie hinterherhinken. Modul 11 hat das fuer CQRS-Projektionen diskutiert; in verteilten Systemen gilt es fuer den Gesamtzustand. Der Knackpunkt ist nicht die Latenz, sondern die Dopplungen: At-least-once-Zustellung heisst, jedes Event kann mehrfach ankommen.
Der Standard-Schutz ist ein Idempotency-Key plus Dedup-Tabelle: Der Consumer merkt sich verarbeitete Event-IDs in derselben Transaktion wie die Fachlichkeit. Kommt das Event erneut, wird es uebersprungen:
async function handle(msg: Message) {
await db.tx(async (tx) => {
const seen = await tx.query(
"SELECT 1 FROM processed_events WHERE id = $1", [msg.id],
);
if (seen.rows.length > 0) return; // Duplikat — schon verarbeitet
await applyBusinessLogic(tx, msg); // lokale Transaktion
await tx.query(
"INSERT INTO processed_events (id) VALUES ($1)", [msg.id],
); // atomar mit der Logik committet
});
}
Bei der Reihenfolge hilft Event Sourcing von selbst: Pro Aggregat-Stream sind Events natuerlich geordnet (Sequence-Number aus Modul 11). Zwischen Aggregaten ist die Reihenfolge egal — sie sind konsistenzunabhaengig. Falls eine Dopplung doch einmal durchrutscht (z.B. ohne Transaktion verarbeitete Side-Effects), bleibt die Kompensation das Werkzeug: Die zweite Buchung wird durch eine Gegenbuchung neutralisiert.
Fuer schreibstarke Daten ohne zentralen Koordinator — Nutzer-Zaehler, Warenkoerbe auf mehreren Geraeten — gibt es CRDTs (Conflict-free Replicated Data Types): Datenstrukturen, deren Merge kommutativ, assoziativ und idempotent ist, sodass Replikate ohne Konfliktauflösung konvergieren. Das ist ein Spezialwerkzeug fuer wenige Faelle, aber gut zu kennen, wenn Lastverteilung die Schreiblast auf mehrere Regionen verteilen muss.
Lesson 4: Kompensierende Aktionen & Rollback-Sagas
Kompensation ist kein Undo. Ein Undo dreht den physischen Zustand zurueck; eine Kompensation ist eine neue, fachliche Transaktion: Statt "Reservierung loeschen" heisst es oft "Reservierung freigeben + Storno-E-Mail senden + Storno-Gebühr verrechnen". Die Geschaeftslogik entscheidet, was "rueckgaengig" fachlich bedeutet — nicht die Technologie.
In einem Event Store landet die Kompensation als neues Event, nicht als
Loeschung: StockReleased, PaymentRefunded folgen auf StockReserved,
PaymentCharged. Die Historie bleibt vollstaendig und ist der Audit-Trail
aus Modul 12. Der Saga-Abbruch ist selbst ein gefuehrter Ablauf mit
Rollback-Schritten:
// Rollback-Saga: rueckwaerts durch die abgeschlossenen Schritte
const compensations = [releaseStock, refundPayment]; // reverse order
for (const compensate of compensations.reverse()) {
try {
await compensate(orderId);
await eventStore.append(orderId, { type: "StepCompensated", /* ... */ });
} catch (err) {
await retryWithBackoff(compensate, orderId); // Modul 8: Resilience
}
}
Fuer sehr lange oder geschaeftskritische Ablaufe lohnt eine Workflow-Engine wie Temporal oder Camunda: Sie persistiert den Saga-Zustand, retries, Timeouts und Kompensationen werden deklarativ definiert. Der Nachteil ist Betriebsaufwand und Lernkurve. Ein hand-gerollter Orchestrator mit Typestate und Event Store reicht oft aus — solange der Ablauf ueberschaubar bleibt und das Team die Fehlerfaelle selbst testet. Genau da hilft die Fehlerbehandlung aus Modul 8: Retries mit Jitter, Circuit Breaker gegen Kaskaden, und Dead-Letter-Queues fuer Schritte, die endgueltig scheitern — jede mit einer Kompensations- oder Eskalationsstrategie.
Lesson 5: Observability verteilter Systeme
Ein Request durchlaeuft fuenf Services; wo haengt er? Der einzelne Service-Log sagt es nicht. Distributed Tracing mit OpenTelemetry reicht eine Trace-ID durch alle beteiligten Services, jeder Abschnitt wird als Span mit eigener Span-ID aufgezeichnet. Das ergibt den vollstaendigen Weg inklusive Latenzen — die dritte Saeule aus Modul 10, jetzt ueber Servicegrenzen hinweg.
// Trace-Kontext wandert mit dem Event mit
const span = tracer.startSpan("saga.chargePayment");
span.setAttribute("saga.id", sagaId);
span.setAttribute("correlation.id", event.correlationId);
try {
await chargeCard(orderId);
} catch (err) {
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
Damit Spans auch asynchron zusammenhaengen, wandert die Korrelations-ID im Event-Header mit — bei Event Sourcing wird sie als Feld im Event gespeichert und beim Replay wiederhergestellt. Das Audit-Log aus Modul 12 zahlt hier doppelt: Es ermoeglicht die Rueckverfolgung, welche Saga-Instanz welches Event ausgeloest hat.
Fuer den Betrieb von Sagas sind Saga-Metriken zentral: Durchlaufzeit vom ersten bis zum letzten Schritt, Fehlerrate pro Schritt, und vor allem die Kompensationsrate — wie viele Sagas brechen ab und muessen zurueckgerollt werden. Steigt sie ploetzlich, stimmt etwas Grundsatzliches nicht (z.B. ein Payment-Provider, der haeufig scheitert). Daraus baut man SLOs: "99 % aller Bestell-Sagas schliessen in unter 30 Sekunden", "Kompensationsrate < 0,5 % pro Woche". Das Error Budget aus Modul 10 liefert das Instrument dazu: Burn-Rate-Alerts warnen, bevor Kunden den Schaden merken.
Lokal atomar, global eventual
Eine Saga verspricht keine verteilte ACID-Transaktion, sondern etwas Erreichbares: Jeder Schritt ist lokal atomar, die Outbox garantiert mindestens einmal Zustellung, Consumer idempotent, und Kompensationen stellen fachliche Konsistenz wieder her. Der Zustand ist danach eventual konsistent — und die Observability macht den Zwischenzustand sichtbar.
Praxis-Szenario: Die Bestellung, die zwei Mal bezahlt wurde
Ein Kunde bestaetigt die Zahlung doppelt (Doppelklick). Der Payment-Service buchte zweimal, der Lagerbestand wurde zweimal reserviert. Wie faengst du das mit Outbox und idempotenten Consumern ab, und wie sorgst du dafuer, dass es nicht mehr passiert?
✅ Wichtige Erkenntnisse
Haken setzen, um deinen Lernfortschritt zu markieren: