Files

8 lines
21 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 69,
"slug": "editorial-2026-02-practice-resilience",
"title": "Как остановить каскадный отказ: один бюджет для retry и fallback",
"excerpt": "Медленная зависимость превращает несколько разумных защит в каскад лишней работы. Разбираем владельца retry, предел fan-out, безопасный fallback и проверяемую точку остановки.",
"contentHtml": "<p>Сервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос, адаптер повторяет тот же вызов, а fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад: один пользовательский запрос порождает несколько обращений к уже перегруженной зависимости.</p>\n<p>Симптом можно увидеть без догадок: на один <code>request_id</code> приходится больше исходящих попыток, очередь не сокращается после добавления реплик, а trace показывает несколько независимых владельцев retry. Цена ошибки — не только лишние миллисекунды. Ослабленная зависимость получает новую работу в момент, когда не справляется с прежней, а частичный отказ распространяется на соседние компоненты.</p>\n<p>В этой статье «один бюджет» означает один бюджет попыток для одной логической операции и одного владельца retry. Это не глобальный лимит сервиса и не разрешение повторять всё подряд. Для защиты от общей перегрузки нужен отдельный сервисный лимит или circuit breaker, но он не должен превращаться в ещё один слой автоматических повторов.</p>\n<h2>Сначала определите логическую операцию</h2>\n<p>Логическая операция — это запрос пользователя или сообщение, которое система должна обработать. Внутри неё могут быть вызов API, чтение из базы и запрос к необязательному сервису. Считать только успешные ответы опасно: стоимость отказа состоит из всех попыток, включая те, что завершились timeout или были отменены по дедлайну.</p>\n<p>Запишите границу операции и единицу счёта. Например: <code>checkout-481</code> — одна команда оформления, а вызовы <code>pricing</code> и <code>inventory</code> — её дочерние действия. Если верхний слой разрешает две попытки, это обычно означает одну исходную попытку и одну повторную, а не две дополнительные попытки на каждом нижнем сервисе.</p>\n<p>При независимых retry на нескольких слоях верхняя граница растёт мультипликативно. Если три слоя делают по четыре попытки, один запрос может создать до 64 вызовов нижней зависимости. Это верхняя оценка для такого дерева, а не обещание, что каждый вызов будет выполнен: отмена, дедлайн и ошибки могут остановить ветку раньше. Но уже сама оценка показывает, почему локальные настройки нельзя складывать без общей модели.</p>\n<figure><img src=\"/assets/editorial/2026/resilience-2026-degradation-mode-matrix.svg\" alt=\"Матрица режимов деградации: временный timeout допускает одну повторную попытку, необязательная часть получает именованный fallback, а исчерпанный бюджет останавливает новые вызовы\" loading=\"lazy\" /><figcaption>Матрица разделяет временный отказ, необязательную часть, неограниченный fan-out и исчерпанный бюджет. Число попыток в схеме — условное значение модели; рабочий контракт приведён и проверяется в примере ниже.</figcaption></figure>\n<h2>Назначьте владельца retry</h2>\n<p>У retry должен быть один владелец на границе, где видны дедлайн всей операции и её итог. Остальные слои возвращают ему исход вызова: <code>temporary-timeout</code>, <code>overloaded</code>, постоянную ошибку или успех. Они не запускают собственные циклы, если это не отдельный, явно описанный контракт.</p>\n<p>Бюджет нужно проверять до создания следующего вызова. Полезная последовательность такая: прочитать остаток бюджета, проверить тип ошибки, выбрать ровно одну реплику, создать попытку, уменьшить бюджет и записать результат. Если лимит проверяется после выбора нескольких реплик или после запуска параллельных запросов, это уже не ограничитель каскада, а журнал случившегося.</p>\n<p>Случайный экспоненциальный backoff с jitter снижает синхронный всплеск повторов, но не заменяет предел попыток. Backoff отвечает на вопрос «когда повторить», бюджет — «можно ли создавать ещё работу». Постоянную ошибку запроса или неверный вход повторять нельзя: задержка не сделает такой ответ успешным.</p>\n<h2>Разделите retry, fan-out и fallback</h2>\n<p>Retry — повтор той же логической операции после временного исхода. Fan-out — запуск нескольких ветвей для одного шага. Fallback — заранее названный результат с меньшей полнотой или явная ошибка, если необязательная часть недоступна. Смешивание этих понятий и создаёт скрытый рост нагрузки.</p>\n<div class=\"table-scroll\"><table><caption>Симптом, проверка и действие для одной логической операции</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Вероятная причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Вызовов больше, чем операций</td><td>Retry включён на нескольких слоях</td><td>Сгруппировать попытки по <code>request_id</code> и записать владельца каждой</td><td>Оставить один цикл, остальные слои сделать pass-through</td></tr><tr><td>Timeout запускает несколько реплик</td><td>Fan-out задан как «все здоровые»</td><td>Посчитать запуски до получения первого результата</td><td>Задать число и имя реплики на каждую попытку</td></tr><tr><td>Fallback увеличивает нагрузку</td><td>Запасной путь скрывает сетевой вызов или retry</td><td>Развернуть trace fallback до terminal result</td><td>Сделать путь локальным и без повторов либо вернуть ошибку</td></tr><tr><td>После лимита видны новые spans</td><td>Бюджет проверяется после расширения маршрута</td><td>Сверить порядок budget → route → call</td><td>Перенести проверку перед выбором и запуском ветки</td></tr><tr><td>Повтор дублирует запись</td><td>Timeout не говорит, дошла ли команда до сервера</td><td>Проверить идемпотентность и ключ операции</td><td>Не повторять без идемпотентного контракта или компенсации</td></tr></tbody></table></div>\n<h2>Воспроизводимая модель бюджета</h2>\n<p>Ниже — самостоятельный пример для Node.js 18+. Он не открывает сеть и не имитирует реальную производительность. Его задача — сделать видимыми три решения: две попытки означают «исходная плюс одна повторная», на одну попытку выбирается одна реплика, а fallback после успешного основного вызова не запускает новый retry.</p>\n<pre><code>const 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 &lt; 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'));</code></pre>\n<p>Сохраните блок как <code>resilience-demo.mjs</code> и выполните <code>node resilience-demo.mjs</code>. В первом результате будут две попытки и успех. Во втором — две попытки и строка <code>terminal=no-new-call</code>; третьей реплики или вложенного fallback нет. В третьем — один основной вызов и <code>fallback=named-summary retry=false</code>. Так проверяется именно контракт переходов, а не доступность конкретного сервиса.</p>\n<p>Имена реплик, набор retryable-исходов и смысл degraded result здесь проектные. В рабочей системе их нужно заменить на значения своего клиента и доказать отдельными тестами: с timeout, с перегрузкой, с постоянной ошибкой и с отменой по дедлайну.</p>\n<h2>Что записывать в trace и метрики</h2>\n<p>Один trace должен позволять восстановить решение без чтения всех исходников. Для каждой логической операции записывайте <code>request_id</code>, владельца retry, номер попытки, выбранную реплику, исход, остаток бюджета и итоговое действие. Не называйте условные units миллисекундами, если это не измерение времени.</p>\n<p>Минимальная последовательность может выглядеть так: <code>logical-01 → edge attempt=1 → primary-a → temporary-timeout</code>, затем <code>edge attempt=2 → primary-b → complete</code>. Для необязательной части допустима ветка <code>optional-result-unavailable → named-summary</code>. В ней не должно появиться нового обращения к основной зависимости.</p>\n<p>Полезны четыре счётчика: количество логических операций, количество всех попыток, количество повторов и количество terminal actions по исчерпанию бюджета. Сравнивайте их на одном сценарии и одинаковой нагрузке. Если растёт только число повторов, зависимость может оставаться здоровой; если растут одновременно повторы, timeout и очередь, ищите положительную обратную связь. Метрика сама по себе не доказывает причинность, поэтому связывайте её с trace.</p>\n<h2>HTTP-граница: 503 и Retry-After</h2>\n<p>Для HTTP-сервиса ответ <code>503 Service Unavailable</code> может сообщать временную недоступность. Заголовок <code>Retry-After</code> указывает, сколько ждать до следующего запроса; RFC 9110 допускает задержку в секундах или HTTP-date. Это полезный сигнал клиенту, но он не назначает владельца retry и не ограничивает fan-out.</p>\n<p>Клиент всё равно должен применить бюджет всей логической операции, дедлайн и список действительно временных исходов. Нельзя превращать любой <code>5xx</code> или любой timeout в бесконечный повтор. Если сервер отвечает 503 с <code>Retry-After</code>, верхний слой может учесть указание, но обязан остановиться при исчерпании бюджета. Если операция меняет состояние, проверьте идемпотентность до включения автоматического retry.</p>\n<h2>Порядок проверки в проекте</h2>\n<ol><li>Назовите одну логическую операцию и отделите её от исходящих вызовов.</li><li>Соберите все места, где timeout, 5xx, отмена или пустой ответ создают дополнительную работу.</li><li>Выберите одного владельца retry и зафиксируйте, означает ли лимит общее число попыток или число повторов.</li><li>Разрешите только явно временные исходы; постоянные ошибки должны завершать ветку.</li><li>Опишите fan-out числом и именами реплик, а также запретите неограниченный поиск «любой доступной» реплики.</li><li>Назовите fallback, его входной исход, стоимость, полноту результата и terminal action.</li><li>Поставьте проверку бюджета до маршрута и вызова; добавьте backoff с jitter, если повтор действительно разрешён.</li><li>Прогоните четыре сценария: успех с первой попытки, временный отказ и успех, исчерпание бюджета, недоступность необязательной части.</li><li>Сравните trace, число попыток и очередь при одинаковом failure injection. Отдельно проверьте операцию с побочным эффектом.</li></ol>\n<h2>Ограничения применимости</h2>\n<p>Эта схема ограничивает работу, но не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, балансировку, отмену, распределённые транзакции, согласованность реплик и стоимость запуска процесса. Для этих свойств нужны нагрузочные и отказоустойчивые испытания на своей архитектуре.</p>\n<p>Fallback подходит только там, где неполный результат честно описывает состояние для пользователя. Для платежа, записи, команды или операции с неизвестным исходом безопаснее вернуть явную ошибку и запустить согласованную компенсацию. Degraded result не должен маскировать факт, что побочный эффект мог выполниться.</p>\n<p>Даже ограниченный retry может продублировать действие: запрос мог дойти до сервера, а timeout возник при чтении ответа. Поэтому для записи нужен идемпотентный ключ, дедупликация на стороне обработчика или другой явно проверенный контракт. Без него снижение числа попыток уменьшает риск, но не устраняет его.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Сценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбрана на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых логических операций и одинакового отказа число запусков не превышает установленный предел.</p>\n<p>Если хотя бы один ответ требует догадки, причина каскада не доказана. Сначала назовите скрытый переход и назначьте его владельца. Затем подтвердите поведение на production-подобном пути с реальной сетью, правами, дедлайном и данными. Учебный скрипт проверяет модель бюджета, но не заменяет такой тест.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://sre.google/sre-book/addressing-cascading-failures/\" target=\"_blank\" rel=\"noopener noreferrer\">Google SRE Book: Addressing Cascading Failures</a> — официальный разбор положительной обратной связи, ограничения retry на запрос и серверного retry budget. Пример с 64 вызовами — иллюстрация формулы из этого материала, а не измерение конкретного сервиса.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — стандарт HTTP с определениями 503 Service Unavailable и Retry-After. RFC описывает семантику ответа и заголовка, но не задаёт архитектуру retry owner, fan-out или fallback.</li></ul>"
}