8 lines
25 KiB
JSON
8 lines
25 KiB
JSON
{
|
||
"index": 68,
|
||
"slug": "editorial-2026-02-mechanism-resilience",
|
||
"title": "Механика устойчивости: сначала ограничьте каскад, потом настраивайте retry",
|
||
"excerpt": "Timeout ограничивает ожидание одного вызова, но не объём дополнительной работы. Разбираем, как связать retry, fan-out, fallback и точку остановки в одну проверяемую модель.",
|
||
"contentHtml": "<p>Внешний сервис начинает отвечать медленно, очередь на входе растёт, а собственное приложение расходует соединения на незавершённые запросы. Gateway повторяет вызов после timeout, адаптер делает ещё одну попытку, а балансировщик параллельно отправляет работу на несколько реплик. Каждый механизм по отдельности выглядит разумно. Вместе они могут превратить временный сбой в каскад: перегруженная зависимость получает новые запросы именно тогда, когда ей нужно уменьшить нагрузку.</p>\n<p>Главный вопрос здесь не «какое значение поставить для timeout». Сначала нужно определить, сколько дополнительной работы может породить один логический запрос, кто имеет право повторять вызов и где маршрут обязан остановиться. Только после этого выбирают задержку между попытками, реакцию на 503 и режим деградации. Иначе разные слои незаметно складывают свои политики: три попытки на клиенте и три на gateway дают до девяти вызовов одной зависимости ещё до учёта fan-out.</p>\n<p>Далее используется маленькая фиксированная модель. Она считает только структуру маршрута: два логических запроса, максимум две попытки на каждый и один выбранный адрес за попытку. В ней нет сети, часов, очереди или измерения latency. Такой пример помогает увидеть границу и проверить отрицательный путь, но не подбирает значения для настоящего сервиса.</p>\n<figure><img src=\"/assets/editorial/2026/resilience-2026-degradation-mode-matrix.svg\" alt=\"Матрица каскада: временный отказ ведёт к одной ограниченной попытке, необязательная часть — к именованному краткому ответу, исчерпанный бюджет — к остановке маршрута\" loading=\"lazy\" /><figcaption>Схема показывает порядок принятия решения: сначала ограничивается новая работа, затем выбирается допустимый результат. Это логическая модель, а не карта конкретной инфраструктуры.</figcaption></figure>\n<h2>Логический запрос, попытка и fan-out — разные величины</h2>\n<p>Единица расчёта — логический запрос пользователя или вызывающего сервиса. Один такой запрос может породить несколько исходящих вызовов. Попытка — один запуск зависимости; повтор добавляет следующую попытку после разрешённого исхода. Fan-out, или ширина разветвления, отвечает на другой вопрос: сколько направлений запускаются для одной попытки. Список из пяти реплик сам по себе не означает пять вызовов.</p>\n<p>Fallback также нельзя смешивать с retry. Он меняет результат: вместо полного ответа возвращает, например, короткую сводку из локального кеша. Если fallback сначала ищет данные в ещё трёх местах или запускает собственный retry, это уже новая работа, которую надо включить в расчёт. Точка остановки задаёт момент, после которого никакая ветвь не имеет права расширить маршрут.</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>2</td><td>Объём сравниваемой нагрузки</td><td>Реальный QPS и размер очереди</td></tr><tr><td>Владелец retry</td><td>gateway</td><td>Единственное право создать повтор</td><td>Правильность выбора самого gateway</td></tr><tr><td>Максимум попыток</td><td>2, включая исходную</td><td>Последовательное размножение вызовов</td><td>Безопасность повторения операции</td></tr><tr><td>Максимальный fan-out</td><td>1</td><td>Число адресов на одну попытку</td><td>Доступность и равномерность реплик</td></tr><tr><td>Fallback</td><td>cached-summary, 1 work unit</td><td>Цена сокращённого результата</td><td>Его полезность для продукта</td></tr><tr><td>Точка остановки</td><td>до route expansion</td><td>Запуск новых ветвей после нулевого бюджета</td><td>Отмену уже начатого сетевого вызова</td></tr></tbody></table></div>\n<p>Для верхней границы в этой игрушечной схеме достаточно перемножить независимые множители: 2 логических запроса × 2 попытки × 1 направление = 4 route attempts. Это число не является прогнозом нагрузки. Оно отвечает на узкий вопрос: не появилась ли в конфигурации скрытая ветвь, которой нет в контракте.</p>\n<h2>Где возникает усиление нагрузки</h2>\n<p>Представим один вызов с временным отказом. Gateway ждёт до своего deadline и создаёт второй вызов. Если адаптер внутри gateway тоже считает тот же отказ разрешением на повтор, один внешний retry превращается ещё в несколько внутренних. Если оба слоя выбирают две реплики, число запросов растёт дополнительно. При этом исходная причина — перегрузка или задержка — никуда не исчезает.</p>\n<p>У политики должен быть один владелец. Остальные слои могут передавать deadline, отмену и контекст попытки, но не должны тайно запускать собственный цикл. Если владельцев несколько по архитектурной необходимости, это надо считать произведением и ограничивать как отдельный контракт. Фраза «везде всего по две попытки» не описывает верхнюю границу, пока не указано, где заканчивается одна операция и начинается другая.</p>\n<pre><code>const assert = require('node:assert/strict');\n\nconst policy = {\n logicalRequests: 2,\n maxAttempts: 2, // первая попытка уже входит в число\n maxFanout: 1,\n retryOwner: 'gateway',\n retryableStatuses: new Set([503]),\n fallback: { name: 'cached-summary', workUnits: 1 },\n};\n\nfunction assess(status, retryOwners) {\n if (retryOwners.length !== 1 || retryOwners[0] !== policy.retryOwner) {\n return { status: 'stop', reason: 'retry должен иметь одного владельца' };\n }\n\n const retryable = policy.retryableStatuses.has(status);\n const attempts = retryable ? policy.maxAttempts : 1;\n const routeAttempts =\n policy.logicalRequests * attempts * policy.maxFanout;\n\n return {\n status: 'ok',\n routeAttempts,\n terminal: retryable ? 'fallback:' + policy.fallback.name : 'response',\n };\n}\n\nassert.deepEqual(assess(503, ['gateway']), {\n status: 'ok',\n routeAttempts: 4,\n terminal: 'fallback:cached-summary',\n});\nassert.equal(assess(503, ['gateway', 'adapter']).status, 'stop');\nconsole.log(assess(503, ['gateway']));</code></pre>\n<p>Сохраните блок в файл <code>cascade-check.cjs</code> и запустите командой <code>node cascade-check.cjs</code>. В штатном пути он напечатает объект с четырьмя маршрутными попытками. Второй assert проверяет отрицательный путь: два владельца retry останавливают проверку до запуска какого-либо маршрута. Пример намеренно не вызывает HTTP-клиент; его задача — сделать правило видимым и воспроизводимым на чистом Node.js.</p>\n<h2>Timeout, 503 и Retry-After не задают всю политику</h2>\n<p>Timeout ограничивает время ожидания конкретного вызова. Он не сообщает, кто может повторить этот вызов и сколько дополнительной работы разрешено создать. Слишком короткое значение иногда увеличивает нагрузку: слой объявляет вызов неуспешным, пока зависимость продолжает обрабатывать его, а затем отправляет копию. Поэтому deadline должен распространяться по цепочке и учитывать отмену, а не превращаться в независимый таймер на каждом уровне.</p>\n<p>HTTP 503 означает, что сервер временно не может обработать запрос; RFC 9110 допускает заголовок Retry-After как подсказку, сколько подождать перед следующим запросом. Это семантика ответа, а не готовое решение о повторе. Клиенту всё равно нужно сопоставить статус с идемпотентностью операции, остатком общего бюджета и допустимым результатом. Ошибку валидации или неверный запрос нельзя «лечить» повтором: одинаковый вход снова даст тот же постоянный исход.</p>\n<p>Backoff снижает вероятность синхронной волны повторов, но не ограничивает их количество. Для повторов нужен конечный максимум, а для всего процесса — наблюдаемый budget. В качестве отдельных сигналов полезно видеть исходные вызовы, повторы, время ожидания, отмены и переход в fallback. Одна метрика ошибок без этих разрезов не показывает, что именно увеличивает нагрузку.</p>\n<h2>Fallback должен быть дешёвым и именованным</h2>\n<p>Режим деградации не означает «вернуть что-нибудь». Он меняет контракт для пользователя, поэтому его результат должен иметь имя, условие включения и понятную цену. В примере <code>cached-summary</code> возвращает неполную сводку после исчерпания попыток. Он не выбирает новые реплики и не запускает retry. Продуктовая команда отдельно решает, допустима ли такая сводка для конкретного экрана.</p>\n<p>Проверяйте fallback как самостоятельный маршрут. Запишите его work units: чтение локального кеша, обращение к резервному хранилищу и сериализация ответа не обязательно стоят одинаково. Если резервный путь дороже основного или тоже зависит от перегруженного сервиса, его нельзя считать безопасной деградацией. Иногда правильный fallback — немедленный отказ с понятным кодом, а не ещё один поиск.</p>\n<h2>Точка остановки должна стоять до расширения</h2>\n<p>Лимит, проверяемый после выбора всех реплик, лишь сообщает о проблеме постфактум. К моменту проверки лишние вызовы уже заняли соединения. Сначала проверьте остаток бюджета, затем выберите следующий маршрут, а после ответа решите, нужен ли единственный разрешённый повтор. Когда бюджет равен нулю, результат должен быть terminal: ответ, fallback или ошибка. Новая попытка после terminal action — дефект контракта.</p>\n<p>Для диагностики запишите переходы в trace: <code>request → attempt-1 → temporary failure → budget-1 → attempt-2 → fallback</code>. В опасном варианте будет видно другое: <code>attempt-1 → replica-a + replica-b → adapter-retry → ...</code>. Такой trace помогает найти место размножения без спора о том, какой timeout «выглядит правильнее». Проверяйте порядок событий, а не только итоговый счётчик.</p>\n<h2>Симптомы, проверка и действие</h2>\n<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>После 504 исходящих вызовов становится больше</td><td>Повтор включён на двух слоях</td><td>Сопоставить retry owner в конфигурации и trace</td><td>Оставить один цикл, остальные слои сделать pass-through</td></tr><tr><td>Один request_id встречается на нескольких репликах сразу</td><td>Включён fan-out или hedging</td><td>Посчитать адреса до первого успешного ответа</td><td>Задать maxFanout и отдельно доказать безопасность копий</td></tr><tr><td>Fallback задерживает ответ</td><td>Он сам ищет данные и повторяет запрос</td><td>Развернуть его переходы и work units</td><td>Упростить до дешёвого результата или отключить режим</td></tr><tr><td>Лимит срабатывает, но нагрузка уже выросла</td><td>Проверка стоит после route expansion</td><td>Сравнить порядок trace transitions</td><td>Перенести точку остановки перед созданием ветвей</td></tr><tr><td>Два теста дают разные выводы</td><td>Различаются нагрузка или тип сбоя</td><td>Сверить logical load и failure injection</td><td>Сравнивать только одинаковые входы либо признать тесты несопоставимыми</td></tr></tbody></table>\n<h2>Как перенести модель в настоящий сервис</h2>\n<ol><li>Назовите границу логического запроса: где он начинается и какой слой возвращает окончательный результат.</li><li>Найдите все места, где код или библиотека может повторить вызов, и назначьте одного владельца политики.</li><li>Перечислите retryable исходы. Для каждого проверьте идемпотентность, deadline и влияние уже начатой работы.</li><li>Задайте конечный максимум попыток, причём явно укажите, входит ли исходный вызов в это число.</li><li>Отдельно ограничьте fan-out и hedging. Не считайте число адресов в пуле разрешённым числом одновременных вызовов.</li><li>Опишите fallback как контракт результата с именем, условием, стоимостью и отсутствием скрытого retry.</li><li>Поставьте limit point до выбора новой ветви и добавьте terminal action после исчерпания бюджета.</li><li>Проверьте положительный и отрицательный сценарий с одинаковой логической нагрузкой и одинаковым типом отказа.</li><li>Запустите нагрузочный и отказоустойчивый тест в среде, где разрешены безопасные данные и есть метрики очереди, соединений, отмен и задержек.</li></ol>\n<h2>Ограничения и границы применимости</h2>\n<p>Формула из примера проверяет только число потенциальных маршрутных попыток. Она не учитывает время, параллелизм, уже выполняющиеся запросы, размер ответа, пропускную способность, rate limit, очередь, кэш-промахи и отмену на сервере. Четыре попытки в модели могут быть четырьмя последовательными вызовами или четырьмя одновременными — эти режимы имеют разный риск.</p>\n<p>Числа 2 и 1 выбраны для ручной проверки, а не для переноса в конфигурацию. В реальном сервисе предел зависит от стоимости операции, класса данных, SLO, ёмкости зависимости и допустимой потери результата. Для записи, платежа или другой операции с побочным эффектом одного timeout недостаточно, чтобы признать повтор безопасным. Нужны идемпотентный ключ, семантика сервера и проверка компенсации.</p>\n<p>Не следует превращать статус <code>ok</code> из скрипта в обещание доступности. Он означает только, что фиксированная карточка удовлетворяет нескольким формальным условиям. Доказательство устойчивости требует измерения под нагрузкой, наблюдения за отменами и проверки поведения при частичном отказе. Если факт неизвестен, модель должна остановиться и назвать недостающую проверку.</p>\n<h2>Критерий готовности решения</h2>\n<p>Схема готова к инженерному испытанию, если можно без догадки ответить на пять вопросов: кто владеет retry; сколько попыток разрешено и как они считаются; сколько направлений может появиться на попытку; что именно возвращает fallback и сколько работы он добавляет; где прекращается создание новых ветвей. Для каждого ответа должен существовать trace или тестовый assert. Отдельно зафиксируйте, что в этом доказательстве не проверяется: реальная ёмкость, пользовательская ценность деградации и безопасность побочных эффектов.</p>\n<p>Порядок важнее набора терминов. Сначала ограничьте дополнительную работу и сделайте её видимой. Затем настройте timeout, backoff и протокол ответа под измеренный сценарий. Такой маршрут не отменяет нагрузочные испытания, но не даёт локальной настройке retry скрыть каскад за красивым числом попыток.</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 budget, ограничения повторов и риска перемножения попыток на нескольких уровнях. Источник объясняет механизм, но не измеряет приведённый скрипт.</li><li><a href=\"https://raw.githubusercontent.com/grpc/proposal/dc9fd4fe5b94b90b82fe2833ad1d80938e6a49c1/A6-client-retries.md\" target=\"_blank\" rel=\"noopener noreferrer\">gRPC A6: Client Retries, закреплённый commit</a> — design proposal gRPC с <code>maxAttempts</code>, списком повторяемых статусов, backoff и throttling. В документе максимум попыток включает исходный RPC; proposal не является общей политикой для HTTP и других стеков.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.3\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110, раздел 10.2.3: Retry-After</a> — нормативное описание задержки перед последующим запросом и формата заголовка. <a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#section-15.6.4\" target=\"_blank\" rel=\"noopener noreferrer\">Раздел 15.6.4: 503 Service Unavailable</a> уточняет временную перегрузку и возможность передать Retry-After; RFC не выбирает владельца retry и не доказывает идемпотентность конкретной операции.</li></ul>"
|
||
}
|