{ "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Предположим, внешний вызов получает timeout. Край делает две попытки. Каждая попытка попадает в адаптер, который тоже разрешает два повтора. Затем необязательная часть запускает fallback. Число обращений растёт не как сумма настроек. Оно перемножается по слоям. Формула полезна как сигнал: общий fan-out равен произведению локальных ветвей, пока слой не остановит работу.
\nRetry должен иметь одного владельца. Остальные слои передают ему исход и принимают конечный результат. Они не запускают собственные циклы. Выбор реплики также должен иметь числовой предел. На одну разрешённую попытку выбирается одна заранее названная реплика, а не «любая доступная» без ограничения.
\nНиже синтетический JavaScript-фрагмент. Он не обращается к сети, не запускает таймеры и не измеряет настоящий сервис. Имена реплик, исходы и размеры бюджета нужны только для объяснения переходов.
\nconst 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| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Вызовов больше, чем логических запросов | 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 | Сначала выровнять сценарии, затем сравнивать изменения |
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. В ней не должно появляться скрытого выбора третьей реплики.
Проверяйте место лимита по порядку событий, а не по имени поля конфигурации. Если trace сначала показывает два новых вызова, а потом сообщает об исчерпанном бюджете, счётчик не остановил каскад. Он только зафиксировал уже сделанную работу.
\nСхема не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, отмену, дедлайны, балансировку, идемпотентность, транзакции, распределённое состояние и стоимость запуска процесса. Она также не отвечает, какой ответ полезен пользователю после деградации.
\nВременный timeout не означает, что повтор безопасен. Запрос мог дойти до сервера и завершить запись до того, как клиент получил ответ. Для операции с побочным эффектом сначала проверьте идемпотентность и контракт повторной доставки. Без такого контракта даже ограниченный retry может продублировать действие.
\nКод 503 и заголовок Retry-After помогают передать перегрузку на HTTP-уровне, но сами не назначают владельца retry и не ограничивают fan-out. Автоматическое повторение включайте только для исходов, которые действительно могут стать успешными, и с учётом бюджета всей цепочки.
\nСценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбирается на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых logical requests и одинакового failure injection число дополнительных запусков не превышает заданный предел.
\nЕсли хотя бы один ответ требует догадки, сценарий не готов. Сначала назовите скрытый переход и назначьте его владельца. После этого отдельный тест с реальной сетью, данными, правами и наблюдением должен подтвердить поведение уже production-подобного пути. Результат учебной модели такой тест не предсказывает.
\n