Модуль: Распределённые системы · Уровень: Senior+

TL;DR#

  • Circuit breaker («предохранитель») оборачивает вызов нестабильной зависимости и при росте ошибок размыкает цепь — перестаёт слать запросы, мгновенно возвращая ошибку (fail fast).
  • Три состояния: Closed (запросы идут, считаем ошибки) → при превышении порога → Open (запросы блокируются, fail fast) → по таймауту → Half-Open (пропускаем пробные запросы) → успех → Closed, провал → снова Open.
  • Зачем: fail fast (не ждать таймаутов на мёртвый сервис), защита downstream от добивания нагрузкой во время деградации, дать сервису время восстановиться, предотвратить каскадные сбои.
  • Bulkhead — изоляция ресурсов (отдельные пулы соединений/семафоры на каждую зависимость), чтобы один тормозящий downstream не выел все воркеры/горутины.
  • В Go стандарт де-факто — github.com/sony/gobreaker: Settings{ReadyToTrip, OnStateChange, Interval, Timeout, MaxRequests}, cb.Execute(fn), Counts.
  • Fallback: вернуть кэш, дефолт или деградированный ответ вместо ошибки.
  • Circuit breaker ≠ retry. Retry борется с транзиентными сбоями повтором; breaker борется с устойчивыми сбоями прекращением запросов. Комбинация retry+breaker опасна — ретраи накручивают счётчик ошибок и раздувают нагрузку; порядок и настройка критичны.

Теория#

Проблема: каскадный сбой#

Сервис A зависит от B. B начинает тормозить (latency 5s вместо 50ms). Без предохранителя:

  • Запросы к B копятся, каждый держит горутину/соединение 5 секунд до таймаута.
  • Пул соединений и воркеры A исчерпываются ожиданием B.
  • A перестаёт обслуживать все запросы, даже не зависящие от B.
  • Сервисы, зависящие от A, начинают падать. Каскад распространяется вверх.

Circuit breaker разрывает эту цепочку: обнаружив, что B нездоров, A немедленно отвечает ошибкой на запросы к B (fail fast), освобождая ресурсы и не добивая B.

Три состояния и переходы#

                  ошибок >= порог (ReadyToTrip)
        ┌──────────────────────────────────────────┐
        │                                           ▼
   ┌─────────┐                                  ┌────────┐
   │ CLOSED  │                                  │  OPEN  │
   │ запросы │                                  │ fail   │
   │ проходят│◄──────────┐                      │ fast   │
   └─────────┘           │ проба УСПЕШНА        └────────┘
        ▲                │ (>= MaxRequests)          │
        │                │                           │ прошёл Timeout
        │           ┌──────────┐                     │
        │ проба     │HALF-OPEN │◄────────────────────┘
        │ ПРОВАЛ ───│ пробные  │
        └───────────│ запросы  │
                    └──────────┘

Closed — нормальная работа. Все запросы проходят к downstream. Breaker считает успехи/провалы. Когда условие ReadyToTrip выполнено (например, доля ошибок > 50% или N подряд) — переход в Open.

Open — цепь разомкнута. Запросы не идут к downstream, breaker сразу возвращает ошибку (ErrOpenState). Это и есть fail fast. По истечении TimeoutHalf-Open.

Half-Open — пробное состояние. Пропускается ограниченное число «разведочных» запросов (MaxRequests). Если они успешны — downstream восстановился, переход в Closed, счётчики сброшены. Если хоть один провалился — обратно в Open, таймер заново. В half-open параллелизм ограничен, чтобы не обрушить только что вставший сервис лавиной.

Зачем это нужно (что именно даёт)#

  1. Fail fast. Вместо ожидания таймаута на заведомо мёртвый сервис — мгновенная ошибка. Освобождает ресурсы вызывающего, держит хвост latency низким.
  2. Защита downstream. Перегруженный сервис не добивается потоком запросов — ему дают передышку для восстановления (сброс очередей, GC, переподключение к БД).
  3. Предотвращение каскада. Локализует сбой одной зависимости, не давая ему «съесть» весь сервис и распространиться выше.
  4. Управляемая деградация. В связке с fallback — пользователь видит деградированный, но рабочий ответ вместо 500.

Bulkhead pattern (переборки)#

Метафора из судостроения: корпус делят на изолированные отсеки (bulkheads), пробоина в одном не топит весь корабль. В софте — изоляция ресурсов по зависимостям.

Без bulkhead все вызовы делят один пул горутин/соединений. Одна тормозящая зависимость занимает весь пул → голодают остальные. С bulkhead каждая зависимость получает свой ограниченный пул (семафор / отдельный thread pool / отдельный connection pool):

Без bulkhead:                  С bulkhead:
┌─────────────────┐            ┌────────┬────────┬────────┐
│  общий пул 100  │            │ B: 30  │ C: 30  │ D: 40  │
│  весь занят B   │            │ занят  │ свободн│ свободн│
│  C, D голодают  │            │ C, D работают   │
└─────────────────┘            └────────┴────────┴────────┘

В Go bulkhead просто реализуется буферизованным каналом-семафором:

type Bulkhead struct{ sem chan struct{} }

func NewBulkhead(max int) *Bulkhead { return &Bulkhead{sem: make(chan struct{}, max)} }

func (b *Bulkhead) Do(ctx context.Context, fn func() error) error {
	select {
	case b.sem <- struct{}{}: // захватили слот
		defer func() { <-b.sem }()
		return fn()
	case <-ctx.Done():
		return ctx.Err() // или ErrBulkheadFull при неблокирующем варианте
	}
}

Bulkhead и circuit breaker дополняют друг друга: bulkhead ограничивает количество одновременных вызовов (изоляция), breaker — вообще пускать ли вызовы (по здоровью). Часто применяются вместе.

sony/gobreaker#

github.com/sony/gobreaker — порт Hystrix-идей, де-факто стандарт в Go. Конфигурируется через Settings:

type Settings struct {
	Name          string
	MaxRequests   uint32        // макс. запросов в Half-Open (по умолч. 1)
	Interval      time.Duration // период сброса Counts в Closed (0 = не сбрасывать по времени)
	Timeout       time.Duration // как долго breaker в Open до перехода в Half-Open (по умолч. 60s)
	ReadyToTrip   func(counts Counts) bool        // когда размыкать
	OnStateChange func(name string, from, to State) // колбэк смены состояния
	IsSuccessful  func(err error) bool            // считать ли ошибку «провалом» breaker'а
}

type Counts struct {
	Requests             uint32
	TotalSuccesses       uint32
	TotalFailures        uint32
	ConsecutiveSuccesses uint32
	ConsecutiveFailures  uint32
}

Ключевое:

  • ReadyToTrip(Counts) bool — предикат размыкания. Вызывается после каждого провала в Closed. Типичные политики: ConsecutiveFailures > N или доля ошибок TotalFailures / Requests > 0.5 при достаточном объёме.
  • Counts — счётчики окна. Interval определяет, как часто они сбрасываются в Closed (скользящее окно по времени; в Open сбрасываются при входе).
  • OnStateChange — для метрик/алертов (логировать переходы Closed↔Open).
  • IsSuccessful — позволяет не считать ожидаемые ошибки (например 404, context.Canceled) за провал предохранителя. Очень важно: иначе бизнес-ошибки откроют breaker.
  • Execute(fn) (any, error) — обёртка вызова. В Open сразу вернёт gobreaker.ErrOpenState; в Half-Open при превышении MaxRequestsErrTooManyRequests.
package main

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

	"github.com/sony/gobreaker"
)

func newBreaker() *gobreaker.CircuitBreaker {
	st := gobreaker.Settings{
		Name:        "payments-api",
		MaxRequests: 3,                // пробных запросов в Half-Open
		Interval:    10 * time.Second, // окно подсчёта в Closed
		Timeout:     30 * time.Second, // сидим в Open 30s, потом Half-Open
		ReadyToTrip: func(c gobreaker.Counts) bool {
			// размыкаем при >= 5 запросах и доле ошибок > 60%
			failRatio := float64(c.TotalFailures) / float64(c.Requests)
			return c.Requests >= 5 && failRatio >= 0.6
		},
		IsSuccessful: func(err error) bool {
			if err == nil {
				return true
			}
			// 4xx-ошибки — это «успех» для breaker'а (downstream жив, виноват запрос)
			var he *httpError
			if errors.As(err, &he) && he.code < 500 {
				return true
			}
			return false
		},
		OnStateChange: func(name string, from, to gobreaker.State) {
			log.Printf("breaker %s: %s -> %s", name, from, to)
			// здесь — метрика/алерт
		},
	}
	return gobreaker.NewCircuitBreaker(st)
}

type httpError struct{ code int }

func (e *httpError) Error() string { return fmt.Sprintf("http %d", e.code) }

func callDownstream(cb *gobreaker.CircuitBreaker, url string) ([]byte, error) {
	body, err := cb.Execute(func() (interface{}, error) {
		resp, err := http.Get(url) // в проде — клиент с таймаутом!
		if err != nil {
			return nil, err
		}
		defer resp.Body.Close()
		if resp.StatusCode >= 400 {
			return nil, &httpError{code: resp.StatusCode}
		}
		return readAll(resp), nil
	})

	if err != nil {
		switch {
		case errors.Is(err, gobreaker.ErrOpenState):
			return fallback() // цепь разомкнута — fail fast + fallback
		case errors.Is(err, gobreaker.ErrTooManyRequests):
			return fallback() // half-open перегружен пробами
		default:
			return nil, err // реальная ошибка вызова
		}
	}
	return body.([]byte), nil
}

Fallback-стратегии#

Размыкание breaker’а само по себе лишь меняет 5-секундный таймаут на мгновенную ошибку. Чтобы пользователь не увидел 500, нужен fallback:

СтратегияКогдаПример
Cached valueданные допускают устареваниеотдать последний успешный ответ из кэша (stale-while-error)
Default valueесть разумный дефолтрекомендации недоступны → показать топ-популярное
Graceful degradationчасть функциональности можно отключитьлента без блока «персонализация», страница без виджета отзывов
Queue / asyncоперация терпит отложенностьположить в очередь, обработать когда downstream встанет
Fail fast (явная ошибка)fallback невозможен/опасен (платёж)честный 503 с Retry-After, лучше чем неверный ответ

Принцип: fallback должен быть дешевле и надёжнее защищаемого вызова, иначе он сам станет точкой отказа.

Circuit breaker vs Retry#

Это разные инструменты для разных сбоев:

RetryCircuit Breaker
Цельпережить транзиентный сбой (мелькнул и прошёл)защититься от устойчивого сбоя
Действиеповторить запроспрекратить запросы
Когда хорошредкая сетевая ошибка, 503 на долю секундыdownstream упал/перегружен надолго
Рискпри массовом сбое усиливает нагрузкуможет «зарубить» уже выздоровевший сервис

Они комплементарны: retry для случайных глюков, breaker — чтобы не ретраить в стену.

Опасность retry + circuit breaker вместе#

Наивная комбинация создаёт проблемы:

  1. Retry накручивает счётчик breaker’а. Если retry внутри Execute — каждая попытка считается отдельным запросом, breaker размыкается раньше/позже, чем ожидаешь, на искажённой статистике. Если retry снаружи breaker’а — после размыкания каждый retry мгновенно получает ErrOpenState, что бессмысленно тратит попытки. Правильно: breaker оборачивает один вызов, retry — снаружи, с проверкой ErrOpenState (не ретраить при открытом breaker’е).

  2. Усиление нагрузки (retry storm). При деградации downstream все клиенты одновременно ретраят → нагрузка ×N в худший момент → добивают сервис. Обязательны exponential backoff + jitter и ограничение числа попыток. Breaker здесь — последняя линия: когда retry не помогает, он размыкается и гасит шторм.

  3. Retry amplification по слоям. Если ретраит и клиент (3×), и mesh (3×), и сервис своему downstream (3×) — итого 27× нагрузки на нижний слой. Правило: ретраить только на одном уровне (обычно ближе к краю), глубже — только breaker + fail fast.

Рекомендуемый порядок обёрток (снаружи внутрь): retry(backoff+jitter) → circuit breaker → bulkhead → timeout → call. Retry проверяет: если ошибка = ErrOpenState, не ретраить (или ретраить с большим backoff), т.к. повтор всё равно мгновенно отлетит.

Подводные камни / gotchas#

  • Нет таймаута на сам вызов. Breaker не отменяет зависший запрос — без context/таймаута в HTTP-клиенте горутины всё равно зависнут, breaker не успеет «увидеть» провалы. Таймаут обязателен под breaker’ом.
  • Бизнес-ошибки открывают breaker. Если IsSuccessful не настроен, 404/401/валидационные ошибки считаются провалами и размыкают цепь, хотя downstream здоров. Считать провалом только 5xx/сетевые/таймауты.
  • Слишком чувствительный ReadyToTrip. Порог по абсолютному числу без учёта объёма (ConsecutiveFailures > 3) флапает на низком трафике. Лучше доля ошибок при минимальном Requests.
  • Один общий breaker на много хостов/эндпоинтов. Сбой одного инстанса размыкает цепь ко всем. Breaker должен быть на гранулярность зависимости (per-host / per-endpoint).
  • Half-Open лавина. Большой MaxRequests в Half-Open запустит много проб разом и снова уронит едва вставший сервис. Держать малым (1–3).
  • Retry внутри Execute. Искажает статистику breaker’а и множит нагрузку. Retry — снаружи, с учётом ErrOpenState.
  • Retry amplification по слоям. Ретраи на нескольких уровнях перемножаются. Ретраить на одном уровне.
  • Breaker без fallback. Размыкание просто меняет вид ошибки. Без fallback пользователь всё равно получает сбой — продумать деградацию.
  • Глобальное состояние при горизонтальном масштабировании. Каждый под имеет свой in-process breaker; они не синхронизированы. Это нормально (и даже желательно), но мониторинг должен агрегировать состояния, а не смотреть на один под.
  • OnStateChange не подключён к метрикам. Без алертов на переход в Open сбой downstream остаётся незамеченным до жалоб.

Вопросы на собеседовании#

В: Опишите три состояния circuit breaker и переходы. О: Closed — запросы проходят, считаем ошибки; при превышении порога (ReadyToTrip) → Open. Open — fail fast, запросы блокируются; по истечении Timeout → Half-Open. Half-Open — пропускаем ограниченное число проб; все успешны → Closed (счётчики сброшены), любой провал → снова Open.

В: Зачем нужно состояние Half-Open, почему не переходить из Open сразу в Closed? О: Чтобы безопасно проверить восстановление, не обрушив downstream лавиной. Half-Open пропускает лишь несколько пробных запросов; если они успешны — сервис здоров, закрываем цепь, иначе возвращаемся в Open. Прямой переход Open→Closed послал бы весь трафик на возможно ещё нездоровый сервис.

В: Чем circuit breaker отличается от retry? О: Retry борется с транзиентными сбоями повтором запроса; breaker — с устойчивыми сбоями прекращением запросов (fail fast). Retry при массовом сбое усиливает нагрузку, breaker — гасит её. Они комплементарны: retry для редких глюков, breaker — чтобы не долбить в мёртвый сервис.

В: Почему опасно комбинировать retry и circuit breaker и как делать правильно? О: Retry внутри breaker искажает его счётчики и множит нагрузку; ретраи разных слоёв перемножаются (retry amplification), создавая шторм при деградации. Правильно: breaker оборачивает одиночный вызов, retry — снаружи с backoff+jitter и проверкой ErrOpenState (не ретраить при открытом breaker’е); ретраить только на одном уровне стека.

В: Что такое bulkhead и как он соотносится с circuit breaker? О: Bulkhead — изоляция ресурсов: каждой зависимости выделяется свой ограниченный пул (семафор/пул соединений), чтобы одна тормозящая зависимость не выела все воркеры. Breaker решает «пускать ли вызов вообще» (по здоровью), bulkhead — «сколько одновременных вызовов разрешено» (изоляция). Применяются вместе.

В: Как настроить gobreaker, чтобы 404-ответы не размыкали цепь? О: Через Settings.IsSuccessful: вернуть true для ошибок с кодом < 500 (и для context.Canceled). Тогда breaker считает провалами только 5xx/сетевые/таймауты, а клиентские ошибки не влияют на его состояние.

В: Какие fallback-стратегии вы знаете? О: Cached/stale value (отдать последний успешный ответ), default value (разумный дефолт), graceful degradation (отключить часть функциональности), async/queue (отложить операцию), либо явный fail fast с 503+Retry-After, если корректный fallback невозможен (например, платёж). Fallback должен быть надёжнее защищаемого вызова.

В: Как выбрать условие размыкания (ReadyToTrip)? О: Предпочтительно доля ошибок при достаточном объёме запросов (Requests >= N && failures/requests >= threshold), а не абсолютное число подряд — последнее флапает на низком трафике. Учитывать Interval (окно сброса счётчиков) и характер зависимости.

В: Breaker в каждом поде свой — это проблема при масштабировании? О: Нет, in-process breaker на под — норма и даже желательно (локальное решение, без сетевого оверхеда на координацию). Каждый под защищает себя независимо. Важно лишь агрегировать состояния в мониторинге, а не смотреть на один под, и не делать общий распределённый breaker без необходимости.

На что копают на senior+#

  • Понимание каскадных сбоев. Кандидат должен объяснить механику истощения пулов и распространения сбоя вверх по графу зависимостей, а не просто «breaker отключает плохой сервис».
  • Взаимодействие паттернов. Грамотная композиция timeout + bulkhead + breaker + retry + fallback и правильный порядок обёрток; понимание, что breaker без таймаута почти бесполезен.
  • Retry amplification. Глубокое осознание опасности перемножения ретраев по слоям и retry-storm, требование backoff+jitter и ретраев на одном уровне.
  • Настройка под нагрузку. Осмысленный выбор ReadyToTrip (доля vs абсолют), Timeout, MaxRequests, Interval, IsSuccessful — и понимание, что бизнес-ошибки не должны открывать breaker.
  • Гранулярность. Per-dependency / per-host breaker’ы вместо одного глобального; обсуждение partial outage одного инстанса.
  • Fallback-мышление. Что breaker без продуманной деградации лишь меняет вид ошибки; trade-offs stale-данных vs честного отказа (особенно для денег/консистентности).
  • Наблюдаемость. OnStateChange → метрики/алерты; SLO на долю fail-fast ответов; дашборды состояний по подам.
  • Знание экосистемы. sony/gobreaker, наследие Hystrix (и почему он в maintenance), аналоги (resilience4j, Polly), реализация в service mesh (Envoy outlier detection) — и когда лучше mesh, а когда in-process.