8 lines
24 KiB
JSON
8 lines
24 KiB
JSON
{
|
||
"index": 67,
|
||
"slug": "editorial-2026-02-field-resilience",
|
||
"title": "Retry под нагрузкой: как остановить каскад запросов",
|
||
"excerpt": "Медленная зависимость сама по себе не валит сервис: его валят неограниченные повторы, fan-out и поздний fallback. Разбираем бюджет попыток, проверку трассы и безопасную деградацию.",
|
||
"contentHtml": "<p>Внешний API вместо обычных 200 миллисекунд отвечает за 3 секунды. На верхнем уровне включён retry, адаптер пробует следующую реплику, а затем запускает fallback. Через минуту очередь растёт, соединения заняты ожиданием, а внутренние запросы получают таймауты. Ошибка зависимости стала отказом собственного приложения.</p>\n<p>Ниже — не рецепт с универсальными числами, а разбор механизма. Главный вопрос: как доказать, что одна ошибка не создаёт новый поток работы? Ответ начинается с разделения логического запроса и физических вызовов, продолжается единым бюджетом и заканчивается явным terminal action — последним действием после исчерпания попыток.</p>\n<figure><img src=\"/assets/editorial/2026/resilience-2026-failure-cascade-graph.svg\" alt=\"Один логический запрос проходит через ограниченный retry, выбор одной реплики и именованный fallback; после исчерпания бюджета поток останавливается\" loading=\"lazy\" /><figcaption>Одна логическая операция должна иметь видимую границу: повтор, выбор направления, fallback и окончательное действие не могут бесконечно порождать новые вызовы.</figcaption></figure>\n<h2>Сначала отделим симптом от механизма</h2>\n<p>Симптом обычно выглядит как рост latency или числа ошибок. Эти показатели важны, но они не отвечают, сколько работы создал один пользовательский запрос. Начните с идентификатора логической операции и посчитайте все физические обращения к зависимости. Включите в счётчик вызовы после таймаута, обращения к другим репликам и работу fallback.</p>\n<p>Полезно заранее записать базовый сценарий: один запрос, одна зависимость, один исход отказа. Например, <code>read-01</code> получает <code>temporary-timeout</code> от <code>primary-a</code>, один раз повторяется на <code>primary-b</code> и получает <code>ok</code>. Любой дополнительный вызов должен объясняться политикой. Если в трассе появляется <code>primary-c</code>, но правило разрешает одну реплику на попытку, это уже дефект маршрута, даже если пользователь в итоге получил ответ.</p>\n<p>Не называйте каждый 5xx временной ошибкой. RFC 9110 определяет 503 как временную неспособность обслужить запрос из-за перегрузки или обслуживания и допускает <code>Retry-After</code> как подсказку клиенту о задержке. Это описание состояния сервера, а не приказ повторять конкретную операцию. Безопасность повтора определяется смыслом операции и вашим контрактом.</p>\n<h2>Считаем физическую работу</h2>\n<p>Пусть верхний слой делает до четырёх попыток, адаптер — до четырёх, а клиент зависимости — ещё до четырёх. При условии, что каждый слой действительно достигает следующего, один логический запрос может породить до 4 × 4 × 4 = 64 попыток. Это верхняя оценка для независимых retry-контуров, а не измерение любого конкретного сервиса. Google SRE использует тот же пример, чтобы показать, почему повтор на нескольких уровнях усиливает перегрузку.</p>\n<p>Для проектирования разделите бюджет на четыре поля:</p>\n<ul><li><strong>maxAttempts</strong> — сколько всего попыток разрешено одной операции; в этой статье первая попытка входит в число.</li><li><strong>retryable outcomes</strong> — только те исходы, для которых повтор имеет смысл и безопасен.</li><li><strong>maxFanout</strong> — сколько направлений может выбрать одна попытка; значение 1 означает одну реплику, а не «перебрать список».</li><li><strong>terminal action</strong> — что вернуть или записать после остановки: деградированный результат, явную ошибку или задачу на последующую обработку.</li></ul>\n<p>Бюджет действует до расширения маршрута. Сначала проверяется, осталась ли попытка; затем выбирается реплика; только после этого допускается следующий исход. Если fallback сам делает retry, он не является бесплатной запасной веткой: его вызовы нужно включить в тот же бюджет либо запретить.</p>\n<h2>Retry, fallback и hedging — разные операции</h2>\n<p>Retry заменяет завершившийся неуспешно вызов новым. Fallback выбирает другой способ получить допустимый результат. Hedging отправляет несколько копий до получения ошибки или параллельно с задержкой. Последняя техника особенно опасна для операций с побочными эффектами: несколько копий могут быть выполнены на сервере.</p>\n<p>Официальная документация gRPC рекомендует определить пригодность операции к повтору, экспоненциальную задержку, число попыток и метрики. В её конфигурации <code>maxAttempts</code> задаёт предел RPC, а jitter слегка разносит повторы по времени. Эти поля относятся к gRPC и не становятся стандартом для HTTP-клиента автоматически. В собственной библиотеке нужно явно зафиксировать, кто отвечает за backoff, deadline и отмену.</p>\n<p>У retry и hedging разные условия остановки. Retry ждёт финала попытки и реагирует на разрешённый исход. Hedging оставляет несколько запросов in-flight, поэтому требует отмены проигравших и доказанной идемпотентности. Не объединяйте их одним флагом <code>retryEnabled</code>: по трассе должно быть видно, был ли второй вызов следствием ошибки или истечения задержки.</p>\n<h2>Контракт: исход, deadline и идемпотентность</h2>\n<p>Начните с классификатора исходов. Для чтения временный timeout может быть повторяемым, а ошибка схемы — нет. Ошибка авторизации тоже не станет успешной от повтора. Для HTTP-сервиса статус сам по себе не описывает безопасность операции: RFC 9110 называет идемпотентными безопасные методы, <code>PUT</code> и <code>DELETE</code>, но допускает повтор <code>POST</code> только когда приложение знает, что семантика конкретного ресурса безопасна или умеет обнаружить, что действие не применилось.</p>\n<p>Затем задайте общий deadline логической операции. Он включает ожидание, backoff и все попытки. Иначе каждый слой получит собственные 2 секунды и суммарно превысит время, которое разрешил клиент. Вызов, для которого дедлайн уже истёк, нельзя запускать снова только потому, что локальный счётчик ещё не достиг максимума.</p>\n<p>Укажите владельца retry. Когда edge, SDK и адаптер одновременно считают попытки, локально каждый выглядит разумно, а суммарно политика становится непроверяемой. Оставьте один слой владельцем повторов или оформите межслойный контракт с общей квотой. В любом случае логируйте <code>logical_id</code>, <code>attempt</code>, <code>owner</code>, <code>outcome</code>, выбранное направление и причину остановки.</p>\n<h2>Воспроизводимый пример без сети</h2>\n<p>Следующая команда запускается в терминале с установленным Node.js. Она не обращается к API и не измеряет производительность. Код имитирует только классификацию исходов, поэтому результат детерминирован: первый запрос восстанавливается, второй получает fallback, третий останавливается после двух попыток.</p>\n<pre><code>node <<'NODE'\nconst policy = {\n maxAttempts: 2,\n maxFanout: 1,\n retryable: new Set(['temporary-timeout']),\n fallbackTrigger: 'optional-result-unavailable',\n onExhaustion: 'fail-fast',\n};\n\nfunction decide(outcome, attempt) {\n if (outcome === 'ok') return { action: 'complete' };\n if (outcome === policy.fallbackTrigger) {\n return { action: 'fallback', name: 'cached-summary' };\n }\n if (policy.retryable.has(outcome) &&\n attempt < policy.maxAttempts) {\n return { action: 'retry', owner: 'edge' };\n }\n return { action: policy.onExhaustion };\n}\n\nconst requests = [\n { logicalId: 'read-01', outcomes: ['temporary-timeout', 'ok'] },\n { logicalId: 'read-02', outcomes: ['optional-result-unavailable'] },\n { logicalId: 'read-03', outcomes: ['temporary-timeout', 'temporary-timeout'] },\n];\n\nfor (const request of requests) {\n for (let i = 0; i < request.outcomes.length; i += 1) {\n const attempt = i + 1;\n const outcome = request.outcomes[i];\n const decision = decide(outcome, attempt);\n console.log(JSON.stringify({\n logicalId: request.logicalId,\n attempt,\n outcome,\n ...decision,\n }));\n if (decision.action !== 'retry') break;\n }\n}\nNODE</code></pre>\n<p>Ожидаемый вывод — две попытки для <code>read-01</code>, одна ветка fallback для <code>read-02</code> и две попытки с <code>fail-fast</code> для <code>read-03</code>. В этом примере <code>maxFanout</code> присутствует в политике, но не выбирает реплику: это намеренная граница модели. Реальный адаптер должен отдельно доказать, что за одну попытку выполняется не больше одного физического вызова.</p>\n<h2>Как читать трассу</h2>\n<p>Trace — путь запроса через приложение. Для этой задачи он должен отвечать на пять вопросов: какой логический запрос начался, сколько было попыток, кто разрешил каждую, какой исход получен и где создан terminal action. OpenTelemetry разделяет traces, metrics и logs; не стоит подменять отсутствующую связь между попытками общей метрикой latency.</p>\n<p>Минимальная полезная последовательность выглядит так: <code>read-01 / attempt=1 / edge / primary-a / temporary-timeout</code>; затем <code>read-01 / attempt=2 / edge / primary-b / ok</code>. Для <code>read-03</code> последняя запись должна содержать <code>attempt=2</code> и <code>fail-fast</code>. Если после неё виден вызов fallback или третьей реплики, ограничение стоит слишком поздно либо другой слой повторяет работу.</p>\n<p>Проверяйте трассу при фиксированном failure injection. Сравнение «до» и «после» ничего не доказывает, если в первом запуске было 100 запросов с timeout, а во втором — 20 запросов с ошибкой схемы. Сохраните ключ сравнения: нагрузку, исход, deadline, список реплик и версию политики. Не записывайте в атрибуты trace токены, содержимое платежа и другие секреты.</p>\n<h2>Матрица симптомов и действий</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика каскада по одному logical request</caption><thead><tr><th scope=\"col\">Наблюдение</th><th scope=\"col\">Проверяемая гипотеза</th><th scope=\"col\">Evidence</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Физических вызовов больше, чем пользовательских операций</td><td>Retry включён на нескольких слоях</td><td>Сверить <code>owner</code> и <code>attempt</code> в trace</td><td>Оставить одного владельца или ввести общую квоту</td></tr><tr><td>Один timeout обращается ко всем репликам</td><td>Fan-out не ограничен на попытку</td><td>Посчитать направления для одного <code>logical_id</code></td><td>Задать целочисленный <code>maxFanout</code> и проверить его до выбора</td></tr><tr><td>Fallback вызывает ту же зависимость повторно</td><td>Запасная ветка содержит скрытый retry</td><td>Найти дочерние spans после <code>fallback</code></td><td>Запретить повтор или включить вызовы в общий бюджет</td></tr><tr><td>Последний span закрыт, но очередь продолжает расти</td><td>Нет отмены in-flight работы или load shedding</td><td>Сопоставить deadline, cancellation и queue depth</td><td>Остановить позднюю работу и отклонять нагрузку раньше</td></tr><tr><td>Нельзя объяснить, почему повтор разрешён</td><td>Outcome классифицируется по общему 5xx</td><td>Сверить исход, метод и семантику операции</td><td>Разделить retryable и terminal outcomes</td></tr><tr><td>После исправления цифра лучше, но сценарий изменился</td><td>Сравниваются разные входы</td><td>Проверить failure injection и baseline key</td><td>Повторить оба запуска на одинаковой нагрузке</td></tr></tbody></table></div>\n<h2>Порядок безопасной проверки</h2>\n<ol><li>Опишите один логический запрос и допустимый результат. Не смешивайте его с количеством сетевых вызовов.</li><li>Назовите failure injection: зависимость, исход, длительность и область действия.</li><li>Найдите всех владельцев retry в клиенте, SDK, адаптере, gateway и очереди.</li><li>Для каждого retryable исхода проверьте идемпотентность или ключ, который защищает повтор.</li><li>Задайте общий deadline, <code>maxAttempts</code>, backoff и jitter. Значения берите из capacity-теста, а не из этого примера.</li><li>Ограничьте fan-out на одну попытку. Для hedging отдельно проверьте отмену проигравших вызовов.</li><li>Опишите fallback и terminal action. После terminal action не должно быть скрытого retry.</li><li>Добавьте в trace <code>logical_id</code>, <code>attempt</code>, <code>owner</code>, <code>outcome</code>, направление и причину остановки.</li><li>Выполните положительный и отрицательный сценарии: восстановление, fallback, исчерпание бюджета, второй retry owner и неограниченный fan-out.</li><li>Сравните результат с тем же сигналом, который обнаружил проблему: количество физических вызовов, очередь, latency и долю ошибок.</li></ol>\n<h2>Что нельзя обещать по этому примеру</h2>\n<p>Сам по себе счётчик попыток не доказывает устойчивость. Он не выбирает timeout, не рассчитывает пропускную способность и не отменяет in-flight запросы. Малое число повторов может быть правильным для чтения и опасным для операции, которая создаёт заказ, списывает деньги или отправляет письмо.</p>\n<p>Равным образом нельзя переносить настройки gRPC в любой HTTP-клиент. У gRPC есть собственные transparent retry, pushback и retry throttling; фактическое поведение зависит от библиотеки и service config. Для HTTP нужно проверить реализацию клиента, прокси, gateway и сервер отдельно. <code>Retry-After</code> следует учитывать как сигнал задержки, но не превращать в автоматическое разрешение повторить побочный эффект.</p>\n<p>Fallback не всегда безопаснее ошибки. Урезанный результат подходит для необязательного блока, но может скрыть устаревшие или неполные данные. Для критичной команды лучше вернуть явный отказ и сохранить её для повторной обработки с идемпотентным ключом. Политику деградации должен принять владелец продукта и операции, а не библиотека retry.</p>\n<p>Наконец, локальная симуляция не является нагрузочным тестом и не подтверждает SLA. В тестовой среде воспроизведите задержку, частичный отказ, исчерпание очереди и отмену in-flight работы. Только после этого можно делать вывод о конкретной версии сервиса, его лимитах и допустимой нагрузке.</p>\n<h2>Критерий готовности</h2>\n<p>Для каждого класса операций есть заполненные owner, retryable outcomes, <code>maxAttempts</code>, deadline, backoff, <code>maxFanout</code>, fallback и terminal action. Положительный сценарий восстанавливается в пределах бюджета. Отрицательный сценарий останавливается с названной причиной. В trace каждый физический вызов привязан к одному логическому запросу, а метрики показывают, не выросла ли работа на единицу полезного результата.</p>\n<p>Если команда не может ответить, кто разрешил третий вызов, почему повтор безопасен или когда отменился проигравший запрос, политика ещё не готова. Следующий шаг — зафиксировать этот пробел отдельным тестом или ограничением, а не увеличить число попыток.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics</a> — разделы об идемпотентных методах, <code>503 Service Unavailable</code> и <code>Retry-After</code>. Документ описывает семантику протокола, но не решает за приложение вопрос о безопасном повторе.</li><li><a href=\"https://grpc.io/docs/guides/retry/\" target=\"_blank\" rel=\"noopener noreferrer\">gRPC: Retry</a> — официальная документация о retry policy, <code>maxAttempts</code>, backoff, jitter, transparent retry, throttling и метриках. Настройки специфичны для gRPC и требуют проверки версии клиента.</li><li><a href=\"https://sre.google/sre-book/addressing-cascading-failures/\" target=\"_blank\" rel=\"noopener noreferrer\">Google SRE Book: Addressing Cascading Failures</a> — рекомендации по ограничению retry на запрос, серверному бюджету, backoff, раннему отказу и тестированию перегрузки.</li><li><a href=\"https://opentelemetry.io/docs/concepts/signals/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry: Signals</a> — определения traces, metrics и logs, необходимые для разделения пути запроса, измерения и записи событий.</li></ul>"
|
||
}
|