{ "index": 69, "slug": "editorial-2026-02-practice-resilience", "title": "Как остановить каскадный отказ: один бюджет для retry и fallback", "excerpt": "Медленный dependency превращает несколько разумных защит в каскад лишней работы. Разбираем владельца retry, предел fan-out, безопасный fallback и проверяемую точку остановки.", "contentHtml": "

Сервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос. Адаптер повторяет тот же вызов. Затем fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад.

\n

Симптом виден в трёх местах: исходящих вызовов на один пользовательский запрос становится больше, очередь не сокращается после добавления реплик, а trace показывает несколько владельцев retry. Цена ошибки выше задержки. Ослабленный dependency получает дополнительную работу в момент, когда уже не справляется с прежней. Каскад может перегрузить соседние компоненты и превратить частичный отказ в общий.

\n

Тезис простой: устойчивость начинается с ограничения работы. Для каждой логической операции нужно назначить одного владельца retry, задать конечный fan-out, назвать fallback и определить точку, после которой новые вызовы запрещены. Если эти границы нельзя восстановить из trace, систему нельзя считать готовой к проверке отказа.

\n

Механизм: считать логическую операцию

\n

Логическая операция — это один запрос пользователя или одно сообщение, которое система должна обработать. Внутри неё могут быть несколько сетевых вызовов. Поэтому успешные ответы не показывают полную стоимость. Считайте попытки, созданные после каждого временного исхода.

\n

Предположим, внешний вызов получает timeout. Край делает две попытки. Каждая попытка попадает в адаптер, который тоже разрешает два повтора. Затем необязательная часть запускает fallback. Число обращений растёт не как сумма настроек. Оно перемножается по слоям. Формула полезна как сигнал: общий fan-out равен произведению локальных ветвей, пока слой не остановит работу.

\n

Retry должен иметь одного владельца. Остальные слои передают ему исход и принимают конечный результат. Они не запускают собственные циклы. Выбор реплики также должен иметь числовой предел. На одну разрешённую попытку выбирается одна заранее названная реплика, а не «любая доступная» без ограничения.

\n
\"Схема
Учебная схема показывает порядок контроля: лимит срабатывает до расширения маршрута, поэтому fallback не становится вторым слоем повторов.
\n

Учебный пример с явной границей

\n

Ниже синтетический JavaScript-фрагмент. Он не обращается к сети, не запускает таймеры и не измеряет настоящий сервис. Имена реплик, исходы и размеры бюджета нужны только для объяснения переходов.

\n
const policy = {\n  retryOwner: 'edge',\n  maxAttempts: 2,\n  retryable: ['temporary-timeout'],\n  replicas: ['primary-a', 'primary-b'],\n  maxFanoutPerAttempt: 1,\n  fallback: {\n    name: 'named-summary',\n    trigger: 'optional-result-unavailable',\n    addsRetry: false,\n  },\n  exhausted: 'return-degraded-result',\n};\n\nfunction nextAction(outcome, retryBudget) {\n  if (retryBudget === 0) return policy.exhausted;\n  if (outcome === 'temporary-timeout') return 'retry-on-one-named-replica';\n  if (outcome === 'optional-result-unavailable') return policy.fallback.name;\n  return 'complete';\n}\n\n// Учебная проверка: это не production-код и не нагрузочный тест.\nconsole.log(nextAction('temporary-timeout', 0));\n// return-degraded-result
\n

Отрицательный путь здесь важнее положительного. При нулевом бюджете функция не выбирает новую реплику и не входит в fallback. Она возвращает конечное состояние. В реальной системе такой degraded result подходит не каждой операции. Для платежа, записи или команды с побочным эффектом он может скрыть неопределённость. Тогда контракт должен вернуть явную ошибку или запустить безопасную компенсацию. Учебный результат нельзя переносить туда без отдельной проверки.

\n

Положительный путь тоже ограничен. При temporary-timeout край может выполнить одну разрешённую повторную попытку на одной реплике. Fallback не получает право повторить основной вызов. Его задача — вернуть заранее названный урезанный результат, если необязательная часть недоступна. Если fallback сам ищет реплику, он становится новым маршрутом и получает собственный лимит.

\n

Симптом → причина → проверка → действие

\n
Карта проверки каскада
СимптомПричинаПроверкаДействие
Вызовов больше, чем логических запросовRetry включён на нескольких слояхПосчитать попытки на один request id и найти их владельцев в traceОставить одного владельца, вложенный retry отключить
При timeout запускаются несколько репликFan-out задан как «все здоровые»Проверить список имён и максимум запусков на попыткуЗадать число и одно имя на одну попытку
Fallback увеличивает нагрузкуЗапасной путь скрывает сетевой вызов или retryРазвернуть trace fallback до terminal resultСделать fallback режимом без повторов или выключить его
Лимит виден после новых вызововОграничение стоит после расширения маршрутаСравнить порядок: budget, маршрут, вызовПеренести limit до retry, fan-out и fallback
Сравниваются разные сценарииРазличаются нагрузка или failure injectionСверить logical requests, исход и baselineСначала выровнять сценарии, затем сравнивать изменения
\n

Что фиксировать в trace

\n

Trace должен позволять восстановить решение без чтения всех исходников. Для каждой логической операции запишите request id, владельца retry, номер попытки, выбранную реплику, исход, остаток бюджета и terminal action. Если модель использует условные units, не называйте их миллисекундами. Единица измерения должна быть ясна.

\n

Минимальная последовательность ограниченного сценария может выглядеть так: logical-01 → primary-a → temporary-timeout, затем edge retry budget: 2 → 1, затем logical-01 → primary-b → complete. Для необязательной части допустима ветка optional-result-unavailable → named-summary. В ней не должно появляться скрытого выбора третьей реплики.

\n

Проверяйте место лимита по порядку событий, а не по имени поля конфигурации. Если trace сначала показывает два новых вызова, а потом сообщает об исчерпанном бюджете, счётчик не остановил каскад. Он только зафиксировал уже сделанную работу.

\n

Порядок действий

\n
  1. Назовите логическую операцию и отделите её от исходящих вызовов.
  2. Перечислите все места, где отказ может создать дополнительную работу.
  3. Выберите одного владельца retry и задайте конечное число попыток.
  4. Опишите fan-out числом и именами реплик; запретите неограниченный поиск.
  5. Назовите fallback, его входной исход, стоимость и terminal result.
  6. Поставьте limit до расширения маршрута и укажите действие при нулевом бюджете.
  7. Проверьте положительный и отрицательный trace на одинаковой логической нагрузке.
  8. Отдельно решите, допустим ли degraded result для операции с побочным эффектом.
\n

Ограничения

\n

Схема не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, отмену, дедлайны, балансировку, идемпотентность, транзакции, распределённое состояние и стоимость запуска процесса. Она также не отвечает, какой ответ полезен пользователю после деградации.

\n

Временный timeout не означает, что повтор безопасен. Запрос мог дойти до сервера и завершить запись до того, как клиент получил ответ. Для операции с побочным эффектом сначала проверьте идемпотентность и контракт повторной доставки. Без такого контракта даже ограниченный retry может продублировать действие.

\n

Код 503 и заголовок Retry-After помогают передать перегрузку на HTTP-уровне, но сами не назначают владельца retry и не ограничивают fan-out. Автоматическое повторение включайте только для исходов, которые действительно могут стать успешными, и с учётом бюджета всей цепочки.

\n

Проверяемый критерий готовности

\n

Сценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбирается на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых logical requests и одинакового failure injection число дополнительных запусков не превышает заданный предел.

\n

Если хотя бы один ответ требует догадки, сценарий не готов. Сначала назовите скрытый переход и назначьте его владельца. После этого отдельный тест с реальной сетью, данными, правами и наблюдением должен подтвердить поведение уже production-подобного пути. Результат учебной модели такой тест не предсказывает.

\n

Проверяемые источники

\n" }