30. Circuit breaker

В электрическом щитке любого дома установлены автоматические выключатели — предохранители (circuit breakers). Если на кухне закоротит проводку тостера, биметаллическая пластина в щитке мгновенно сработает с сухим щелчком, обесточив только одну проблемную линию. Это предотвратит перегрев кабелей в стенах и спасет весь дом от пожара. Никому и в голову не придет упорно включать рубильник обратно каждую миллисекунду, пока проводка плавится.

Майкл Нейгард в классической книге «Release It!» перенес эту физическую концепцию в архитектуру программного обеспечения. В распределенных системах любая внешняя зависимость (база данных, платежный шлюз, сервис рекомендаций, сторонний API) рано или поздно начнет сбоить или отвечать с чудовищной задержкой. Без автоматического выключателя входящие HTTP-горутины начинают накапливаться, блокируя пулы соединений и память процесса, пока весь ваш бэкенд не рухнет по принципу домино. Circuit Breaker реализует спасительный принцип fast-fail: мгновенно прерывает вызовы к умирающей зависимости, спасая ваш сервис и давая внешней системе время на восстановление.

Философия Circuit Breaker

В распределенных системах временный отказ внешней зависимости (БД, внешний API, кеш) не должен превращаться в полный крах вашего сервиса. Без защитных механизмов горутины накапливаются, пулы соединений исчерпываются, а латентность растет экспоненциально. Circuit Breaker (автоматический выключатель) решает эту проблему через принцип fast-fail: если зависимость стабильно отвечает ошибками, вызовы немедленно прерываются, давая инфраструктуре время на восстановление и предотвращая каскадный отказ.

В Go Circuit Breaker — это не внешний прокси или тяжелый AOP-интерцептор, а легковесная машина состояний, работающая в памяти процесса. Она использует атомарные счетчики, контексты отмены и неблокирующие переходы, что минимизирует накладные расходы на CPU и планировщик.

1. Машина состояний и переходы

Классический Circuit Breaker оперирует тремя фундаментальными состояниями:

  • Closed (Замкнут): Нормальный режим работы. Электрическая цепь замкнута, запросы свободно проходят к целевой зависимости. Ошибки и задержки непрерывно учитываются статистическим скользящим окном.
  • Open (Разомкнут): Защита сработала. Цепь разорвана. Все входящие запросы мгновенно прерываются локальной ошибкой без физического обращения к сети. Запускается таймер паузы восстановления.
  • Half-Open (Полуразомкнут): Пробный канареечный режим. По истечении таймера выключатель осторожно пропускает ограниченное число запросов (MaxRequests = 1..3). Если пробные запросы завершаются успехом, выключатель переходит обратно в Closed. При первой же ошибке — мгновенный возврат в Open.
flowchart TD
    A["Запрос к зависимости"] --> B["Проверка состояния"]
    B -->|"Closed"| C["Вызов целевой функции"]
    C -->|"Успех"| D["Сброс счетчика ошибок"]
    C -->|"Ошибка"| E["Инкремент отказов"]
    E --> F{"Порог превышен?"}
    F -->|"Нет"| B
    F -->|"Да"| G["Переход в Open"]
    G --> H["Мгновенный возврат ошибки"]
    H --> I{"Истек интервал восстановления?"}
    I -->|"Да"| J["Переход в Half-Open"]
    J --> K["Пробный запрос"]
    K -->|"Успех"| D
    K -->|"Ошибка"| G

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef decision fill:#334155,stroke:#f59e0b,color:#f8fafc;
    classDef closed fill:#0f172a,stroke:#10b981,color:#f8fafc;
    classDef open fill:#4c0519,stroke:#f43f5e,color:#ffffff;
    class A,C,D,K normal;
    class B,F,I decision;
    class D closed;
    class G,H open;
    class J decision;
  

2. Идиоматичная интеграция в Go

Использование проверенной библиотеки github.com/sony/gobreaker предпочтительнее самописных решений, так как она корректно обрабатывает конкурентные переходы, учет скользящих окон и паники внутри вызываемого кода.

Go
package client

import (
    "context"
    "fmt"
    "net/http"
    "time"

    "github.com/sony/gobreaker"
)

type ExternalClient struct {
    cb         *gobreaker.CircuitBreaker
    httpClient *http.Client
}

func NewExternalClient() *ExternalClient {
    cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        "payment-api",
        MaxRequests: 3,              // Сколько запросов пропустить в Half-Open
        Interval:    60 * time.Second, // Окно сбора статистики
        Timeout:     10 * time.Second, // Время в Open перед переходом в Half-Open
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            // Переход в Open при 50% ошибок (при выборке от 10 запросов)
            failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
            return counts.Requests >= 10 && failureRatio >= 0.5
        },
    })

    return &ExternalClient{
        cb:         cb,
        httpClient: &http.Client{Timeout: 5 * time.Second},
    }
}

func (c *ExternalClient) Call(ctx context.Context, payload []byte) error {
    // Execute оборачивает вызов и управляет состояниями
    _, err := c.cb.Execute(func() (interface{}, error) {
        // Контекст прокидывается внутрь для корректной отмены
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.example.com/pay", nil)
        if err != nil {
            return nil, err
        }
        resp, err := c.httpClient.Do(req)
        if err != nil {
            return nil, err
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 500 {
            return nil, fmt.Errorf("server error: %d", resp.StatusCode)
        }
        return nil, nil
    })

    if err == gobreaker.ErrOpenState {
        return fmt.Errorf("circuit breaker is open: %w", err)
    }
    return err
}

3. Механика работы и Mechanical Sympathy

Circuit Breaker работает на границе сетевого вызова и бизнес-логики. Его производительность напрямую влияет на latency:

  • Счетчики ошибок: Должны быть атомарными или шардированными. При 10 000 RPS обновление глобального счетчика через sync.Mutex вызывает contention на кэш-линиях (cache line bouncing). Использование atomic.AddInt64 решает проблему, но при экстремальных нагрузках применяют локальные счетчики на горутину с периодическим агрегированием.
  • Таймер восстановления: Переход Open → Half-Open не должен использовать time.Sleep в отдельной горутине. Это расходует память стека и ресурс планировщика. Идиоматично проверять time.Since(lastStateChange) при входе в Execute() или использовать sync.Timer с однократным срабатыванием.
  • Escape Analysis: Функция, передаваемая в cb.Execute(), является замыканием. Компилятор часто вынужден размещать захваченные переменные (ctx, payload, c.httpClient) в куче. Для минимизации аллокаций передавайте данные через аргументы или используйте sync.Pool для структур запросов.
  • Влияние на GC: Каждый неудачный вызов создает error, который сохраняется для статистики. При частых переходах в Open и обратно возникает микро-волна аллокаций. Настройка Interval и MaxRequests должна соответствовать профилю трафика.

4. Сравнение с другими экосистемами

Аспект Java (Resilience4j / Hystrix) PHP (Envoy / Nginx Proxy) Python (pybreaker) Go (gobreaker / hystrix-go)
Модель выполнения Thread-pool isolation, тяжелые прокси Внешний sidecar, нулевой оверхед в коде Синхронный, блокирующий In-process, горутинный, неблокирующий
Управление состоянием Атомарные счетчики + Volatile переменные Shared memory / Redis в прокси Локальный map + мьютекс atomic + lock-free переходы
Изоляция сбоев Отдельный пул тредов на зависимость Нет (зависит от прокси) Нет (блокирует процесс) Отказ на уровне вызова, горутины не блокируются
Оверхед Высокий (контекст тредов, GC pressure) Нулевой (на уровне сети) Низкий, но синхронный Минимальный (атомарные операции, компиляция)

5. Собеседование и типичные вопросы

6. Итог

  1. Circuit Breaker защищает от каскадных отказов через быстрое отключение неисправных зависимостей.
  2. Состояния Closed → Open → Half-Open должны переключаться атомарно, избегая блокировок на hot-path.
  3. Никогда не комбинируйте CB и Retry без координации: проверяйте состояние выключателя до начала цикла повторов.
  4. Ограничивайте MaxRequests в Half-Open до минимума (1-3), чтобы не перегрузить восстанавливающуюся систему.
  5. Учитывайте латентность как отказ: засчитывайте таймауты контекста в счетчик ошибок выключателя.
  6. В Go CB реализуется через легковесные библиотеки (gobreaker), использующие атомики и минимальные аллокации, что соответствует философии языка.

Следующая статья: 31. Таймауты и SLA