Circuit Breaker Pattern: Resiliente Microservices ohne Kaskadierende Fehler

Praxiserprobte Implementierung des Circuit Breaker Patterns zur Vermeidung von Kaskadenfehlern in verteilten Systemen

Letzten Monat hat unser Payment Service die gesamte Plattform für 47 Minuten lahmgelegt. Nicht weil er ausgefallen ist - das wäre managebar gewesen. Er ist langsam ausgefallen. Jede Anfrage brauchte 30 Sekunden für den Timeout, was einen Stau verursachte, der sich durch 12 andere Services zurückstaute. Klassischer Kaskadenausfall. So haben wir es mit dem Circuit Breaker Pattern gefixt, und was ich über Resilienz gelernt hab, nachdem ich zu viele Male um 3 Uhr morgens verteilte Systeme debuggen musste.

Das Problem: Wenn langsam schlimmer als tot ist#

Stell dir vor: Die API deines Payment Providers antwortet langsam. Nicht down, braucht nur 20-30 Sekunden pro Request statt der üblichen 200ms. Dein Service wartet brav. Währenddessen stauen sich eingehende Requests. Thread Pools erschöpfen sich. Memory-Verbrauch explodiert. Schließlich wird dein gesunder Service ungesund, und die Infektion breitet sich upstream aus.

Ich hab gesehen, wie dieses Pattern ganze Plattformen gekillt hat. Das Schlimmste? Dein Monitoring zeigt, dass alle Services "up" sind - sie antworten nur nicht.

Circuit Breaker: Das Sicherheitsventil deines Systems#

Das Circuit Breaker Pattern funktioniert wie ein Leitungsschutzschalter in deinem Haus. Wenn was schiefläuft, löst er aus und verhindert, dass sich der Schaden ausbreitet. Aber anders als dein Hausschalter ist dieser smart - er kann testen, ob das Problem behoben ist und sich automatisch erholen.

Die drei States#

TypeScript
enum CircuitState {
  CLOSED = 'CLOSED',     // Normaler Betrieb, Requests fließen durch
  OPEN = 'OPEN',         // Circuit ausgelöst, Requests schlagen sofort fehl
  HALF_OPEN = 'HALF_OPEN' // Testet ob Service sich erholt hat
}

Denk daran wie ein Türsteher im Club:

  • CLOSED: "Kommt rein, alles okay"
  • OPEN: "Keiner kommt rein, drinnen gibt's ein Problem"
  • HALF_OPEN: "Lass mich mit einer Person checken, ob's jetzt sicher ist"

Echte Implementation: Was wirklich funktioniert#

Hier ist der Circuit Breaker, den wir nach unserem Incident gebaut haben. Er ist in 40+ Services getestet, die 2M Requests/Tag handlen:

TypeScript
interface CircuitBreakerConfig {
  failureThreshold: number;      // Failures vor dem Öffnen
  successThreshold: number;       // Erfolge zum Schließen von half-open
  timeout: number;               // Request Timeout in ms
  resetTimeout: number;          // Zeit vor half-open Versuch
  volumeThreshold: number;       // Min Requests vor Evaluierung
  errorThresholdPercentage: number; // Error % zum Auslösen
}

class CircuitBreaker<T> {
  private state: CircuitState = CircuitState.CLOSED;
  private failureCount = 0;
  private successCount = 0;
  private lastFailureTime?: Date;
  private requestCount = 0;
  private errorCount = 0;
  private window = new RollingWindow(10000); // 10 Sekunden Fenster

  constructor(
    private readonly config: CircuitBreakerConfig,
    private readonly protectedFunction: () => Promise<T>
  ) {}

  async execute(): Promise<T> {
    // Check ob wir half-open versuchen sollten
    if (this.state === CircuitState.OPEN) {
      if (this.shouldAttemptReset()) {
        this.state = CircuitState.HALF_OPEN;
      } else {
        throw new CircuitOpenError('Circuit breaker ist OPEN');
      }
    }

    try {
      const result = await this.executeWithTimeout();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private async executeWithTimeout(): Promise<T> {
    return Promise.race([
      this.protectedFunction(),
      new Promise<T>((_, reject) =>
        setTimeout(() => reject(new TimeoutError()), this.config.timeout)
      )
    ]);
  }

  private onSuccess(): void {
    this.failureCount = 0;
    this.window.recordSuccess();

    if (this.state === CircuitState.HALF_OPEN) {
      this.successCount++;
      if (this.successCount >= this.config.successThreshold) {
        this.state = CircuitState.CLOSED;
        this.successCount = 0;
      }
    }
  }

  private onFailure(): void {
    this.failureCount++;
    this.lastFailureTime = new Date();
    this.window.recordFailure();

    if (this.state === CircuitState.HALF_OPEN) {
      this.state = CircuitState.OPEN;
      this.successCount = 0;
      return;
    }

    // Check sowohl absolute als auch prozentuale Thresholds
    const stats = this.window.getStats();
    if (stats.totalRequests >= this.config.volumeThreshold) {
      const errorRate = (stats.failures / stats.totalRequests) * 100;
      if (errorRate >= this.config.errorThresholdPercentage ||
          this.failureCount >= this.config.failureThreshold) {
        this.state = CircuitState.OPEN;
      }
    }
  }

  private shouldAttemptReset(): boolean {
    return this.lastFailureTime &&
      Date.now() - this.lastFailureTime.getTime() >= this.config.resetTimeout;
  }
}

Lektionen aus der Produktion: Was die Tutorials nicht sagen#

1. Timeout ist deine wichtigste Einstellung#

Nach der Analyse von 6 Monaten Incidents wurden 73% durch langsame Antworten verursacht, nicht durch komplette Ausfälle. Setz deinen Timeout aggressiv:

TypeScript
const config = {
  timeout: 3000,  // 3 Sekunden - unser P99 ist 1.2s, das fängt Probleme
  // NICHT 30000!  // Das hat uns gekillt. 30s warten = Thread Exhaustion
};

Echte Zahlen von unserem Payment Service:

  • Normal P50: 180ms
  • Normal P99: 1.2s
  • Circuit Breaker Timeout: 3s
  • Ergebnis: 94% Reduktion bei Kaskadenfehlern

2. Die Half-Open State Falle#

Früher sind wir zu half-open gewechselt, haben einen Request geschickt, Erfolg gehabt, den Circuit geschlossen und dann sofort wieder mit vollem Traffic gefailt. Der Fix: Mehrere Erfolge vor dem Schließen verlangen.

TypeScript
// Mach das nicht
if (testRequest.succeeded) {
  this.state = CircuitState.CLOSED; // Boom! Voller Traffic kommt zurück
}

// Mach stattdessen das
if (++this.successCount >= this.config.successThreshold) {
  this.state = CircuitState.CLOSED; // Schrittweise Recovery
}

3. Mit Retry Logic kombinieren (aber vorsichtig)#

Circuit Breaker und Retries können Feedback-Schleifen erzeugen. Hier unsere getestete Kombination:

TypeScript
class ResilientClient {
  private circuitBreaker: CircuitBreaker<any>;

  async callWithResilience(request: Request): Promise<Response> {
    // Circuit Breaker wrappt Retry Logic, nicht umgekehrt
    return this.circuitBreaker.execute(async () => {
      return await this.retryWithBackoff(request, {
        maxAttempts: 3,
        backoffMs: [100, 200, 400],
        shouldRetry: (error) => {
          // Keine Retries für Circuit Breaker Errors
          if (error instanceof CircuitOpenError) return false;
          // Keine Retries für Client Errors
          if (error.statusCode >= 400 && error.statusCode &lt;500) return false;
          return true;
        }
      });
    });
  }
}

4. Die richtigen Metriken monitoren#

Was du tracken solltest (nach Wichtigkeit):

  1. Circuit State Changes - Sofort alarmieren bei OPEN
  2. Reset Attempt Results - Failed Resets = anhaltendes Problem
  3. Request Rejection Rate - Business Impact Metrik
  4. Zeit im OPEN State - Hilft beim Reset Timeout Tuning

Unser CloudWatch Dashboard:

TypeScript
// Custom Metriken die wir pushen
await cloudwatch.putMetricData({
  Namespace: 'CircuitBreakers',
  MetricData: [
    {
      MetricName: 'StateChange',
      Value: 1,
      Unit: 'Count',
      Dimensions: [
        { Name: 'ServiceName', Value: this.serviceName },
        { Name: 'FromState', Value: oldState },
        { Name: 'ToState', Value: newState }
      ]
    },
    {
      MetricName: 'RejectedRequests',
      Value: rejectedCount,
      Unit: 'Count',
      Dimensions: [{ Name: 'ServiceName', Value: this.serviceName }]
    }
  ]
});

Fortgeschrittene Patterns: Über Basic Circuit Breaking hinaus#

Bulkheading: Isolierte Circuit Breaker#

Verwende nicht einen Circuit Breaker für einen ganzen Service. Isoliere kritische Pfade:

TypeScript
class PaymentService {
  private readonly chargeBreaker = new CircuitBreaker(chargeConfig);
  private readonly refundBreaker = new CircuitBreaker(refundConfig);
  private readonly queryBreaker = new CircuitBreaker(queryConfig);

  async chargeCard(request: ChargeRequest): Promise<ChargeResponse> {
    // Charge-Fehler beeinflussen keine Refunds
    return this.chargeBreaker.execute(() => this.api.charge(request));
  }

  async refundPayment(request: RefundRequest): Promise<RefundResponse> {
    // Refunds bleiben verfügbar auch wenn Charges failen
    return this.refundBreaker.execute(() => this.api.refund(request));
  }
}

Das hat uns am Black Friday gerettet, als unser Charge Endpoint überlastet war, aber Refunds (kritisch für den Kundenservice) weiter funktionierten.

Fallback-Strategien#

Nicht alle Fehler sind gleich. Manchmal kannst du graceful degradieren:

TypeScript
async getProductRecommendations(userId: string): Promise<Product[]> {
  try {
    return await this.recommendationBreaker.execute(
      () => this.mlService.getRecommendations(userId)
    );
  } catch (error) {
    if (error instanceof CircuitOpenError) {
      // Fallback zu einfachen popularity-basierten Empfehlungen
      return this.getPopularProducts();
    }
    throw error;
  }
}

Circuit Breaker Vererbung#

Für Microservices die andere Microservices aufrufen, vererbe den Circuit State:

TypeScript
// API Gateway
if (paymentServiceBreaker.state === CircuitState.OPEN) {
  // Versuch nicht mal den Order Service zu callen, der vom Payment abhängt
  return { error: 'Payment Service nicht verfügbar', status: 503 };
}

Real-World Konfigurationsbeispiele#

Was in der Produktion für verschiedene Service-Typen wirklich funktioniert:

TypeScript
// Externe API (Payment Provider, Third-Party Services)
const externalAPIConfig: CircuitBreakerConfig = {
  failureThreshold: 5,           // 5 aufeinanderfolgende Fehler
  successThreshold: 2,           // 2 Erfolge zur Recovery
  timeout: 5000,                // 5 Sekunden Timeout
  resetTimeout: 30000,          // Recovery nach 30s versuchen
  volumeThreshold: 10,          // Mindestens 10 Requests nötig
  errorThresholdPercentage: 50  // 50% Error Rate löst aus
};

// Interner Microservice
const internalServiceConfig: CircuitBreakerConfig = {
  failureThreshold: 10,          // Toleranter
  successThreshold: 3,
  timeout: 3000,                // Schnellerer Timeout
  resetTimeout: 10000,          // Schnellere Recovery-Versuche
  volumeThreshold: 20,
  errorThresholdPercentage: 30  // Sensibler für Error Rates
};

// Database Connections
const databaseConfig: CircuitBreakerConfig = {
  failureThreshold: 3,           // Schnell auslösen
  successThreshold: 5,           // Langsam erholen
  timeout: 1000,                // Sehr schneller Timeout
  resetTimeout: 5000,           // Schneller Retry
  volumeThreshold: 5,
  errorThresholdPercentage: 20  // Sehr sensibel
};

Circuit Breaker testen: Chaos Engineering#

Du kannst einem Circuit Breaker nicht vertrauen, den du nicht getestet hast. Unser Chaos Testing Ansatz:

TypeScript
describe('Circuit Breaker Chaos Tests', () => {
  it('sollte schrittweise Degradation handlen', async () => {
    const scenarios = [
      { latency: 100, errorRate: 0 },    // Normal
      { latency: 500, errorRate: 0.1 },  // Leichte Degradation
      { latency: 2000, errorRate: 0.3 }, // Große Degradation
      { latency: 5000, errorRate: 0.7 }, // Fast Ausfall
    ];

    for (const scenario of scenarios) {
      mockService.setScenario(scenario);
      await runLoadTest(1000); // 1000 Requests

      const metrics = await breaker.getMetrics();
      if (scenario.errorRate > 0.5) {
        expect(breaker.state).toBe(CircuitState.OPEN);
      }
    }
  });
});

In der Produktion verwenden wir AWS Fault Injection Simulator, um zufällig Fehler zu injizieren und zu verifizieren, dass unsere Circuit Breaker korrekt reagieren.

Die Fehler, die uns teuer zu stehen kamen#

Fehler 1: Nur Client-Side Circuit Breaking#

Wir haben anfangs Circuit Breaker nur in Clients implementiert. Wenn der Server selbst Probleme hatte, konnte er sich nicht schützen:

TypeScript
// Schlecht: Client schützt sich aber Server noch überlastet
class Client {
  private breaker = new CircuitBreaker();
  async call() { return this.breaker.execute(() => fetch('/api')); }
}

// Gut: Server schützt sich auch selbst
class Server {
  private downstreamBreaker = new CircuitBreaker();
  async handleRequest(req, res) {
    try {
      const data = await this.downstreamBreaker.execute(() =>
        this.database.query(req.query)
      );
      res.json(data);
    } catch (error) {
      if (error instanceof CircuitOpenError) {
        res.status(503).json({ error: 'Service temporär nicht verfügbar' });
      }
    }
  }
}

Fehler 2: Circuit Breaker über unabhängige Operationen teilen#

Wir hatten einen Circuit Breaker für "Database Operations". Wenn Writes failten, wurden auch Reads blockiert:

TypeScript
// Schlecht: Ein Breaker für alles
class UserService {
  private dbBreaker = new CircuitBreaker();

  async getUser(id) {
    return this.dbBreaker.execute(() => db.query('SELECT...'));
  }

  async createUser(data) {
    return this.dbBreaker.execute(() => db.query('INSERT...'));
  }
}

// Gut: Separate Breaker für verschiedene Operationen
class UserService {
  private readBreaker = new CircuitBreaker(readConfig);
  private writeBreaker = new CircuitBreaker(writeConfig);

  async getUser(id) {
    return this.readBreaker.execute(() => db.query('SELECT...'));
  }

  async createUser(data) {
    return this.writeBreaker.execute(() => db.query('INSERT...'));
  }
}

Fehler 3: Business Impact nicht berücksichtigen#

Wir haben alle Services gleich behandelt. Dann haben wir Payment Processing blockiert während wir Metrics Collection durchgelassen haben. Diese Lektion haben wir schnell gelernt.

Die Implementierungs-Checkliste#

Nach der Implementierung von Circuit Breakern in 40+ Services, hier meine Checkliste:

  • Timeout auf 2-3x deiner P99 Latenz setzen
  • Mehrere Erfolge vor dem Schließen von half-open verlangen
  • Separate Breaker für Read/Write Operationen implementieren
  • Fallback-Verhalten für business-kritische Pfade hinzufügen
  • Metriken für State Changes und Rejections exportieren
  • Mit Chaos Engineering vor Produktion testen
  • Timeout und Threshold Entscheidungen dokumentieren
  • Bei Circuit OPEN alarmieren, nicht bei einzelnen Fehlern
  • Business-Priorität in der Konfiguration berücksichtigen
  • Schrittweise Recovery implementieren, nicht instant

Abschließende Gedanken: Es geht ums schnelle Scheitern#

Die härteste Lektion? Manchmal ist das Beste, was dein Service tun kann, sofort zu failen. Diese 503 Response in 10ms ist unendlich besser als ein Timeout nach 30 Sekunden. Deine User können retrien. Dein System kann sich erholen. Aber Thread Exhaustion? Das ist ein One-Way-Ticket zu einem 3 Uhr morgens Weckruf.

Circuit Breaker geht's nicht darum, Fehler zu verhindern - es geht darum, zu verhindern, dass sich Fehler ausbreiten. Es geht darum, genug System-Gesundheit zu erhalten, dass du tatsächlich recovern kannst, wenn das Problem behoben ist.

Implementiere sie bevor du sie brauchst. Vertrau mir dabei.

Loading...

Kommentare (0)

An der Unterhaltung teilnehmen

Melde dich an, um deine Gedanken zu teilen und mit der Community zu interagieren

Noch keine Kommentare

Sei der erste, der deine Gedanken zu diesem Beitrag teilt!

Related Posts