BEGIN TRANSACTION. Vier Services. Ein Commit. Funktioniert nicht.
Kein theoretisches Problem. Du kannst keine verteilte Transaktion über vier unabhängige Services aufspannen, weil es keinen gemeinsamen Transaction Manager gibt. Ja, es gibt 2PC (Two-Phase Commit). Nein, du willst es nicht in Produktion. Es ist langsam, es blockiert Locks auf allen Teilnehmern, und wenn der Coordinator stirbt, hängen alle Services im “Prepared”-State fest. Kein guter Tag wenn das passiert.
Also: kein ACID über Service-Grenzen hinweg. Was dann?
Die Saga
Eine Saga zerlegt eine verteilte Transaktion in eine Kette von lokalen Transaktionen. Jeder Service macht sein Ding, lokal, mit seiner eigenen DB. Wenn alles gut geht, sind am Ende vier lokale Commits passiert und die Bestellung ist durch.
Das Entscheidende: jeder Schritt hat eine Kompensation. Eine Gegenaktion, die den Effekt des Schritts rückgängig macht. Nicht per Rollback, sondern per neuer Aktion. Inventory reserviert? Compensation: Inventory freigeben. Payment gebucht? Compensation: Refund auslösen.
Wähl aus welcher Schritt fehlschlägt und klick auf Bestellen:
Wenn kein Fehler markiert ist, gehen alle vier Schritte durch: Happy Path. Aber sobald ein Schritt fehlschlägt, feuern die Kompensationen rückwärts. Jeder vorherige Schritt wird kompensiert, in umgekehrter Reihenfolge. Das System endet nicht in einem “halb fertigen” Zustand.
Kompensationen sind nicht dasselbe wie Rollbacks. Ein Rollback macht es so, als wäre nichts passiert. Eine Kompensation macht etwas Neues: ein Refund ist eine neue Transaktion, nicht das Rückgängigmachen der alten. Das ist ein semantischer Unterschied, der in der Praxis enorm wichtig ist.
Und wenn die Kompensation fehlschlägt?
Das ist die Frage, die jeder sofort stellt. Zu Recht.
Wenn der Refund bei Payment fehlschlägt, hast du eine Saga die weder vorwärts noch rückwärts kann. Stuck State. Deswegen müssen Kompensationen idempotent und retriable sein, du musst sie beliebig oft wiederholen können bis sie durchgehen. Retry mit Exponential Backoff. Und wenn nach 10 Retries immer noch nichts geht: Dead Letter Queue, Alert, manuelle Intervention.
Klingt nicht elegant? Ist es nicht. Aber es funktioniert.
Ohne Idempotency Keys bei Compensations riskierst du doppelte Refunds bei Retries. Ein doppelter Refund ist ein finanzieller Bug, kein technischer.
Choreografie vs Orchestrierung
Zwei Wege, eine Saga zu implementieren. Gleicher Ablauf, fundamental anderes Kontrollmodell.
Choreografie: Jeder Service reagiert auf Events der anderen. Inventory published “InventoryReserved”, Payment hört das und legt los. Kein zentraler Koordinator. Klingt elegant, bis du debuggen musst welcher Service welches Event wann gesehen hat.
Orchestrierung: Ein zentraler Orchestrator sagt jedem Service was er tun soll und wartet auf die Antwort. Klare Kontrolle, klare Reihenfolge. Mehr Single Point of Failure, aber auch mehr Transparenz.
Gleiche Saga, gleicher Fehler. Schau dir den Unterschied an:
Links siehst du, wie Events zwischen Services hin und her fliegen: jeder Service muss wissen, auf welche Events er reagieren soll und welche er publishen muss. Die Logik ist verteilt. Rechts geht alles durch den Orchestrator. Eine Stelle, die den gesamten Ablauf kennt.
Meine Meinung: für alles über drei Schritte nimm Orchestrierung. Die Traceability allein ist es wert. Choreografie funktioniert für zwei, drei Services mit klarer Event-Kette. Danach wird es Spaghetti.
Orchestrator in Code
Ein Saga-Orchestrator ist im Kern überraschend simpel:
interface SagaStep<T> {
execute: (ctx: T) => Promise<void>;
compensate: (ctx: T) => Promise<void>;
}
async function executeSaga<T>(steps: SagaStep<T>[], ctx: T) {
const completed: SagaStep<T>[] = [];
for (const step of steps) {
try {
await step.execute(ctx);
completed.push(step);
} catch (err) {
// Rückwärts kompensieren
for (const done of completed.reverse()) {
await retry(() => done.compensate(ctx), { maxRetries: 10 });
}
throw new SagaRolledBackError(err, completed.length);
}
}
}
Die Steps für eine Bestellung:
const orderSaga: SagaStep<OrderContext>[] = [
{
execute: (ctx) => inventoryService.reserve(ctx.items),
compensate: (ctx) => inventoryService.release(ctx.items),
},
{
execute: (ctx) => paymentService.charge(ctx.amount, ctx.idempotencyKey),
compensate: (ctx) => paymentService.refund(ctx.amount, ctx.idempotencyKey),
},
{
execute: (ctx) => shippingService.create(ctx.orderId),
compensate: (ctx) => shippingService.cancel(ctx.orderId),
},
{
execute: (ctx) => notificationService.send(ctx.orderId),
compensate: () => Promise.resolve(), // Keine Kompensation nötig
},
];
Beachte den idempotencyKey bei Payment. Jede Compensation muss denselben Key verwenden wie die ursprüngliche Aktion. Sonst riskierst du doppelte Refunds, und das ist schlimmer als der ursprüngliche Fehler.
Der Saga State Store
Was ich eben gezeigt habe, ist die Minimal-Version. In Produktion brauchst du einen Saga State Store: eine Tabelle, die für jede laufende Saga den aktuellen Schritt, den Status und den Kontext persistiert.
CREATE TABLE saga_instances (
saga_id UUID PRIMARY KEY,
saga_type TEXT NOT NULL,
step_index INT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running',
context JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Warum? Weil der Orchestrator selbst crashen kann. Nach dem Restart muss er wissen: wo war ich? Was ist schon committed? Was muss ich kompensieren? Ohne State Store fängst du von vorne an. Oder schlimmer: du machst Dinge doppelt.
Die eigentliche Arbeit
Die Happy Paths sind trivial. Die Schwierigkeit steckt in den Randfällen: Compensation die fehlschlägt, Orchestrator der mitten in der Kompensation stirbt, Timeouts bei externen Services, Race Conditions wenn zwei Sagas dieselbe Ressource betreffen.
Fang mit zwei Steps an. Nicht vier. Bau erst den Orchestrator, den State Store, das Retry-Handling und das Monitoring. Und dann füg Schritte hinzu. Wer sofort eine 6-Step-Saga baut, debuggt drei Monate später immer noch Edge Cases.