sequenceDiagram
participant K as Koordinator
participant A as Teilnehmer A
participant B as Teilnehmer B
K->>A: prepare?
K->>B: prepare?
A->>K: ja
B->>K: ja
Note over K: Entscheidung: commit
K->>A: commit
K->>B: commit
Verteilte Transaktionen: Zwei-Phasen-Commit und Saga
Track Konzepte · Verteilte Daten · ca. 45 Min.
Worum es geht
Sobald ein Vorgang mehrere Systeme berührt (Bestellung, Zahlung, Lager), gibt es keine einzelne Datenbank-Transaktion mehr, die alles zusammenhält. Teil 1 behandelt zwei Antworten darauf:
- Zwei-Phasen-Commit (two-phase commit, 2PC): alle Beteiligten committen gemeinsam oder gar nicht. Sauber, aber es kann blockieren.
- Saga: eine Kette lokaler Transaktionen, jede mit einer Kompensation (compensating action) zum Rückgängigmachen. Kein Blockieren, dafür musst du Fehlerfälle selbst behandeln.
Am Ende kannst du die Frage “Wann Saga statt 2PC, und was musst du dann selbst lösen?” in eigenen Worten beantworten und einen Saga-Orchestrator schreiben. Schema-Evolution, Event Sourcing und OLTP gegen OLAP folgen in Teil 2. Wie in Lektion 1 wird alles simuliert: keine echten Netzwerke oder Server, sondern deterministischer Code, der jedes Mal gleich abläuft.
Von JS/TS her gedacht
| Konzept | Was du schon kennst |
|---|---|
| Saga mit Kompensation | try/catch mit Aufräumen in catch, aber über mehrere Dienste und in umgekehrter Reihenfolge |
| Zwei-Phasen-Commit | Promise.all mit Zusage “ich kann”: erst alle fragen, dann alle gemeinsam bestätigen. Fällt der Fragende zwischen den Phasen aus, hängen alle |
Konzept
Schritt 1: Zwei-Phasen-Commit und sein Problem
Ein Koordinator (coordinator) steuert mehrere Teilnehmer (participants), z. B. zwei Datenbanken.
- Phase 1 (prepare): Der Koordinator fragt jeden Teilnehmer: “Kannst du committen?” Wer “ja” sagt, ist danach im Zustand prepared: Er hat versprochen zu committen, hält seine Sperren und wartet auf die Entscheidung. Wer “nein” sagt, bricht sofort ab.
- Phase 2 (decision): Sagen alle “ja”, schickt der Koordinator
commit, sonstabort.
Ein Teilnehmer im Zustand prepared darf nicht von sich aus entscheiden: Vielleicht hat der andere “nein” gesagt (dann müsste er abbrechen), vielleicht ist die Entscheidung schon commit (dann müsste er committen). Er weiß es nicht, bis der Koordinator sich meldet. Fällt der Koordinator jetzt aus, blockieren (block) die Teilnehmer und halten ihre Sperren.
Ein kleines Modell. absturz sagt, wann der Koordinator stirbt: gar nicht (None), nach den Stimmen aber vor der Entscheidung ("vor_entscheidung"), oder eine Zahl k: Die Entscheidung erreicht nur die ersten k Teilnehmer.
Ohne Ausfall: alle committen, oder alle brechen ab. Das ist die Stärke von 2PC: Atomarität über Systemgrenzen. Jetzt der Ausfall:
Ein Teilnehmer hat committed, zwei hängen in prepared und halten Sperren, bis der Koordinator zurückkommt. Konsens-Verfahren wie Raft und Paxos lösen dieses Problem, indem der Koordinator selbst repliziert wird. Das ist ein eigenes Thema; hier reicht das Stichwort.
Übung 1: Wer blockiert? (Vorhersage, ca. 7 Min.)
Sage für vier Szenarien voraus, wie viele Teilnehmer am Ende im Zustand prepared hängen. Die Stimmen stehen als Liste (True = ja). Trage ein Tupel mit vier Zahlen ein.
- Drei Teilnehmer, alle stimmen
ja. Der Koordinator stirbt vor der Entscheidung. - Vier Teilnehmer, der dritte stimmt
nein. Der Koordinator stirbt vor der Entscheidung. - Drei Teilnehmer, alle stimmen
ja. Die Entscheidung erreicht nur den ersten. - Vier Teilnehmer, der zweite stimmt
nein. Die Entscheidung (die dannabortlautet) erreicht nur die ersten zwei.
Zähle nicht die Teilnehmer, sondern frage pro Teilnehmer: Hat er “ja” gesagt, und hat ihn danach noch eine Entscheidung erreicht? Wer “nein” gesagt hat, braucht keine Entscheidung mehr.
antwort = (3, 3, 2, 2)
antwortSzenario 1: Alle drei haben “ja” gesagt und nie eine Entscheidung erhalten. Szenario 2: Der “nein”-Teilnehmer hat sofort abgebrochen, die anderen drei hängen. Szenario 3: Einer hat committed, zwei hängen. Szenario 4: Der “nein”-Teilnehmer ist schon fertig, die ersten zwei sind nach abort fertig (einer davon war es vorher schon), die letzten zwei hängen.
Schritt 2: Saga statt 2PC
2PC hält Sperren über Systemgrenzen und blockiert, wenn der Koordinator ausfällt. Viele Systeme (Microservices, externe Zahlungsanbieter) bieten es auch gar nicht an. Die Alternative ist die Saga: Jeder Schritt ist eine lokale Transaktion (sofort committed, für andere sichtbar). Zu jedem Schritt gibt es eine Kompensation, die seine Wirkung fachlich rückgängig macht (“Zahlung erstatten”, nicht “Zahlung löschen”). Scheitert Schritt n, werden die Kompensationen der Schritte n-1 bis 1 in umgekehrter Reihenfolge ausgeführt.
Ein Orchestrator (orchestrator) steuert die Schritte zentral. Die Alternative ist Choreography: Jeder Dienst reagiert auf Ereignisse der anderen, ohne zentrale Steuerung. Hier baust du einen Orchestrator.
Zuerst von Hand, mit drei Schritten (Bestellung, Zahlung, Lager). Das Lager ist leer:
Es klappt, aber jeder neue Schritt verschachtelt den Code tiefer. Besser: eine Liste von Schritten und eine Schleife, die sich merkt, was schon erledigt ist. In TypeScript (Typannotationen weggelassen, mit Node als JavaScript ausgeführt):
async function saga(schritte) {
const erledigt = [];
try {
for (const s of schritte) {
await s.aktion();
erledigt.push(s.kompensation); // erst NACH Erfolg merken
}
return "ok";
} catch {
for (const komp of erledigt.reverse()) await komp();
return "kompensiert";
}
}Mit denselben drei Schritten und leerem Lager ergibt das kompensiert und das Log bestellung angelegt, zahlung abgebucht, zahlung erstattet, bestellung storniert. Achte auf zwei Details: Der gescheiterte Schritt selbst wird nicht kompensiert (seine Aktion hat ja nicht stattgefunden), und die Kompensationen laufen rückwärts (die Zahlung hängt an der Bestellung).
Was du bei einer Saga selbst behandeln musst (bei 2PC macht das der Koordinator, bei einer Saga niemand):
- Kompensation scheitert (z. B. Zahlungsanbieter kurz nicht erreichbar): wiederholen (Retry), also muss jede Kompensation idempotent sein (Lektion 1). Hilft auch das nicht: Mensch alarmieren (manueller Eingriff).
- Zwischenzustände sind sichtbar: Die Saga bietet keine Isolation (isolation). Eine Bestellung ist kurz “angelegt, aber nicht bezahlt”. Abhilfe: Status wie
PENDING, den andere Teile des Systems nicht als fertig behandeln. - Orchestrator stürzt ab: Der Fortschritt muss persistent gespeichert sein (welche Schritte sind erledigt), damit nach dem Neustart weitergemacht wird.
- Doppelte Nachrichten: Aktionen und Kompensationen brauchen Idempotency Keys.
- Nicht kompensierbare Schritte (E-Mail verschickt, Paket abgeschickt): ans Ende der Saga legen, nach allen Schritten, die noch scheitern können.
Übung 2: Saga-Orchestrator selbst schreiben (ca. 15 Min.)
Schreibe saga(schritte, max_versuche=3). schritte ist eine Liste von Tupeln (name, aktion, kompensation), beide Funktionen ohne Argumente. Eine Funktion, die scheitert, wirft eine Exception.
- Alle Aktionen laufen der Reihe nach. Geht alles gut: Rückgabe
"ok". - Scheitert eine Aktion, werden die Kompensationen der zuvor erfolgreichen Schritte in umgekehrter Reihenfolge ausgeführt. Die Kompensation des gescheiterten Schritts und die der noch nicht gestarteten Schritte laufen nicht. Rückgabe
"kompensiert". - Wirft eine Kompensation eine Exception, wird sie bis zu
max_versucheMal insgesamt versucht. Klappt sie auch dann nicht, geht es mit den übrigen Kompensationen weiter, und die Rückgabe lautet am Ende"manuell"(ein Mensch muss eingreifen).
Führe eine Liste erledigt mit den Kompensationen. Hänge eine Kompensation erst an, nachdem ihre Aktion geklappt hat. Die Kompensationen lassen sich mit reversed(erledigt) rückwärts durchlaufen. Für den Retry brauchst du eine kleine Schleife über die Versuche, die bei Erfolg abbricht.
def saga(schritte, max_versuche=3):
erledigt = []
for name, aktion, kompensation in schritte:
try:
aktion()
except Exception:
break
erledigt.append(kompensation)
else:
return "ok"
manuell = False
for kompensation in reversed(erledigt):
for _ in range(max_versuche):
try:
kompensation()
break
except Exception:
pass
else:
manuell = True
return "manuell" if manuell else "kompensiert"
sagaDie for ... else-Konstruktion läuft den else-Zweig nur, wenn die Schleife nicht per break verlassen wurde. Hier heißt das: keine Aktion ist gescheitert. In der Praxis würdest du den Fortschritt (erledigt) nach jedem Schritt in einer Datenbank speichern, damit ein Neustart des Orchestrators weitermachen kann.
Übung 3: Saga oder 2PC? (Fallstudie, ca. 7 Min.)
Ein Reiseportal legt eine Buchung in drei Schritten an: (1) Flug beim Partner reservieren, (2) Hotelzimmer in der eigenen Datenbank reservieren, (3) Kreditkarte beim externen Zahlungsanbieter belasten. Randbedingungen:
- Partner und Zahlungsanbieter bieten nur eine HTTP-API mit “reservieren” und “stornieren” bzw. “belasten” und “erstatten”. Ein
prepareodercommitgibt es nicht. - Ein Vorgang kann bis zu zwei Minuten dauern. Hotelzimmer so lange zu sperren ist nicht akzeptabel.
- Scheitert ein Schritt, muss alles Vorherige fachlich zurückgenommen werden.
Welche Lösung passt? Gib den Buchstaben zurück.
- A Zwei-Phasen-Commit über alle drei Systeme, der eigene Dienst übernimmt die Rolle des Koordinators.
- B Eine Saga mit Orchestrator: sofort committen, je Schritt eine Kompensation, den Fortschritt speichern.
- C Eine einzige Datenbank-Transaktion um alle drei Aufrufe, mit dem höchsten Isolation Level der Datenbank.
- D Die drei Schritte nacheinander ausführen und im Fehlerfall “bitte später erneut versuchen” anzeigen, ohne etwas zurückzunehmen.
Prüfe jede Option gegen die drei Randbedingungen: Was verlangt sie von den fremden Systemen, was hält sie wie lange gesperrt, und wird im Fehlerfall zurückgenommen?
antwort = "B"
antwortDie Saga braucht von den fremden Systemen nur das, was sie ohnehin anbieten (Aktion und Gegenaktion), hält keine Sperren über die zwei Minuten und nimmt im Fehlerfall zurück. Der Preis: Kompensationen müssen idempotent sein, Zwischenzustände sind sichtbar, und der Fortschritt muss gespeichert werden.
Falle
Die häufigste Falle ist, eine Saga für “2PC ohne Aufwand” zu halten. Eine Saga bietet keine Isolation: Andere sehen Zwischenzustände, und Kompensationen können selbst scheitern. Ebenso gefährlich: Wer 2PC wählt, ohne zu prüfen, ob alle Beteiligten prepare und commit überhaupt anbieten, scheitert an fremden APIs oder blockiert bei Koordinatorausfall.
Merksatz
Verteilte Vorgänge brauchen entweder einen Koordinator, der blockieren kann (2PC), oder eine Saga, bei der du Kompensation, Retry und Zwischenzustände selbst verantwortest.
Prüfstein
Wann nimmst du eine Saga statt Zwei-Phasen-Commit, und welche Fehlerfälle musst du dann selbst behandeln?
Weiter mit Teil 2: Schema-Evolution, Event Sourcing und OLAP.
Quelle: quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 5, Punkt “Verteilte Transaktionen” (Zwei-Phasen-Commit, Saga, Konsens) und der Prüfstein dazu.
Über die Quelle hinaus (allgemeines Fachwissen, die Quelle enthält nur Stichworte): Ablauf von 2PC und das Blockieren der Teilnehmer, Orchestration vs. Choreography, die Liste der Saga-Fehlerfälle (Kompensation scheitert, sichtbare Zwischenzustände, nicht kompensierbare Schritte), Raft und Paxos nur als Stichwort.