Das Bulkhead-Muster: Entwerfen fehlertoleranter Microservices

Das Bulkhead-Muster: Entwerfen fehlertoleranter Microservices

In einer Microservices-Architektur ist eine einzelne Anwendung in Dutzende oder Hunderte unabhängiger, zusammenarbeitender Dienste unterteilt. Während dieses Design die Modularität und Skalierbarkeit verbessert, birgt es auch ein großes Risiko: Ein Ausfall in einem Dienst kann zu einer Kaskade führen und das gesamte System zum Absturz bringen.

Wenn ein Downstream-Dienst träge wird oder nicht mehr reagiert, häufen sich die eingehenden Anfragen an Ihre Upstream-Dienste. Wenn alle den gleichen Speicher, die gleiche CPU oder den gleichen Thread-Pool nutzen, kann eine langsame Abhängigkeit schnell alle verfügbaren Ressourcen erschöpfen und zum Absturz Ihrer gesamten Anwendung führen.

Dieser kaskadierende Fehler wird als Dominoeffekt bezeichnet. Um dies zu verhindern, verwenden Systemarchitekten das Bulkhead Pattern.

In diesem Leitfaden werden wir untersuchen, was das Bulkhead-Muster ist, wie es funktioniert und wie es mithilfe einfacher Analogien, Architekturkonzepte und Codebeispiele in Java (Resilience4j) und Go implementiert wird.


Die Analogie aus der realen Welt: Wasserdichte Schiffsschotte

Der Name dieses Musters stammt aus der Schiffbauindustrie.

Ein Schott ist eine wasserdichte Wand, die in den Rumpf eines Schiffes eingebaut wird. Anstatt einen einzigen, riesigen offenen Raum im Schiffsrumpf zu haben, ist der Innenraum in mehrere unabhängige, versiegelte Abteilungen unterteilt.

Bulkhead-Pattern-Architekturdiagramm, das gemeinsam genutzte und isolierte Thread-Pools zeigt

Wenn das Schiff mit einem Hindernis kollidiert und sein Rumpf durchbricht, strömt Wasser in den beschädigten Raum. Aufgrund der wasserdichten Schotte bleibt das Wasser jedoch in diesem einzigen Fach. Der Rest des Schiffes bleibt trocken und schwimmfähig, sodass es über Wasser bleiben und sich in Sicherheit bringen kann.

Ohne Schotten würde das Wasser ungehindert durch den gesamten Rumpf fließen und das Schiff schließlich versenken.

Im Software-Engineering:

  • Das Schiff ist Ihre gesamte Anwendung oder Dienstleistung.
  • Die Compartments sind isolierte Ressourcenpools (Threads, Verbindungen, CPU).
  • Der Hull Breach ist ein Fehler oder eine Verlangsamung in einem nachgelagerten Mikrodienst.
  • Die Überschwemmung ist eine Ressourcenerschöpfung.

Das Problem: Gemeinsam genutzte Ressourcenpools und Thread-Erschöpfung

Um zu verstehen, warum Schotte notwendig sind, schauen wir uns an, was passiert, wenn Ressourcen global gemeinsam genutzt werden.

Stellen Sie sich ein API-Gateway oder einen Webserver vor, der Benutzeranfragen verarbeitet. Es verfügt über einen einzigen globalen Thread-Pool mit 100 Threads zur Verarbeitung aller eingehenden Anrufe. Der Server interagiert mit drei Downstream-Diensten:

  1. Katalogservice (schnell, liest Produktliste)
  2. Zahlungsservice (schnell, verarbeitet den Checkout)
  3. Empfehlungsservice (langsam, berechnet personalisierte Artikel)

Normalerweise funktioniert alles gut. Angenommen, der Empfehlungsdienst leidet unter einem Datenbank-Deadlock und benötigt statt 200 Millisekunden 30 Sekunden für die Antwort.

Folgendes passiert:

  1. Benutzer besuchen weiterhin die Homepage und lösen Anfragen an den Empfehlungsdienst aus.
  2. Der Server weist jeder Anfrage einen Thread aus dem globalen Pool zu.
  3. Da der Empfehlungsdienst langsam ist, warten diese Threads auf Antworten.
  4. Innerhalb von Sekunden warten alle 100 Threads im Pool auf den Empfehlungsdienst.
  5. Wenn ein neuer Benutzer versucht, den Katalog auszuchecken oder anzuzeigen, hat der Server keine Threads mehr, um seine Anfrage zu verarbeiten.

Obwohl die Katalog- und Zahlungsdienste völlig fehlerfrei sind, sind sie jetzt nicht erreichbar, da der langsame Empfehlungsdienst den gemeinsamen Thread-Pool erschöpft hat. Das gesamte System ist offline gegangen.


Die Lösung: Das Bulkhead-Muster

Das Bulkhead Pattern löst dieses Problem, indem es Ressourcenpools so partitioniert, dass ein Ausfall in einem Bereich keine Auswirkungen auf die anderen hat.

Anstelle eines einzelnen globalen Pools weisen wir jedem Dienst oder jeder Downstream-Abhängigkeit separate, begrenzte Pools zu.

Wenn wir 10 Threads speziell für den Empfehlungsdienst zuweisen, können höchstens 10 Threads blockiert werden, die darauf warten. Wenn der Empfehlungsdienst langsamer wird, sind diese 10 Threads erschöpft und nachfolgende Empfehlungsanfragen werden sofort abgelehnt (ausfallsicher).

Die restlichen 90 Threads sind jedoch weiterhin für die Katalog- und Zahlungsdienste reserviert. Benutzer können weiterhin Produkte durchsuchen und Einkäufe tätigen, auch wenn das Empfehlungs-Widget vorübergehend nicht verfügbar ist.


Arten der Schottisolierung

Es gibt hauptsächlich zwei Möglichkeiten, Bulkheads in Softwaresystemen zu implementieren:

1. Thread-Pool-Isolierung

In diesem Modell wird jeder Downstream-Abhängigkeit ein eigener dedizierter Thread-Pool und eine eigene Ausführungswarteschlange zugewiesen.

Thread-Pool-Isolationsdiagramm, das eingehende Anforderungen in der Warteschlange für Arbeitsthreads zeigt
  • So funktioniert es: Der Hauptanwendungsthread übergibt die Aufgabe an einen bestimmten Thread-Pool. Wenn der Pool voll ist, wird die Anfrage entweder in die Warteschlange gestellt oder abgelehnt.
  • Vorteile: Bietet vollständige Isolation. Wenn ein Dienst langsam wird, ist nur sein Thread-Pool betroffen. Threads werden auf Betriebssystem-/JVM-Ebene isoliert.
  • Nachteile: Führt aufgrund von Thread-Planung, Kontextwechsel und Warteschlangenverwaltung zu zusätzlichem CPU-Overhead.

2. Semaphor-Isolierung

Anstatt neue Thread-Pools zu erstellen, verwendet die Semaphor-Isolation einen Zähler (einen Semaphor), um die Anzahl gleichzeitiger Aufrufe zu begrenzen, die für einen bestimmten Dienst zulässig sind.

Semaphor-Isolationsdiagramm, das Anforderungsthreads zeigt, die Aufgaben ausführen, nachdem sie eine Genehmigung erhalten haben
  • So funktioniert es: Wenn eine Anfrage startet, versucht sie, eine Genehmigung vom Semaphor zu erhalten. Wenn eine Genehmigung verfügbar ist, wird die Anforderung im aufrufenden Thread ausgeführt und die Genehmigung freigegeben, wenn sie abgeschlossen ist. Liegen keine Genehmigungen vor, wird der Antrag sofort abgelehnt.
  • Vorteile: Sehr leichtgewichtig mit praktisch keinem Overhead, da kein Thread-Kontextwechsel erforderlich ist.
  • Nachteile: Keine Thread-Trennung. Wenn ein Anruf auf einem Netzwerk-Socket ohne ordnungsgemäße Zeitüberschreitung blockiert wird, kann er dennoch den aufrufenden Thread blockieren.

Implementierungsbeispiele

Schauen wir uns an, wie man Bulkheads in zwei beliebten Back-End-Sprachen implementiert.

1. Java (Resilience4j & Spring Boot)

Resilience4j ist eine leichte, benutzerfreundliche Fehlertoleranzbibliothek, die für Java entwickelt wurde. Im Folgenden erfahren Sie, wie Sie ein Bulkhead für einen nachgelagerten Zahlungsdienst in einer Spring Boot-Anwendung konfigurieren.

Konfiguration (application.yml)

resilience4j.bulkhead:
  instances:
    paymentService:
      maxConcurrentCalls: 10
      maxWaitDuration: 10ms

resilience4j.threadpoolbulkhead:
  instances:
    paymentService:
      maxThreadPoolSize: 10
      coreThreadPoolSize: 5
      queueCapacity: 20

Code-Implementierung

import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class OrderService {

    private final RestTemplate restTemplate;

    public OrderService(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply semaphore bulkhead
    @Bulkhead(name = "paymentService", fallbackMethod = "paymentFallback")
    public String processPayment(OrderDetails details) {
        return restTemplate.postForObject("http://payment-service/charge", details, String.class);
    }

    // Fallback method executed when the bulkhead is full
    public String paymentFallback(OrderDetails details, Throwable throwable) {
        return "Payment service is currently busy. Please try again later.";
    }
}

2. Los (Golang)

In Go benötigen wir nicht unbedingt ein umfangreiches Framework, da die Sprache native Parallelitätsprimitive wie Goroutinen und gepufferte Kanäle bereitstellt. Wir können einen sauberen Semaphor-Schott mithilfe eines gepufferten Kanals implementieren:

package main

import (
	"errors"
	"fmt"
	"net/http"
	"time"
)

// Bulkhead represents a concurrency limiter
type Bulkhead struct {
	semaphore chan struct{}
}

// NewBulkhead initializes a bulkhead with a max concurrency limit
func NewBulkhead(maxConcurrency int) *Bulkhead {
	return &Bulkhead{
		semaphore: make(chan struct{}, maxConcurrency),
	}
}

// Execute runs the task if resource permit is available, otherwise returns error
func (b *Bulkhead) Execute(task func() error) error {
	select {
	case b.semaphore <- struct{}{}:
		// Acquired permit
		defer func() { <-b.semaphore }() // Release permit
		return task()
	default:
		// Bulkhead is full, reject immediately
		return errors.New("bulkhead is full: request rejected")
	}
}

func main() {
	// Allow maximum of 3 concurrent calls
	paymentBulkhead := NewBulkhead(3)

	mockTask := func() error {
		fmt.Println("Processing payment...")
		time.Sleep(2 * time.Second) // Simulate network delay
		return nil
	}

	// Simulate 5 rapid requests
	for i := 1; i <= 5; i++ {
		go func(reqID int) {
			err := paymentBulkhead.Execute(mockTask)
			if err != nil {
				fmt.Printf("Request %d failed: %v\n", reqID, err)
			} else {
				fmt.Printf("Request %d completed successfully\n", reqID)
			}
		}(i)
	}

	// Keep main alive to watch output
	time.Sleep(3 * time.Second)
}

Häufige Anwendungsfälle für das Bulkhead-Muster

Hier sind einige typische Szenarien, in denen die Implementierung eines Schottmusters von entscheidender Bedeutung ist:

  • API-Gateway-Routing: Routen für verschiedene Backend-Dienste isolieren. Wenn der Empfehlungsdienst ausfällt, bleiben die Bestelldienstrouten auf dem Gateway voll funktionsfähig.
  • Datenbankverbindungspools: Aufteilung der Datenbankverbindungspools nach Dienst oder Mandant. Eine Flut umfangreicher analytischer Abfragen von einem Mandanten führt nicht dazu, dass alle verfügbaren Verbindungshandles aufgebraucht werden, sodass Transaktionsabfragen für andere Mandanten eingespart werden.
  • SaaS-Anwendungen mit mehreren Mandanten: Trennung von Rechenressourcen oder Ausführungswarteschlangen für Premium- und kostenlose Mandanten. Ressourcenspitzen im Free-Tarif führen nicht dazu, dass Anfragen im Premium-Tarif nicht an CPU oder Arbeitsspeicher gehindert werden.
  • API-Integrationen von Drittanbietern: Bereitstellung separater HTTP-Client-Pools für externe Zahlungsgateways, Versandanbieter oder Benachrichtigungs-Engines. Wenn ein Drittanbieterdienst langsamer wird, werden andere externe Interaktionen ohne Blockaden fortgesetzt.

Warum Kafka/Message Brokers das Bulkhead-Muster nicht ersetzen können

Eine häufig gestellte Frage lautet: „Wenn wir Nachrichtenbroker wie Apache Kafka haben, warum brauchen wir dann das Bulkhead-Muster? Können wir nicht einfach Warteschlangen zum Puffern von Anforderungen verwenden?“

Während Nachrichtenbroker Systeme entkoppeln, können sie das Bulkhead-Muster nicht ersetzen. Hier ist der Grund:

1. Synchrone vs. asynchrone Kommunikation

Kafka ist für asynchrone, ereignisgesteuerte Architekturen konzipiert. Der Produzent sendet eine Nachricht an ein Thema und der Verbraucher verarbeitet sie schließlich. Allerdings erfordern benutzerorientierte Anwendungen häufig synchrone (Anfrage-Antwort)-Kommunikation (z. B. das Laden eines Produktkatalogs oder das Belasten einer Kreditkarte über eine REST/gRPC-API). Die Einführung von Kafka hier erfordert komplexe Anfrage-Antwort-Muster, was zu hoher Latenz und Overhead führt. Bulkheads wurden speziell zum Schutz dieser synchronen Ausführungsthreads in Echtzeit entwickelt.

2. Thread-Hunger in Kafka-Konsumenten

Selbst wenn Ihr System vollständig ereignisgesteuert ist und Kafka verwendet, benötigen Sie dennoch Schotten! Angenommen, ein einzelner Consumer-Microservice lauscht auf mehrere Kafka-Themen (z. B. user-registrations und video-transcoding). Wenn der Verbraucher alle seine internen Arbeitsthreads für die Verarbeitung einer großen Menge langsamer video-transcoding-Jobs zuweist, kommt es zu einem Thread-Ausfall. Der Verbraucher kann keine einfachen user-registrations-Nachrichten verarbeiten, auch wenn diese Partition fehlerfrei ist. Sie benötigen weiterhin interne Bulkheads (separate Thread-Pools) innerhalb des Verbraucherdienstes, um die Arbeit zu isolieren.

3. Clientseitiger Overhead und Fail-Fast-Anforderung

Wenn ein Downstream-Dienst ausfällt, ermöglicht ein Bulkhead dem aufrufenden Dienst einen schnellen Ausfall und die sofortige Rückgabe einer Fallback-Antwort. Wenn Sie stattdessen alles in Kafka in die Warteschlange stellen, kann die Warteschlange ins Unendliche wachsen, was zu veralteten Anforderungen, hohem Speicherverbrauch und verzögerten Zeitüberschreitungen bei der Systemwiederherstellung führt.

Kurz gesagt: Kafka entkoppelt die Kommunikation zwischen Systemen über das Netzwerk, während Bulkheads die Ressourcenausführung innerhalb einer laufenden Anwendungsinstanz isolieren. Sie ergänzen sich und schließen sich nicht gegenseitig aus.


Best Practices bei der Verwendung von Schotten

  • Immer Zeitüberschreitungen festlegen: Ein Bulkhead schränkt die Parallelität ein, löst jedoch keine langsamen Socket-Lesevorgänge. Kombinieren Sie Bulkheads mit strengen Netzwerk-Timeouts, um Threads so schnell wie möglich freizugeben.
  • Kombination mit Leistungsschaltern: Verwenden Sie Schottwände neben Leistungsschaltern. Wenn ein Schott anfängt, Anfragen ständig abzulehnen, sollte der Leistungsschalter auslösen, um den Verkehr vollständig zu stoppen und dem nachgeschalteten Service Raum zur Wiederherstellung zu geben.
  • Pool-Sättigung überwachen: Implementieren Sie Warnungen zu den Längen Ihrer Bulkhead-Warteschlangen und der Anzahl aktiver Threads. Wenn ein Schott ständig voll ist, müssen Sie möglicherweise Ihre Infrastruktur skalieren oder den Downstream-Service optimieren.
  • Größen individuell anpassen: Verwenden Sie keine Einheitsgröße, die für alle passt. Messen Sie die Latenz und Anforderungsrate jeder Abhängigkeit, um die richtigen Bulkhead-Grenzwerte zu ermitteln.

Abschluss

Das Bulkhead-Muster ist ein wesentliches Entwurfsmuster für den Aufbau belastbarer Cloud-Systeme. Durch die Partitionierung Ihrer Ressourcen isolieren Sie Ausfälle, verhindern Kaskadeneffekte und stellen sicher, dass ein lokalisierter Fehler nicht zu einem globalen Ausfall wird.