Wzorzec projektowy Circuit Breaker – bezpiecznik

Tworzysz systemy w oparciu o mikro-serwisy lub integrujesz się z zewnętrznymi API? Musisz więc poznać wzorzec Circuit Breaker!

W świecie oprogramowania, a szczególnie w świecie systemów rozproszonych czy systemów opartych o architekturę mikro-serwisową, umiejętne radzenie sobie z awariami jest kluczowe, a szczególnie, kiedy dotyczą one innych usług, z którymi się łączymy. Jedną z popularnych technik stosowanych w takich sytuacjach jest wzorzec projektowy „Circuit Breaker”. Został on zaprojektowany w celu zapobiegania kaskadowym awariom systemów poprzez minimalizowanie wpływu awarii jednej usługi na inne, często zależne usługi. Dzisiaj przyjrzymy się, czym jest wzorzec „Circuit Breaker”, dlaczego jest przydatny i jak można go zaimplementować.

Czym jest Circuit Breaker?

Wzorzec Circuit Breaker inspirowany jest bezpiecznikami elektrycznymi, które przerywają (otwierają) obwód, gdy system wykryje awarię, zapobiegając w ten sposób dalszym uszkodzeniom.

Wzorzec Circuit Breaker monitoruje wywołania zewnętrznych usług lub zależności, z którymi się integrujemy i „otwiera się”, gdy pewien umowny wskaźnik awaryjności przekracza określony próg. Kiedy obwód jest otwarty, żądania do danej usługi są blokowane (lub obsługiwane za pomocą mechanizmów awaryjnych), zamiast pozwalać na dalsze nieudane próby. Po upływie ustalonego czasu obwód przechodzi w stan półotwarty czyli przepuszcza np. 50% pierwotnej ilości wywołań, aby sprawdzić czy usługa została przywrócona, a jeśli nie została, to wciąż ograniczać straty związane np. ze zużywaniem się zasobów.

Bezpiecznik może znajdować się w jednym z trzech stanów:

  • Zamknięty: wszystkie wywołania są przepuszczane;
  • Otwarty: usług/zasób jest uważany za nieczynny i wszystkie wywołania są blokowane;
  • Półotwarty: ograniczona liczba wywołań może zostać przepuszczona w celu sprawdzenia, czy usługa działa już poprawnie.

Czy wiesz, że Circuit Breaker jest czasami rozdzielany do dwóch wzorców?

Circuit Breaker pełni czasami rolę jedynie mechanizmu kontrolującego swój stan, natomiast osobny byt zwany strategią powtórzeń (Retry Pattern) realizuje faktyczną logikę ponowień.

My będziemY implementować je razem, ponieważ można powiedzieć, że Circuit Breaker to po prostu Retry Pattern wzbogacony o dodatkową logikę kontroli.

Kiedy zastosować Circuit Breaker?

Wyobraźmy sobie, że nasz system musi na żądanie pobrać pewne dane po rest api z innego systemu. Wysyłamy zapytania za każdym razem kiedy użytkownik o to prosi, a użytkowników mamy, powiedzmy kilkaset. Danych nie możemy trzymać w pamięci podręcznej, ponieważ są one unikalne dla każdego wywołania. Zapytania do serwisu zewnętrznego kosztują nas pewne pieniądze, ponieważ zużywamy własne zasoby serwerowe, korzystamy z quot-y zewnętrznego serwisu i tak dalej.

W przypadku awarii zewnętrznego serwisu będziemy marnować zasoby oraz niepotrzebnie wykonywać (D)DoS-a na nim, utrudniając mu powrót do działania.

class ExternalKycService implements KycService {
    constructor(
      private readonly externalKycClient: ExternalKycHttpClient
    ) {}

    async getKycInfo(customer: Customer): Promise<Result<KycInfo, Error>> {
        const result = await this.externalKycClient.getInfo({
          docId: customer.docId,
          birthDate: customer.birthDate.toString()
        });

        if (!result.success) {
           return result;
        }

        Result.ok({
          customer,
          kycRequest: result.request,
          kycResult: result.info,
          checkedAt: new Date()
        });
    }
}

Sposoby implementacji bezpiecznika

Mechanizm bezpiecznika możemy zaimplementować na kilka sposobów. Najbardziej trywialnym będzie umieszczenie licznika błędów oraz czasu ich wystąpienia w obrębie samej metody, a następnie reagowanie na ich zmiany. Innym sposobem jest zapisywanie stanu i ponawianie, np. za pomocą zadania zaplanowanego czy mechanizmu reagującego na błąd w kolejce.

Możemy również utworzyć reużywalną klasę, która będzie miała określone reguły ponowień, bazując na przeczekiwaniu pomiędzy kolejnymi wywołaniami (sleep, delay?). Nie jest to może najwydajniejszy i najoszczędniejszy sposób implementacji, a dodatkowo nie obsługuję on faktu, że ktoś może manualnie ponowić wywołanie metody getKycInfo. Niemniej jednak pokazuje on doskonale działanie Circuit Breaker-a i taką wersję zaimplementujemy.

Zacznijmy od zaimplementowania stanów bezpiecznika. Posłużę się osobnymi klasami, które zwracać będą ilość sekund, która reprezentuje przerwy pomiędzy kolejnymi powtórzeniami zapytania. W stanie zamkniętym używamy domyślnego czasu (zaraz do tego przejdziemy). W stanie półotwartym chcemy zwiększyć przerwę dwukrotnie, zaś w otwartym, dziesięciokrotnie.

class OpenCircuit implements CircuitState {
  getSecondsDelay(baseSecondsDelay: number): number {
    return baseSecondsDelay * 10;
  }
}

class ClosedCircuit implements CircuitState {
  getSecondsDelay(baseSecondsDelay: number): number {
    return baseSecondsDelay;
  }
}

class HalfOpenCircuit implements CircuitState {
  getSecondsDelay(baseSecondsDelay: number): number {
    return baseSecondsDelay * 2;
  }
}

Chciałbym mieć możliwość konfigurowania niektórych wartości w swoim bezpieczniku, tak, aby móc dostosowywać go pod różne miejsca systemu. Pozwolę, więc aby konstruktor akceptował, jaka ma być liczba sekund pomiędzy wywołaniami, a także maksymalna ilość powtórzeń.

class InMemoryCircuitBreaker implements CircuitBreaker {
  constructor(
    private readonly triesAmountThreshold: number = 20,
    private readonly secondsBetweenTries: number = 5,
    private state: CircuitState = new ClosedCircuit()
  ) {}
}

Kolej na ciało klasy bezpiecznika. W pierwszej kolejności będę potrzebował wykonawcę logiki z obsługą błędu. Prosta metoda, która przyjmuje callback (funkcję do wywołania), robi na niej try-catch i zwraca odpowiedni rezultat, który będę mógł za chwilę obsłużyć.

  private async execute<T>(request: () => Promise<T>): Promise<Result<T | Error>> {
    try {
      const result = await request(); 
      return ok(result);
    } catch (error) {
      return fail(error as Error);
    }
  }

Kolejna kwestia to obsługa przejść pomiędzy stanami (otwarty, półotwarty, zamknięty). Utworzę w tym celu osobną metodę o nazwie transit, która w zależności od numeru wywołania logiki zamieni stan bezpiecznika.

  • Jeśli liczba powtórzeń przekroczyła połowę dozwolonych, to wejdziemy w stan półotwarty, który oznacza, że kolejne wywołania będą dwukrotnie opóźnione.
  • Jeżeli mamy do czynienia z przedostatnim dozwolonym wywołaniem, to chciałbym przejść w stan otwarty i ostatnią próbę wykonać po dziesięciokrotnie powiększonym, domyślnym czasie powtórzenia.
  private transit(tryNo: number): void 
  {
    if (tryNo >= this.triesAmountThreshold / 2 && !(this.state instanceof HalfOpenCircuit)) {
      this.state = new HalfOpenCircuit();
      return;
    }

    if (tryNo === this.triesAmountThreshold - 1 && !(this.state instanceof OpenCircuit)) {
      this.state = new OpenCircuit();
    }
  }

Ostatni etap, to główna metoda przyjmująca zlecenie kontroli – nazwę ją call. Jej zadaniem jest ponawianie logiki do czasu uzyskania rezultatu bez błędu lub przekroczenia ilości dozwolonych powtórzeń. Kontroluje ona przerwy pomiędzy wywołaniami, a także wywołuje metodę transit w celu obsługi zmian stanów.

async call<T>(request: () => Promise<T>): Promise<Result<T | Error>> {
    let error: Error = new Error('Circuit breaker has finished in opened state.');

    for (let tryNo = 0; tryNo <= this.triesAmountThreshold; tryNo++) {
      await delay(this.state.getSecondsDelay(this.secondsBetweenTries));

      const result = await this.execute(request);
      if (result.success) {
        return result;
      }

      error = result.payload as Error;

      this.transit(tryNo);
    }

    return fail(error);
  }

Całość prezentuje się następująco:

class InMemoryCircuitBreaker implements CircuitBreaker {
  constructor(
    private readonly triesAmountThreshold: number = 20,
    private readonly secondsBetweenTries: number = 5,
    private state: CircuitState = new ClosedCircuit()
  ) {}

  async call<T>(request: () => Promise<T>): Promise<Result<T | Error>> {
    let error: Error = new Error('Circuit breaker has finished in opened state.');

    for (let tryNo = 0; tryNo <= this.triesAmountThreshold; tryNo++) {
      await delay(this.state.getSecondsDelay(this.secondsBetweenTries));

      const result = await this.execute(request);
      if (result.success) {
        return result;
      }

      error = result.payload as Error;

      this.transit(tryNo);
    }

    return fail(error);
  }

  private async execute<T>(request: () => Promise<T>): Promise<Result<T | Error>> {
    try {
      const result = await request(); 
      return ok(result);
    } catch (error) {
      return fail(error as Error);
    }
  }

  private transit(tryNo: number): void 
  {
    if (tryNo >= this.triesAmountThreshold / 2 && !(this.state instanceof HalfOpenCircuit)) {
      this.state = new HalfOpenCircuit();
      return;
    }

    if (tryNo === this.triesAmountThreshold - 1 && !(this.state instanceof OpenCircuit)) {
      this.state = new OpenCircuit();
    }
  }
}

Jak zaimplementować wzorzec Circuit Breaker?

Spróbujmy teraz zastosować zaimplementowany wzorzec w przykładzie, który pokazałem na początku. Początkowe wywołanie metody getInfo opakujmy w dodatkową funkcję, która będzie naszym callback-iem, a następnie przekażmy ją do metody call z Circuit Breaker-a.

class ExternalKycService implements KycService {
    constructor(
      private readonly externalKycClient: ExternalKycHttpClient,
      private readonly circuitBreaker: CircuitBreaker,
    ) {}

    async getKycInfo(customer: Customer): Promise<Result<KycInfo, Error>> {
        const callback = async () => await this.externalKycClient.getInfo({
          docId: customer.docId,
          birthDate: customer.birthDate.toString()
        });

        const result = await this.circuitBreaker.call<RawKycInfo>(callback);

        if (!result.success) {
           return result;
        }

        Result.ok({
          customer,
          kycRequest: result.request,
          kycResult: result.info,
          checkedAt: new Date()
        });
    }
}

Sam Circuit Breaker został użyty z domyślnymi ustawienia, ponieważ tak wstrzyknął go mechanizm DI (użył domyślnych wartości). Jeśli jednak chcielibyśmy coś zmienić w konfiguracji, to oczywiście możemy to zrobić, poprzez manualne wstrzyknięcie instancji, bądź dodanie dodatkowego elementu w kontenerze DI.

Podsumowanie

  • Wzorzec Circuit Breaker inspirowany jest bezpiecznikami elektrycznymi.
  • Może znajdować się w jednym z trzech stanów: zamknięty, półotwarty, otwarty.
  • Głównym problemem, który rozwiązuje ten wzorzec są kaskadowe awarie w systemie.
Autor wpisu

blog@orbisbit.com

Komentarze

Jedna odpowiedź do „Wzorzec projektowy Circuit Breaker – bezpiecznik”

  1. Awatar Paweł
    Paweł

    Ma to sens, ale dałeś (jak sam zaznaczyłeś) mało produkcyjny przykład. Chciałbym zobaczyć przykład z cronem 🙂

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Sprawdź również