Resilience patterns на собеседовании системного аналитика

Проверь себя · 1/3разбор после ответа
Есть таблицы payments(user_id) и refunds(user_id). Нужно получить пользователей, у которых был платёж, но не было ни одного возврата. Какой запрос корректнее всего описывает задачу?

Зачем комбинировать паттерны

По отдельности timeout, retry, circuit breaker и bulkhead знают многие. Но на собесе системного аналитика ценнее показать, что вы понимаете, как они работают вместе: каждый паттерн закрывает свой тип отказа, и вся ценность появляется, только когда они собраны в правильном порядке. Один retry без circuit breaker добьёт умирающий сервис, а один circuit breaker без bulkhead не спасёт, если пул соединений уже забит зависшими запросами.

Вопрос почти всегда звучит как «спроектируй устойчивый вызов внешнего сервиса» или «что произойдёт, если downstream начнёт тормозить». Интервьюер смотрит, думаете ли вы про отказы системно — а не «навесил ретраи и пойдёт».

Полный стек resilience-паттернов

Устойчивый вызов внешнего сервиса — это несколько паттернов, вложенных друг в друга. Рекомендованный порядок обёртывания (снаружи внутрь) такой:

Retry             — повторяет упавшие попытки (транзиентные ошибки)
  └ Circuit Breaker  — размыкается, если сервис стабильно нездоров (fail fast)
      └ Timeout        — ограничивает КАЖДУЮ попытку по времени
          └ Bulkhead    — изолирует пул ресурсов под этот вызов
              └ вызов downstream

Каждый уровень закрывает свой сценарий отказа:

  • Retry повторяет попытку при кратковременных сбоях (сетевой blip, единичный таймаут).
  • Circuit Breaker отслеживает долю ошибок и, если сервис стабильно падает, размыкается — перестаёт вообще ходить в него и сразу возвращает ошибку (fail fast), давая downstream время восстановиться.
  • Timeout не даёт зависнуть на одной попытке дольше заданного времени.
  • Bulkhead выделяет вызову отдельный пул ресурсов (потоков, соединений), чтобы один тормозящий downstream не забрал все ресурсы приложения.

Именно такой порядок (retry снаружи, bulkhead внутри) рекомендует, например, Resilience4j.

Порядок важен

Порядок вложенности — не формальность, он меняет поведение системы. Ключевой и самый обсуждаемый на собесе момент — как соотносятся retry и circuit breaker.

Circuit breaker логично держать внутри retry (то есть retry снаружи). Тогда breaker видит каждую отдельную попытку и честно считает долю ошибок по реальной статистике вызовов. Если поставить breaker снаружи retry, он увидит только финальный результат всей серии попыток — и среагирует позже и грубее, потому что несколько неудачных ретраев засчитаются как одна ошибка. Есть и второй плюс такого порядка: когда breaker уже разомкнут, попытки retry сразу получают отказ (fail fast) вместо реального вызова, и не добивают лежащий сервис.

Дальше внутри — timeout на каждую попытку, а в самой середине — bulkhead, изолирующий ресурсы конкретного вызова. Перепутать порядок — значит получить тонкие баги: например, breaker снаружи retry будет размыкаться медленнее, чем нужно, и приложение продолжит слать запросы в уже мёртвый сервис.

Где ставить таймаут

Про таймауты важно понимать разницу между двумя уровнями, и на собесе это любят уточнять.

Таймаут на попытку (per-attempt). Ограничивает каждую отдельную попытку вызова. Поскольку timeout стоит внутри retry, у каждого повтора свой лимит времени — зависшая попытка прерывается, и retry делает следующую.

Общий бюджет операции: 5 с
Число попыток: 3
Таймаут на попытку: ~1.5 с (плюс backoff между попытками)

Общий бюджет операции (total / deadline). Жёсткий потолок на всю операцию целиком. Он нужен, потому что таймауты на попытки складываются: три попытки по 1.5 секунды плюс паузы backoff легко превысят SLA вызывающего. Общий дедлайн гарантирует, что вся цепочка «попытки + ретраи + backoff» уложится в отведённое время и не подвесит запрос пользователя.

На практике задают оба: per-attempt защищает от одной зависшей попытки, total — от того, что ретраи в сумме растянутся дольше, чем готов ждать клиент.

Готовишься к собесу системного аналитика?
827 вопросов: REST, UML, OAuth, ERD, требования. Тренируйся в Telegram
Тренировать SA в Telegram

Типы отказов

Смысл всей конструкции в том, что разные паттерны закрывают разные типы отказов. На собесе полезно проговорить это явно:

  • Транзиентный (кратковременный). Сетевой сбой на долю секунды, единичный таймаут. Лечится retry — вторая попытка, скорее всего, пройдёт.
  • Устойчивый. Сервис лежит целиком. Retry здесь только вредит — добивает downstream. Спасает circuit breaker: размыкается и перестаёт слать запросы.
  • Медленный ответ. Сервис отвечает, но слишком долго. Retry не поможет, нужен timeout — прервать попытку и не держать ресурс занятым.
  • Каскадный. Один тормозящий downstream забирает все потоки/соединения, и падает всё приложение. Изолирует bulkhead: тормозит только пул этого вызова, остальное живёт.
  • Перегрузка. Запросов больше, чем система способна обработать. Здесь работают backpressure и rate limiting — притормозить или отклонить лишнее, чтобы не уйти в лавину.

Частые ошибки

  • Retry на неидемпотентных операциях. Повтор запроса на списание денег или создание заказа без идемпотентности приводит к двойному списанию или дублю. Ретраить можно только то, что безопасно повторить.
  • Retry без backoff и jitter. Одновременные повторы от многих клиентов создают retry storm и добивают и без того нагруженный сервис. Нужны экспоненциальный backoff и случайный разброс (jitter).
  • Retry на неповторяемых ошибках. Повторять запрос при ошибке валидации или 4xx бессмысленно — результат не изменится, только зря нагружаете сервис. Ретраить стоит транзиентные ошибки (сеть, 5xx, таймаут).
  • Нет общего дедлайна. Без total-таймаута per-attempt-таймауты и backoff складываются и превышают SLA вызывающего — пользователь ждёт дольше, чем должен.
  • Один пул на все downstream. Без bulkhead один медленный внешний сервис забивает общий пул соединений, и вместе с ним падают вызовы ко всем остальным сервисам.

Связанные темы

FAQ

В каком порядке оборачивать resilience-паттерны?

Рекомендуемый порядок снаружи внутрь: retry → circuit breaker → timeout → bulkhead → сам вызов. Retry снаружи повторяет транзиентные сбои, circuit breaker внутри него честно считает долю ошибок по каждой попытке, timeout ограничивает каждую попытку, bulkhead изолирует ресурсы вызова. Такой порядок рекомендует Resilience4j.

Retry должен быть снаружи или внутри circuit breaker?

Обычно retry снаружи, circuit breaker внутри. Так breaker видит каждую отдельную попытку и размыкается по реальной статистике ошибок, а когда он уже разомкнут, ретраи сразу получают fail fast и не добивают сервис. Если поставить breaker снаружи retry, он среагирует медленнее, засчитав целую серию попыток как одну ошибку.

Чем per-attempt timeout отличается от общего?

Per-attempt ограничивает каждую отдельную попытку вызова, а общий (total / deadline) — всю операцию целиком, включая все ретраи и паузы backoff. Нужны оба: без per-attempt одна зависшая попытка держит ресурс, а без общего дедлайна сумма попыток и backoff может превысить SLA вызывающего.

Зачем bulkhead, если уже есть circuit breaker?

Они закрывают разные проблемы. Circuit breaker реагирует на долю ошибок и размыкается, когда сервис стабильно падает. Bulkhead изолирует ресурсы: даже если сервис ещё «формально жив», но отвечает медленно, он не должен забрать весь пул соединений и уронить вызовы к другим сервисам. Bulkhead ограничивает урон от медленного (а не только упавшего) downstream.

Это официальная информация?

Нет. Статья основана на документации Resilience4j и книге Michael Nygard «Release It!». Конкретные формулировки вопросов зависят от компании, команды и уровня позиции.


Тренируйте системный анализ — откройте тренажёр с 1500+ вопросами для собесов.