Saga-Pattern für verteilte Transaktionen in Microservices
1. Übersicht
In einer Monolith-Anwendung setzt du @Transactional auf eine Methode und die Datenbank kümmert sich um den Rest: Entweder wird alles committed oder alles wird zurückgerollt. Wenn du zu Microservices übergehst, hat jeder Service seine eigene Datenbank, daher kann eine einzelne ACID-Transaktion nicht mehr den gesamten Geschäftsprozess abdecken.
Dieser Artikel stellt das Saga-Pattern vor, den häufigsten Weg, dieses Problem zu lösen. Danach wirst du zwei Arten von Sagas (Choreography und Orchestration) verstehen, einen kleinen Saga-Orchestrator in Java 21 schreiben können, Kompensationsabläufe testen und typische Fehler beim Einsatz von Saga in der Produktion kennen.
2. Problem: ein Geschäftsprozess, mehrere Datenbanken
Betrachten wir einen Bestellprozess mit drei Services:
- InventoryService verwaltet Waren im Lager.
- PaymentService belastet das Kundenkonto.
- ShippingService erstellt ein Versanddokument.
Angenommen, Schritt 1 ist erfolgreich, aber Schritt 2 schlägt fehl, weil der Kunde nicht genug Geld hat. Jetzt sind die Waren im Lager reserviert, aber es gibt keine Zahlung. Du kannst die Datenbank von InventoryService nicht aus PaymentService heraus „rollback” durchführen.
Die klassische Lösung ist Two-Phase Commit (2PC). Allerdings wird 2PC in Microservices selten verwendet, weil:
- Es sperrt Ressourcen während der Wartezeit auf die Antworten aller Parteien, was den Durchsatz erheblich reduziert.
- Der Koordinator ist ein Single Point of Failure.
- Viele Systeme wie Kafka, MongoDB oder REST-APIs von Drittanbietern unterstützen keine XA-Transaktionen.
3. Was ist Saga?
Saga teilt eine große Transaktion in eine Reihe von lokalen Transaktionen auf. Jede lokale Transaktion läuft in einem Service und wird sofort in die Datenbank dieses Service committed. Jeder Schritt wird von einer kompensierenden Transaktion begleitet, d.h. einer Geschätsoperation, die diesen Schritt „rückgängig macht”.
| Schritt | Aktion | Kompensation |
|---|---|---|
| 1 | Ware reservieren (reserve) |
Ware freigeben (release) |
| 2 | Zahlung einziehen (charge) |
Rückerstattung (refund) |
| 3 | Versand erstellen (ship) |
Versand stornieren (cancel) |
Wenn Schritt n fehlschlägt, führt die Saga die Kompensationen der Schritte n-1, n-2, …, 1 in umgekehrter Reihenfolge aus.
Zwei wichtige Unterschiede zum echten Rollback:
- Kompensation löscht die Historie nicht. Sie erstellt stattdessen eine neue Änderung, zum Beispiel eine Rückerstattungstransaktion. In einem kurzen Zeitraum konnten andere Services bereits einen Zwischenzustand sehen.
- Saga garantiert Eventual Consistency, aber nicht Isolation wie ACID.
Es gibt zwei Möglichkeiten, eine Saga zu orchestrieren:
- Choreography: Jeder Service lauscht auf Events und entscheidet selbst über den nächsten Schritt. Es gibt kein zentrales Kontrollzentrum.
- Orchestration: Ein Orchestrator ruft jeden Service der Reihe nach auf und ist verantwortlich für die Ausführung von Kompensationen bei Fehlern.
4. Orchestration-Saga mit Java 21
Dieses Abschnitt baut einen reinen Java-Orchestrator ohne Framework-Abhängigkeiten, damit du den internen Mechanismus deutlich sehen kannst.
4.1. Definieren eines Saga-Schritts
import java.util.function.Consumer;
public interface SagaStep<C> {
String name();
void execute(C context);
void compensate(C context);
static <C> SagaStep<C> of(String name, Consumer<C> action, Consumer<C> compensation) {
return new SagaStep<>() {
public String name() { return name; }
public void execute(C ctx) { action.accept(ctx); }
public void compensate(C ctx) { compensation.accept(ctx); }
};
}
}
Jeder Schritt hat zwei Verhalten: execute für den Vorwärtslauf und compensate für den Rückgängigmachungslauf. Das generische C ist ein gemeinsamer Kontext zwischen Schritten, z. B. Bestellinformationen. Die Factory-Methode of ermöglicht die Deklaration eines Schritts mit Lambda für Prägnanz.
4.2. Orchestrator
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
public record SagaResult(boolean success, String failedStep, String reason) {
static SagaResult ok() { return new SagaResult(true, null, null); }
static SagaResult failed(String step, String reason) { return new SagaResult(false, step, reason); }
}
public class SagaOrchestrator<C> {
private final List<SagaStep<C>> steps;
public SagaOrchestrator(List<SagaStep<C>> steps) {
this.steps = List.copyOf(steps);
}
public SagaResult run(C context) {
Deque<SagaStep<C>> completed = new ArrayDeque<>();
for (SagaStep<C> step : steps) {
try {
step.execute(context);
completed.push(step);
} catch (RuntimeException ex) {
compensate(completed, context);
return SagaResult.failed(step.name(), ex.getMessage());
}
}
return SagaResult.ok();
}
private void compensate(Deque<SagaStep<C>> completed, C context) {
while (!completed.isEmpty()) {
SagaStep<C> step = completed.pop();
try {
step.compensate(context);
} catch (RuntimeException ex) {
// Production: speichern zur Wiederholung, nicht ignorieren
System.err.println("Compensation failed at " + step.name() + ": " + ex.getMessage());
}
}
}
}
Wichtige Punkte:
completedist ein Stack (ArrayDequemitpush/pop). Jeder erfolgreiche Schritt wird auf den Stack gepusht, also haben wir beim Poppen die genaue umgekehrte Reihenfolge.- Der fehlgeschlagene Schritt ist nicht im Stack. Dieser Schritt hat nichts committed, also muss er nicht kompensiert werden.
- Ein fehlgeschlagener Kompensationslauf darf die restlichen Kompensationen nicht stoppen. Daher wird jeder
compensate-Aufruf in einen eigenentry/catcheingewickelt.
4.3. Mock-Services
import java.math.BigDecimal;
import java.util.*;
public record Order(String id, String customerId, String sku, int quantity, BigDecimal amount) {}
public class InventoryService {
private final Map<String, Integer> stock = new HashMap<>(Map.of("BOOK-01", 10));
public void reserve(Order o) {
int left = stock.get(o.sku()) - o.quantity();
if (left < 0) throw new IllegalStateException("Out of stock");
stock.put(o.sku(), left);
}
public void release(Order o) { stock.merge(o.sku(), o.quantity(), Integer::sum); }
public int available(String sku) { return stock.get(sku); }
}
public class PaymentService {
private final Map<String, BigDecimal> balances = new HashMap<>(Map.of("alice", new BigDecimal("100")));
public void charge(Order o) {
BigDecimal balance = balances.get(o.customerId());
if (balance.compareTo(o.amount()) < 0) throw new IllegalStateException("Insufficient funds");
balances.put(o.customerId(), balance.subtract(o.amount()));
}
public void refund(Order o) { balances.merge(o.customerId(), o.amount(), BigDecimal::add); }
public BigDecimal balance(String customerId) { return balances.get(customerId); }
}
public class ShippingService {
private final Set<String> shipments = new HashSet<>();
public void ship(Order o) { shipments.add(o.id()); }
public void cancel(Order o) { shipments.remove(o.id()); }
public boolean hasShipment(String orderId) { return shipments.contains(orderId); }
}
In der Praxis ist jede Methode hier ein REST/gRPC-Aufruf oder eine Nachricht an einen anderen Service. Diese Simulation wird nur zur Veranschaulichung und zum Testen verwendet.
4.4. Zusammenstellen zu einer Bestellungs-Saga
import java.math.BigDecimal;
import java.util.List;
public class OrderSagaDemo {
public static void main(String[] args) {
var inventory = new InventoryService();
var payment = new PaymentService();
var shipping = new ShippingService();
var saga = new SagaOrchestrator<Order>(List.of(
SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
SagaStep.of("charge-payment", payment::charge, payment::refund),
SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
));
var cheap = new Order("ORD-1", "alice", "BOOK-01", 2, new BigDecimal("40"));
var pricey = new Order("ORD-2", "alice", "BOOK-01", 3, new BigDecimal("500"));
System.out.println(saga.run(cheap));
System.out.println(saga.run(pricey));
System.out.println("Stock: " + inventory.available("BOOK-01"));
System.out.println("Balance: " + payment.balance("alice"));
}
}
Ergebnis:
SagaResult[success=true, failedStep=null, reason=null]
SagaResult[success=false, failedStep=charge-payment, reason=Insufficient funds]
Stock: 8
Balance: 60
Die Bestellung ORD-1 durchläuft alle drei Schritte. Die Bestellung ORD-2 reserviert 3 Bücher, schlägt aber beim Zahlungsabzug fehl. Der Orchestrator ruft dann inventory.release auf, also geht der Bestand auf 8 zurück (10 − 2 der ersten Bestellung) statt auf 5.
5. Choreography-Saga mit Events
Bei Choreography kennt kein Service den gesamten Ablauf. Jeder Service gibt ein Event ab, wenn er fertig ist, und ein anderer Service reagiert auf dieses Event. Das folgende Beispiel verwendet ApplicationEventPublisher aus Spring in einem Process zur Veranschaulichung. In einem echten System würdest du es durch Kafka oder RabbitMQ ersetzen.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
public record StockReserved(Order order) {}
public record PaymentFailed(Order order, String reason) {}
@Component
public class PaymentHandler {
private final PaymentService payment;
private final ApplicationEventPublisher events;
public PaymentHandler(PaymentService payment, ApplicationEventPublisher events) {
this.payment = payment;
this.events = events;
}
@EventListener
public void on(StockReserved event) {
try {
payment.charge(event.order());
} catch (IllegalStateException ex) {
events.publishEvent(new PaymentFailed(event.order(), ex.getMessage()));
}
}
}
@Component
public class InventoryCompensationHandler {
private final InventoryService inventory;
public InventoryCompensationHandler(InventoryService inventory) {
this.inventory = inventory;
}
@EventListener
public void on(PaymentFailed event) {
inventory.release(event.order());
}
}
PaymentHandler lauscht auf StockReserved. Wenn die Zahlung fehlschlägt, gibt es PaymentFailed ab. InventoryCompensationHandler empfängt dieses Event und gibt die Ware ins Lager zurück. Damit der Code funktioniert, müssen InventoryService und PaymentService auch als Beans deklariert werden.
Schneller Vergleich der beiden Typen:
- Choreography ist einfach und hat geringes Coupling, wenn der Ablauf nur 2–3 Schritte hat. Wenn der Ablauf länger ist, verteilt sich die Logik über mehrere Services, was schwer zu verfolgen ist und leicht zu Event-Zyklen führt.
- Orchestration konzentriert den Ablauf an einer Stelle, ist leicht zu lesen, zu testen und zu speichern. Im Gegenzug wird der Orchestrator zu einer Komponente, die du sorgfältig betreiben musst.
6. Testen von Kompensationsverhalten
Füge die Test-Abhängigkeit hinzu:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderSagaTest {
private final InventoryService inventory = new InventoryService();
private final PaymentService payment = new PaymentService();
private final ShippingService shipping = new ShippingService();
private final SagaOrchestrator<Order> saga = new SagaOrchestrator<>(List.of(
SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
SagaStep.of("charge-payment", payment::charge, payment::refund),
SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
));
@Test
void givenInsufficientFunds_whenRunSaga_thenStockIsReleased() {
var order = new Order("ORD-9", "alice", "BOOK-01", 3, new BigDecimal("500"));
SagaResult result = saga.run(order);
assertThat(result.success()).isFalse();
assertThat(result.failedStep()).isEqualTo("charge-payment");
assertThat(inventory.available("BOOK-01")).isEqualTo(10);
assertThat(payment.balance("alice")).isEqualByComparingTo("100");
assertThat(shipping.hasShipment("ORD-9")).isFalse();
}
@Test
void givenValidOrder_whenRunSaga_thenAllStepsCommitted() {
var order = new Order("ORD-10", "alice", "BOOK-01", 1, new BigDecimal("30"));
assertThat(saga.run(order).success()).isTrue();
assertThat(inventory.available("BOOK-01")).isEqualTo(9);
assertThat(payment.balance("alice")).isEqualByComparingTo("70");
assertThat(shipping.hasShipment("ORD-10")).isTrue();
}
}
Der erste Test überprüft das Wichtigste bei Sagas: Nach einem Fehler sind alle Nebenwirkungen rückgängig gemacht. Der Bestand geht auf 10 zurück, der Kontostand bleibt gleich und es wird kein Versanddokument erstellt. Der zweite Test bestätigt, dass der erfolgreiche Ablauf alle drei Schritte committed.
7. Häufige Fehler und deren Vermeidung
Hinweis: Der Orchestrator in diesem Artikel speichert den Status im Speicher. Wenn der Prozess mittendrin abstürzt, weißt du nicht, auf welchem Schritt er sich befand, und kannst auch nicht kompensieren. Speichere in der Produktion den Saga-Status (aktueller Schritt, abgeschlossene Schritte) nach jedem Schritt in der Datenbank und erlaube ein Fortsetzen beim Neustart.
Kompensation muss idempotent sein. Nachrichten können mehrmals neu gesendet werden, daher darf refund zweimal nicht zweimal Geld zurückgeben. Speichere die orderId der bereits verarbeiteten Transaktionen und ignoriere doppelte Anfragen.
Kompensation darf nicht „aufgeben”. Der catch-Block, der protokolliert, ist nur zur Veranschaulichung gedacht (siehe 4.2). In der Praxis müssen fehlgeschlagene Kompensationen mit Backoff wiederholt werden. Nach mehreren Versuchen mit Fehler sollte die Nachricht in eine Dead-Letter Queue verschoben werden, damit sie ein Mensch verarbeitet.
Dual Write. Wenn ein Service sowohl die Datenbank committed als auch eine Nachricht an Kafka sendet, kann eine der beiden Operationen fehlschlagen. Verwende Transactional Outbox: Schreibe das Event in eine outbox-Tabelle in der gleichen lokalen Transaktion, dann liest ein separater Prozess diese Tabelle und veröffentlicht sie.
Fehlende Isolation. Zwischen der Zeit des Geldabzugs und der Rückerstattung können Benutzer einen vorübergehend reduzierten Kontostand sehen. Du kannst diese Auswirkung durch Semantic Locks reduzieren, z. B. indem du den Bestellstatus bis zum Ende der Saga auf PENDING setzt. Eine andere Möglichkeit ist es, die Schritte umzuordnen und den am wahrscheinlichsten fehlschlagenden Schritt zuerst zu platzieren.
Es gibt Schritte, die nicht kompensiert werden können. Eine bereits gesendete E-Mail kann nicht zurückgerufen werden. Platziere solche Schritte (genannt Pivot oder Retriable Step) am Ende der Saga, nachdem alle fehlschlagbaren Schritte erfolgreich waren.
Wann sollte Saga NICHT verwendet werden:
- Alle Daten befinden sich in einer Datenbank. Verwende dann einfach lokale Transaktionen.
- Das Geschäft erfordert absolute Strong Consistency und toleriert keine Zwischenzustände, z. B. bestimmte Buchhaltungsprozesse. Überdenke in diesem Fall die Service-Grenzen.
- Der Ablauf hat nur einen Service, der Daten schreibt, während andere Services nur lesen.
8. Fazit
Saga ersetzt eine verteilte Transaktion durch eine Reihe lokaler Transaktionen mit kompensierenden Transaktionen, wodurch das System Eventual Consistency ohne 2PC erreicht. Du hast einen kleinen Orchestrator mit einem Stack gebaut, um Schritte in umgekehrter Reihenfolge zu kompensieren, die Choreography-Koordination über Events gesehen und Tests geschrieben, die bestätigen, dass alle Nebenwirkungen bei Fehlern rückgängig gemacht werden.
Beim Einsatz von Saga in der Produktion merke dir vier Dinge: Speichere den Saga-Status dauerhaft, schreibe idempotente Kompensationen mit Retry-Logik, verwende Transactional Outbox um Dual Writes zu vermeiden, und gestalte die Schrittreihenfolge so, dass nicht kompensierbare Operationen am Ende des Ablaufs stehen.