8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 69,
|
||
"slug": "editorial-2026-02-practice-resilience",
|
||
"title": "Как остановить каскадный отказ: один бюджет для retry и fallback",
|
||
"excerpt": "Медленный dependency превращает несколько разумных защит в каскад лишней работы. Разбираем владельца retry, предел fan-out, безопасный fallback и проверяемую точку остановки.",
|
||
"contentHtml": "<p>Сервис начинает отвечать дольше обычного. Клиент получает timeout и повторяет запрос. Адаптер повторяет тот же вызов. Затем fallback выбирает другую реплику. Каждый слой выглядит разумно отдельно, но вместе они создают каскад.</p>\n<p>Симптом виден в трёх местах: исходящих вызовов на один пользовательский запрос становится больше, очередь не сокращается после добавления реплик, а trace показывает несколько владельцев retry. Цена ошибки выше задержки. Ослабленный dependency получает дополнительную работу в момент, когда уже не справляется с прежней. Каскад может перегрузить соседние компоненты и превратить частичный отказ в общий.</p>\n<p>Тезис простой: устойчивость начинается с ограничения работы. Для каждой логической операции нужно назначить одного владельца retry, задать конечный fan-out, назвать fallback и определить точку, после которой новые вызовы запрещены. Если эти границы нельзя восстановить из trace, систему нельзя считать готовой к проверке отказа.</p>\n<h2>Механизм: считать логическую операцию</h2>\n<p>Логическая операция — это один запрос пользователя или одно сообщение, которое система должна обработать. Внутри неё могут быть несколько сетевых вызовов. Поэтому успешные ответы не показывают полную стоимость. Считайте попытки, созданные после каждого временного исхода.</p>\n<p>Предположим, внешний вызов получает timeout. Край делает две попытки. Каждая попытка попадает в адаптер, который тоже разрешает два повтора. Затем необязательная часть запускает fallback. Число обращений растёт не как сумма настроек. Оно перемножается по слоям. Формула полезна как сигнал: общий fan-out равен произведению локальных ветвей, пока слой не остановит работу.</p>\n<p>Retry должен иметь одного владельца. Остальные слои передают ему исход и принимают конечный результат. Они не запускают собственные циклы. Выбор реплики также должен иметь числовой предел. На одну разрешённую попытку выбирается одна заранее названная реплика, а не «любая доступная» без ограничения.</p>\n<figure><img src=\"/assets/editorial/2026/resilience-2026-failure-cascade-graph.svg\" alt=\"Схема ограниченного каскада: один владелец retry, одна реплика на попытку и остановка перед расширением маршрута\" loading=\"lazy\" /><figcaption>Учебная схема показывает порядок контроля: лимит срабатывает до расширения маршрута, поэтому fallback не становится вторым слоем повторов.</figcaption></figure>\n<h2>Учебный пример с явной границей</h2>\n<p>Ниже синтетический JavaScript-фрагмент. Он не обращается к сети, не запускает таймеры и не измеряет настоящий сервис. Имена реплик, исходы и размеры бюджета нужны только для объяснения переходов.</p>\n<pre><code>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</code></pre>\n<p>Отрицательный путь здесь важнее положительного. При нулевом бюджете функция не выбирает новую реплику и не входит в fallback. Она возвращает конечное состояние. В реальной системе такой degraded result подходит не каждой операции. Для платежа, записи или команды с побочным эффектом он может скрыть неопределённость. Тогда контракт должен вернуть явную ошибку или запустить безопасную компенсацию. Учебный результат нельзя переносить туда без отдельной проверки.</p>\n<p>Положительный путь тоже ограничен. При temporary-timeout край может выполнить одну разрешённую повторную попытку на одной реплике. Fallback не получает право повторить основной вызов. Его задача — вернуть заранее названный урезанный результат, если необязательная часть недоступна. Если fallback сам ищет реплику, он становится новым маршрутом и получает собственный лимит.</p>\n<h2>Симптом → причина → проверка → действие</h2>\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>Посчитать попытки на один request id и найти их владельцев в trace</td><td>Оставить одного владельца, вложенный retry отключить</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>Сделать fallback режимом без повторов или выключить его</td></tr><tr><td>Лимит виден после новых вызовов</td><td>Ограничение стоит после расширения маршрута</td><td>Сравнить порядок: budget, маршрут, вызов</td><td>Перенести limit до retry, fan-out и fallback</td></tr><tr><td>Сравниваются разные сценарии</td><td>Различаются нагрузка или failure injection</td><td>Сверить logical requests, исход и baseline</td><td>Сначала выровнять сценарии, затем сравнивать изменения</td></tr></tbody></table></div>\n<h2>Что фиксировать в trace</h2>\n<p>Trace должен позволять восстановить решение без чтения всех исходников. Для каждой логической операции запишите request id, владельца retry, номер попытки, выбранную реплику, исход, остаток бюджета и terminal action. Если модель использует условные units, не называйте их миллисекундами. Единица измерения должна быть ясна.</p>\n<p>Минимальная последовательность ограниченного сценария может выглядеть так: <code>logical-01 → primary-a → temporary-timeout</code>, затем <code>edge retry budget: 2 → 1</code>, затем <code>logical-01 → primary-b → complete</code>. Для необязательной части допустима ветка <code>optional-result-unavailable → named-summary</code>. В ней не должно появляться скрытого выбора третьей реплики.</p>\n<p>Проверяйте место лимита по порядку событий, а не по имени поля конфигурации. Если trace сначала показывает два новых вызова, а потом сообщает об исчерпанном бюджете, счётчик не остановил каскад. Он только зафиксировал уже сделанную работу.</p>\n<h2>Порядок действий</h2>\n<ol><li>Назовите логическую операцию и отделите её от исходящих вызовов.</li><li>Перечислите все места, где отказ может создать дополнительную работу.</li><li>Выберите одного владельца retry и задайте конечное число попыток.</li><li>Опишите fan-out числом и именами реплик; запретите неограниченный поиск.</li><li>Назовите fallback, его входной исход, стоимость и terminal result.</li><li>Поставьте limit до расширения маршрута и укажите действие при нулевом бюджете.</li><li>Проверьте положительный и отрицательный trace на одинаковой логической нагрузке.</li><li>Отдельно решите, допустим ли degraded result для операции с побочным эффектом.</li></ol>\n<h2>Ограничения</h2>\n<p>Схема не доказывает доступность, пропускную способность или восстановление реального сервиса. Она не моделирует очереди, отмену, дедлайны, балансировку, идемпотентность, транзакции, распределённое состояние и стоимость запуска процесса. Она также не отвечает, какой ответ полезен пользователю после деградации.</p>\n<p>Временный timeout не означает, что повтор безопасен. Запрос мог дойти до сервера и завершить запись до того, как клиент получил ответ. Для операции с побочным эффектом сначала проверьте идемпотентность и контракт повторной доставки. Без такого контракта даже ограниченный retry может продублировать действие.</p>\n<p>Код 503 и заголовок Retry-After помогают передать перегрузку на HTTP-уровне, но сами не назначают владельца retry и не ограничивают fan-out. Автоматическое повторение включайте только для исходов, которые действительно могут стать успешными, и с учётом бюджета всей цепочки.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Сценарий готов к следующей проверке, если по одному trace можно ответить на пять вопросов: кто повторяет; сколько попыток разрешено; какая реплика выбирается на каждой попытке; что делает fallback; где прекращается новая работа. Для одинаковых logical requests и одинакового failure injection число дополнительных запусков не превышает заданный предел.</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> — официальный материал Google о каскадных отказах, ограничении retry и положительной обратной связи. Он не подтверждает результаты учебного примера.</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. Он не задаёт архитектуру retry owner, fan-out или fallback конкретного сервиса.</li></ul>"
|
||
}
|