{ "index": 69, "slug": "editorial-2026-02-practice-resilience", "title": "Как остановить каскадный отказ: один бюджет для retry и fallback", "excerpt": "Медленная зависимость превращает несколько разумных защит в каскад лишней работы. Разбираем владельца retry, предел fan-out, безопасный fallback и проверяемую точку остановки.", "contentHtml": "
Сервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос, адаптер повторяет тот же вызов, а fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад: один пользовательский запрос порождает несколько обращений к уже перегруженной зависимости.
\nСимптом можно увидеть без догадок: на один request_id приходится больше исходящих попыток, очередь не сокращается после добавления реплик, а trace показывает несколько независимых владельцев retry. Цена ошибки — не только лишние миллисекунды. Ослабленная зависимость получает новую работу в момент, когда не справляется с прежней, а частичный отказ распространяется на соседние компоненты.
В этой статье «один бюджет» означает один бюджет попыток для одной логической операции и одного владельца retry. Это не глобальный лимит сервиса и не разрешение повторять всё подряд. Для защиты от общей перегрузки нужен отдельный сервисный лимит или circuit breaker, но он не должен превращаться в ещё один слой автоматических повторов.
\nЛогическая операция — это запрос пользователя или сообщение, которое система должна обработать. Внутри неё могут быть вызов API, чтение из базы и запрос к необязательному сервису. Считать только успешные ответы опасно: стоимость отказа состоит из всех попыток, включая те, что завершились timeout или были отменены по дедлайну.
\nЗапишите границу операции и единицу счёта. Например: checkout-481 — одна команда оформления, а вызовы pricing и inventory — её дочерние действия. Если верхний слой разрешает две попытки, это обычно означает одну исходную попытку и одну повторную, а не две дополнительные попытки на каждом нижнем сервисе.
При независимых retry на нескольких слоях верхняя граница растёт мультипликативно. Если три слоя делают по четыре попытки, один запрос может создать до 64 вызовов нижней зависимости. Это верхняя оценка для такого дерева, а не обещание, что каждый вызов будет выполнен: отмена, дедлайн и ошибки могут остановить ветку раньше. Но уже сама оценка показывает, почему локальные настройки нельзя складывать без общей модели.
\nУ retry должен быть один владелец на границе, где видны дедлайн всей операции и её итог. Остальные слои возвращают ему исход вызова: temporary-timeout, overloaded, постоянную ошибку или успех. Они не запускают собственные циклы, если это не отдельный, явно описанный контракт.
Бюджет нужно проверять до создания следующего вызова. Полезная последовательность такая: прочитать остаток бюджета, проверить тип ошибки, выбрать ровно одну реплику, создать попытку, уменьшить бюджет и записать результат. Если лимит проверяется после выбора нескольких реплик или после запуска параллельных запросов, это уже не ограничитель каскада, а журнал случившегося.
\nСлучайный экспоненциальный backoff с jitter снижает синхронный всплеск повторов, но не заменяет предел попыток. Backoff отвечает на вопрос «когда повторить», бюджет — «можно ли создавать ещё работу». Постоянную ошибку запроса или неверный вход повторять нельзя: задержка не сделает такой ответ успешным.
\nRetry — повтор той же логической операции после временного исхода. Fan-out — запуск нескольких ветвей для одного шага. Fallback — заранее названный результат с меньшей полнотой или явная ошибка, если необязательная часть недоступна. Смешивание этих понятий и создаёт скрытый рост нагрузки.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Вызовов больше, чем операций | Retry включён на нескольких слоях | Сгруппировать попытки по request_id и записать владельца каждой | Оставить один цикл, остальные слои сделать pass-through |
| Timeout запускает несколько реплик | Fan-out задан как «все здоровые» | Посчитать запуски до получения первого результата | Задать число и имя реплики на каждую попытку |
| Fallback увеличивает нагрузку | Запасной путь скрывает сетевой вызов или retry | Развернуть trace fallback до terminal result | Сделать путь локальным и без повторов либо вернуть ошибку |
| После лимита видны новые spans | Бюджет проверяется после расширения маршрута | Сверить порядок budget → route → call | Перенести проверку перед выбором и запуском ветки |
| Повтор дублирует запись | Timeout не говорит, дошла ли команда до сервера | Проверить идемпотентность и ключ операции | Не повторять без идемпотентного контракта или компенсации |
Ниже — самостоятельный пример для Node.js 18+. Он не открывает сеть и не имитирует реальную производительность. Его задача — сделать видимыми три решения: две попытки означают «исходная плюс одна повторная», на одну попытку выбирается одна реплика, а fallback после успешного основного вызова не запускает новый retry.
\nconst policy = {\n maxAttempts: 2, // всего: исходная попытка + один retry\n retryable: new Set(['temporary-timeout', 'overloaded']),\n replicas: ['primary-a', 'primary-b'],\n fallback: 'named-summary',\n};\n\nfunction execute(mainOutcomes, optionalOutcome = 'available') {\n const trace = [];\n\n for (let i = 0; i < policy.maxAttempts; i += 1) {\n const outcome = mainOutcomes[i] ?? 'temporary-timeout';\n const replica = policy.replicas[i];\n trace.push(`attempt=${i + 1} replica=${replica} outcome=${outcome}`);\n\n if (outcome === 'complete') {\n if (optionalOutcome === 'optional-result-unavailable') {\n trace.push(`fallback=${policy.fallback} retry=false`);\n return { result: 'degraded', trace };\n }\n return { result: 'complete', trace };\n }\n\n if (!policy.retryable.has(outcome)) {\n return { result: 'error', trace };\n }\n }\n\n trace.push('terminal=no-new-call');\n return { result: 'error', trace };\n}\n\nconsole.log(execute(['temporary-timeout', 'complete']));\nconsole.log(execute(['temporary-timeout', 'temporary-timeout']));\nconsole.log(execute(['complete'], 'optional-result-unavailable'));\nСохраните блок как resilience-demo.mjs и выполните node resilience-demo.mjs. В первом результате будут две попытки и успех. Во втором — две попытки и строка terminal=no-new-call; третьей реплики или вложенного fallback нет. В третьем — один основной вызов и fallback=named-summary retry=false. Так проверяется именно контракт переходов, а не доступность конкретного сервиса.
Имена реплик, набор retryable-исходов и смысл degraded result здесь проектные. В рабочей системе их нужно заменить на значения своего клиента и доказать отдельными тестами: с timeout, с перегрузкой, с постоянной ошибкой и с отменой по дедлайну.
\nОдин trace должен позволять восстановить решение без чтения всех исходников. Для каждой логической операции записывайте request_id, владельца retry, номер попытки, выбранную реплику, исход, остаток бюджета и итоговое действие. Не называйте условные units миллисекундами, если это не измерение времени.
Минимальная последовательность может выглядеть так: logical-01 → edge attempt=1 → primary-a → temporary-timeout, затем edge attempt=2 → primary-b → complete. Для необязательной части допустима ветка optional-result-unavailable → named-summary. В ней не должно появиться нового обращения к основной зависимости.
Полезны четыре счётчика: количество логических операций, количество всех попыток, количество повторов и количество terminal actions по исчерпанию бюджета. Сравнивайте их на одном сценарии и одинаковой нагрузке. Если растёт только число повторов, зависимость может оставаться здоровой; если растут одновременно повторы, timeout и очередь, ищите положительную обратную связь. Метрика сама по себе не доказывает причинность, поэтому связывайте её с trace.
\nДля HTTP-сервиса ответ 503 Service Unavailable может сообщать временную недоступность. Заголовок Retry-After указывает, сколько ждать до следующего запроса; RFC 9110 допускает задержку в секундах или HTTP-date. Это полезный сигнал клиенту, но он не назначает владельца retry и не ограничивает fan-out.
Клиент всё равно должен применить бюджет всей логической операции, дедлайн и список действительно временных исходов. Нельзя превращать любой 5xx или любой timeout в бесконечный повтор. Если сервер отвечает 503 с Retry-After, верхний слой может учесть указание, но обязан остановиться при исчерпании бюджета. Если операция меняет состояние, проверьте идемпотентность до включения автоматического retry.
Эта схема ограничивает работу, но не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, балансировку, отмену, распределённые транзакции, согласованность реплик и стоимость запуска процесса. Для этих свойств нужны нагрузочные и отказоустойчивые испытания на своей архитектуре.
\nFallback подходит только там, где неполный результат честно описывает состояние для пользователя. Для платежа, записи, команды или операции с неизвестным исходом безопаснее вернуть явную ошибку и запустить согласованную компенсацию. Degraded result не должен маскировать факт, что побочный эффект мог выполниться.
\nДаже ограниченный retry может продублировать действие: запрос мог дойти до сервера, а timeout возник при чтении ответа. Поэтому для записи нужен идемпотентный ключ, дедупликация на стороне обработчика или другой явно проверенный контракт. Без него снижение числа попыток уменьшает риск, но не устраняет его.
\nСценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбрана на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых логических операций и одинакового отказа число запусков не превышает установленный предел.
\nЕсли хотя бы один ответ требует догадки, причина каскада не доказана. Сначала назовите скрытый переход и назначьте его владельца. Затем подтвердите поведение на production-подобном пути с реальной сетью, правами, дедлайном и данными. Учебный скрипт проверяет модель бюджета, но не заменяет такой тест.
\n