Eine Transaktion über Services hinweg (Saga)
Kurz gesagt: Eine Saga ist eine Transaktion über mehrere Services als Liste von Schritten, jeder mit einem Kompensationsschritt; bei einem Fehler laufen die Kompensationen rückwärts, ab dem letzten erledigten Schritt.
Problem
Das Geld ist abgebucht, die Kopfhörer sind weg
Ein Kunde bestellt die letzten Kopfhörer für 59 € (Bestellung 1042). Die Zahlung geht durch. Eine Sekunde später antwortet das Lager: „nicht auf Lager“. Ein anderer Kunde hat das letzte Stück kurz vorher genommen.
var payments = new Payments();var warehouse = new Warehouse(new() { ["Kopfhörer"] = 0 });var delivery = new Delivery();var order = new Order(1042, "Kopfhörer", 59, "Varna");try{ payments.Charge(order); // die Zahlung geht durch warehouse.Reserve(order); // das Lager: nicht vorrätig delivery.Book(order);}catch (StepFailed e){ Console.WriteLine($" ✗ {e.Message}");}Console.WriteLine($"von der Karte abgebucht: {payments.Charged} €"); ✓ abgebucht 59 € ✗ kein Kopfhörer auf Lagervon der Karte abgebucht: 59 €Das Programm fängt den Fehler ab, aber das Geld bleibt abgebucht. Morgen schreibt der Kunde an den Support, und jemand erstattet 59 € von Hand.
In einem Monolithen mit einer Datenbank ist die Antwort eine Transaktion: alles oder nichts (du hast sie in der Lektion über Datenbanken und in „ACID: alles oder nichts“ gesehen). Hier liegt die Zahlung aber bei einem externen Anbieter, und das Lager ist ein eigener Service mit eigener Datenbank. Eine Transaktion, die alle drei abdeckt, gibt es nicht.
Die Idee
Eine Saga: Schritte mit Gegenschritten
Wenn du nicht zurückgehen kannst, führst du neue Aktionen aus, die die Folgen wieder in Ordnung bringen. Zu jedem Schritt der Bestellung gehört ein Gegenschritt (eine Kompensation):
| Schritt | Service | Gegenschritt |
|---|---|---|
| bucht 59 € ab | Zahlungen | erstattet 59 € |
| reserviert die Kopfhörer | Lager | gibt sie frei |
| bucht einen Kurier | Lieferung | storniert den Kurier |
Jeder Schritt ist eine kleine lokale Transaktion in seinem eigenen Service. Lehnt ein Schritt ab, laufen die Gegenschritte der bereits erledigten Schritte rückwärts: Was zuletzt getan wurde, wird zuerst zurückgenommen. Das nennt man eine Saga.
Ein Gegenschritt ist kein Rollback. Ein Rollback löscht, als wäre nichts geschehen. Die Erstattung von 59 € ist eine zweite Zahlung in die andere Richtung, die der Kunde neben der ersten in seiner Bank sieht. Zwischen den Schritten ist das System also in einem Zwischenzustand („die Bestellung ist in Bearbeitung“), und alle müssen damit zurechtkommen.
Hier führt ein Objekt die Saga, der Orchestrator: Er kennt die Schritte, ruft sie der Reihe nach auf und ruft bei einem Fehler die Kompensationen auf. Die Schritte stehen in einer Liste und die Kompensationen auf einem Stack: Was zuletzt daraufgelegt wurde, kommt zuerst heraus.
Sieh es dir an
Eine Saga im Code
Die Ablehnung eines Services ist eine Exception. In diesem kleinen System sind die Services Objekte in einem Prozess, und die Ablehnungen folgen einem Drehbuch: Es gibt ein Paar Kopfhörer, und nach Vidin gibt es keinen Kurier.
record Order(int Id, string Item, int Price, string City);
class StepFailed(string reason) : Exception(reason);
record Step(Action<Order> Do, Action<Order> Undo);class Warehouse(Dictionary<string, int> stock){ public void Reserve(Order o) { int left = stock.GetValueOrDefault(o.Item); if (left == 0) throw new StepFailed($"kein {o.Item} auf Lager"); stock[o.Item] = left - 1; Console.WriteLine($" ✓ reserviert: {o.Item}"); }
public void Release(Order o) { stock[o.Item] = stock.GetValueOrDefault(o.Item) + 1; Console.WriteLine($" ↩ freigegeben: {o.Item}"); }}Step ist ein Record mit zwei Action<Order>-Delegates: Der eine führt den Schritt aus, der andere nimmt ihn zurück. Zahlungen und Lieferung sind wie das Lager: eine Methode für den Schritt und eine für den Gegenschritt.
Der Orchestrator:
class Saga(List<Step> steps){ public string Run(Order order) { var undo = new Stack<Action<Order>>(); try { foreach (var step in steps) { step.Do(order); undo.Push(step.Undo); } return "bestätigt"; } catch (StepFailed e) { Console.WriteLine($" ✗ {e.Message}"); while (undo.Count > 0) undo.Pop()(order); return "storniert"; } }}Schritt für Schritt, in Worten
- Bestellung 1043: ein Ladegerät für 25 € nach Vidin. Jeder Schritt liegt in einem anderen Service, und jeder hat einen Gegenschritt: abbuchen ↔ erstatten, reservieren ↔ freigeben, buchen ↔ stornieren.
- Schritt 1: Payments bucht 25 € ab. Sein Gegenschritt kommt auf den undo-Stack (Push).
- Schritt 2: Warehouse reserviert das Ladegerät. „Freigeben“ legt sich auf „erstatten“.
- Schritt 3: Es gibt keinen Kurier nach Vidin, und Delivery lehnt ab. Die ersten zwei Schritte sind in anderen Systemen schon passiert, und einen Rollback über sie hinweg gibt es nicht.
- Die Saga fängt die Ablehnung ab und läuft zurück: Sie holt die Kompensationen vom Stack, die oberste zuerst.
- Pop liefert die zuletzt abgelegte: Zuerst wird das Ladegerät freigegeben. Was zuletzt getan wurde, wird zuerst zurückgenommen.
- Dann die Zahlung: 25 € gehen zurück auf die Karte. Eine Erstattung ist eine neue Aktion, kein gelöschter Schritt: Der Kunde sieht beide in seiner Bank.
- Storniert: Das Ladegerät ist wieder auf Lager, und 0 € bleiben abgebucht. Alle Systeme sind wieder einig, durch spätere Aktionen statt durch eine Transaktion.
Drei Bestellungen: 1041 geht durch, 1042 bleibt im Lager hängen, 1043 bleibt bei der Lieferung hängen.
var payments = new Payments();var warehouse = new Warehouse(new() { ["Kopfhörer"] = 1, ["Ladegerät"] = 5 });var delivery = new Delivery();var saga = new Saga([ new Step(payments.Charge, payments.Refund), new Step(warehouse.Reserve, warehouse.Release), new Step(delivery.Book, delivery.Cancel),]);Order[] orders = [ new Order(1041, "Kopfhörer", 59, "Sofia"), new Order(1042, "Kopfhörer", 59, "Varna"), new Order(1043, "Ladegerät", 25, "Vidin"),];foreach (var order in orders){ Console.WriteLine($"Bestellung {order.Id}"); Console.WriteLine($" = {saga.Run(order)}");}Console.WriteLine($"von den Karten abgebucht: {payments.Charged} €");Bestellung 1041 ✓ abgebucht 59 € ✓ reserviert: Kopfhörer ✓ Kurier nach Sofia = bestätigtBestellung 1042 ✓ abgebucht 59 € ✗ kein Kopfhörer auf Lager ↩ erstattet 59 € = storniertBestellung 1043 ✓ abgebucht 25 € ✓ reserviert: Ladegerät ✗ kein Kurier nach Vidin ↩ freigegeben: Ladegerät ↩ erstattet 25 € = storniertvon den Karten abgebucht: 59 €Am Ende sind 59 € von den Karten abgebucht: nur für die Bestellung, die durchgegangen ist.
Probier es aus
Sag die Ausgabe voraus
Dieselbe Saga, nur ist das Lager der erste Schritt und die Zahlung der zweite. Die Kopfhörer sind ausverkauft. Was gibt sie aus?
var payments = new Payments();var warehouse = new Warehouse(new() { ["Kopfhörer"] = 0 });var delivery = new Delivery();var saga = new Saga([ new Step(warehouse.Reserve, warehouse.Release), new Step(payments.Charge, payments.Refund), new Step(delivery.Book, delivery.Cancel),]);Console.WriteLine($" = {saga.Run(new Order(1042, "Kopfhörer", 59, "Varna"))}");Das Lager lehnt schon beim allerersten Schritt ab, also ist der Stack leer: Es gibt kein Geld zu erstatten. Die Reihenfolge der Schritte ist eine Architekturentscheidung. Zuerst kommen die Schritte, die am häufigsten scheitern und sich am billigsten zurücknehmen lassen. Die Zahlung, die eine Gebühr kostet und den Kunden verärgert, kommt danach.
Die Idee
Was sich nicht zurücknehmen lässt
Nicht jeder Schritt hat einen Gegenschritt. Die E-Mail „deine Bestellung ist bestätigt“ lässt sich nicht zurückschicken. Ein Paket, das der Kurier schon abgeholt hat, kommt nicht mit einem Aufruf zurück.
Solche Schritte kommen deshalb ganz zum Schluss, nach allem, was ablehnen kann. Der Schritt, nach dem die Saga nur noch vorwärts läuft, heißt Pivot (Wendepunkt). Danach werden Fehler nicht kompensiert, sondern wiederholt, bis es klappt: mit Timeout und Retry.
Probier es aus
Finde den Fehler
Ein Kollege ordnet die Schleife um, und die Tests laufen durch: Bei einem Fehler wird das Geld erstattet. Eine Woche später findet die Buchhaltung Erstattungen für Zahlungen, die nie stattgefunden haben: Die Karte wurde abgelehnt, und der Kunde hat trotzdem 59 € bekommen. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Der Gegenschritt kommt vor dem Schritt selbst auf den Stack. Lehnt die Zahlung ab (die Karte wird abgelehnt), liegt ihre Erstattung schon auf dem Stack und wird aufgerufen: Der Kunde bekommt Geld, das er nie gezahlt hat. Eine Kompensation wird erst notiert, nachdem der Schritt gelungen ist.
Die Idee
Orchestrierung oder Choreografie
Der Orchestrator ist eine Stelle, die den ganzen Ablauf kennt. Die andere Art kommt ohne eine solche Stelle aus: Jeder Service hört auf Events und antwortet mit einem eigenen. Die Zahlungen hören „Bestellung“ und sagen „abgebucht“. Das Lager hört „abgebucht“ und sagt „reserviert“ oder „nicht vorrätig“. Die Zahlungen hören „nicht vorrätig“ und erstatten das Geld. Das nennt man Choreografie.
Der Bus ist eine Warteschlange im Speicher mit Abonnements, wie in der Lektion über Events:
record Event(string Type, int Order);
class Bus{ readonly Dictionary<string, List<Action<Event>>> handlers = []; readonly Queue<Event> queue = new();
public void On(string type, Action<Event> handler) { if (!handlers.TryGetValue(type, out var list)) handlers[type] = list = []; list.Add(handler); }
public void Publish(Event e) => queue.Enqueue(e);
public void Run() { while (queue.TryDequeue(out var e)) { Console.WriteLine($"{e.Type} {e.Order}"); foreach (var handler in handlers.GetValueOrDefault(e.Type, [])) handler(e); } }}Dieselbe Ablehnung von 1042, als Kette von Events:
var bus = new Bus();// die Zahlungenbus.On("OrderPlaced", e => bus.Publish(new Event("PaymentCharged", e.Order)));bus.On("StockMissing", e => bus.Publish(new Event("PaymentRefunded", e.Order)));// das Lager (in diesem Beispiel ist die Ware für 1042 ausverkauft)bus.On("PaymentCharged", e => bus.Publish(new Event("StockMissing", e.Order)));// die Bestellungenbus.On("PaymentRefunded", e => bus.Publish(new Event("OrderCancelled", e.Order)));bus.Publish(new Event("OrderPlaced", 1042));bus.Run();OrderPlaced 1042PaymentCharged 1042StockMissing 1042PaymentRefunded 1042OrderCancelled 1042| Orchestrierung | Choreografie | |
|---|---|---|
| Wo der Ablauf liegt | in einem Objekt | verteilt über die Abonnements |
| Ein neuer Schritt | eine Zeile in der Liste | ein neues Abonnement und ein neues Event |
| „Warum ist 1042 stehen geblieben?“ | der Zustand des Orchestrators | du sammelst die Events aus jedem Service |
| Kopplung | der Orchestrator kennt alle | jeder Service kennt nur die Events |
Bei zwei oder drei Schritten ist die Choreografie leichter. Bei fünf Schritten mit Bedingungen geht der Ablauf verloren, und der Orchestrator ist die klarere Wahl.
Die Idee
Die Kompensation kommt zweimal an
Zwischen Services laufen Befehle und Events über eine Warteschlange, und die Warteschlange liefert mindestens einmal (du hast das in der Lektion über Events gesehen). Kommt „59 € für 1042 erstatten“ zweimal an, bekommt der Kunde 118 €.
Deshalb ist jeder Schritt und jede Kompensation idempotent: Beim zweiten Mal mit derselben Eingabe passiert nichts. Der Schlüssel besteht aus der Aktion und der Bestellung. An den echten Anbieter wird er als Idempotency-Key geschickt, damit auch er die wiederholte Erstattung ablehnt.
class Refunds{ readonly HashSet<string> done = []; public int Total { get; private set; }
public void Refund(int order, int euros) { // der Schlüssel des Schritts: Aktion + Bestellung if (!done.Add($"refund:{order}")) { Console.WriteLine($" · {order}: schon erstattet"); return; } Total += euros; Console.WriteLine($" ↩ erstattet {euros} € für {order}"); }}var refunds = new Refunds();// die Warteschlange liefert „mindestens einmal“: 1042 kommt zweimalrefunds.Refund(1042, 59);refunds.Refund(1042, 59);refunds.Refund(1043, 25);Console.WriteLine($"insgesamt erstattet: {refunds.Total} €"); ↩ erstattet 59 € für 1042 · 1042: schon erstattet ↩ erstattet 25 € für 1043insgesamt erstattet: 84 €Probier es aus
Ergänze
Was prüft, ob die Erstattung schon erfolgt ist?
if (!done.($"refund:{order}"))
{
Console.WriteLine($" · {order}: schon erstattet");
return;
}HashSet.Add gibt false zurück, wenn der Schlüssel schon im Set ist. Prüfung und Eintrag sind ein Aufruf. In einem echten Service liegen die Schlüssel in der Datenbank, in derselben Transaktion wie die Erstattung; sonst ergibt ein Absturz zwischen den beiden wieder ein Duplikat.
Die Idee
Wann eine Saga schadet
Eine Saga ist nicht umsonst:
- Der Code verdoppelt sich. Jeder Schritt braucht einen Gegenschritt, Tests für beide und Tests für einen Fehler an jeder Stelle.
- Zwischenzustände sind sichtbar. Der Kunde sieht „in Bearbeitung“, und das Geld ist für einen Moment abgebucht, bevor es zurückkommt. Jemand anderes kann eine Bestellung lesen, die danach abgebrochen wird: Eine Saga hat keine Isolation wie eine Transaktion.
- Ein Fehler ist schwerer zu untersuchen. Die Schritte liegen in verschiedenen Services mit verschiedenen Logs.
Setze sie also nicht ein, wo eine Transaktion reicht. Sind Lager und Bestellungen Module eines Monolithen mit einer Datenbank, sichert eine Transaktion sie einfacher und sicherer ab. Eine Saga ist für die Schritte, die das System verlassen: ein externer Anbieter, ein anderer Service, eine andere Firma.
Genauer, für Neugierige
Warum nicht zwei Phasen (2PC)? Es gibt ein Protokoll für eine gemeinsame Transaktion über mehrere Datenbanken: Der Koordinator fragt alle „seid ihr bereit?“ und sagt dann allen „commit“. Der externe Zahlungsanbieter nimmt an so einem Protokoll nicht teil. Und solange der Koordinator wartet, halten alle gesperrte Zeilen; stürzt er auf halbem Weg ab, bleiben sie gesperrt. Deshalb nehmen Services fast immer eine Saga.
Auch der Orchestrator stürzt ab. Wenn der Prozess nach der Zahlung stehen bleibt, woher weiß er, wie weit er war? Ein echter Orchestrator speichert den Zustand jeder Saga in einer Datenbank (Schritt 2 von 3, Bestellung 1042) und macht nach einem Neustart weiter. Die Befehle an die Services gehen über eine Outbox hinaus, damit sie zwischen Schreiben und Senden nicht verloren gehen.
Eine semantische Sperre. Eine Bestellung „in Bearbeitung“ ist ein Zeichen für andere: nicht in die Rechnung schicken, keine Adressänderung zulassen. So gleicht man die fehlende Isolation aus.
Fertige Werkzeuge: Temporal, Camunda und AWS Step Functions halten den Zustand langer Abläufe und wiederholen die Schritte. Es gibt auch Saga-Bibliotheken in den Frameworks: Axon (Java), MassTransit und NServiceBus (.NET). Die Warteschlange zwischen Services ist in einem echten Projekt Kafka oder RabbitMQ. Wie man sie betreibt und überwacht, steht im DevOps-Kurs ZeroToOps.
Der Name stammt aus einem Aufsatz von Hector Garcia-Molina und Kenneth Salem von 1987 über lange Transaktionen in Datenbanken.
Übung
Treuepunkte
Löse die Aufgabe in deinem Editor.
Der Shop gibt für je volle 10 € einen Punkt. Füge einen Service Loyalty hinzu, mit einer Methode für den Schritt (vergibt die Punkte) und einer für den Gegenschritt (nimmt sie weg). Setze ihn als dritten Schritt in die Saga, zwischen Lager und Lieferung. Führe Bestellung 1044 aus: ein Lautsprecher für 120 € nach Vidin, im Lager liegen 2 Lautsprecher. Gib am Ende die Punkte und das abgebuchte Geld aus.
Bestellung 1044 ✓ abgebucht 120 € ✓ reserviert: Lautsprecher ✓ +12 Punkte ✗ kein Kurier nach Vidin ↩ -12 Punkte ↩ freigegeben: Lautsprecher ↩ erstattet 120 € = storniertPunkte: 0, abgebucht: 0 €Weitere Aufgaben
- Eine abgelehnte Karte. Lass die Zahlung ab 100 € ablehnen („Karte abgelehnt“) und führe 1044 nach Sofia aus. Was liegt auf dem Stack, wenn die Zahlung ablehnt?
- Choreografie. Füge dem Bus die Events
StockReservedundOrderConfirmedhinzu und führe eine Bestellung aus, für die Ware da ist.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Transactional Outbox · API-Design: Vertrag, Versionen, wiederholte Anfragen · Trade-offs · Wenn der Nachbar langsam ist: Timeout, Retry, Circuit Breaker
- Geht weiter in: Alte und fremde Systeme anbinden · Ein laufendes System verändern (Strangler Fig, Expand–Contract) · Deine erste Architekturaufgabe
- Aufgaben zum Thema: Der Architekt bei der Arbeit