Verteilte Systeme
Software-Muster jenseits von GoF · Gruppe 7 von 7
Sobald eine Anwendung aus mehreren Diensten besteht, die über ein Netzwerk kommunizieren, gelten neue Regeln: Das Netz ist unzuverlässig, Dienste fallen aus oder antworten langsam, und es gibt keine gemeinsame Datenbanktransaktion mehr. Die Muster dieser Gruppe stammen vor allem aus der Microservice-Welt, etwa aus Michael Nygards Release It! und Chris Richardsons Microservices Patterns. Sie machen verteilte Systeme robust gegen Ausfälle, konsistent ohne verteilte Transaktionen und schrittweise veränderbar.
Die Codebeispiele simulieren Dienste, Ausfälle und Zeit im Speicher, damit sie ohne Netzwerk sofort laufen. Beim Sidecar gibt es keinen Code, weil er gerade nicht im Programm, sondern daneben läuft.
- Anwendung / Objekt
- Muster-Baustein
- Speicher / Ressource
- 45. Circuit Breaker
- 46. Retry mit Backoff
- 47. Bulkhead
- 48. Saga
- 49. API Gateway
- 50. Transactional Outbox
- 51. Strangler Fig
- 52. Sidecar
- 53. Publish-Subscribe / Message Bus
45. Circuit Breaker · Sicherung
Die Idee
Ein Circuit Breaker funktioniert wie eine elektrische Sicherung. Solange ein entfernter Dienst funktioniert, ist er geschlossen, und Aufrufe gehen durch. Häufen sich Fehler, springt er auf offen: Eine Zeit lang wird der Dienst gar nicht mehr aufgerufen, stattdessen gibt es sofort einen Fehler oder ein Ersatzergebnis. Nach einer Wartezeit wird er halb offen und lässt einen Probeaufruf durch. Gelingt der, schließt er sich wieder, sonst bleibt er offen. Bekannt gemacht hat das Muster Michael Nygard in Release It!.
Das Problem
Antwortet ein Empfehlungsdienst nicht mehr und hängt jeder Aufruf fünf Sekunden bis zum Timeout, warten alle Produktseiten des Shops fünf Sekunden. Threads und Verbindungen stauen sich, und am Ende fällt der Shop aus, weil ein unwichtiger Dienst ausgefallen ist. Außerdem bekommt der kranke Dienst weiter die volle Last ab und kann sich nicht erholen.
Im Code
Die Sicherung zählt Fehler in Folge und merkt sich, wann sie geöffnet wurde. Die Zeit wird simuliert, damit das Beispiel sofort läuft. Der Dienst fällt bei Sekunde 2 aus und ist ab Sekunde 20 wieder da. Die Tabelle zeigt, wie die Sicherung nach drei Fehlern öffnet, Aufrufe überspringt, bei Sekunde 14 eine erfolglose Probe macht und bei Sekunde 26 mit erfolgreicher Probe wieder schließt. Nur 8 von 12 Aufrufen erreichen den Dienst überhaupt.
Java-21-Code: CircuitBreaker.java
Direkt ausführbar mit java CircuitBreaker.java
// Circuit Breaker
// Schützt vor wiederholten Aufrufen eines ausgefallenen Dienstes: Nach zu vielen Fehlern wird
// eine Zeit lang gar nicht mehr aufgerufen, sondern sofort ein Ersatzergebnis geliefert.
// Start: java CircuitBreaker.java
import java.util.function.Supplier;
public class CircuitBreaker {
enum Zustand { GESCHLOSSEN, OFFEN, HALB_OFFEN }
static final class Sicherung {
private final int fehlerGrenze;
private final long wartezeitMs;
private Zustand zustand = Zustand.GESCHLOSSEN;
private int fehlerInFolge = 0;
private long geoeffnetUm;
Sicherung(int fehlerGrenze, long wartezeitMs) { this.fehlerGrenze = fehlerGrenze; this.wartezeitMs = wartezeitMs; }
<T> T rufe(Supplier<T> dienst, Supplier<T> ersatz, long jetztMs) {
if (zustand == Zustand.OFFEN) {
if (jetztMs - geoeffnetUm < wartezeitMs) return ersatz.get(); // gar nicht erst versuchen
zustand = Zustand.HALB_OFFEN; // Probeaufruf erlauben
}
try {
T ergebnis = dienst.get();
fehlerInFolge = 0;
zustand = Zustand.GESCHLOSSEN;
return ergebnis;
} catch (RuntimeException e) {
fehlerInFolge++;
if (zustand == Zustand.HALB_OFFEN || fehlerInFolge >= fehlerGrenze) {
zustand = Zustand.OFFEN;
geoeffnetUm = jetztMs;
}
return ersatz.get();
}
}
Zustand zustand() { return zustand; }
}
// --- Ein externer Dienst, der zeitweise ausfällt ---
static boolean dienstAusgefallen = false;
static int echteAufrufe = 0;
static String empfehlungen() {
echteAufrufe++;
if (dienstAusgefallen) throw new IllegalStateException("Timeout nach 5 s");
return "3 Empfehlungen";
}
public static void main(String[] args) {
Sicherung sicherung = new Sicherung(3, 10_000); // 3 Fehler -> 10 Sekunden offen
long[] zeitpunkte = { 0, 1_000, 2_000, 3_000, 4_000, 5_000, 6_000, 8_000, 14_000, 15_000, 26_000, 27_000 };
System.out.printf("%-7s %-12s %-26s %s%n", "Zeit", "Dienst", "Antwort", "Sicherung danach");
for (long t : zeitpunkte) {
if (t == 2_000) dienstAusgefallen = true; // Dienst fällt aus
if (t >= 20_000) dienstAusgefallen = false; // und ist ab Sekunde 20 wieder da
int vorher = echteAufrufe;
String antwort = sicherung.rufe(CircuitBreaker::empfehlungen, () -> "Bestseller (Ersatz)", t);
System.out.printf("%5.0f s %-12s %-26s %s%n", t / 1000.0,
echteAufrufe > vorher ? "aufgerufen" : "übersprungen", antwort, sicherung.zustand());
}
System.out.println("\nEchte Aufrufe des Dienstes: " + echteAufrufe + " von " + zeitpunkte.length);
}
}Ausgabe
Zeit Dienst Antwort Sicherung danach
0 s aufgerufen 3 Empfehlungen GESCHLOSSEN
1 s aufgerufen 3 Empfehlungen GESCHLOSSEN
2 s aufgerufen Bestseller (Ersatz) GESCHLOSSEN
3 s aufgerufen Bestseller (Ersatz) GESCHLOSSEN
4 s aufgerufen Bestseller (Ersatz) OFFEN
5 s übersprungen Bestseller (Ersatz) OFFEN
6 s übersprungen Bestseller (Ersatz) OFFEN
8 s übersprungen Bestseller (Ersatz) OFFEN
14 s aufgerufen Bestseller (Ersatz) OFFEN
15 s übersprungen Bestseller (Ersatz) OFFEN
26 s aufgerufen 3 Empfehlungen GESCHLOSSEN
27 s aufgerufen 3 Empfehlungen GESCHLOSSEN
Echte Aufrufe des Dienstes: 8 von 12Worauf achten
Das Ersatzergebnis (Fallback) ist eine fachliche Entscheidung: Bestseller statt persönlicher Empfehlungen ist sinnvoll, ein erfundener Kontostand nicht. Schwellwerte und Wartezeiten müssen zum Dienst passen. Moderne Implementierungen arbeiten statt mit Fehlern in Folge mit einer Fehlerquote über ein gleitendes Zeitfenster. Ein Circuit Breaker sollte immer mit sinnvollen Timeouts kombiniert werden und ist das Gegenstück zum Retry: Retry versucht es erneut, der Circuit Breaker hört bewusst damit auf.
In der Praxis
Resilience4j (der Standard in Java, mit Spring-Boot-Integration), Hystrix von Netflix (nicht mehr weiterentwickelt), Circuit Breaker in Service Meshes wie Istio und in API-Gateways.
46. Retry mit Backoff · Wiederholung mit Wartezeit
Die Idee
Viele Fehler in verteilten Systemen sind vorübergehend: ein kurzer Netzwerkaussetzer, ein überlasteter Dienst, ein Neustart. Ein zweiter Versuch kurze Zeit später gelingt oft. Retry wiederholt fehlgeschlagene Aufrufe, und Backoff sorgt dafür, dass die Abstände zwischen den Versuchen wachsen, meist exponentiell: 0,5 s, 1 s, 2 s, 4 s. Ein zufälliger Anteil, der Jitter, verhindert, dass viele Aufrufer im Gleichtakt wiederholen.
Das Problem
Ohne Wiederholung führt jeder kleine Aussetzer zu einer Fehlermeldung beim Kunden. Mit sofortiger, häufiger Wiederholung ohne Pause dagegen bekommt ein ohnehin überlasteter Dienst noch mehr Anfragen und bricht endgültig zusammen. Wiederholen tausend Aufrufer alle exakt nach einer Sekunde, trifft ihn die Welle immer wieder gleichzeitig.
Im Code
mitWiederholung versucht es bis zu fünfmal, verdoppelt die Wartezeit nach jedem Fehlschlag (höchstens 8 s) und addiert bis zu 20 % Zufall. Das eigentliche Warten ist auskommentiert, damit das Beispiel sofort durchläuft. Ein fester Startwert für den Zufall sorgt für immer gleiche Ausgabe. Im ersten Fall gelingt der vierte Versuch, im zweiten wird nach fünf Versuchen aufgegeben.
Java-21-Code: RetryBackoff.java
Direkt ausführbar mit java RetryBackoff.java
// Retry mit Backoff
// Fehlgeschlagene Aufrufe werden wiederholt, mit wachsendem Abstand zwischen den Versuchen.
// Start: java RetryBackoff.java
import java.util.Random;
import java.util.function.Supplier;
public class RetryBackoff {
// --- Wiederholung mit exponentiellem Backoff und Zufallsanteil (Jitter) ---
static <T> T mitWiederholung(Supplier<T> aufruf, int maxVersuche, long startMs, Random zufall) {
long warte = startMs;
for (int versuch = 1; ; versuch++) {
try {
T ergebnis = aufruf.get();
System.out.println(" Versuch " + versuch + ": erfolgreich");
return ergebnis;
} catch (VoruebergehenderFehler e) {
if (versuch == maxVersuche) {
System.out.println(" Versuch " + versuch + ": " + e.getMessage() + " -> aufgeben");
throw e;
}
long jitter = (long) (warte * 0.2 * zufall.nextDouble()); // bis zu 20 % zufällig dazu
System.out.printf(" Versuch %d: %s -> warte %d ms%n", versuch, e.getMessage(), warte + jitter);
// Thread.sleep(warte + jitter); // im echten Code; hier weggelassen, damit das Beispiel sofort läuft
warte = Math.min(warte * 2, 8_000); // verdoppeln, aber höchstens 8 s
}
}
}
static class VoruebergehenderFehler extends RuntimeException {
VoruebergehenderFehler(String m) { super(m); }
}
// --- Ein Zahlungsdienst, der die ersten n Aufrufe ablehnt ---
static Supplier<String> dienstMitFehlern(int fehlschlaege) {
int[] zaehler = { 0 };
return () -> {
if (++zaehler[0] <= fehlschlaege) throw new VoruebergehenderFehler("503 Service Unavailable");
return "Zahlung bestätigt";
};
}
public static void main(String[] args) {
Random zufall = new Random(42); // fester Startwert: gleiche Ausgabe
System.out.println("Dienst ist kurz überlastet (3 Fehlschläge):");
System.out.println(" Ergebnis: " + mitWiederholung(dienstMitFehlern(3), 5, 500, zufall));
System.out.println("\nDienst bleibt ausgefallen (10 Fehlschläge):");
try {
mitWiederholung(dienstMitFehlern(10), 5, 500, zufall);
} catch (VoruebergehenderFehler e) {
System.out.println(" Ergebnis: Fehler an den Aufrufer weitergeben");
}
}
}Ausgabe
Dienst ist kurz überlastet (3 Fehlschläge):
Versuch 1: 503 Service Unavailable -> warte 572 ms
Versuch 2: 503 Service Unavailable -> warte 1136 ms
Versuch 3: 503 Service Unavailable -> warte 2123 ms
Versuch 4: erfolgreich
Ergebnis: Zahlung bestätigt
Dienst bleibt ausgefallen (10 Fehlschläge):
Versuch 1: 503 Service Unavailable -> warte 527 ms
Versuch 2: 503 Service Unavailable -> warte 1133 ms
Versuch 3: 503 Service Unavailable -> warte 2361 ms
Versuch 4: 503 Service Unavailable -> warte 4295 ms
Versuch 5: 503 Service Unavailable -> aufgeben
Ergebnis: Fehler an den Aufrufer weitergebenWorauf achten
Nur wiederholen, was sich lohnt: Eine Zeitüberschreitung oder „503 Service Unavailable“ ja, „400 Bad Request“ oder „Konto gesperrt“ nein, die Antwort wird beim nächsten Mal dieselbe sein. Nur wiederholen, was idempotent ist: Wird eine Zahlung wiederholt, deren erste Ausführung schon geklappt hat und nur die Antwort verloren ging, wird doppelt abgebucht, wenn der Empfänger kein Idempotent Receiver ist. Wiederholungen auf mehreren Ebenen vervielfachen sich: Wiederholen Gateway, Dienst und Datenbanktreiber je dreimal, sind es 27 Versuche. Und ein Retry braucht einen Circuit Breaker als Gegenspieler für lang anhaltende Ausfälle.
In der Praxis
Resilience4j Retry, Spring Retry (@Retryable), Failsafe, eingebaute Wiederholungen in den AWS- und Azure-SDKs, die Wiederholungslogik von Kafka-Produzenten und Message Brokern.
47. Bulkhead · Schott
Die Idee
Ein Schiffsrumpf ist durch Schotten in wasserdichte Abteilungen geteilt. Schlägt ein Leck, läuft nur eine Abteilung voll, und das Schiff schwimmt weiter. Das Bulkhead-Muster überträgt das auf Software: Ressourcen wie Threads, Verbindungen oder Speicher werden in getrennte Kontingente aufgeteilt, eines pro Dienst oder Aufgabe. Hängt ein Dienst, erschöpft er nur sein eigenes Kontingent.
Das Problem
Teilen sich alle ausgehenden Aufrufe einen gemeinsamen Pool von 50 Threads, und der Empfehlungsdienst antwortet plötzlich nicht mehr, belegen die Aufrufe an ihn nach kurzer Zeit alle 50 Threads. Dann kann der Shop auch keine Zahlungen mehr abwickeln, obwohl der Zahlungsdienst einwandfrei läuft.
Im Code
Jedes Schott ist ein Semaphore mit einer festen Zahl von Plätzen. tryAcquire() wartet nicht, sondern weist ab, wenn kein Platz frei ist. Der hängende Empfehlungsdienst bekommt drei Plätze, der Zahlungsdienst zehn. Von 20 Anfragen an den hängenden Dienst werden 17 sofort abgewiesen, alle 10 Zahlungen laufen durch.
Java-21-Code: Bulkhead.java
Direkt ausführbar mit java Bulkhead.java
// Bulkhead (Schott)
// Ressourcen werden in getrennte Abteilungen aufgeteilt, damit ein überlasteter Teil nicht alles mitreißt.
// Start: java Bulkhead.java
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;
public class Bulkhead {
// --- Ein Schott: höchstens n gleichzeitige Aufrufe, der Rest wird sofort abgewiesen ---
static final class Schott {
final String name;
private final Semaphore plaetze;
final AtomicInteger angenommen = new AtomicInteger(), abgewiesen = new AtomicInteger();
Schott(String name, int max) { this.name = name; this.plaetze = new Semaphore(max); }
void rufe(Runnable aufruf) {
if (!plaetze.tryAcquire()) { // kein Platz frei: nicht warten, sofort ablehnen
abgewiesen.incrementAndGet();
return;
}
try {
angenommen.incrementAndGet();
aufruf.run();
} finally {
plaetze.release();
}
}
public String toString() { return String.format("%-14s angenommen %2d, abgewiesen %2d", name, angenommen.get(), abgewiesen.get()); }
}
static void warte(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
public static void main(String[] args) {
Schott empfehlungen = new Schott("Empfehlungen", 3); // langsamer, unwichtiger Dienst: wenige Plätze
Schott zahlung = new Schott("Zahlung", 10); // wichtiger Dienst: eigene Plätze
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 20; i++) {
ex.submit(() -> empfehlungen.rufe(() -> warte(500))); // Empfehlungsdienst hängt
}
warte(50);
for (int i = 0; i < 10; i++) {
ex.submit(() -> zahlung.rufe(() -> warte(20))); // Zahlungen laufen normal
}
}
System.out.println("20 Anfragen an den hängenden Empfehlungsdienst, 10 an den Zahlungsdienst:");
System.out.println(" " + empfehlungen);
System.out.println(" " + zahlung);
System.out.println("Der hängende Dienst belegt höchstens 3 Plätze. Zahlungen sind nicht betroffen.");
}
}Ausgabe
20 Anfragen an den hängenden Empfehlungsdienst, 10 an den Zahlungsdienst:
Empfehlungen angenommen 3, abgewiesen 17
Zahlung angenommen 10, abgewiesen 0
Der hängende Dienst belegt höchstens 3 Plätze. Zahlungen sind nicht betroffen.Worauf achten
Die Kontingente müssen sinnvoll bemessen sein: Zu klein abgewiesen wird schon bei normaler Last, zu groß schützt nicht. Abgewiesene Aufrufe brauchen eine Antwort, meist ein Fallback wie beim Circuit Breaker, mit dem Bulkhead oft kombiniert wird. Bulkheads gibt es auf allen Ebenen: getrennte Thread-Pools oder Semaphoren im Programm, getrennte Verbindungspools pro Datenbank, getrennte Instanzen oder Cluster für wichtige und unwichtige Kunden.
In der Praxis
Resilience4j Bulkhead (als Semaphor- oder Thread-Pool-Variante), getrennte ExecutorService-Instanzen pro Abhängigkeit, Ressourcenlimits für Container in Kubernetes, getrennte Verbindungspools in HikariCP.
48. Saga · Folge lokaler Transaktionen
Die Idee
Wenn ein Geschäftsvorgang mehrere Dienste mit jeweils eigener Datenbank betrifft, gibt es keine gemeinsame Transaktion, die alles oder nichts garantiert. Eine Saga zerlegt den Vorgang in eine Folge lokaler Transaktionen, eine pro Dienst. Zu jedem Schritt gehört eine Ausgleichsaktion, die ihn fachlich rückgängig macht. Scheitert ein Schritt, werden die bereits erledigten in umgekehrter Reihenfolge ausgeglichen. Das Konzept stammt von Hector Garcia-Molina und Kenneth Salem (1987).
Zwei Arten der Steuerung
- Orchestrierung: Ein zentraler Koordinator ruft die Schritte nacheinander auf und löst bei Fehlern die Ausgleiche aus. Übersichtlich, aber ein zentraler Baustein. Das entspricht dem Process Manager der EIP.
- Choreografie: Jeder Dienst reagiert auf Ereignisse des vorherigen und veröffentlicht selbst ein Ereignis. Kein zentraler Koordinator, aber der Gesamtablauf ist nirgends sichtbar.
Im Code
Eine orchestrierte Saga mit drei Schritten: Ware reservieren, Zahlung einziehen, Versand beauftragen. Jeder Schritt ist ein Record mit Aktion und Ausgleich. Bestellung 1 läuft durch. Bei Bestellung 2 scheitert der Versand, und die Saga erstattet die Zahlung und hebt die Reservierung auf. Am Ende ist nur Bestellung 1 im System.
Java-21-Code: Saga.java
Direkt ausführbar mit java Saga.java
// Saga
// Eine verteilte Transaktion als Folge lokaler Schritte. Schlägt ein Schritt fehl,
// werden die bereits erledigten durch Ausgleichsaktionen rückgängig gemacht.
// Start: java Saga.java
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
public class Saga {
// --- Ein Schritt: Aktion + Ausgleichsaktion ---
record Schritt(String name, Runnable aktion, Runnable ausgleich) {}
// --- Orchestrierte Saga: führt Schritte aus, gleicht bei Fehler rückwärts aus ---
static boolean fuehreAus(String vorgang, List<Schritt> schritte) {
System.out.println(vorgang + ":");
Deque<Schritt> erledigt = new ArrayDeque<>();
for (Schritt s : schritte) {
try {
s.aktion().run();
erledigt.push(s);
System.out.println(" ✓ " + s.name());
} catch (RuntimeException e) {
System.out.println(" ✗ " + s.name() + ": " + e.getMessage());
while (!erledigt.isEmpty()) {
Schritt zurueck = erledigt.pop(); // in umgekehrter Reihenfolge
zurueck.ausgleich().run();
System.out.println(" ↩ Ausgleich für: " + zurueck.name());
}
return false;
}
}
return true;
}
// --- Drei Dienste mit eigener Datenbank (hier nur Zustandsvariablen) ---
static int lagerReserviert = 0;
static long kontoBelastet = 0;
static int sendungen = 0;
static List<Schritt> bestellung(int menge, long cent, boolean versandMoeglich) {
return List.of(
new Schritt("Ware reservieren (Lagerdienst)",
() -> lagerReserviert += menge, () -> lagerReserviert -= menge),
new Schritt("Zahlung einziehen (Zahlungsdienst)",
() -> kontoBelastet += cent, () -> kontoBelastet -= cent),
new Schritt("Versand beauftragen (Versanddienst)",
() -> { if (!versandMoeglich) throw new IllegalStateException("Adresse nicht zustellbar"); sendungen++; },
() -> sendungen--));
}
public static void main(String[] args) {
boolean ok1 = fuehreAus("Bestellung 1", bestellung(2, 5_980, true));
System.out.println(" -> " + (ok1 ? "abgeschlossen" : "zurückgerollt"));
System.out.println();
boolean ok2 = fuehreAus("Bestellung 2", bestellung(1, 3_990, false));
System.out.println(" -> " + (ok2 ? "abgeschlossen" : "zurückgerollt"));
System.out.printf(java.util.Locale.GERMANY, "%nEndstand: reserviert %d Stück, belastet %.2f €, Sendungen %d (nur Bestellung 1)%n",
lagerReserviert, kontoBelastet / 100.0, sendungen);
}
}Ausgabe
Bestellung 1:
✓ Ware reservieren (Lagerdienst)
✓ Zahlung einziehen (Zahlungsdienst)
✓ Versand beauftragen (Versanddienst)
-> abgeschlossen
Bestellung 2:
✓ Ware reservieren (Lagerdienst)
✓ Zahlung einziehen (Zahlungsdienst)
✗ Versand beauftragen (Versanddienst): Adresse nicht zustellbar
↩ Ausgleich für: Zahlung einziehen (Zahlungsdienst)
↩ Ausgleich für: Ware reservieren (Lagerdienst)
-> zurückgerollt
Endstand: reserviert 2 Stück, belastet 59,80 €, Sendungen 1 (nur Bestellung 1)Worauf achten
Ausgleichen ist nicht dasselbe wie Zurückrollen: Zwischen Schritt und Ausgleich waren die Zwischenstände für andere sichtbar. Die Ware war kurz reserviert, das Geld kurz abgebucht. Manche Schritte lassen sich gar nicht ausgleichen, etwa eine verschickte E-Mail. Solche Schritte stellt man ans Ende. Ausgleichsaktionen müssen selbst zuverlässig und wiederholbar sein. Und eine Saga muss ihren Fortschritt dauerhaft speichern, damit sie nach einem Absturz weiß, wo sie stand.
In der Praxis
Temporal, Camunda und AWS Step Functions für orchestrierte Sagas, Axon Framework, Eventuate Tram. Choreografierte Sagas laufen über Event-Messaging mit Kafka oder RabbitMQ, meist zusammen mit dem Outbox-Muster (Muster 50).
49. API Gateway · Zugangstor
Die Idee
Besteht ein System aus vielen kleinen Diensten, sollen Clients nicht jeden einzeln kennen und ansprechen müssen. Ein API Gateway ist der einzige Einstiegspunkt von außen. Es leitet Anfragen an den zuständigen Dienst weiter und übernimmt Querschnittsaufgaben an zentraler Stelle: Anmeldung prüfen, Anfragemengen begrenzen, protokollieren. Außerdem kann es die Antworten mehrerer Dienste zu einer zusammenfassen.
Das Problem
Die App-Startseite zeigt Kundendaten, offene Bestellungen und Empfehlungen aus drei verschiedenen Diensten. Ohne Gateway macht die App drei Anfragen über das langsame Mobilfunknetz, und jeder der drei Dienste muss selbst die Anmeldung prüfen. Ändert sich die Aufteilung der Dienste, muss jede App aktualisiert werden.
Im Code
Das Gateway prüft den Token, zählt Anfragen pro Token und lehnt ab der vierten mit „429“ ab. Es leitet Pfade an die zuständigen Dienste weiter. Für /api/startseite ruft es drei Dienste parallel auf und liefert eine gemeinsame Antwort.
Java-21-Code: ApiGateway.java
Direkt ausführbar mit java ApiGateway.java
// API Gateway
// Ein einziger Einstiegspunkt vor vielen Diensten: prüft, leitet weiter und fasst Antworten zusammen.
// Start: java ApiGateway.java
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Function;
public class ApiGateway {
// --- Interne Dienste, von außen nicht direkt erreichbar ---
static String kundenDienst(String pfad) { return "{\"kunde\":815,\"name\":\"Sven\"}"; }
static String bestellDienst(String pfad) { return "{\"offeneBestellungen\":2}"; }
static String empfehlDienst(String pfad) { return "[\"Lampe\",\"Tasse\"]"; }
static final class Gateway {
private final Map<String, Function<String, String>> routen = new LinkedHashMap<>();
private final Set<String> gueltigeTokens = Set.of("token-sven");
private final Map<String, Integer> anfragenProToken = new LinkedHashMap<>();
private final int limit = 3;
private final ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor();
Gateway() {
routen.put("/api/kunden", ApiGateway::kundenDienst);
routen.put("/api/bestellungen", ApiGateway::bestellDienst);
routen.put("/api/empfehlungen", ApiGateway::empfehlDienst);
}
String anfrage(String token, String pfad) {
// 1. Querschnittsaufgaben an EINER Stelle: Anmeldung, Mengenbegrenzung
if (!gueltigeTokens.contains(token)) return "401 Nicht angemeldet";
int n = anfragenProToken.merge(token, 1, Integer::sum);
if (n > limit) return "429 Zu viele Anfragen";
// 2. Zusammengesetzter Endpunkt für die App-Startseite: drei Dienste parallel, eine Antwort
if (pfad.equals("/api/startseite")) {
var k = CompletableFuture.supplyAsync(() -> kundenDienst(pfad), ex);
var b = CompletableFuture.supplyAsync(() -> bestellDienst(pfad), ex);
var e = CompletableFuture.supplyAsync(() -> empfehlDienst(pfad), ex);
return "200 {\"kunde\":" + k.join() + ",\"bestellungen\":" + b.join() + ",\"empfehlungen\":" + e.join() + "}";
}
// 3. Weiterleitung an den zuständigen Dienst
return routen.entrySet().stream().filter(r -> pfad.startsWith(r.getKey())).findFirst()
.map(r -> "200 " + r.getValue().apply(pfad))
.orElse("404 Unbekannter Pfad");
}
}
public static void main(String[] args) {
Gateway gw = new Gateway();
String[][] anfragen = {
{ "token-sven", "/api/kunden/815" },
{ "token-sven", "/api/startseite" },
{ "falsch", "/api/bestellungen" },
{ "token-sven", "/api/lager" },
{ "token-sven", "/api/bestellungen" } };
for (String[] a : anfragen) {
System.out.printf("%-12s %-20s -> %s%n", a[0], a[1], gw.anfrage(a[0], a[1]));
}
gw.ex.shutdown();
}
}Ausgabe
token-sven /api/kunden/815 -> 200 {"kunde":815,"name":"Sven"}
token-sven /api/startseite -> 200 {"kunde":{"kunde":815,"name":"Sven"},"bestellungen":{"offeneBestellungen":2},"empfehlungen":["Lampe","Tasse"]}
falsch /api/bestellungen -> 401 Nicht angemeldet
token-sven /api/lager -> 404 Unbekannter Pfad
token-sven /api/bestellungen -> 429 Zu viele AnfragenWorauf achten
Das Gateway ist ein zentraler Engpass und Ausfallpunkt. Es muss hochverfügbar und schnell sein. Geschäftslogik gehört nicht hinein, sonst wird es zum neuen Monolithen. Haben Web, App und Partner sehr unterschiedliche Bedürfnisse, bekommt jede Art von Client ein eigenes Gateway, das Muster Backends for Frontends (BFF). Das Gateway entspricht im Großen der Facade der GoF.
In der Praxis
Spring Cloud Gateway, Kong, Traefik, NGINX, Amazon API Gateway, Azure API Management, Apigee. Auch GraphQL-Server übernehmen oft die Rolle, Daten aus mehreren Diensten zu einer Antwort zusammenzusetzen.
50. Transactional Outbox · Ausgangskorb
Die Idee
Ein Dienst muss oft zweierlei tun: seine Daten speichern und eine Nachricht verschicken, etwa „Bestellung aufgegeben“. Datenbank und Message Broker lassen sich aber nicht zuverlässig in einer Transaktion verbinden. Bei der Transactional Outbox schreibt der Dienst die Nachricht deshalb zunächst in eine eigene Tabelle in seiner Datenbank, die Outbox, und zwar in derselben Transaktion wie die Fachdaten. Ein separater Prozess, das Relay, liest die Outbox, veröffentlicht die Nachrichten und entfernt sie erst danach.
Das Problem
Speichert der Dienst zuerst die Bestellung und schickt dann die Nachricht, kann er dazwischen abstürzen: Die Bestellung ist da, aber das Lager erfährt nie davon. Schickt er zuerst die Nachricht und speichert dann, kann die Speicherung scheitern: Das Lager verschickt Ware für eine Bestellung, die es nicht gibt. Dieses Dual-Write-Problem tritt selten auf, aber wenn, dann mit teuren Folgen.
Im Code
Bestellung 4711 wird zusammen mit ihrem Outbox-Eintrag gespeichert. Bei Bestellung 4712 gibt es einen Absturz vor dem Commit, und beides verschwindet. Das Relay findet den Broker zunächst nicht erreichbar und lässt den Eintrag liegen. Beim nächsten Lauf veröffentlicht es ihn und löscht ihn erst dann. Am Ende passen gespeicherte Bestellungen und zugestellte Nachrichten genau zusammen.
Java-21-Code: Outbox.java
Direkt ausführbar mit java Outbox.java
// Transactional Outbox
// Ausgehende Nachrichten werden in derselben Datenbanktransaktion gespeichert wie die Fachdaten
// und erst danach von einem eigenen Prozess zuverlässig verschickt.
// Start: java Outbox.java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class Outbox {
record Bestellung(long nr, String kunde) {}
record OutboxEintrag(long id, String thema, String inhalt) {}
// --- Simulierte Datenbank mit Transaktionen ---
static final class Datenbank {
final List<Bestellung> bestellungen = new ArrayList<>();
final List<OutboxEintrag> outbox = new ArrayList<>();
void inTransaktion(Runnable arbeit, boolean absturzVorCommit) {
int b = bestellungen.size(), o = outbox.size();
arbeit.run();
if (absturzVorCommit) { // Rollback: beides verschwindet
bestellungen.subList(b, bestellungen.size()).clear();
outbox.subList(o, outbox.size()).clear();
System.out.println(" Absturz vor dem Commit: Bestellung UND Nachricht verworfen");
} else {
System.out.println(" COMMIT: Bestellung und Nachricht gemeinsam gespeichert");
}
}
}
// --- Broker, der zeitweise nicht erreichbar ist ---
static boolean brokerErreichbar = false;
static final List<String> zugestellt = new ArrayList<>();
// --- Relay: liest die Outbox und veröffentlicht, löscht erst nach Erfolg ---
static void relayLauf(Datenbank db) {
Iterator<OutboxEintrag> it = db.outbox.iterator();
while (it.hasNext()) {
OutboxEintrag e = it.next();
if (!brokerErreichbar) {
System.out.println(" Relay: Broker nicht erreichbar, Eintrag " + e.id() + " bleibt in der Outbox");
return;
}
zugestellt.add(e.thema() + ": " + e.inhalt());
it.remove();
System.out.println(" Relay: Eintrag " + e.id() + " veröffentlicht und aus der Outbox entfernt");
}
}
public static void main(String[] args) {
Datenbank db = new Datenbank();
long[] id = { 1 };
System.out.println("Bestellung 4711:");
db.inTransaktion(() -> {
db.bestellungen.add(new Bestellung(4711, "Sven"));
db.outbox.add(new OutboxEintrag(id[0]++, "bestellungen", "BestellungAufgegeben 4711"));
}, false);
System.out.println("Bestellung 4712:");
db.inTransaktion(() -> {
db.bestellungen.add(new Bestellung(4712, "Anna"));
db.outbox.add(new OutboxEintrag(id[0]++, "bestellungen", "BestellungAufgegeben 4712"));
}, true);
System.out.println("\nRelay, Broker ausgefallen:");
relayLauf(db);
System.out.println("Relay, Broker wieder da:");
brokerErreichbar = true;
relayLauf(db);
System.out.println("\nGespeicherte Bestellungen: " + db.bestellungen.stream().map(Bestellung::nr).toList());
System.out.println("Zugestellte Nachrichten: " + zugestellt);
System.out.println("Beides passt zusammen: keine Bestellung ohne Nachricht, keine Nachricht ohne Bestellung.");
}
}Ausgabe
Bestellung 4711:
COMMIT: Bestellung und Nachricht gemeinsam gespeichert
Bestellung 4712:
Absturz vor dem Commit: Bestellung UND Nachricht verworfen
Relay, Broker ausgefallen:
Relay: Broker nicht erreichbar, Eintrag 1 bleibt in der Outbox
Relay, Broker wieder da:
Relay: Eintrag 1 veröffentlicht und aus der Outbox entfernt
Gespeicherte Bestellungen: [4711]
Zugestellte Nachrichten: [bestellungen: BestellungAufgegeben 4711]
Beides passt zusammen: keine Bestellung ohne Nachricht, keine Nachricht ohne Bestellung.Worauf achten
Das Relay garantiert „mindestens einmal“: Stürzt es nach dem Veröffentlichen, aber vor dem Löschen ab, wird die Nachricht ein zweites Mal verschickt. Die Empfänger müssen deshalb Idempotent Receiver sein. Die Reihenfolge der Nachrichten muss erhalten bleiben, wenn sie fachlich wichtig ist. Statt die Outbox regelmäßig abzufragen, kann das Relay auch das Transaktionslog der Datenbank mitlesen (Change Data Capture).
In der Praxis
Debezium mit dem Outbox Event Router für Kafka, Eventuate Tram, Spring Modulith mit seinem Event Publication Registry, eigene Outbox-Tabellen mit einem geplanten Job als Relay.
51. Strangler Fig · Würgefeige
Die Idee
Benannt nach der Würgefeige, die an einem Baum emporwächst, ihn nach und nach umschließt und am Ende ersetzt. Statt ein Altsystem in einem großen Wurf durch ein neues zu ersetzen, wird eine Weiche davorgesetzt, über die alle Anfragen laufen. Dann wird Funktion für Funktion im neuen System gebaut und die Weiche entsprechend umgestellt. Irgendwann bekommt das Altsystem keine Anfragen mehr und kann abgeschaltet werden. Martin Fowler hat das Muster 2004 benannt.
Das Problem
Die Ablösung eines großen Altsystems am Stück, der „Big Bang“, ist eines der riskantesten Vorhaben der Softwareentwicklung: jahrelange Entwicklung ohne Nutzen für die Anwender, ein Stichtag, an dem alles auf einmal funktionieren muss, und keine Möglichkeit, zurückzugehen. Viele solcher Projekte scheitern.
Im Code
Die Weiche hält eine Menge bereits umgezogener Bereiche und leitet jede Anfrage anhand des ersten Pfadteils ans alte oder neue System. In vier Phasen ziehen Katalog, Warenkorb, Bestellung und Rechnung um. Mit zurueck() lässt sich ein Bereich bei Problemen sofort wieder auf das Altsystem stellen.
Java-21-Code: StranglerFig.java
Direkt ausführbar mit java StranglerFig.java
// Strangler Fig
// Ein Altsystem wird schrittweise ersetzt: Eine Weiche leitet Funktion für Funktion auf das neue System um.
// Start: java StranglerFig.java
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class StranglerFig {
interface Anwendung { String bearbeite(String pfad); }
static final class Altsystem implements Anwendung {
public String bearbeite(String pfad) { return "ALT " + pfad; }
}
static final class NeuesSystem implements Anwendung {
public String bearbeite(String pfad) { return "NEU " + pfad; }
}
// --- Die Weiche (Fassade): entscheidet pro Funktion, wer zuständig ist ---
static final class Weiche {
private final Anwendung alt = new Altsystem(), neu = new NeuesSystem();
private final Set<String> migriert = new LinkedHashSet<>();
void migriere(String bereich) { migriert.add(bereich); }
void zurueck(String bereich) { migriert.remove(bereich); } // falls im neuen System etwas schiefgeht
String anfrage(String pfad) {
String bereich = pfad.split("/")[1];
return (migriert.contains(bereich) ? neu : alt).bearbeite(pfad);
}
}
public static void main(String[] args) {
Weiche weiche = new Weiche();
List<String> anfragen = List.of("/katalog/lampe", "/warenkorb/815", "/bestellung/4711", "/rechnung/2026-10");
String[][] phasen = {
{ "Phase 0: alles im Altsystem" },
{ "Phase 1: Katalog umgezogen", "katalog" },
{ "Phase 2: Warenkorb umgezogen", "warenkorb" },
{ "Phase 3: Bestellung und Rechnung umgezogen", "bestellung", "rechnung" } };
for (String[] phase : phasen) {
for (int i = 1; i < phase.length; i++) weiche.migriere(phase[i]);
System.out.println(phase[0]);
anfragen.forEach(a -> System.out.println(" " + weiche.anfrage(a)));
}
System.out.println("\nDas Altsystem bekommt keine Anfragen mehr und kann abgeschaltet werden.");
}
}Ausgabe
Phase 0: alles im Altsystem
ALT /katalog/lampe
ALT /warenkorb/815
ALT /bestellung/4711
ALT /rechnung/2026-10
Phase 1: Katalog umgezogen
NEU /katalog/lampe
ALT /warenkorb/815
ALT /bestellung/4711
ALT /rechnung/2026-10
Phase 2: Warenkorb umgezogen
NEU /katalog/lampe
NEU /warenkorb/815
ALT /bestellung/4711
ALT /rechnung/2026-10
Phase 3: Bestellung und Rechnung umgezogen
NEU /katalog/lampe
NEU /warenkorb/815
NEU /bestellung/4711
NEU /rechnung/2026-10
Das Altsystem bekommt keine Anfragen mehr und kann abgeschaltet werden.Worauf achten
Jeder Schritt ist klein, bringt sofort Nutzen und lässt sich zurücknehmen. Die schwierigste Frage ist meist die Datenhaltung: Solange alt und neu parallel laufen, müssen ihre Daten synchron bleiben, etwa über Change Data Capture oder einen Anti-Corruption Layer (Gruppe 4). Man beginnt sinnvollerweise mit Bereichen, die wenig verflochten sind oder bei denen das Altsystem am meisten Probleme macht. Und man muss das Ende wirklich erreichen: Viele Projekte bleiben auf halbem Weg stehen und betreiben dann dauerhaft zwei Systeme.
In der Praxis
Als Weiche dienen Reverse Proxys und API Gateways (NGINX, Spring Cloud Gateway), Feature Flags im Code oder eine Fassade vor dem Altsystem. Typisch bei der Zerlegung von Monolithen in Microservices und bei der Ablösung von Großrechner-Anwendungen.
52. Sidecar · Beiwagen
Die Idee
Wie ein Beiwagen am Motorrad läuft neben jedem Dienst ein eigener Begleitprozess, der Sidecar. Er teilt sich mit dem Dienst den Host bzw. in Kubernetes den Pod und übernimmt technische Querschnittsaufgaben: Verschlüsselung der Verbindungen, Wiederholungen und Circuit Breaker, Metriken, Weiterleitung von Logs, Konfiguration. Der Dienst selbst enthält nur noch Fachlogik und spricht über localhost mit seinem Sidecar.
Das Problem
In einer Landschaft mit Dutzenden Diensten, womöglich in verschiedenen Programmiersprachen, müsste jeder Dienst Verschlüsselung, Wiederholungslogik, Metriken und Tracing selbst einbauen, über eine Bibliothek pro Sprache. Updates dieser Bibliotheken bedeuten, jeden Dienst neu auszuliefern. Und jedes Team setzt es etwas anders um.
Warum hier kein Code
Der Sidecar ist ein Muster der Betriebsumgebung, nicht des Programmcodes: Er ist ein eigener Prozess, meist ein eigener Container, der neben der Anwendung gestartet wird. Im Java-Code des Dienstes ist er gerade nicht zu sehen. Das ist der Sinn der Sache.
Worauf achten
Jeder Sidecar kostet Speicher, Rechenzeit und eine zusätzliche Station auf jedem Netzwerkweg. Bei Hunderten von Dienstinstanzen summiert sich das. Fehler sind schwerer zu finden, weil ein Teil des Verhaltens nicht mehr im eigenen Code steckt. Neuere Service Meshes bieten deshalb auch Varianten ohne Sidecar an, bei denen ein gemeinsamer Proxy pro Rechner die Aufgabe übernimmt.
In der Praxis
Envoy als Sidecar in den Service Meshes Istio und Consul Connect, Linkerd, Dapr, Log-Sammler wie Fluent Bit als Sidecar-Container, das Ambassador-Muster als Variante für ausgehende Verbindungen.
53. Publish-Subscribe / Message Bus · Veröffentlichen und Abonnieren
Die Idee
Publish-Subscribe ist das Observer-Muster der GoF über Systemgrenzen hinweg: Ein Dienst veröffentlicht Ereignisse in ein Thema (Topic), und beliebig viele andere Dienste abonnieren es und erhalten jeweils eine eigene Kopie. Der Veröffentlichende kennt seine Abonnenten nicht. Ein Message Bus ist die gemeinsame Infrastruktur, über die alle Dienste einer Organisation auf diese Weise kommunizieren, mit vereinbarten Themen und Formaten.
In verteilten Systemen
Pub/Sub ist die Grundlage ereignisgetriebener Microservice-Architekturen: Dienste reagieren auf Ereignisse anderer, statt sie direkt aufzurufen. Das entkoppelt sie zeitlich und organisatorisch. Ein neuer Analysedienst kann sich anschließen, ohne dass der Bestelldienst davon erfährt. Zusammen mit Outbox, Idempotent Receiver und Saga entsteht so ein robustes Zusammenspiel ohne verteilte Transaktionen.
Beide Muster sind bei den Enterprise Integration Patterns ausführlich beschrieben: Publish-Subscribe Channel (Muster 12) und Message Bus (Muster 19).
Im Überblick
| Ziel | Muster | Kurz gesagt |
|---|---|---|
| Robust gegen Ausfälle | Circuit Breaker | bei Dauerausfall aufhören zu rufen |
| Retry mit Backoff | bei kurzen Aussetzern wiederholen | |
| Bulkhead | Ausfälle auf eine Abteilung begrenzen | |
| Konsistent ohne verteilte Transaktion | Saga | Schritte mit Ausgleichsaktionen |
| Transactional Outbox | Daten und Nachricht gemeinsam speichern | |
| Publish-Subscribe | Ereignisse statt direkter Aufrufe | |
| Zugang und Betrieb | API Gateway | ein Einstiegspunkt für alle Clients |
| Sidecar | Technik neben dem Dienst statt darin | |
| Veränderung | Strangler Fig | Altsystem schrittweise ablösen |
Die drei Resilienzmuster werden fast immer zusammen eingesetzt, in einer festen Reihenfolge von außen nach innen: Bulkhead begrenzt die gleichzeitigen Aufrufe, der Circuit Breaker entscheidet, ob überhaupt gerufen wird, Retry wiederholt einzelne Fehlschläge, und jeder einzelne Aufruf hat ein Timeout.