Circuit breaker и bulkhead: пережить лежащего партнёра

Перед началом: модули 9–11 (таймауты, ретраи, graceful shutdown). Инструменты: Go 1.22+, pprof/метрики для проверки деградации. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).

🔥 Инцидент: партнёр лёг на двадцать минут — и уронил нас

Антифрод-партнёр отвечает на каждый заказ. В понедельник его API перестало отвечать: соединения принимаются, ответа нет. Наш сервис честно ждал — таймаут 10 секунд, три ретрая с бэкоффом. Через пять минут в http-хендлерах висело 4 000 горутин, память выросла вдвое, а новые запросы встали в очередь на пул соединений: мы деградировали из-за чужого отказа, хотя бизнес-логика была цела.

Разбор показал три ошибки, каждая из которых — отдельный паттерн:

  1. Не было circuit breaker: 100 % вызовов уходили в мёртвый партнёр и висели в таймаутах; мы платили горутинами и памятью за каждый.
  2. Не было bulkhead: вызовы к антифроду занимали общий пул соединений сервиса, и «чужая» деградация съела ресурсы всех остальных зависимостей.
  3. Ретраи умножали нагрузку: три попытки × 10 секунд = 30 секунд ожидания на запрос вместо быстрой ошибки.

Этот модуль — про два паттерна, которые превращают «партнёр лежит» в «мы работаем с деградацией, но не падаем».

📖 Теория

12.1 Почему таймаута и ретраев недостаточно

Таймаут ограничивает один вызов, но не общий поток: 10 000 запросов в секунду × 10 секунд таймаута = до 100 000 одновременно ждущих горутин. Это не «немного медленнее» — это память, планировщик и исчерпанный пул соединений. Ретраи утраивают эту нагрузку и добавляют задержку даже тем, кому можно было ответить быстро.

Нужен механизм, который перестаёт ходить наружу после серии отказов, — и делает это на уровне решения «пускать/не пускать вызов», а не на уровне отдельного запроса.

12.2 Circuit breaker: закрыт, открыт, полуоткрыт

Состояния и переходы:

Состояния circuit breaker
  • closed — вызовы идут; считаем отказы. Обычно порог — «N подряд» или «доля ошибок выше X % за окно» (окно важнее: подряд ловит всплески, доля — устойчивую деградацию).
  • open — вызовы не идут: сразу ошибка ErrOpen (fail fast). Это экономит горутины, память, соединения и время ответа.
  • halfOpen — после таймера охлаждения пропускаем одну пробу: успех → closed, ошибка → снова open с новым таймером.

Ключевые детали реализации:

go
if b.state == open {
    if time.Since(b.openedAt) < cooldown {
        return ErrOpen // даже не пытаемся
    }
    b.state = halfOpen // время пробовать
}

Состояние живёт в памяти процесса — у каждого инстанса свой breaker. Это нормально: если партнёр лёг, каждый инстанс выяснит это за несколько секунд сам; общий breaker через Redis нужен только когда порогов на инстанс слишком много и суммарный трафик велик.

12.3 Bulkhead: по своему пулу на каждую зависимость

Bulkhead (переборка корабля) — изоляция ресурсов: вызовы к антифроду идут через свой семафор/пул, а не общий. Если партнёр лёг и медленные вызовы заняли все 10 слотов антифрод-пула, остальные зависимости (база, платежи) не чувствуют этого: их пулы свободны.

go
var antifraudSem = make(chan struct{}, 10) // лимит именно на партнёра

select {
case antifraudSem <- struct{}{}:
    defer func() { <-antifraudSem }()
default:
    return ErrBulkheadFull // своих горутин не тратим
}

Размер bulkhead — не «сколько влезет», а «сколько одновременных вызовов партнёр реально переваривает» (обычно десятки). Всё, что сверх, отклоняется быстро и осознанно.

12.4 Fallback: что отвечаем, когда вызов не прошёл

Без fallback отказ партнёра всё равно превращается в ошибку пользователю. Варианты по возрастанию риска:

  • Отказ явный: «не можем проверить — повторите позже» (для финтеха это часто единственный корректный вариант).
  • Кэш последнего ответа (если партнёр — источник справочных данных).
  • Разрешить без проверки — только если риск заведомо ограничен и зафиксирован в ADR; для антифрода это обычно запрещено.

Fallback — это не «улучшайзинг», а продуктовое решение: он определяет, что сервис делает при деградации. Выберите его до инцидента.

12.5 Компромиссы

  • Порог CB низкий vs высокий: низкий (3 подряд) слишком дёрганый — открывается на случайном всплеске; высокий (50 %) пропускает слишком много мусора до реакции.
  • Проба одна vs несколько: одна проба быстрее закрывает breaker, но при нестабильном партнёре даёт «дребезг» open↔halfOpen; N проб (например, 3 успеха подряд) стабильнее, но медленнее.
  • Bulkhead на зависимость vs общий пул: общий пул проще, но одна мёртвая зависимость съедает ресурсы всех; per-dependency — точечно, но требует дисциплины и конфигурации на каждую зависимость.
  • Fail open vs fail closed в fallback: fail open сохраняет доступность ценой риска (пропустить мошенника), fail closed — целостность ценой отказов; решение фиксируется в ADR и не меняется по ночам.

💻 Практика

  1. Сломайте партнёра. Фейковый клиент антифрода: 20 % запросов падают по таймауту (200 мс). Оберните его: таймаут, ретраи, затем circuit breaker с порогом 5 подряд и cooldown 2 с. Снимите метрики allowed/blocked и время ответа API до и после.
  2. Добавьте bulkhead. Семафор на 10 слотов только для антифрода; пока партнёр лежит, прогоните нагрузку на «список заказов» (не зависящую от антифрода) и покажите, что её latency не меняется.
  3. Настройте пробы. Сравните поведение breaker с одной пробой и с тремя успехами подряд при «мигающем» партнёре (падает на 1 с, поднимается на 1 с). Какой вариант меньше дёргается — и чем платит?
  4. Запустите сниппет модуля: брейкер с состояниями и переходами на 14 вызовах, плюс bulkhead-семафор с ответом «занято».

🧩 Quiz

1. В каком состоянии circuit breaker сразу возвращает ошибку, не делая вызова?
2. Что такое bulkhead?
3. Зачем нужен circuit breaker, если у каждого вызова есть таймаут?
4. Какой fallback для антифрода корректен по умолчанию?
5. Почему circuit breaker с состоянием в памяти на каждом инстансе — это обычно нормально?

✅ Итоги модуля

Студент должен:

  • Объяснить, почему таймаут и ретраи не защищают от лежащего партнёра
  • Реализовать circuit breaker с closed/open/halfOpen и пробами
  • Настроить пороги (подряд vs доля) и таймер охлаждения осознанно
  • Изолировать зависимости bulkhead-семафором и объяснить размер лимита
  • Выбрать fallback и знать, чем платит fail open
  • Добавить метрики состояний CB и отклонений bulkhead
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 13

CB и bulkhead решают, что делать при отказе синхронного вызова. Следующая задача — работа, которая не помещается в HTTP-ответ: письма, вебхуки, отчёты. Горутина «в фоне после ответа» теряет задачи при рестарте; нужен диспетчер с ограниченной очередью, ретраями и политикой переполнения — и понимание, когда пора выносить его в брокер.