Circuit breaker и bulkhead: пережить лежащего партнёра
Перед началом: модули 9–11 (таймауты, ретраи, graceful shutdown). Инструменты: Go 1.22+, pprof/метрики для проверки деградации. Трудоёмкость: ~4 ч (теория 1.5 ч · практика 2 ч · квиз 0.5 ч).
🔥 Инцидент: партнёр лёг на двадцать минут — и уронил нас
Антифрод-партнёр отвечает на каждый заказ. В понедельник его API
перестало отвечать: соединения принимаются, ответа нет. Наш сервис
честно ждал — таймаут 10 секунд, три ретрая с бэкоффом. Через пять
минут в http-хендлерах висело 4 000 горутин, память выросла вдвое,
а новые запросы встали в очередь на пул соединений: мы деградировали
из-за чужого отказа, хотя бизнес-логика была цела.
Разбор показал три ошибки, каждая из которых — отдельный паттерн:
- Не было circuit breaker: 100 % вызовов уходили в мёртвый партнёр и висели в таймаутах; мы платили горутинами и памятью за каждый.
- Не было bulkhead: вызовы к антифроду занимали общий пул соединений сервиса, и «чужая» деградация съела ресурсы всех остальных зависимостей.
- Ретраи умножали нагрузку: три попытки × 10 секунд = 30 секунд ожидания на запрос вместо быстрой ошибки.
Этот модуль — про два паттерна, которые превращают «партнёр лежит» в «мы работаем с деградацией, но не падаем».
📖 Теория
12.1 Почему таймаута и ретраев недостаточно
Таймаут ограничивает один вызов, но не общий поток: 10 000 запросов в секунду × 10 секунд таймаута = до 100 000 одновременно ждущих горутин. Это не «немного медленнее» — это память, планировщик и исчерпанный пул соединений. Ретраи утраивают эту нагрузку и добавляют задержку даже тем, кому можно было ответить быстро.
Нужен механизм, который перестаёт ходить наружу после серии отказов, — и делает это на уровне решения «пускать/не пускать вызов», а не на уровне отдельного запроса.
12.2 Circuit breaker: закрыт, открыт, полуоткрыт
Состояния и переходы:
- closed — вызовы идут; считаем отказы. Обычно порог — «N подряд» или «доля ошибок выше X % за окно» (окно важнее: подряд ловит всплески, доля — устойчивую деградацию).
- open — вызовы не идут: сразу ошибка
ErrOpen(fail fast). Это экономит горутины, память, соединения и время ответа. - halfOpen — после таймера охлаждения пропускаем одну пробу: успех → closed, ошибка → снова open с новым таймером.
Ключевые детали реализации:
goif b.state == open { if time.Since(b.openedAt) < cooldown { return ErrOpen // даже не пытаемся } b.state = halfOpen // время пробовать }
Состояние живёт в памяти процесса — у каждого инстанса свой breaker. Это нормально: если партнёр лёг, каждый инстанс выяснит это за несколько секунд сам; общий breaker через Redis нужен только когда порогов на инстанс слишком много и суммарный трафик велик.
12.3 Bulkhead: по своему пулу на каждую зависимость
Bulkhead (переборка корабля) — изоляция ресурсов: вызовы к антифроду идут через свой семафор/пул, а не общий. Если партнёр лёг и медленные вызовы заняли все 10 слотов антифрод-пула, остальные зависимости (база, платежи) не чувствуют этого: их пулы свободны.
govar 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 и не меняется по ночам.
💻 Практика
- Сломайте партнёра. Фейковый клиент антифрода: 20 % запросов
падают по таймауту (200 мс). Оберните его: таймаут, ретраи, затем
circuit breaker с порогом 5 подряд и cooldown 2 с. Снимите метрики
allowed/blockedи время ответа API до и после. - Добавьте bulkhead. Семафор на 10 слотов только для антифрода; пока партнёр лежит, прогоните нагрузку на «список заказов» (не зависящую от антифрода) и покажите, что её latency не меняется.
- Настройте пробы. Сравните поведение breaker с одной пробой и с тремя успехами подряд при «мигающем» партнёре (падает на 1 с, поднимается на 1 с). Какой вариант меньше дёргается — и чем платит?
- Запустите сниппет модуля: брейкер с состояниями и переходами на 14 вызовах, плюс bulkhead-семафор с ответом «занято».
🧩 Quiz
✅ Итоги модуля
Студент должен:
- Объяснить, почему таймаут и ретраи не защищают от лежащего партнёра
- Реализовать circuit breaker с closed/open/halfOpen и пробами
- Настроить пороги (подряд vs доля) и таймер охлаждения осознанно
- Изолировать зависимости bulkhead-семафором и объяснить размер лимита
- Выбрать fallback и знать, чем платит fail open
- Добавить метрики состояний CB и отклонений bulkhead
- Ответить на все вопросы Quiz
🔗 Мостик к Модулю 13
CB и bulkhead решают, что делать при отказе синхронного вызова. Следующая задача — работа, которая не помещается в HTTP-ответ: письма, вебхуки, отчёты. Горутина «в фоне после ответа» теряет задачи при рестарте; нужен диспетчер с ограниченной очередью, ретраями и политикой переполнения — и понимание, когда пора выносить его в брокер.