Распределённые системы
Усиление ретраев: как 3 повтора на слой превращаются в метастабильную аварию
Failover базы занимает 8 секунд. Мелочь — клиенты просто ретраят. Но ретраит каждый слой: API-шлюз повторяет вызов сервиса 3 раза, сервис повторяет слой данных 3 раза, слой данных повторяет пул соединений 3 раза. Failover завершается на 8-й секунде. Авария — нет. Бэкенд завален потоком ретраев, прилетевших разом, таймаутится и порождает ещё ретраи. Через сорок минут кто-то понимает: триггер ушёл полчаса назад — система лежит только из-за самих ретраев.
Множение геометрическое, а не линейное
Ретрай кажется бесплатным. Интуиция говорит «отправь ещё раз, один лишний вызов». Эта интуиция верна только для листа. В слоёной системе у каждого хопа своя политика ретраев, и политики композируются умножением. Если цепочка вызовов глубиной 4 сервиса и каждый слой повторяет 3 раза при ошибке, один пользовательский запрос, упавший на дне, может породить до 3 × 3 × 3 × 3 = 3⁴ = 81 вызова к самой глубокой зависимости. Книга Google SRE предупреждает ровно об этом эффекте накопления — вложенные ретраи умножаются, а не складываются, — и канонический пример: 3 слоя по 4 повтора, что превращает одно действие пользователя в 4³ = 64 попытки к базе.
Число геометрическое по глубине, поэтому оно подкрадывается незаметно. Два слоя по 3 ретрая — это 9, неприятно, но переживаемо. Четыре слоя — 81. Добавь пятый — 243. Коэффициент усиления — повторы^глубина, а глубина в современном микросервисном меше редко равна двум. Самое скверное: это множение включается во время инцидента — ровно тогда, когда у бэкенда меньше всего запаса, чтобы его поглотить.
| Глубина цепочки | Повторов на слой | Вызовов к глубокой зависимости | Эффект |
|---|---|---|---|
| 1 (только лист) | 3 | 3 | То, что предполагает интуиция |
| 2 | 3 | 3² = 9 | В основном переживаемо |
| 4 | 3 | 3⁴ = 81 | 1% ошибок → ~81% лишней нагрузки |
| 5 | 3 | 3⁵ = 243 | Самонаведённый DDoS |
Почему оно не останавливается: метастабильная авария
Глубже ловушка в том, что происходит после того, как исходная проблема залечилась. 8-секундный failover закончился. Ёмкость восстановлена. А система лежит. Это метастабильная авария — термин, который Bronson и соавторы ввели в статье HotOS 2021, чтобы объединить retry-штормы, congestion collapse и death spirals под одной рамкой. У системы два устойчивых состояния: здоровое и деградировавшее. Временный триггер (спайк, failover, короткий сбой зависимости) толкает её в деградировавшее состояние, а поддерживающая петля обратной связи держит её там, хотя триггер давно ушёл.
Ретраи — каноничная поддерживающая петля. Пройди цикл: бэкенд замедляется → запросы таймаутятся → таймауты порождают ретраи → ретраи добавляют нагрузку → бэкенд замедляется сильнее → больше таймаутов → больше ретраев. Теперь ретраи — доминирующий трафик. Работа, которую делает система, — почти целиком повторы запросов, чьи исходные дедлайны уже прошли: бесполезная работа, не дающая ничего, кроме новой нагрузки. Триггер уже неважен; петля сама себя кормит. Поэтому «подождём, восстановится» не работает, и операторам приходится сбрасывать нагрузку или перезапускать слои, чтобы разорвать цикл силой.
Почему это работает
Метастабильная авария так дезориентирует в постмортеме потому, что корневая причина в таймлайне (failover) и корневая причина затянувшейся аварии (петля ретраев) — это разные вещи. Люди жгут инцидент в погоне за триггером, который уже ушёл. Честная строка постмортема: триггер запустил это, но усиление ретраев — то, что держало нас лежащими; и единственный сработавший фикс — снижение нагрузки, а не починка триггера.
Фикс 1: экспоненциальный бэкофф с jitter
Первая причина смертоносности ретраев — синхронизация. Когда зависимость моргает, все клиенты таймаутятся примерно в один момент и ретраят примерно в один момент — thundering herd, прилетающий спайком. Ретраи с фиксированным интервалом делают хуже: они ресинхронизируют стадо на каждом круге. Экспоненциальный бэкофф (жди base, потом 2×base, потом 4×base) разносит попытки во времени, но не декоррелирует их — клиенты, стартовавшие вместе, всё равно шагают в ногу.
Фикс — jitter: добавь случайность, чтобы клиенты размазались по окну. Разбор AWS в Architecture Blog — каноничная ссылка. Full jitter берёт sleep = random(0, base × 2^attempt) — максимальный разброс, меньше всего вызовов. Equal jitter держит гарантированный пол: sleep = base/2 + random(0, base/2), чтобы ни один клиент не спал меньше половины бэкоффа. AWS обнаружили, что full и equal jitter завершаются примерно за одинаковое число вызовов; full jitter делает чуть меньше работы, equal jitter избегает слишком коротких сонов. Главный итог: jitter-бэкофф резко снижает и общее число вызовов, и время восстановления против простого экспоненциального бэкоффа. Фиксированный бэкофф без jitter — то, что нельзя выкатывать никогда.
Фикс 2: retry budget и circuit breaker
Бэкофф сглаживает тайминг ретраев; он не ограничивает их объём. Это делает retry budget. Вместо счётчика повторов на запрос ты ограничиваешь ретраи как долю от всего трафика — Google SRE и Finagle оба берут ~10%: клиент может тратить ретраи лишь до 10% объёма своих запросов, а как только бюджет исчерпан — падает быстро вместо повтора. Это превращает безграничное усиление в жёсткий потолок: даже при полной аварии трафик ретраев добавит максимум 10% лишней нагрузки, а не 80×.
Circuit breaker атакует ту же проблему с другого конца. Он следит за частотой ошибок к зависимости; как только ошибки переходят порог, он открывается — каждый вызов падает мгновенно, не касаясь сети, — на время кулдауна. После кулдауна он переходит в half-open, пропуская один пробный вызов; успех закрывает его, ошибка снова открывает. Открытый breaker — то, что останавливает поддерживающую петлю: бэкенд получает окно нулевого трафика ретраев, восстанавливается, и проба плавно впускает трафик обратно. Сочетай оба с двумя нерушимыми правилами: ретрай только идемпотентных операций и retryable-ошибок (никогда не ретрай 400, никогда не ретрай вслепую неидемпотентный POST) и проброс дедлайна — передавай оставшийся бюджет времени вниз по цепочке, чтобы сервис не ретраил запрос, от которого вызывающий уже отказался. Ретрай мёртвого запроса — чистейшая форма бесполезной, усиливающей работы.
Цепочка глубиной 4 сервиса схлопывается под усилением ретраев при сбоях зависимости. Выбери фикс с наибольшим рычагом.
Запрос проходит через 4 слоя сервисов, и каждый слой повторяет 3 раза при ошибке. В худшем случае сколько вызовов достигнет самой глубокой зависимости на один упавший пользовательский запрос?
Сбой зависимости, запустивший аварию, ушёл 30 минут назад, но система всё ещё лежит под retry-штормом. Что на самом деле держит её лежащей?
Расставь защиты, которые сеньор накладывает слоями, от самого широкого сокращения радиуса взрыва к самому тонкому правилу корректности:
- 1 Retry budget: ограничь ретраи ~10% объёма запросов, чтобы у усиления был жёсткий потолок
- 2 Circuit breaker: открой по порогу ошибок, чтобы дать бэкенду окно восстановления без ретраев
- 3 Экспоненциальный бэкофф с jitter: десинхронизируй thundering herd во времени
- 4 Ретрай только идемпотентных операций и retryable-ошибок — никогда 400, никогда слепой POST
- 5 Пробрасывай дедлайн, чтобы ни один слой не ретраил запрос, от которого вызывающий уже отказался
- 01Объясни коллеге, почему система может лежать 30 минут после того, как запустивший аварию сбой уже ушёл.
- 02Почему экспоненциального бэкоффа с jitter в одиночку недостаточно, и что добавить, чтобы реально ограничить усиление?
Ретраи кажутся бесплатными, но композируются умножением, а не сложением: в цепочке глубиной 4, где каждый слой повторяет 3 раза, один упавший запрос может стать 3⁴ = 81 вызовом к самой глубокой зависимости, и усиление растёт геометрически с глубиной. Хуже того, трафик ретраев становится самоподдерживающимся — это метастабильная авария, где временный триггер толкает систему в деградировавшее устойчивое состояние, а петля обратной связи (медленно → таймаут → ретрай → больше нагрузки → ещё медленнее) держит её там долго после того, как триггер ушёл; поэтому системы лежат 30 минут после того, как вызвавшая их авария закончилась. Защиты накладываются слоями: экспоненциальный бэкофф с jitter десинхронизирует thundering herd и режет время восстановления (full или equal jitter, никогда фиксированный бэкофф); retry budget ограничивает ретраи примерно 10% объёма запросов, чтобы у усиления был жёсткий потолок; circuit breaker открывается по порогу ошибок, давая бэкенду окно восстановления без ретраев. Ограничь дальше, ретраяя только идемпотентные операции и retryable-ошибки и пробрасывая дедлайны, чтобы ни один слой не ретраил запрос, от которого вызывающий уже отказался. Цель — никогда не ноль ретраев — временные сбои реально успевают со второй попытки — а ретраи, которые не могут размножиться в шторм, кладущий тебя.