Zum Inhalt
wannacode

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.

payments = Payments()
warehouse = Warehouse({"Kopfhörer": 0})
delivery = Delivery()
order = 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)
except StepFailed as e:
print(f" ✗ {e}")
print(f"von der Karte abgebucht: {payments.charged} €")
Ausgabe
✓ abgebucht 59 €
✗ kein Kopfhörer auf Lager
von 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.

@dataclass(frozen=True)
class Order:
id: int
item: str
price: int
city: str
class StepFailed(Exception):
pass
@dataclass(frozen=True)
class Step:
action: Callable[[Order], None]
undo: Callable[[Order], None]
class Warehouse:
def __init__(self, stock):
self.stock = dict(stock)
def reserve(self, o):
left = self.stock.get(o.item, 0)
if left == 0:
raise StepFailed(f"kein {o.item} auf Lager")
self.stock[o.item] = left - 1
print(f" ✓ reserviert: {o.item}")
def release(self, o):
self.stock[o.item] = self.stock.get(o.item, 0) + 1
print(f" ↩ freigegeben: {o.item}")

Step ist eine dataclass mit zwei Funktionen. Python braucht kein Interface: Alles, was sich mit einer Bestellung aufrufen lässt, genügt. Zahlungen und Lieferung sind wie das Lager: eine Methode für den Schritt und eine für den Gegenschritt.

Der Orchestrator:

class Saga:
def __init__(self, steps):
self.steps = steps
def run(self, order):
undo = []
try:
for step in self.steps:
step.action(order)
undo.append(step.undo)
return "bestätigt"
except StepFailed as e:
print(f" ✗ {e}")
while undo:
undo.pop()(order)
return "storniert"
Schritt für Schritt, in Worten
  1. 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.
  2. Schritt 1: Payments bucht 25 € ab. Sein Gegenschritt kommt auf den undo-Stack (append).
  3. Schritt 2: Warehouse reserviert das Ladegerät. „Freigeben“ legt sich auf „erstatten“.
  4. 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.
  5. Die Saga fängt die Ablehnung ab und läuft zurück: Sie holt die Kompensationen vom Stack, die oberste zuerst.
  6. pop liefert die zuletzt abgelegte: Zuerst wird das Ladegerät freigegeben. Was zuletzt getan wurde, wird zuerst zurückgenommen.
  7. 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.
  8. 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.

payments = Payments()
warehouse = Warehouse({"Kopfhörer": 1, "Ladegerät": 5})
delivery = Delivery()
saga = Saga([
Step(payments.charge, payments.refund),
Step(warehouse.reserve, warehouse.release),
Step(delivery.book, delivery.cancel),
])
orders = [
Order(1041, "Kopfhörer", 59, "Sofia"),
Order(1042, "Kopfhörer", 59, "Varna"),
Order(1043, "Ladegerät", 25, "Vidin"),
]
for order in orders:
print("Bestellung", order.id)
print(" =", saga.run(order))
print(f"von den Karten abgebucht: {payments.charged} €")
Ausgabe
Bestellung 1041
✓ abgebucht 59 €
✓ reserviert: Kopfhörer
✓ Kurier nach Sofia
= bestätigt
Bestellung 1042
✓ abgebucht 59 €
✗ kein Kopfhörer auf Lager
↩ erstattet 59 €
= storniert
Bestellung 1043
✓ abgebucht 25 €
✓ reserviert: Ladegerät
✗ kein Kurier nach Vidin
↩ freigegeben: Ladegerät
↩ erstattet 25 €
= storniert
von 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?

payments = Payments()
warehouse = Warehouse({"Kopfhörer": 0})
delivery = Delivery()
saga = Saga([
Step(warehouse.reserve, warehouse.release),
Step(payments.charge, payments.refund),
Step(delivery.book, delivery.cancel),
])
print(" =", saga.run(Order(1042, "Kopfhörer", 59, "Varna")))
Antwort

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.

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:

class Bus:
def __init__(self):
self.handlers = defaultdict(list)
self.queue = deque()
def on(self, kind, handler):
self.handlers[kind].append(handler)
def publish(self, kind, order):
self.queue.append((kind, order))
def run(self):
while self.queue:
kind, order = self.queue.popleft()
print(kind, order)
for handler in self.handlers[kind]:
handler(order)

Dieselbe Ablehnung von 1042, als Kette von Events:

bus = Bus()
# die Zahlungen
bus.on("OrderPlaced", lambda order: bus.publish("PaymentCharged", order))
bus.on("StockMissing", lambda order: bus.publish("PaymentRefunded", order))
# das Lager (in diesem Beispiel ist die Ware für 1042 ausverkauft)
bus.on("PaymentCharged", lambda order: bus.publish("StockMissing", order))
# die Bestellungen
bus.on("PaymentRefunded", lambda order: bus.publish("OrderCancelled", order))
bus.publish("OrderPlaced", 1042)
bus.run()
Ausgabe
OrderPlaced 1042
PaymentCharged 1042
StockMissing 1042
PaymentRefunded 1042
OrderCancelled 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:
def __init__(self):
self.done = set()
self.total = 0
def refund(self, order, euros):
# der Schlüssel des Schritts: Aktion + Bestellung
key = f"refund:{order}"
if key in self.done:
print(f" · {order}: schon erstattet")
return
self.done.add(key)
self.total += euros
print(f" ↩ erstattet {euros} € für {order}")
refunds = Refunds()
# die Warteschlange liefert „mindestens einmal“: 1042 kommt zweimal
refunds.refund(1042, 59)
refunds.refund(1042, 59)
refunds.refund(1043, 25)
print(f"insgesamt erstattet: {refunds.total} €")
Ausgabe
↩ erstattet 59 € für 1042
· 1042: schon erstattet
↩ erstattet 25 € für 1043
insgesamt erstattet: 84 €

Probier es aus

Ergänze

Was prüft, ob die Erstattung schon erfolgt ist?

key = f"refund:{order}"
if key  self.done:
    print(f"  · {order}: schon erstattet")
    return
self.done.add(key)

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.

Ausgabe
Bestellung 1044
✓ abgebucht 120 €
✓ reserviert: Lautsprecher
✓ +12 Punkte
✗ kein Kurier nach Vidin
↩ -12 Punkte
↩ freigegeben: Lautsprecher
↩ erstattet 120 €
= storniert
Punkte: 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 StockReserved und OrderConfirmed hinzu und führe eine Bestellung aus, für die Ware da ist.

Test

Teste dich

1. Warum lässt sich die Bestellung nicht mit einer einzigen Datenbanktransaktion absichern wie im Monolithen?
2. Die Schritte sind Zahlung, Lager, Lieferung. Die Lieferung lehnt ab. In welcher Reihenfolge laufen die Kompensationen?
3. Warum muss die Kompensation „59 € für 1042 erstatten“ idempotent sein?
4. Wann ist Choreografie (Services reagieren auf Events) die bessere Wahl als Orchestrierung?
5. Der Shop ist ein Monolith mit einer Datenbank: Bestellungen, Lagerbestand und Zahlungen in verschiedenen Tabellen. Die Zahlung läuft über einen externen Anbieter. Brauchst du eine Saga für Lager und Bestellung?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·