Teil 2 – Die Architektur: Services und Event-Broker
Willkommen zu Teil 2 unserer Serie über das SAGA Pattern! In Teil 1 haben wir die theoretischen Grundlagen geklärt, warum wir Sagas in Microservice-Architekturen benötigen und den Unterschied zwischen Choreografie und Orchestrierung kennengelernt.
Heute geht es an die Praxis. Wir implementieren eine Choreografie-basierte Saga mit Spring Boot und RabbitMQ. Das Ziel ist, den Bestellprozess (Order, Payment, Inventory) resilient und “Eventual Consistent” zu gestalten.
Unser Event-Setup mit RabbitMQ
Das Herzstück einer Choreografie-Saga ist der Message Broker. Er entkoppelt unsere Services. Anstatt dass der Order Service den Payment Service direkt per REST aufrufen muss (was zu einer engen Kopplung führen würde), sendet er einfach ein Event “in die Welt hinaus”.
Wir verwenden RabbitMQ und definieren für unsere Saga einen zentralen Topic Exchange. Ein Topic Exchange ist ideal, da er uns flexibles Routing basierend auf “Routing Keys” (Mustern) erlaubt.
Unser Exchange: saga_exchange
Der “Happy Path” (alles klappt) unserer Saga wird über folgende Events (und Routing Keys) gesteuert:
- Client ->
Order Service(REST API): Ein Kunde löst eine Bestellung aus. Order Service:- Legt die Bestellung in seiner lokalen DB an (Status:
PENDING). - Sendet ein
OrderCreatedEventan densaga_exchangemit dem Routing Keyorder.created.
- Legt die Bestellung in seiner lokalen DB an (Status:
Payment Service:- Hört auf Events mit dem Key
order.created. - Verarbeitet die Zahlung.
- Sendet ein
PaymentSuccessfulEventan densaga_exchangemit dem Keypayment.successful.
- Hört auf Events mit dem Key
Inventory Service:- Hört auf Events mit dem Key
payment.successful. - Reserviert die Ware im Lager.
- Sendet ein
InventorySuccessfulEventan densaga_exchangemit dem Keyinventory.successful.
- Hört auf Events mit dem Key
Order Service(optional, aber empfohlen):- Hört auf
inventory.successful. - Aktualisiert den Bestellstatus in seiner DB auf
COMPLETED.
- Hört auf
Die Spring Boot Konfiguration (Beispiel: Payment Service)
Damit dieser Event-Fluss funktioniert, müssen wir Spring Boot und RabbitMQ (via spring-boot-starter-amqp) konfigurieren. Jeder Service definiert die Queues, die er benötigt, und bindet sie an den zentralen Exchange.
Hier ist ein Beispiel, wie die Konfiguration im Payment Service aussehen könnte. Er muss auf order.created lauschen:
// In einer @Configuration Klasse im Payment Service
@Bean
public TopicExchange sagaExchange() {
// Definiert den Exchange (idempotent)
return new TopicExchange("saga_exchange");
}
@Bean
public Queue paymentQueue() {
// Definiert die Queue, in der dieser Service lauscht
return new Queue("payment_queue");
}
@Bean
public Binding paymentBinding(Queue paymentQueue, TopicExchange sagaExchange) {
// Wir binden die 'payment_queue' an den 'saga_exchange'
// und lauschen auf den Routing Key 'order.created'
return BindingBuilder.bind(paymentQueue).to(sagaExchange).with("order.created");
}
// ... eventuell weitere Bindings für Kompensationsevents ...
Der Code zum Senden von Events (z.B. PaymentSuccessfulEvent) ist ebenfalls unkompliziert. Wir injizieren das RabbitTemplate von Spring:
// Im PaymentService, nachdem die Zahlung erfolgreich war
@Autowired
private RabbitTemplate rabbitTemplate;
public void processPayment(OrderCreatedEvent event) {
// ... Logik zur Zahlungsabwicklung ...
if (paymentSuccessful) {
PaymentSuccessfulEvent successEvent = new PaymentSuccessfulEvent(/* ... */);
// Sende das Erfolgs-Event an den Exchange
rabbitTemplate.convertAndSend("saga_exchange", "payment.successful", successEvent);
} else {
// ... Fehlerbehandlung (dazu später mehr)
}
}
Und um auf das Event zu lauschen, verwenden wir die @RabbitListener-Annotation:
// Im PaymentService
@RabbitListener(queues = "payment_queue")
public void handleOrderCreated(OrderCreatedEvent event) {
System.out.println("Received OrderCreatedEvent: " + event.getOrderId());
processPayment(event);
}
Der “Unhappy Path” – Kompensation per Event
Was passiert, wenn einer unserer Schritte fehlschlägt? Nehmen wir das häufigste Szenario: Der Order Service erstellt die Bestellung (Status PENDING), sendet das OrderCreatedEvent, aber der Payment Service kann die Zahlung nicht autorisieren (z.B. mangelnde Deckung).
Jetzt muss die Saga rückabgewickelt werden.
Payment Service(Fehlerfall):- Die Zahlungslogik schlägt fehl.
- Statt eines
PaymentSuccessfulEventsendet der Service nun einPaymentFailedEventan densaga_exchangemit dem Routing Keypayment.failed.
Order Service(Kompensation):- Der
Order Servicemuss nun seine ursprüngliche Aktion (das Anlegen der Bestellung) kompensieren. - Er lauscht auf das
payment.failed-Event. - Wenn er es empfängt, ändert er den Status der Bestellung in seiner lokalen Datenbank von
PENDINGaufCANCELLED.
- Der
Das System ist danach wieder eventually consistent: Es gibt eine stornierte Bestellung und keine Zahlung.
Konfiguration der Kompensation
Dazu muss der Order Service eine zusätzliche Queue und ein Binding für die Fehler-Events definieren:
// In einer @Configuration Klasse im Order Service
// ... (Exchange und 'order_queue' etc. von vorher) ...
@Bean
public Queue orderCompensationQueue() {
// Eine dedizierte Queue für Kompensations-Events, die diesen Service betreffen
return new Queue("order_compensation_queue");
}
@Bean
public Binding paymentFailedBinding(Queue orderCompensationQueue, TopicExchange sagaExchange) {
// Wir binden die Kompensations-Queue an den Exchange
// und lauschen auf den Routing Key 'payment.failed'
return BindingBuilder.bind(orderCompensationQueue).to(sagaExchange).with("payment.failed");
}
Senden des Fehler-Events (im Payment Service)
Wenn processPayment fehlschlägt, senden wir das entsprechende Event:
// Im PaymentService
public void processPayment(OrderCreatedEvent event) {
// ... Logik zur Zahlungsabwicklung ...
if (paymentSuccessful) {
// ... (Happy Path)
} else {
// UNHAPPY PATH
PaymentFailedEvent failureEvent = new PaymentFailedEvent(event.getOrderId(), "Insufficient funds");
// Sende das Fehler-Event an den Exchange
rabbitTemplate.convertAndSend("saga_exchange", "payment.failed", failureEvent);
}
}
Empfangen des Fehler-Events (im Order Service)
Der Order Service fängt dieses Event ab und führt die Kompensation (Stornierung) durch:
// Im OrderService
@RabbitListener(queues = "order_compensation_queue")
public void handlePaymentFailed(PaymentFailedEvent event) {
System.out.println("Compensation: Received PaymentFailedEvent for Order: " + event.getOrderId());
// Finde die Bestellung
Order order = orderRepository.findById(event.getOrderId()).orElse(null);
if (order != null && order.getStatus() == OrderStatus.PENDING) {
// Führe die kompensierende Transaktion aus
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
System.out.println("Order " + event.getOrderId() + " compensated (CANCELLED).");
} else {
// Wichtig: Logik idempotent halten!
// Falls das Event mehrfach kommt oder die Bestellung schon storniert ist.
}
}
Die Tücken der Choreografie
Dieser Ansatz funktioniert, aber du siehst vielleicht schon die Herausforderung:
- Verteilte Geschäftslogik: Der
Order Servicemuss jetzt wissen, dass er aufpayment.failedreagieren muss. - Zyklische Abhängigkeiten: Was passiert, wenn die Kompensation selbst fehlschlägt? (z.B. DB-Fehler beim Setzen auf
CANCELLED). Dies erfordert komplexe Retry-Mechanismen und Dead-Letter-Queues. - Monitoring: Es ist sehr schwierig, den Gesamtstatus einer Saga zu überblicken. Wenn ein Event verloren geht oder ein Service down ist, bleibt die Saga “stecken”, und es ist schwer zu debuggen, wo genau sie steckt.
Für eine Saga mit 3 Schritten ist dies überschaubar. Bei 5 oder mehr Schritten wird die Anzahl der Event-Verknüpfungen (wer auf wen hört, wer wen kompensiert) schnell exponentiell komplex.
Verstanden. Die Choreografie-Implementierung ist damit vollständig abgedeckt. Wir haben den “Happy Path” und den “Unhappy Path” mit Kompensation durchgesprochen.
Hier ist der Entwurf für das Fazit von Teil 2, der die Vor- und Nachteile dieses Ansatzes zusammenfasst.
Fazit Teil 2 – Die Stärken und Schwächen der Choreografie
Wir haben in diesem Artikel eine vollständige, Choreografie-basierte Saga implementiert. Mit Spring Boot und RabbitMQ ist es uns gelungen, drei Microservices (Order, Payment, Inventory) lose zu koppeln.
Der entscheidende Vorteil dieses Ansatzes ist die Dezentralisierung:
- Lose Kopplung: Kein Service kennt die anderen Services. Sie müssen nur den zentralen
saga_exchangeund die Event-Strukturen kennen. Das Hinzufügen neuer Services (z.B. einShipping Service, der aufInventorySuccessfulEventhört) ist einfach, ohne bestehende Services ändern zu müssen. - Eigenständigkeit: Jeder Service ist autonom und nur für seine eigene Logik und Kompensation verantwortlich.
Allerdings haben wir auch die Tücken der Choreografie gesehen:
- Verteilte Geschäftslogik: Der Gesamtprozessfluss ist nirgendwo zentral definiert. Um die Saga zu verstehen, muss man sich ansehen, wie alle Services über Events interagieren. Dies wird oft als “Knowing too much” bezeichnet – der
Order Servicemuss wissen, dass einpayment.failedEvent eine Stornierung seinerseits erfordert. - Schwieriges Monitoring: Es ist extrem schwierig festzustellen, in welchem Zustand sich eine bestimmte Saga (z.B. für
OrderID-123) befindet. Ist sie beim Payment? Wartet sie aufs Inventar? Ist sie fehlgeschlagen, aber die Kompensation hängt? - Komplexes Error-Handling: Wenn eine kompensierende Transaktion selbst fehlschlägt (z.B. der
Order Serviceist down, während dasPaymentFailedEventeintrifft), geraten wir in einen schwer zu lösenden Zustand, der manuelles Eingreifen oder komplexe Retry-Mechanismen (Dead Letter Queues) erfordert.
Fazit: Die Choreografie ist elegant für einfache Sagas mit wenigen Teilnehmern. Bei steigender Komplexität kann der Mangel an zentraler Steuerung jedoch zu einem schwer wartbaren System führen.
Genau hier setzt der alternative Ansatz an: die Orchestrierung, bei der ein zentraler Koordinator die Fäden in der Hand hält. Aber das ist ein Thema für einen zukünftigen Artikel.
