Resilience4j in Spring Boot: Von null zu produktionsreifer Fehlertoleranz

Veröffentlicht auf lama-space.com, Medium und Java SPEKTRUM.
Warum dein Microservice ausfallen wird — und warum das in Ordnung ist
Jedes verteilte System fällt irgendwann aus. Ein nachgelagerter Service läuft in ein Timeout. Eine Datenbank ist kurzzeitig nicht erreichbar. Eine Drittanbieter-API drosselt dich bei Spitzenlast. In einem Monolithen sind solche Ausfälle eingedämmt. In einer Microservice-Architektur kann eine einzige langsame Abhängigkeit durch das gesamte System kaskadieren und Services lahmlegen, die mit dem ursprünglichen Problem nichts zu tun hatten.
Das nennt man einen kaskadierenden Ausfall — und er ist der größte Zuverlässigkeitskiller in großen Spring-Boot-Anwendungen.
Resilience4j ist die Antwort darauf. Es ist eine leichtgewichtige, modulare Fehlertoleranz-Bibliothek, speziell für Java 8+ und funktionale Programmierung gebaut. Es hat das inzwischen abgekündigte Netflix Hystrix als De-facto-Standard abgelöst und integriert sich nativ in Spring Boot, Micrometer-Metriken und Spring Cloud.
In diesem Artikel gehen wir von einem einfachen Circuit Breaker bis zu einem vollständigen Produktionsmuster — mit echtem Code, echter Konfiguration und echten Fehlern, die es zu vermeiden gilt.
Das Problem, das wir lösen
Stell dir folgende Architektur vor:
OrderService → PaymentService → BankingAPI (external, slow)→ InventoryService → Database→ NotificationService → EmailProvider
Wenn BankingAPI plötzlich 8 Sekunden statt 200ms braucht:
PaymentService-Threads stauen sich beim WartenOrderService-Threads stauen sich beim Warten aufPaymentService- Der gesamte Bestellablauf verschlechtert sich
- Nutzer sehen Timeouts auf völlig unabhängigen Seiten
- Der Bereitschaftsdienst wird um 2 Uhr nachts gepiept
Resilience4j verhindert diese Kettenreaktion mit fünf Kernmodulen. Wir behandeln alle fünf.
Setup
Maven-Abhängigkeit
<!-- Core --><dependency><groupId>io.github.resilience4j</groupId><artifactId>resilience4j-spring-boot3</artifactId><version>2.2.0</version></dependency><!-- Required for annotations to work --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency><!-- Metrics exposure (optional but recommended) --><dependency><groupId>io.micrometer</groupId><artifactId>micrometer-registry-prometheus</artifactId></dependency>
Hinweis: Für Spring Boot 3.x resilience4j-spring-boot3 verwenden. Für Spring Boot 2.x resilience4j-spring-boot2.
Modul 1: Circuit Breaker
Der Circuit Breaker ist das wichtigste Muster. Er überwacht Aufrufe an einen externen Service und "öffnet" den Kreis, sobald Fehler einen Schwellenwert überschreiten — weitere Aufrufe werden sofort abgewiesen, damit der nachgelagerte Service Zeit zur Erholung bekommt.
Die drei Zustände
CLOSED → Normalbetrieb, Aufrufe laufen durch↓ (Fehlerrate überschreitet Schwellenwert)OPEN → Aufrufe werden sofort abgelehnt, Fallback wird aufgerufen↓ (nach waitDurationInOpenState)HALF-OPEN → begrenzte Aufrufe werden zum Testen der Erholung durchgelassen↓ (bei Erfolg)CLOSED → zurück zum Normalbetrieb
Konfiguration (application.yml)
resilience4j:circuitbreaker:instances:paymentService:# How many calls to sample before calculating failure rateslidingWindowSize: 10# Open circuit when 50% of calls failfailureRateThreshold: 50# Also open if 60% of calls are too slowslowCallRateThreshold: 60# "Too slow" = longer than 2 secondsslowCallDurationThreshold: 2s# Stay open for 10 seconds before trying againwaitDurationInOpenState: 10s# Allow 3 test calls in HALF-OPEN statepermittedNumberOfCallsInHalfOpenState: 3# Minimum calls before circuit can open (avoids opening on 1/1 failures)minimumNumberOfCalls: 5# Which exceptions count as failuresrecordExceptions:- java.io.IOException- java.util.concurrent.TimeoutException- feign.FeignException# Which exceptions to ignore (e.g. business exceptions)ignoreExceptions:- com.example.exceptions.BusinessValidationException
Java-Implementierung
@Service@RequiredArgsConstructor@Slf4jpublic class PaymentService {private final BankingApiClient bankingApiClient;private final PaymentRepository paymentRepository;@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")public PaymentResponse processPayment(PaymentRequest request) {log.info("Processing payment for orderId: {}", request.getOrderId());return bankingApiClient.charge(request);}// Fallback method — same signature + Throwable parameterprivate PaymentResponse paymentFallback(PaymentRequest request, Throwable ex) {log.warn("Payment circuit open or failed for orderId: {}. Cause: {}",request.getOrderId(), ex.getMessage());// Option 1: Queue for async retrypaymentRepository.saveForRetry(request);return PaymentResponse.pending(request.getOrderId(),"Payment queued. You will be notified when processed.");// Option 2: Return cached/default response// return PaymentResponse.degraded(request.getOrderId());}}
Wichtig: Die Fallback-Methode muss in der gleichen Klasse liegen, den gleichen Rückgabetyp, die gleichen Parameter plus ein Throwable am Ende haben. Stimmt die Signatur nicht, ignoriert Spring den Fallback stillschweigend.
Modul 2: Retry
Das Retry-Modul wiederholt einen fehlgeschlagenen Aufruf automatisch eine konfigurierbare Anzahl an Malen, optional mit exponentiellem Backoff. Einsetzen für transiente Fehler wie kurze Netzwerkaussetzer.
resilience4j:retry:instances:inventoryService:maxAttempts: 3waitDuration: 500ms# Exponential backoff: 500ms, 1000ms, 2000msenableExponentialBackoff: trueexponentialBackoffMultiplier: 2# Only retry on these exceptionsretryExceptions:- java.io.IOException- org.springframework.web.client.ResourceAccessException# Never retry on these (business errors)ignoreExceptions:- com.example.exceptions.InsufficientStockException
@Servicepublic class InventoryService {private final InventoryClient inventoryClient;@Retry(name = "inventoryService", fallbackMethod = "inventoryFallback")@CircuitBreaker(name = "inventoryService", fallbackMethod = "inventoryFallback")public StockResponse checkStock(String productId) {return inventoryClient.getStock(productId);}private StockResponse inventoryFallback(String productId, Throwable ex) {log.error("Inventory check failed after retries for product: {}", productId);// Return cached stock data or conservative estimatereturn StockResponse.unknown(productId);}}
Retry + CircuitBreaker kombinieren: @Retry immer innerhalb von @CircuitBreaker platzieren (Retry wird zuerst ausgewertet, dann CircuitBreaker). Die Reihenfolge der Annotationsverarbeitung ist wichtig — Retry läuft, bevor CircuitBreaker den Fehler zählt.
Modul 3: Rate Limiter
Schützt den eigenen Service davor, überlastet zu werden — entweder durch externe Clients, die ihn aufrufen, oder weil man selbst eine nachgelagerte API mit Rate Limits bombardiert.
resilience4j:ratelimiter:instances:emailProvider:# Allow max 10 calls per 1 secondlimitForPeriod: 10limitRefreshPeriod: 1s# Wait up to 500ms for a permission before throwingtimeoutDuration: 500ms
@Servicepublic class NotificationService {@RateLimiter(name = "emailProvider", fallbackMethod = "emailFallback")public void sendEmail(EmailRequest request) {emailProviderClient.send(request);}private void emailFallback(EmailRequest request, RequestNotPermitted ex) {log.warn("Rate limit reached, queuing email for: {}", request.getRecipient());emailQueue.add(request);}}
Modul 4: Bulkhead
Der Bulkhead isoliert Ressourcen, damit eine langsame Abhängigkeit nicht alle verfügbaren Threads verbraucht und den Rest der Anwendung aushungert. Benannt nach den wasserdichten Abteilen eines Schiffs — läuft eines voll, bleiben die anderen trocken.
Zwei Typen:
SemaphoreBulkhead (Standard) — begrenzt gleichzeitige Aufrufe über ein Semaphore:
resilience4j:bulkhead:instances:reportingService:# Max 5 concurrent calls to reporting (slow, resource-heavy)maxConcurrentCalls: 5# Wait up to 100ms for a slot before rejectingmaxWaitDuration: 100ms
ThreadPoolBulkhead — gibt der Abhängigkeit einen eigenen isolierten Thread-Pool (besser für blockierende Aufrufe):
resilience4j:thread-pool-bulkhead:instances:reportingService:maxThreadPoolSize: 5coreThreadPoolSize: 3queueCapacity: 10
@Servicepublic class ReportingService {@Bulkhead(name = "reportingService", fallbackMethod = "reportFallback")public Report generateReport(ReportRequest request) {// Slow, resource-intensive operationreturn reportGenerator.generate(request);}private Report reportFallback(ReportRequest request, BulkheadFullException ex) {return Report.queued("Report is being generated. Check back in a few minutes.");}}
Modul 5: TimeLimiter
Erzwingt ein Timeout für asynchrone Operationen. Nützlich, wenn der zugrunde liegende Client kein eigenes Timeout konfiguriert hat (zum Beispiel RestTemplate ohne Timeout).
resilience4j:timelimiter:instances:externalApiCall:timeoutDuration: 3scancelRunningFuture: true
@Servicepublic class ExternalDataService {private final ExternalApiClient externalApiClient;@TimeLimiter(name = "externalApiCall", fallbackMethod = "dataFallback")@CircuitBreaker(name = "externalApiCall")public CompletableFuture<DataResponse> fetchExternalData(String query) {return CompletableFuture.supplyAsync(() -> externalApiClient.fetch(query));}// TimeLimiter requires CompletableFuture — fallback must also return itprivate CompletableFuture<DataResponse> dataFallback(String query, Throwable ex) {return CompletableFuture.completedFuture(DataResponse.cached(query));}}
Muster kombinieren: Der vollständige Produktions-Stack
In einer echten Großanwendung kombiniert man alle Module. Die empfohlene Reihenfolge der Annotationen:
@TimeLimiter(name = "paymentService")@Bulkhead(name = "paymentService")@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")@Retry(name = "paymentService")@RateLimiter(name = "paymentService")public CompletableFuture<PaymentResponse> processPayment(PaymentRequest request) {...}
Die Ausführungsreihenfolge (von innen nach außen): RateLimiter → Retry → CircuitBreaker → Bulkhead → TimeLimiter
Monitoring im Produktivbetrieb
Hier hören die meisten Tutorials auf — aber Produktivbetrieb ohne Observability ist Blindflug.
Metriken für Actuator freigeben
management:endpoints:web:exposure:include: health, metrics, prometheushealth:circuitbreakers:enabled: trueratelimiters:enabled: true
Health-Endpoint-Antwort
Öffnet sich ein Circuit, spiegelt Spring Boot Actuators /actuator/health das automatisch wider:
{"status": "DOWN","components": {"circuitBreakers": {"status": "DOWN","details": {"paymentService": {"status": "DOWN","details": {"state": "OPEN","failureRate": "60.0%","slowCallRate": "0.0%","bufferedCalls": 10,"failedCalls": 6}}}}}}
Wichtige Prometheus/Grafana-Metriken im Blick behalten
# Circuit breaker state (0=CLOSED, 1=OPEN, 2=HALF_OPEN)resilience4j_circuitbreaker_state{name="paymentService"}# Failure rate percentageresilience4j_circuitbreaker_failure_rate{name="paymentService"}# Call outcomesresilience4j_circuitbreaker_calls_total{name="paymentService", kind="successful"}resilience4j_circuitbreaker_calls_total{name="paymentService", kind="failed"}resilience4j_circuitbreaker_calls_total{name="paymentService", kind="not_permitted"}# Retry attemptsresilience4j_retry_calls_total{name="inventoryService", kind="successful_with_retry"}# Bulkhead available slotsresilience4j_bulkhead_available_concurrent_calls{name="reportingService"}
Ein Grafana-Dashboard mit diesen Metriken aufbauen. Einen Alert setzen, wenn failure_rate > 40% — man will es wissen, bevor der Circuit öffnet.
Häufige Fehler (und wie man sie vermeidet)
1. AOP-Abhängigkeit vergessen
Annotationen tun ohne spring-boot-starter-aop stillschweigend gar nichts.
2. Falsche Fallback-Signatur
Die Fallback-Methode muss exakt passen: gleiche Parameter + Throwable als letzter Parameter. Falsche Signatur = kein Fallback, nur eine Exception.
3. Annotierte Methoden intern aufrufen
// WRONG — AOP proxy is bypassed, Resilience4j is not appliedpublic void doSomething() {this.processPayment(request); // same class, no proxy}// CORRECT — inject self or use programmatic API@Autowiredprivate PaymentService self;public void doSomething() {self.processPayment(request);}
4. slidingWindowSize zu klein gewählt
Ein slidingWindowSize: 5 mit minimumNumberOfCalls: 5 bedeutet, dass der Circuit schon nach 3 Fehlern in 5 Aufrufen öffnet. In Systemen mit hohem Traffic mag das beabsichtigt sein, aber bei Services mit wenig Traffic öffnet das den Circuit schon bei ganz normalem Rauschen. An das tatsächliche Traffic-Volumen anpassen.
5. Business-Exceptions nicht von technischen Exceptions unterscheiden
BusinessValidationException (falsche Eingabe vom Nutzer) ist kein Fehler des nachgelagerten Service. Immer zu ignoreExceptions hinzufügen — sonst öffnen Nutzerfehler den eigenen Circuit.
6. Retry ohne exponentiellen Backoff bei instabilen Services
Sofortige Wiederholungen bei einem angeschlagenen Service machen es schlimmer, nicht besser. In Produktion immer enableExponentialBackoff: true verwenden.
Praxisbeispiel: SAP-Integration
Wer über REST mit SAP integriert (ein gängiges Szenario in Logistik und Enterprise-Java), kennt das Problem: SAP-Systeme können während Wartungsfenstern langsam oder nicht erreichbar sein. Eine praxistaugliche Konfiguration:
resilience4j:circuitbreaker:instances:sapIntegration:slidingWindowSize: 20failureRateThreshold: 40slowCallRateThreshold: 50slowCallDurationThreshold: 5s # SAP is allowed to be slowwaitDurationInOpenState: 30s # Give SAP time to recoverminimumNumberOfCalls: 10recordExceptions:- java.io.IOException- org.springframework.web.client.HttpServerErrorException- feign.RetryableExceptionignoreExceptions:- com.example.sap.SapValidationExceptionretry:instances:sapIntegration:maxAttempts: 2 # Don't hammer SAP with retrieswaitDuration: 2senableExponentialBackoff: trueexponentialBackoffMultiplier: 2bulkhead:instances:sapIntegration:maxConcurrentCalls: 10 # Limit concurrent SAP connectionsmaxWaitDuration: 200ms
Zusammenfassung
| Modul | Schützt vor | Einsetzen wenn |
|---|---|---|
| Circuit Breaker | Kaskadierende Ausfälle | Bei jedem externen Service-Aufruf |
| Retry | Transiente Netzwerkfehler | Nur bei idempotenten Operationen |
| Rate Limiter | Überlastung / API-Limits | Externe APIs mit Rate Limits |
| Bulkhead | Thread-Aushungerung | Langsame oder ressourcenintensive Abhängigkeiten |
| TimeLimiter | Unbegrenzte Hänger | Async-Aufrufe ohne Timeout im Client |
Resilience4j ist keine Wunderwaffe. Es verlangt, sorgfältig zu überlegen, was ein Fehler für jede einzelne Abhängigkeit bedeutet, was ein akzeptabler Fallback ist und wie man Schwellenwerte auf das tatsächliche Traffic-Volumen abstimmt. Die Muster sind einfach. Das Tuning ist die eigentliche Ingenieursarbeit.
Das vollständige Beispielprojekt zu diesem Artikel ist auf GitHub verfügbar: github.com/mohammedahmadi/resilience4j-spring-demo (folgt in Kürze).
Mohammed Ahmadi
Software Developer
Empfohlen
- JBang: Java als Skriptsprache
- Eine Schritt-für-Schritt-Anleitung aus der Praxis zur Absicherung der Unternehmens-zu-Unternehmens-API-Kommunikation mit OAuth 2.0
- End-to-End-Tests mit Playwright — schnelles Setup & Best Practices
- Visualisierung von SBOMs mit dem Dependency Radar: ein praxisorientierter Ansatz für Dependency Management