Модуль: Распределённые системы · Уровень: 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. По истечении Timeout → Half-Open.
Half-Open — пробное состояние. Пропускается ограниченное число «разведочных» запросов (MaxRequests). Если они успешны — downstream восстановился, переход в Closed, счётчики сброшены. Если хоть один провалился — обратно в Open, таймер заново. В half-open параллелизм ограничен, чтобы не обрушить только что вставший сервис лавиной.
Зачем это нужно (что именно даёт)#
- Fail fast. Вместо ожидания таймаута на заведомо мёртвый сервис — мгновенная ошибка. Освобождает ресурсы вызывающего, держит хвост latency низким.
- Защита downstream. Перегруженный сервис не добивается потоком запросов — ему дают передышку для восстановления (сброс очередей, GC, переподключение к БД).
- Предотвращение каскада. Локализует сбой одной зависимости, не давая ему «съесть» весь сервис и распространиться выше.
- Управляемая деградация. В связке с 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 при превышенииMaxRequests—ErrTooManyRequests.
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#
Это разные инструменты для разных сбоев:
| Retry | Circuit Breaker | |
|---|---|---|
| Цель | пережить транзиентный сбой (мелькнул и прошёл) | защититься от устойчивого сбоя |
| Действие | повторить запрос | прекратить запросы |
| Когда хорош | редкая сетевая ошибка, 503 на долю секунды | downstream упал/перегружен надолго |
| Риск | при массовом сбое усиливает нагрузку | может «зарубить» уже выздоровевший сервис |
Они комплементарны: retry для случайных глюков, breaker — чтобы не ретраить в стену.
Опасность retry + circuit breaker вместе#
Наивная комбинация создаёт проблемы:
Retry накручивает счётчик breaker’а. Если retry внутри Execute — каждая попытка считается отдельным запросом, breaker размыкается раньше/позже, чем ожидаешь, на искажённой статистике. Если retry снаружи breaker’а — после размыкания каждый retry мгновенно получает
ErrOpenState, что бессмысленно тратит попытки. Правильно: breaker оборачивает один вызов, retry — снаружи, с проверкойErrOpenState(не ретраить при открытом breaker’е).Усиление нагрузки (retry storm). При деградации downstream все клиенты одновременно ретраят → нагрузка ×N в худший момент → добивают сервис. Обязательны exponential backoff + jitter и ограничение числа попыток. Breaker здесь — последняя линия: когда retry не помогает, он размыкается и гасит шторм.
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.