Das Wiederholungsmuster: Aufbau widerstandsfähiger Microservices
In einer Microservices-Architektur kommunizieren Dienste über ein Netzwerk und nicht über In-Memory-Aufrufe. Während diese Entkopplung eine massive horizontale Skalierung und unabhängige Bereitstellungen ermöglicht, bringt sie auch eine große Schwachstelle mit sich: Das Netzwerk ist unzuverlässig.
Bei einem Downstream-Dienst kann es jederzeit zu einem kurzen Netzwerkfehler, einem vorübergehenden CPU-Spitzenwert, einem schnellen Datenbanksperrenkonflikt oder einem rollierenden Update-Neustart kommen. Diese vorübergehenden Ausfälle werden als vorübergehende Fehler bezeichnet.
Wenn Ihr Dienst sofort einen Fehler auslöst und die Anfrage fehlschlägt, sobald ein Downstream-Aufruf fehlschlägt, führen Sie zu einer fragilen Benutzererfahrung. Stattdessen können viele dieser vorübergehenden Fehler automatisch behoben werden, indem man einen Moment wartet und es erneut versucht. Hier kommt das Wiederholungsmuster ins Spiel.
In diesem Leitfaden befassen wir uns mit dem Retry-Muster, seiner Funktionsweise, den Gefahren naiver Implementierungen und der korrekten Implementierung in Java (Resilience4j) und Go.
Die Analogie aus der realen Welt: Wahlwiederholung bei besetzter Leitung
Stellen Sie sich vor, Sie versuchen, einen Freund anzurufen. Sie wählen ihre Nummer, erhalten aber ein Besetztzeichen, da sie gerade ein anderes Gespräch führen.
Geben Sie sofort auf, löschen den Kontakt und gehen davon aus, dass Sie nie wieder mit ihnen sprechen können? Natürlich nicht. Sie legen auf, warten eine Minute und wählen die Nummer erneut. Wenn sie immer noch beschäftigt sind, können Sie fünf Minuten warten, bevor Sie es erneut versuchen.
Schließlich wird der Anruf beendet und Ihr erneuter Versuch ist erfolgreich.
In Microservices:
- Der Aufruf ist eine API-Anfrage an einen Downstream-Dienst.
- Das Besetztsignal ist ein vorübergehender Netzwerkfehler oder eine
503 Service Unavailable-Antwort. - Die Wahlwiederholung ist ein Wiederholungsversuch.
- Die Wartezeit ist die Backoff-Dauer.
Die Gefahr: Naive Wiederholungsversuche und „Wiederholungsstürme“
Die Implementierung eines Wiederholungsmechanismus erscheint auf den ersten Blick trivial: Binden Sie Ihren HTTP-Aufruf einfach in eine for-Schleife ein und versuchen Sie es so lange, bis er erfolgreich ist. Allerdings kann eine naive Wiederholungsimplementierung einen kleinen Schluckauf leicht in einen katastrophalen systemweiten Ausfall verwandeln.
Stellen Sie sich einen Downstream-Dienst vor, der unter einem plötzlichen Anstieg des Datenverkehrs zu kämpfen hat. Die Datenbank läuft mit einer CPU-Auslastung von 99 % und es kommt zu Zeitüberschreitungen bei den Anfragen.
Wenn 100 Client-Dienste alle eine Zeitüberschreitung erkennen und es ohne Wartezeit sofort drei Mal erneut versuchen, verdreifachen sie plötzlich das Datenverkehrsvolumen, das den ohnehin schon überlasteten Downstream-Dienst erreicht. Diese plötzliche Zunahme des Datenverkehrs wird als Retry Storm (oder als Thundering Herd-Problem) bezeichnet.
Anstatt dem Downstream-Dienst bei der Wiederherstellung zu helfen, wird er durch Ihre Wiederholungsversuche immer weiter nach unten gedrückt, sodass er nie wieder aufholen kann.
Die Lösung: Backoff & Jitter
Um Wiederholungsstürme zu verhindern, muss ein belastbares System zwei wesentliche Strategien nutzen: Backoff und Jitter.
1. Backoff-Strategien
Backoff bestimmt, wie lange ein Client warten soll, bevor er einen weiteren Wiederholungsversuch durchführt.
- Fester Backoff: Der Client wartet eine konstante Zeitspanne (z. B. genau 200 ms) zwischen den Versuchen. Auch wenn es einfach ist, besteht dennoch das Risiko synchronisierter Datenverkehrsspitzen.
- Exponentielles Backoff: Die Wartezeit erhöht sich exponentiell mit jedem fehlgeschlagenen Versuch (z. B. 100 ms, 200 ms, 400 ms, 800 ms). Dies gibt dem Downstream-Dienst zunehmend mehr Zeit zur Wiederherstellung, wenn der Fehler weiterhin besteht.
2. Jitter (Zufälligkeit)
Selbst bei exponentiellem Backoff berechnen alle 1.000 Clients genau die gleiche Backoff-Verzögerung, wenn ein Netzwerkfehler dazu führt, dass 1.000 Anforderungen gleichzeitig fehlschlagen. Folglich werden sie alle gleichzeitig in Wellen erneut versuchen und den Downstream-Dienst mit synchronisierten Spitzen treffen.
Jitter löst dieses Problem, indem es der Backoff-Verzögerung eine zufällige Varianz hinzufügt.
Without Jitter (Synchronized Waves):
Time: 0ms -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]
With Jitter (Distributed Traffic):
Time: 0ms -> [1000 requests fail]
Time: 92ms -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)
Durch die Verteilung der Wiederholungsversuche über ein zufälliges Intervall wird die Belastung des Downstream-Dienstes geglättet, sodass dieser ordnungsgemäß wiederhergestellt werden kann.
Die goldene Regel der Wiederholungsversuche: Idempotenz
Bevor Sie Wiederholungsversuche auf einen API-Endpunkt anwenden, müssen Sie eine wichtige Frage stellen: Ist dieser Vorgang idempotent?
Eine idempotente Operation ist eine Operation, bei der das Stellen mehrerer identischer Anfragen genau den gleichen Effekt hat wie das Stellen einer einzelnen Anfrage.
- Idempotent: Ein Benutzerprofil lesen (
GET /users/123), ein gesamtes E-Mail-Feld aktualisieren (PUT /users/123/email) oder ein Element löschen (DELETE /items/456). - Nicht-Idempotent: Erstellen einer neuen Bestellung (
POST /orders) oder Verarbeiten einer Kreditkartenbelastung (POST /payments).
Angenommen, Sie rufen POST /payments an, um einem Kunden 50 $ in Rechnung zu stellen. Das nachgelagerte Zahlungs-Gateway empfängt die Anfrage und belastet die Kreditkarte erfolgreich, doch dann kommt es zu einem Netzwerkfehler, bevor es die 200 OK-Antwort an Sie zurücksenden kann.
Ihr Dienst registriert eine Zeitüberschreitung, geht davon aus, dass der Anruf fehlgeschlagen ist, und versucht es automatisch erneut. Wenn das Zahlungsgateway nicht für die Verarbeitung doppelter Anfragen ausgelegt ist, wird dem Kunden doppelt in Rechnung gestellt.
[!WARNUNG] Versuchen Sie niemals nicht-idempotente Vorgänge erneut, es sei denn, der Downstream-Dienst unterstützt Idempotenzschlüssel (eindeutige Anforderungskennungen, die zum Erkennen und Verwerfen doppelter Transaktionen verwendet werden).
Implementierungsbeispiele
Sehen wir uns an, wie das Wiederholungsmuster in Java und Go implementiert wird.
1. Java (Resilience4j & Spring Boot)
Resilience4j bietet ein robustes, hochgradig konfigurierbares Wiederholungsmodul. Hier erfahren Sie, wie Sie es für einen externen Abrechnungsdienst konfigurieren.
Konfiguration (application.yml)
resilience4j.retry:
instances:
billingService:
maxAttempts: 3
waitDuration: 100ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2.0
enableRandomizedWait: true
randomizedWaitFactor: 0.5
retryExceptions:
- org.springframework.web.client.ResourceAccessException
- io.netty.channel.ConnectTimeoutException
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried
Code-Implementierung
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class PaymentProcessor {
private final RestTemplate restTemplate;
public PaymentProcessor(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
// Apply the configured retry policy with a fallback method
@Retry(name = "billingService", fallbackMethod = "billingFallback")
public String chargeUser(BillingRequest request) {
return restTemplate.postForObject("http://billing-service/charge", request, String.class);
}
// Executed when all retry attempts fail
public String billingFallback(BillingRequest request, Throwable throwable) {
return "Billing service is currently unavailable. Your transaction will be queued.";
}
}
2. Los (Golang)
In Go können wir unter Verwendung von Standardbibliotheks-Timern einen eleganten Wiederholungsläufer mit exponentiellem Backoff und zufälligem Jitter schreiben.
package main
import (
"context"
"errors"
"fmt"
"math/rand"
"time"
)
// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
MaxAttempts int
MinBackoff time.Duration
MaxBackoff time.Duration
}
// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
var err error
for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
err = operation()
if err == nil {
return nil // Success!
}
if attempt == config.MaxAttempts {
break
}
// Calculate exponential backoff
// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
backoff := config.MinBackoff * (1 << (attempt - 1))
if backoff > config.MaxBackoff || backoff <= 0 {
backoff = config.MaxBackoff
}
// Apply Full Jitter: wait randomly between 0 and backoff
jitter := time.Duration(rand.Int63n(int64(backoff)))
fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)
select {
case <-time.After(jitter):
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}
func main() {
config := RetryConfig{
MaxAttempts: 4,
MinBackoff: 100 * time.Millisecond,
MaxBackoff: 2000 * time.Millisecond,
}
// Mock function that fails 3 times and succeeds on the 4th
attempts := 0
mockAPI := func() error {
attempts++
if attempts < 4 {
return errors.New("network timeout (503)")
}
return nil
}
ctx := context.Background()
err := Execute(ctx, config, mockAPI)
if err != nil {
fmt.Printf("Final Outcome: %v\n", err)
} else {
fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
}
}
Wiederholung vs. Leistungsschalter: Wann sollte welcher verwendet werden?
Entwickler verwechseln häufig das Wiederholungsmuster mit dem Leistungsschaltermuster. Obwohl beide darauf abzielen, die Ausfallsicherheit des Systems zu verbessern, behandeln sie grundsätzlich unterschiedliche Arten von Ausfällen:
| Metrisch | Wiederholungsmuster | Leistungsschaltermuster |
|---|---|---|
| Hauptziel | Stellt vorübergehende (kurzlebige) Ausfälle automatisch wieder her. | Verhindert, dass dauerhafte (langlebige) Fehler den Anrufer zum Erliegen bringen. |
| Strategie | Versuchen Sie es sofort oder nach einer kurzen Wartezeit noch einmal. | Sofortiger Ausfall ohne Aufruf des Zieldienstes. |
| Downstream-Auswirkung | Erhöht vorübergehend die Belastung des Downstream-Dienstes. | Schützt den Downstream-Dienst vor Datenverkehr und ermöglicht ihm die Wiederherstellung. |
| Typische Auslöser | Kurze Netzwerkunterbrechungen, Datenbank-Timeouts, Socket-Abbrüche. | Der Downstream-Dienst ist vollständig ausgefallen und es werden kontinuierlich 500 Sekunden oder Zeitüberschreitungen zurückgegeben. |
Das Power-Paar: Beides vereinen
In Produktionssystemen sind diese beiden Muster darauf ausgelegt, zusammen verwendet zu werden.
Wenn Sie eine Downstream-Anfrage stellen, sollte diese zuerst den Circuit Breaker und dann den Retry-Wrapper durchlaufen.
Wenn ein vorübergehender Fehler auftritt, fängt der Wiederholungsmechanismus ihn ab und versucht es erneut. Wenn jedoch der Downstream-Dienst ausfällt, häufen sich die Ausfälle. Der Leistungsschalter erkennt die aufeinanderfolgenden Ausfälle, „löst“ aus und blockiert alle zukünftigen Anrufe.
Wenn Sie nun versuchen, den Dienst aufzurufen, schlägt der Leistungsschalter sofort fehl, wodurch die Wiederholungsschleife vollständig umgangen wird und Ihre Rechenressourcen geschont werden.
Best Practices für das Wiederholungsmuster
- Nur bei vorübergehenden Fehlern erneut versuchen: Überprüfen Sie die HTTP-Statuscodes. Wiederholen Sie
503 Service Unavailable,429 Too Many Requests(unter Berücksichtigung derRetry-After-Header, falls vorhanden) und Netzwerk-Timeouts. Versuchen Sie NICHT erneut,400 Bad Request,401 Unauthorizedoder404 Not Foundzu verwenden – diese werden bei einem erneuten Versuch niemals erfolgreich sein. - Jitter anwenden: Verwenden Sie niemals einen festen Wiederholungstimer, ohne zufälligen Jitter einzuführen.
- Maximale Versuche begrenzen: Nach einem angemessenen Schwellenwert (normalerweise 3 bis 5 Versuche) keine erneuten Versuche mehr durchführen. Unbegrenzte Wiederholungsversuche verbrauchen Ressourcen und verkürzen die Latenz.
- Seien Sie vorsichtig bei kaskadierenden Wiederholungsversuchen: Wenn Dienst A Dienst B aufruft (der es dreimal wiederholt) und Dienst B Dienst C aufruft (der es dreimal wiederholt), kann ein Fehler in C zu $3 \times 3 = 9$ kaskadierenden Aufrufen führen. Wenden Sie Wiederholungsversuche nur an den Grenzen an, an denen sie am sinnvollsten sind.
- Legen Sie immer strenge Timeouts fest: Stellen Sie sicher, dass Ihre Netzwerk-Timeouts kürzer sind als Ihre Backoff-Zeiträume, damit Threads nicht auf unbestimmte Zeit gehalten werden.
Abschluss
Das Retry Pattern ist eine leistungsstarke erste Verteidigungslinie gegen Netzwerkunzuverlässigkeit in Microservice-Architekturen. In Kombination mit Exponential Backoff, Jitter und einem Circuit Breaker können Sie Ihre Systeme vor kaskadierenden Ausfällen schützen und Ihren Benutzern ein nahtloses, selbstheilendes Erlebnis bieten.