Назад к блогу

Как Uber защищается от штормов повторных запросов

Как Uber защищается от штормов повторных запросов

Uber разбирает, как каскадные повторы запросов при сбоях умножают нагрузку на деградирующий сервис и превращают локальную ошибку в затяжной шторм. На примере реального инцидента и формул роста нагрузки показано, почему привычные ретраи не спасают, а вредят, и какие механизмы — от бюджетов повторов до передачи ответственности за ошибку вверх по цепочке вызовов — позволяют удержать систему на плаву.

Повторный запрос после ошибки выглядит безобидно: одна неудачная попытка заменяется ещё одной. Но когда цепочка вызовов уходит на десяток уровней вглубь и каждый уровень повторяет неудачу своего соседа, число запросов растёт по экспоненте. Разбираем, как Uber описывает механику этого усиления и что противопоставляет ему — от бюджетов повторов до передачи «владения ошибкой» вверх по цепочке вызовов.

Почему ретраи превращаются в шторм

Если на каждом хопе разрешено одинаковое число повторов R, то узел на глубине ɗ обслуживает Rɗ × Ƞ запросов, где ɗ — номер уровня в цепочке вызовов (корень — уровень 0), R — сколько раз каждый узел повторяет неудавшийся запрос, а Ƞ — базовый поток запросов, приходящий на корень. Множители перемножаются, потому что повторы каждого уровня умножаются на повторы всех уровней ниже. Это и есть усиление нагрузки при ретраях. Ограничение вводится через retry budget: при одинаковом бюджете B на каждом хопе формула принимает вид (1+B)ɗ × Ƞ.

Для бюджета 10% на глубинах 0–6 множители нагрузки выглядят так:

Ƞ  1.1 × Ƞ  1.21 × Ƞ  1.33 × Ƞ  1.33 × Ƞ  1.33 × Ƞ  1.33 × Ƞ

Рост прекращается на 1.33 × Ƞ. Если же ограничить повторы только между узлами D и C, где возникла ошибка, и запретить их на A и B, множители становятся Ƞ, Ƞ, Ƞ, 1.1 × Ƞ, 1.1 × Ƞ, 1.1 × Ƞ, 1.1 × Ƞ. Это удерживает суммарное число запросов, обслуживаемых всеми узлами от D до листового узла G, всего на 10% выше базового уровня.

Насколько это критично, показывает инцидент 18 ноября 2025 года. Проблема возникла в Core Entity service — сервисе, который находится более чем на 5 уровней вглубь цепочки вызовов и критичен для бизнес-операций. Из-за проблемы в нижележащей инфраструктуре сервис начал возвращать очень высокую долю ошибок, и ошибка быстро распространилась вверх по цепочке. Многие вышестоящие сервисы-вызыватели успели повторно отправить неудавшиеся запросы; при простых бюджетах повторов это дало бы рост трафика на деградировавший сервис на 46%–135%, продлевая сбой и снижая шансы на восстановление.

Retry Budgets: как ограничивается доля повторов

Бюджет задаётся одинаковой долей B на каждом узле цепочки, и число обслуживаемых запросов на глубине ɗ описывается формулой (1+B)ɗ × Ƞ. Что это даёт при разной доступности, видно из таблицы доступности: при базовой доступности 99.9% и доле ошибок 0.1% бюджет 10% даёт доступность 99.9999%, а при базовой доступности 70% и доле ошибок 30% — 23% ошибок и 77% доступности.

Здесь и лежит ключевой предел: при падении доступности вызываемого узла до 10% даже одного ретрая достаточно, чтобы воспринимаемая доступность у вызывающего выросла до 99%. Дальше она заметно падает, так как значительная часть запросов не повторяется из-за действующего бюджета. Расчёт при этом предполагает независимость ошибок вызываемого узла и то, что ретраи приведут к восстановлению. В реальных сценариях — перегрузка сервиса, плохие хосты БД, перегрузка БД, проблемы шардирования — вероятность повторных ошибок остаётся высокой. Это противоречит идее, что ретраи к вызываемому узлу всегда повышают воспринимаемую доступность у вызывающего, и лежит в основе концепции error ownership: при высоких частотах ошибок ошибки менее случайны, и большее число ретраев не помогает, а может усугубить деградацию.

Бюджет считается на стороне клиента, потому что именно клиент решает, повторять ли запрос. В Google SRE описан per-client retry budget: клиент отслеживает долю запросов, которые являются повторами, и повторяет только пока эта доля ниже 10%. Без такого бюджета число запросов может вырасти почти до 3X, а с бюджетом 10% — всего до 1.1x. Но у клиентского бюджета есть компромисс: при частичном отказе, когда перегружена лишь часть задач, он может ограничивать повторные попытки, и тогда воспринимаемая доступность падает. Кроме того, клиентский бюджет не различает повторные и новые запросы на стороне балансировщика, поэтому повтор может попасть на ту же перегруженную задачу — вероятность попасть на другую существует лишь за счёт количества участников.

Архитектура: error claims и распространение ошибок

error claim строится на различении симптома и причины. Если сервис вызывает N исходящих вызовов и ошибка одного из них заставляет его вернуть ошибку, то возвращаемая ошибка — только симптом, а причина — входящая ошибка от нижележащего сервиса. Если же ни один исходящий вызов не завершился ошибкой, а сервис всё равно вернул ошибку, то он сам является причиной и владельцем.

Владелец определяется через Service Dependency Analysis Solution, которая коррелирует входящий сбой с исходящим и применяет набор правил для создания или отклонения error claim. Вызывающий использует отдельную логику, чтобы решить, повторять ли запрос: при получении claimed ошибки от вызываемого узла он должен ретраить, но если ретрай не удался, при передаче ошибки выше он обязан её unclaim, и тогда вышестоящий узел не должен ретраить. В продакшене схема работает в retry middleware и Service Dependency Analysis Solution, которые находятся в пути запроса пользовательских API и непрерывно claim/unclaim ошибки по мере прохождения трафика через глубокие цепочки зависимостей.

Правило «первый узел, увидевший отсутствующий error claim от downstream, снимает ошибку» работает так: узел, который видит ошибку от нижестоящего узла, помеченную как unclaimed, сам не должен повторять запрос к нему. Это ограничивает радиус поражения retry-возмущения — оно перестаёт быть штормом, — но всё же позволяет достаточное число ретраев к сервису, вернувшему ошибку. Если retry от B к C не удался, B возвращает ошибку A, но при возврате обязан снять с неё claim; A, увидев ошибку от B с заголовком unclaimed, не повторяет запрос к B. При одном retry на каждом узле это сокращает общее число запросов к узлам B–D вдвое.

Условия обработки claim задаются матрицей решений, где состояние claim отражает, кто несёт ответственность за ошибку. Состояние Claimed означает, что нижележащий сервис признал ошибку своей и способен её устранить, поэтому вызывающий повторяет запрос; состояние Unclaimed означает, что ошибка нижележащему сервису не принадлежит, поэтому вызывающий не повторяет запрос; состояние Missing означает, что claim не был выставлен — например, из-за отсутствия у нижестоящего сервиса service dependency analysis solution или из-за missing context propagation, что даёт неполную корреляцию между исходящими и входящими ошибками, — и тогда вызывающий повторяет запрос. Во всех трёх случаях при собственной ошибке вызывающий снимает claim (Unclaim). Конкретные таймауты и срок жизни claim не определены.

Пограничные случаи

Совпадающие ошибки — это легитимная серверная ошибка или fail-close ошибка зависимости, происходящая во время fail-open ошибки зависимости. Решение анализа зависимостей хранит память о паттернах сбоев и снимает claim только когда входящие сбои коррелируют с исходящими в fail-close зависимостях, что устраняет риск подавления ретраев.

Для гарантии at-least-once ретраев вводится флаг, сигнализирующий, удовлетворены ли критерии ретрая для ошибки от нижележащего сервиса. Retry middleware при получении ошибки от downstream вычисляет это и передаёт в решение анализа зависимостей, которое решает, владеть ли ошибкой на входящем.

При потере внутрисервисного контекста решение не может сопоставить исходящий запрос с входящим, поэтому узел claim'ит ошибку, а retry middleware продолжает развязанные ретраи. Если разрыв произошёл в узле C, разделённые повторы продолжают происходить между C и D, а также между B и C, так как C должен присвоить возвращаемую им ошибку. Когда повторы терпят неудачу и B возвращает ошибку в A, он также возвращает заголовок unclaimed error, что предотвращает повтор из A в B. Общее число запросов к узлам B–D сокращается вдвое при конфигурации с одним повтором (1 попытка и 1 повтор) на всех узлах. Если бы слева от A было 5 узлов, в худшем случае было бы в 32 раза больше запросов без распространения владения ошибкой.

При перегрузке бэкенда клиент получает ответ «task overloaded» и решает, повторять ли запрос. Чтобы избежать лавины, применяется per-request retry budget: не более трёх попыток, после чего ошибка передаётся вызывающему. Дополнительно действует per-client retry budget с порогом 10%. Третий механизм: клиент передаёт в метаданных счётчик попыток, а бэкенд по гистограммам решает, возвращать ли «overloaded; don't retry» вместо «task overloaded». В архитектуре Uber error claims и retry budgets ограничивают распространение повторов: при ошибке на NodeD повтор разрешён только между NodeD и NodeC, а NodeA и NodeB не повторяют, что удерживает нагрузку на уровне 1.1×Ƞ. При этом возможны потерянные легитимные повторы: при совпадающих ошибках NodeA может ошибочно unclaim до 1.9 из 10 запросов, а в худшем случае — 0.396 из 10.

Как сравнивали и что получилось

Сравнение строится на двух наборах данных. Первый — 6 месяцев данных решения по анализу зависимостей сервисов. По ним установлено, что совпадающие ошибки крайне редки: в худшем случае для 80% рёбер с более чем 100 отказами вызываемого сервиса в минуту примерно в 2% случаев вызывающий сервис также отказывал. На примере это дало бы 0.396 из 10 запросов, ошибочно не заявленных узлом Node A. Даже при таком худшем сценарии решение создаёт память о паттернах отказов и может не заявлять ошибки только тогда, когда входящие отказы коррелируют с исходящими в fail-close зависимостях, — это устраняет риск подавления повторных попыток.

Второй набор — эффект от включения error ownership в продакшене. На инциденте 18 ноября 2025 года система автоматически ограничила зону поражения: немедленные повторы были остановлены для непосредственных вызывателей деградировавшего сервиса, причём некоторых вызывателей остановили от совершения до 200 000 дополнительных запросов. Подсчитав остановленные повторы у предков этих непосредственных вызывателей и агрегировав их в корневом узле, Uber выяснил, что смог остановить 9,5 млн ложных запросов в сервисной сети, которые иначе могли бы продлить сбой, постоянно нагружая деградировавший сервис.

По всем пользовательским API максимальное значение снижено до 3, тогда как ранее оно достигало 25; среднее значение снижено до 2 с прежних 20.

Компромиссы и альтернативы

Подход Uber строится на владении ошибкой: сервис, вернувший ошибку без сбоя исходящего вызова, — владелец, и повторять должен только вызывающий непосредственно над владельцем. Это реализуется корреляцией входящих и исходящих сбоев и заголовками error claim: когда retry не удался, ошибка unclaim'ится, и вышестоящие вызывающие её не повторяют.

Google SRE использует клиентское регулирование с per-request и per-client бюджетами: повторы не отличаются от новых запросов и повторяются, пока доля повторов ниже 10%. Клиентское регулирование принимает решение локально на основе вероятности отклонения, которая вычисляется из соотношения requests и accepts и множителя K: requests — это число запросов, а accepts — число принятых запросов, и их соотношение вместе с множителем K задаёт вероятность отклонения запроса. утилизация по мере приближения к порогам приводит к отклонению запросов в зависимости от критичности.

Uber не полагается только на эти сигналы, потому что в реальных сценариях — перегрузка сервиса, плохие хосты БД, перегрузка БД, проблемы шардирования — вероятность повторных попыток остаётся высокой даже при повторах, а ошибки не рандомизируются, поэтому большее число повторов может усугубить деградацию.

Цена подхода Uber — потеря легитимных повторов: в сценарии с совпадающими ошибками 1.9 из 10 запросов были бы ошибочно unclaim'лены, и повтор не состоялся бы. Кроме того, бюджет ограничивает повторы 10% ошибок, поэтому при падении доступности за пределами 10% воспринимаемая доступность значительно снижается — многие запросы вообще не повторяются.

Применение в Uber

Service dependency analysis применяется для установления владения ошибкой в цепочках вызовов: оно сопоставляет входящий сбой с исходящим, чтобы определить, какой сервис — причина, а какой — лишь симптом. Оно работает в паре с retry middleware и встроено в service mesh, через которую проходят пользовательские API, поэтому сервисы получают защиту от retry storm без собственной логики обработки ошибок. Связь с защитой в том, что повторы разрешаются только сервисам-владельцам ошибки, способным реально устранить проблему, что предотвращает экспоненциальное разрастание запросов.

Что из этого следует на практике

  • Бюджет повторов сам по себе не спасает от шторма: при одинаковом бюджете на каждом хопе нагрузка всё равно растёт как (1+B)ɗ × Ƞ. Ограничивать повторы нужно там, где возникла ошибка, а не на каждом уровне цепочки.
  • При падении доступности вызываемого узла до 10% одного ретрая достаточно, чтобы поднять воспринимаемую доступность до 99%; дальше дополнительные повторы не помогают, а при неслучайных ошибках вредят.
  • Клиентский бюджет защищает от шторма, но не различает повторы и новые запросы на балансировщике — повтор может снова попасть на перегруженную задачу.
  • Владение ошибкой работает как распределённый протокол: владелец заявляет ошибку, вызывающий над ним повторяет, а при неудаче снимает claim, чтобы выше по цепочке повторов не было. Это ограничивает радиус шторма, сохраняя повторы к сервису, который реально может восстановиться.
  • Потеря claim или контекста трактуется как Missing — повтор разрешается, но при возврате ошибки выше она снова unclaim'ится, что не даёт шторму распространиться.
  • Цена подхода — потерянные легитимные повторы при совпадающих ошибках: в разобранном примере до 1.9 из 10 запросов могли быть ошибочно unclaim'лены.

Источники

Похожее