SRE и надёжность Деградация вместо отказа: таймауты, ретраи, размыкатели
0%

Деградация вместо отказа: таймауты, ретраи, размыкатели

Деградация вместо отказа: таймауты, ретраи, размыкатели

Товарная страница интернет-магазина собирает данные из шести источников: шлюз, каталог, цены, рекомендации, отзывы, персонализация. В 03:40 сервис рекомендаций уходит в GC-паузу на девяносто секунд. Товарная страница отдаёт 500. Не «страница без блока рекомендаций» — страница целиком, вместе с ценой, кнопкой «купить» и корзиной.

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

Это не история про невнимательность, а про отсутствующее решение: никто никогда не садился и не отвечал на вопрос «что мы показываем пользователю, если рекомендаций нет». Когда решения нет, система принимает его сама — и принимает худшее из возможных: отказ целиком.

Эта глава — про то, как принимать такие решения заранее и во что они обходятся. Каталог самих паттернов подробно разобран в главе про устойчивость трека архитектурных паттернов; здесь нас интересует другое — арифметика (сколько минут доступности покупает каждый рычаг и почему таймаут вычисляется, а не угадывается), цена (второй код-путь, который никто не тестирует) и граница, за которой инженерное решение упирается в продуктовое.

Деградация — это заранее принятое решение о том, чем жертвовать

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

Ключевое слово — «спроектированный»: отказ тоже частичный ответ, просто выбранный случайно. У деградации пять измерений, и почти всегда полезно перебрать все пять, а потом выбрать самое дешёвое.

Измерение Что урезаем Пример Чем платит пользователь
Полнота часть ответа страница без блока рекомендаций не видит части функциональности
Свежесть актуальность данных цена из кэша пятиминутной давности видит устаревшее
Точность качество вычисления общий топ вместо персонального получает хуже подобранное
Скорость сроки исполнения заказ принят, обработка в очередь ждёт дольше
Охват доля пользователей новые регистрации закрыты, старые работают часть пользователей не обслужена

Последнюю строку принято стыдливо не обсуждать, а это самая честная форма деградации: если ёмкости хватает на 70 % трафика, вы обслужите либо 70 % пользователей хорошо, либо 100 % пользователей плохо. Второе обычно хуже — про это в главе про ёмкость и нагрузку.

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

Арифметика: сколько минут покупает вынос звена из цепочки

Вернёмся к товарной странице. Если запрос обязан пройти через N независимых компонентов, доступность произведения — произведение доступностей. Возьмём правдоподобные цифры:

шлюз 0,9999 | каталог 0,9995 | цены 0,9995
рекомендации 0,995 | отзывы 0,99 | персонализация 0,995

0,9999 × 0,9995 × 0,9995 × 0,995 × 0,99 × 0,995 = 0,979047
1 − 0,979047 = 0,020953  →  0,020953 × 43 200 = 905,2 мин/мес ≈ 15 ч 5 мин

Пятнадцать часов в месяц при том, что худшее звено обещает 99 %. Теперь делаем ровно одно изменение — три нижних сервиса становятся необязательными: их отказ означает страницу без блока, а не ошибку.

0,9999 × 0,9995 × 0,9995 = 0,998900
1 − 0,998900 = 0,0011  →  0,0011 × 43 200 = 47,5 мин/мес

С 905 минут до 47,5. Разница — 857,7 минуты в месяц, почти четырнадцать с половиной часов, купленных тремя условиями в коде.

Теперь честный контрольный вопрос: можно ли получить то же самое «в лоб», подняв надёжность трёх сервисов? Допустим, героическими усилиями вы довели все три до 99,99 %: 0,998900 × 0,9999³ = 0,998600, недоступность 0,0014 — это 60,5 мин/мес.

Результат хуже, чем от деградации, — и это при том, что три девятки с половиной на трёх сервисах означают вторую зону доступности для каждого, репликацию состояния, отдельное дежурство и удвоение счёта за инфраструктуру. Если каждый сервис стоит порядка 2 000 USD в месяц, речь о дополнительных 6 000 USD в месяц плюс человеко-месяцы работы. Против сорока строк кода и фича-флага.

Это самый большой рычаг во всей главе, и он не технический: дешевле всего убрать компонент из обязательной цепочки, а не улучшить его. Всё остальное — таймауты, повторы, размыкатели — механика, которая делает такой вынос возможным.

Где здесь можно себя обмануть

Соблазн очевиден: объявить деградированный ответ успешным, и SLO мгновенно позеленеет. Иногда это правда, иногда — перекладывание ущерба из графика в отчёт о выручке.

Проверка одна: измерить, что происходит с пользователем в деградированном режиме. Если конверсия страницы без рекомендаций падает на 12 %, вы не устранили ущерб, а перевели его из «ошибок» в «недополученную выручку», где его никто не увидит. Рабочая практика — держать два показателя (про их выбор): availability — доля запросов, на которые пришёл любой осмысленный ответ (деградированный считается успехом), и completeness — доля ответов, отданных в полном составе.

Первый защищает от катастрофы, второй не даёт деградации стать нормой. Цель по второму мягче: скажем, 99 % при 99,9 % по первому. И тогда «сервис рекомендаций лежал три дня» съедает бюджет по полноте, попадает в разговор о приоритетах и не притворяется, что ничего не случилось. Механика бюджета — в главе про бюджет ошибок.

Таймаут: не «сколько ждать», а «сколько ресурса я готов держать»

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

Здесь работает закон Литтла (J. D. C. Little, 1961): для стационарной системы среднее число заявок внутри равно интенсивности потока, умноженной на среднее время пребывания. Возьмём сервис: 500 rps, нормальное время ответа 40 мс, пул из 200 воркеров.

L = λ × W:  L — занятых слотов пула, λ — запросов в секунду, W — время обработки

норма:      L = 500 × 0,04 = 20 слотов          → занято 10 % пула
зависимость затормозила, все запросы упираются в таймаут 2 с:
            L = 500 × 2 = 1000 слотов           → нужно в 5 раз больше, чем есть

Пул кончается не «постепенно», а за 200 / 500 = 0,4 секунды. Дальше сервис не отвечает никому — включая эндпоинты, которые к затормозившей зависимости вообще не обращаются.

Занятость пула воркеров при разной задержке: закон Литтла и переполнение

Разверните формулу — и получите способ вычислять таймаут, а не угадывать:

W_max = L_max / λ = 200 / 500 = 0,4 с

Любой таймаут выше 400 мс на этом сервисе — арифметическое обещание исчерпать пул при насыщении. Отсюда правило: таймаут выбирают из двух ограничений сразу и берут минимум.

  1. Сверху — сколько система переживёт: W_max = L_max / λ_пик.
  2. Снизу — сколько нужно здоровому вызову: обычно p99 или p99,9 нижестоящего сервиса плюс запас. Не среднее: среднее не описывает хвост, а таймаут — это ровно про хвост (см. измерение производительности).

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

Дефолты, которые убивают

Отдельная категория аварий — таймаут, которого нет, потому что его не поставили.

Инструмент Значение по умолчанию Чем кончается
Go http.Client{} таймаут отсутствует горутины копятся до OOM
Python requests.get(url) таймаута нет поток висит вечно
Python httpx 5 с на всё разумно, но часто больше вашего бюджета
PostgreSQL JDBC socketTimeout 0 — бесконечно соединение висит после падения сети
nginx proxy_read_timeout 60 с шестьдесят секунд удержания воркера
Envoy route timeout 15 с лучше, но всё равно много вашего бюджета

Правило простое: таймаут на сетевой вызов ставится всегда и явно, даже если библиотека что-то обещает по умолчанию. И их несколько: на установку соединения, на TLS-рукопожатие, на чтение, на весь запрос целиком. Общий таймаут без покомпонентных даёт неприятный класс зависаний, когда данные капают по байту и общий счётчик не срабатывает.

Дедлайн важнее таймаута

Таймаут локален: «я жду не больше двух секунд». Дедлайн глобален: «весь запрос должен завершиться к моменту T». Разница видна в цепочке.

Распределение бюджета времени по цепочке вызовов

Клиент готов ждать 2000 мс. Шлюз тратит 100 мс на себя и передаёт вниз оставшиеся 1900, а не свой собственный таймаут. Сервис A тратит 200 и передаёт 1700. Сервис B тратит 300 и идёт в базу с остатком 1400 — при том что p99 запроса 80 мс. Эти 1320 мс запаса и есть мёртвая зона: время, которое база будет держать соединение ради ответа, который наверху уже никому не нужен.

Отсюда два обязательных требования. Первое: дедлайн распространяется вниз — заголовком (grpc-timeout), полем контекста, явным параметром; в gRPC и Go это встроено (context.WithTimeout плюс автоматическая передача остатка). Второе: локальный потолок ограничивает остаток, таймаут = min(остаток_дедлайна, разумный_локальный_потолок), иначе нижние уровни наследуют щедрость верхних.

Без первого требования возникает работа-зомби: клиент уже получил ошибку по таймауту, а три сервиса ниже продолжают считать, держать соединения и грузить базу. Под нагрузкой доля зомби-работы растёт, и она добивает систему ровно в тот момент, когда нужна каждая свободная миллисекунда. Это буквально усиливающая петля из главы про обратные связи: рост задержки → рост числа отменённых сверху запросов → рост бесполезной работы → рост задержки.

// Локальный потолок: столько мы согласны ждать базу, исходя из L_max / λ.
const localCap = 400 * time.Millisecond

func (s *Service) fetchPrice(ctx context.Context, id string) (Price, error) {
    deadline, ok := ctx.Deadline()
    if !ok {
        // Контекст без дедлайна — почти всегда баг вызывающей стороны.
        return Price{}, ErrNoDeadline
    }
    budget := min(time.Until(deadline), localCap)
    // Меньше 50 мс — идти бессмысленно: не успеем и только займём слот.
    if budget < 50*time.Millisecond {
        return Price{}, ErrNoBudget
    }
    ctx, cancel := context.WithTimeout(ctx, budget)
    defer cancel()
    return s.repo.Price(ctx, id)
}

Проверка budget < 50ms важнее, чем кажется: без неё сервис под нагрузкой выполняет тысячи заведомо обречённых вызовов. Отказаться заранее — дешевле для всех.

Повторы: лечение, которое умножает нагрузку

Повтор — единственный механизм в этой главе, который увеличивает нагрузку. Причём увеличивает именно тогда, когда система с ней не справляется. Это делает его самым опасным инструментом набора.

Умножение по цепочке

Пусть на каждом уровне стоит «до трёх попыток» — настройка, которая по отдельности выглядит невинно.

уровень 1: 3 попытки
уровень 2: 3 попытки на каждую попытку уровня 1  → 9
уровень 3: 3 попытки на каждую попытку уровня 2  → 27
500 пользовательских rps → до 13 500 rps на нижний сервис

Двадцатисемикратное усиление приходит ровно в момент, когда нижний сервис уже не тянет. Именно так короткий всплеск превращается в получасовую аварию, которая не заканчивается после устранения исходной причины: система топит себя собственными повторами. Механика этого класса отказов — в следующей главе.

Первое правило: повторяет только один уровень, обычно самый верхний, у которого есть контекст запроса и остаток дедлайна. Остальные отдают ошибку наверх. Если архитектурно так не выходит — нужен бюджет повторов (ниже).

Задержка и джиттер

Немедленный повтор бесполезен: за микросекунду перегруженный сервис не выздоровел. Экспоненциальная задержка с полным случайным разбросом — стандартное решение (AWS Architecture Blog, «Exponential Backoff And Jitter»): sleep(n) = random_uniform(0, min(cap, base × 2^n)) при base = 50 мс и cap = 1000 мс.

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

import random, time, httpx

RETRYABLE = (429, 502, 503, 504)  # то, что имеет шанс пройти со второго раза

def delay(attempt: int, base_ms: int = 50, cap_ms: int = 1000) -> float:
    """Полный джиттер: равномерно из [0, min(cap, base * 2^attempt)]."""
    return random.uniform(0, min(cap_ms, base_ms * 2 ** attempt)) / 1000

def call_with_retries(client: httpx.Client, url: str, budget_s: float, tries: int = 3):
    """Повторяем, пока хватает бюджета времени. Бюджет — жёсткая граница."""
    deadline = time.monotonic() + budget_s
    for attempt in range(tries):
        left = deadline - time.monotonic()
        if left < 0.05:  # попытка должна поместиться целиком
            raise TimeoutError("бюджет исчерпан, повтор невозможен")
        try:
            resp = client.get(url, timeout=min(left, 0.4))
            if resp.status_code not in RETRYABLE:
                return resp                      # успех или неповторяемая ошибка
        except (httpx.TimeoutException, httpx.TransportError):
            pass
        pause = delay(attempt)
        if pause > deadline - time.monotonic():  # задержка тоже внутри бюджета
            break
        time.sleep(pause)
    raise TimeoutError("попытки или бюджет исчерпаны")

Сложность: O(k) по времени при k попытках, O(1) по памяти. Полезнее другая оценка — ожидаемое усиление нагрузки: при доле отказов p и k попытках среднее число запросов на один пользовательский равно (1 − p^k) / (1 − p). При p = 0,5 и k = 3 это 1,75; при p = 0,95 — 2,85. То есть чем хуже сервису, тем сильнее вы его добиваете. График этой функции стоит держать в голове вместо лозунга «повторы улучшают надёжность».

Что повторять можно, а что нельзя

  • Только идемпотентные операции. GET, PUT с ключом, POST с ключом идемпотентности — можно. Голый POST /payments — нет. Разбор ключей и дедупликации — в главе про идемпотентность.
  • Только повторяемые ошибки. 503, 429 (с уважением к Retry-After), отказ соединения — да. 400, 403, 404, 422 — нет: они детерминированы, повтор просто утроит нагрузку без единого шанса на успех.
  • Таймаут — особый случай. Вы не знаете, выполнился ли запрос. Повтор после таймаута безопасен только при идемпотентности; иначе это тихое дублирование платежей.

Бюджет повторов

Ограничение «до трёх попыток» не ограничивает ничего: три попытки на каждый из миллиона запросов — это три миллиона запросов. Правильная единица — доля от общего трафика клиента. Так устроено в gRPC (retryThrottling) и в Envoy: маркерное ведро, из которого повтор берёт токен, а успешный запрос кладёт долю обратно.

бюджет 10 % от исходящего трафика: 500 базовых rps → не более 50 rps повторов
при полном отказе бэкенда, 3 уровня по 3 попытки:
  без бюджета:                 500 × 3³   = 13 500 rps
  с бюджетом 10 % на уровень:  500 × 1,1³ ≈ 665 rps

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

Размыкатель: полезен, но переоценён

Размыкатель (circuit breaker) — счётчик отказов с тремя состояниями. Пока доля ошибок ниже порога, он пропускает трафик; превысила — начинает отказывать мгновенно, не трогая нижестоящий сервис; спустя паузу пропускает несколько пробных запросов.

Арифметика выгоды та же, через закон Литтла. Сервис на 10 000 rps, зависимость легла, таймаут 3 с:

без размыкателя: L = 10 000 × 3 = 30 000 висящих запросов; при 32-64 КБ
                 буферов и стеков на запрос — 1-2 ГБ памяти на ожидание впустую
с размыкателем:  L = 10 000 × 0,001 = 10 слотов (отказ за ~1 мс)

Три порядка. Именно поэтому размыкатель — не про «беречь чужой сервис», а прежде всего про сохранение собственного.

Где он ломается

Теперь честная часть, которую редко пишут в туториалах.

Малый трафик даёт ложные размыкания. Порог по доле требует статистики. Возьмём 0,5 rps и окно 10 секунд — это 5 запросов, и порог «50 % ошибок» срабатывает от трёх неудач. При истинной доле ошибок 5 % вероятность увидеть три и более отказа из пяти — около 0,00116, а окон в сутках 86 400 / 10 = 8640, то есть 8640 × 0,00116 ≈ 10 ложных размыканий в сутки.

Десять раз в день исправный сервис отключается на 30 секунд из-за шума. Это та же проблема малой выборки, что и в главе про алерты, и лечится она так же: минимальное число запросов в окне (minimumNumberOfCalls), более длинное окно, либо отказ от размыкателя в пользу простого таймаута.

Гранулярность. Размыкатель на весь сервис отключает и здоровые эндпоинты. Размыкатель на весь пул адресов отключает и здоровые экземпляры, когда болеет один. Рабочая гранулярность — «эндпоинт × экземпляр», и тогда это уже не размыкатель, а выброс из балансировки (outlier detection в Envoy, passive health checks в nginx). Второе почти всегда лучше первого: трафик уходит на здоровые узлы, а не в ошибку.

Бинарность. Размыкатель — ступенька: 0 % или 100 %. Реальная перегрузка непрерывна: сервис держит 60 % нагрузки, а не ноль. Ступенька в такой ситуации ведёт себя плохо — отключает всё, потом впускает всё, снова отключает. Классические колебания контура с задержкой (про задержки в петлях).

Непрерывная замена описана в книге Google SRE (глава 21, «Handling Overload») под названием adaptive throttling: клиент сам отбрасывает часть исходящих запросов с вероятностью, зависящей от того, какую долю его запросов сервер в последнее время принимал.

Псевдокод (окно последних 2 минут):
  requests — сколько запросов клиент попытался отправить
  accepts  — сколько из них сервер принял (не отверг перегрузкой)
  K        — коэффициент агрессивности, обычно 2
  p_reject = max(0, (requests − K × accepts) / (requests + 1))
  перед каждым вызовом: если random() < p_reject → локальный отказ
class AdaptiveThrottle:
    """Самоограничение клиента (Google SRE, гл. 21). Экспоненциальное затухание
    вместо кольцевого буфера: O(1) по времени и O(1) по памяти на клиента."""

    def __init__(self, k: float = 2.0, half_life_s: float = 60.0):
        self.k, self.half_life = k, half_life_s
        self.requests = self.accepts = 0.0
        self.updated = time.monotonic()

    def _decay(self) -> None:
        now = time.monotonic()
        f = 0.5 ** ((now - self.updated) / self.half_life)
        self.requests, self.accepts, self.updated = self.requests * f, self.accepts * f, now

    def allow(self) -> bool:
        """True — отправляем. False — отказываем локально, не трогая сеть."""
        self._decay()
        p = max(0.0, (self.requests - self.k * self.accepts) / (self.requests + 1))
        if random.random() < p:
            return False
        self.requests += 1
        return True

    def record(self, accepted: bool) -> None:
        self._decay()
        self.accepts += 1 if accepted else 0

Числа, которые стоит прогнать руками при K = 2 и requests = 1000:

accepts = 1000 → (1000 − 2000)/1001 < 0 → p = 0     пропускаем всё
accepts = 500  → (1000 − 1000)/1001 = 0 → p = 0     порог: 50 % приёма
accepts = 200  → (1000 − 400)/1001 = 0,599          отбрасываем 60 %
accepts = 50   → (1000 − 100)/1001 = 0,899          отбрасываем 90 %

Смысл K: клиент начинает себя ограничивать, когда доля принятых падает ниже 1/K. При K = 2 это 50 %, при K = 1,5 — 67 % (агрессивнее). И заметьте главное: функция непрерывна. Сервис, тянущий 60 % нагрузки, получит примерно 60 %, а не ноль и не всё. Реализация — двадцать строк, переносится в любую команду.

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

Переборки: изоляция пулов

Одна из самых недооценённых мер. Если весь исходящий трафик идёт через общий пул соединений или общий пул потоков, то любая одна медленная зависимость забирает весь ресурс — включая долю тех вызовов, которые в порядке.

общий пул 200 слотов: рекомендации затормозили до 2 с при 300 rps → нужно 600
                      слотов → пул исчерпан, цены и каталог не обслуживаются
раздельные пулы (цены 100 | каталог 60 | рекомендации 40): рекомендации выбирают
                      свои 40 за 40/300 ≈ 0,13 с → цены и каталог живы

Цена изоляции — недоиспользование: суммарно 200 слотов при раздельных пулах утилизируются хуже, чем 200 общих. Это и есть содержание компромисса: вы платите эффективностью за то, чтобы отказ не растекался. Ровно тот же обмен, что в переборках корабля, откуда пришло название (термин закрепил Michael Nygard в «Release It!»).

Практическая форма в 2020-х — не столько отдельные тред-пулы, сколько ограничение конкурентности на зависимость: семафор с лимитом, посчитанным как L_max_на_зависимость. Это дешевле пулов потоков и работает в асинхронных рантаймах. Адаптивные варианты (Netflix concurrency-limits, алгоритмы Vegas/Gradient) подбирают лимит сами по росту задержки — по сути, TCP-подобное управление перегрузкой, применённое к RPC.

Кэш и отбрасывание нагрузки

Устаревшие данные как деградация. HTTP давно стандартизовал два нужных механизма (RFC 5861): stale-while-revalidate (отдай старое, обнови в фоне) и stale-if-error (отдай старое, если источник упал). Вклад считается прямо:

доля запросов с записью в кэше (пусть даже просроченной): 92 %; бэкенд лежит 30 минут
без stale-if-error: 30 мин × 100 % = 30 мин бюджета
с stale-if-error:   30 мин × 8 %   = 2,4 мин бюджета

При месячном бюджете 43,2 минуты (99,9 %) разница между «инцидент съел 70 % месячного бюджета» и «съел 5,5 %». Но у устаревания есть предметная цена, и решает её не инженер: цена товара из пятиминутного кэша — вопрос к юристам, остаток на складе — прямой путь к оверселлингу, баланс счёта — к жалобам. Формулируйте это как продуктовое решение с явным ответом на вопрос «насколько старые данные допустимы для этого поля».

Отбрасывание нагрузки (load shedding). Когда входящий поток превышает ёмкость, единственный честный выбор — кого не обслужить. Приёмов три.

  1. Приоритеты запросов. Пометьте трафик классами: платёж и вход — критично; лента и рекомендации — можно сбрасывать первыми. Без приоритетов вы сбрасываете случайно, то есть с равной вероятностью роняете оформление заказа.
  2. Отказ по возрасту, а не по очереди. Если запрос ждёт в очереди дольше своего дедлайна, его исполнение бесполезно — отбрасывайте. Иначе система тратит всю ёмкость на работу, которую никто не ждёт. Обработка очереди в порядке LIFO под перегрузкой звучит кощунственно, но даёт больше уложившихся в дедлайн ответов, чем FIFO (см. AWS Builders’ Library, «Using load shedding to avoid overload»).
  3. Быстрый отказ на входе. Отклонить за 1 мс на границе дешевле, чем за 2 с в глубине: L = λ × 0,001 против L = λ × 2.

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

Тихая деградация — отдельный отказ

Самая частая ошибка внедрения: фолбэк написали, а сигнал — нет. Дальше сценарий один и тот же: сервис рекомендаций отдаёт 30 % ошибок, фолбэк исправно подставляет пустой блок, SLO по доступности зелёный, алертов нет, дежурного не будят — а через месяц продукт спрашивает, почему упала конверсия, и уходит две недели на поиск причины.

Механика узнаваемая: система перестала подавать сигнал о собственном состоянии. Требования к любому фолбэку:

  • отдельный счётчик degraded_responses_total{reason="recommendations"}, а не общий счётчик ошибок, плюс признак в трейсе и логе запроса — чтобы деградация была видна не только в агрегате;
  • отдельный SLI на полноту ответа, чтобы длительная деградация тратила бюджет;
  • алерт на долю деградированных ответов — обычно не пейджер, а тикет с дедлайном.

И отдельно — фолбэк, который сам может отказать. Отличный разбор есть у AWS: «Avoiding fallback in distributed systems». Резервный путь по определению используется редко, значит не прогрет, не протестирован и не покрыт мониторингом. Он включается в худший момент — под пиковой нагрузкой, — и падает сам. Правило: фолбэк должен быть строго проще основного пути (константа, статический файл, локальный кэш), а не «второй такой же, только через другую базу». Если резервный путь по сложности сравним с основным, лучше не делать резервного, а сделать основной надёжнее.

Цена: что вы покупаете вместе с деградацией

Все эти механизмы небесплатны, и счёт приходит в трёх валютах.

Сложность кода. Каждая деградация — второй путь исполнения. Он редко выполняется, значит его не покрывают тесты и не видят на код-ревью. Через год половина фолбэков в системе сломана, и вы об этом не знаете. Единственное лекарство — регулярно включать их принудительно: инъекция отказов в тестовой среде, game day, эксперименты в проде (учения и chaos). Деградация, которую не проверяли учением, — гипотеза, а не свойство системы.

Когнитивная нагрузка дежурного. Система с деградациями сложнее диагностируется: «частично работает» — состояние с большим числом вариантов, чем «работает» или «лежит». Дежурному нужен рунбук, отвечающий на вопрос «какие деградации сейчас активны и что это значит», и дашборд с текущим списком. Иначе первые двадцать минут инцидента уйдут на реконструкцию состояния (реакция на инцидент).

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

Зависимость Класс Поведение при отказе Согласовано с Проверено учением
Аутентификация критично 503, честный текст ошибки безопасность 2026-05-14
Каталог критично 503 продукт 2026-05-14
Цены важно кэш до 60 с, дальше 503 продукт + финансы 2026-06-02
Корзина важно локальное хранилище, синхронизация позже продукт не проверено
Рекомендации необязательно блок скрыт продукт 2026-06-02
Отзывы необязательно блок скрыт продукт 2026-06-02
Аналитика необязательно молча теряем события аналитика 2026-04-20

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

Здесь же проходит граница с главой про дежурства и с менеджерской стороной вопроса (инциденты глазами руководителя): если продукт отказывается признать хоть что-то необязательным, ответ «тогда всё критично» имеет цену, и её надо назвать в деньгах и людях. Обычно после подсчёта список необязательного находится за один разговор.

Что переносится из практики Google, а что нет

Книги SRE описывают механизмы, разработанные в среде, где каждый RPC несёт метку критичности, балансировщик понимает приоритеты, а планировщик кластера умеет двигать нагрузку между дата-центрами.

Переносится почти без потерь — потому что это арифметика и десятки строк кода:

  • расчёт таймаута через L = λ × W и распространение дедлайна вниз по цепочке;
  • экспоненциальная задержка с джиттером;
  • бюджет повторов как доля трафика;
  • клиентское самоограничение по формуле max(0, (requests − K × accepts)/(requests + 1));
  • отбрасывание запросов, чей дедлайн уже истёк.

Не переносится, потому что требует инфраструктуры, которой у вас нет: сквозная метка критичности во всём RPC-стеке (у Google это часть транспорта, у вас — ручной параметр в каждом сервисе), глобальное управление нагрузкой между дата-центрами, автоматическое перераспределение ёмкости планировщиком кластера.

Отдельно про сервисную сетку. Соблазн получить всё сразу через Istio или Linkerd понятен: таймауты, повторы, размыкатели и outlier detection — конфигом, без правок в коде. Плата реальна: сетка добавляет прокси на каждый под, задержку на каждый вызов, собственный слой отказов и требует людей, которые её понимают. Для команды из восьми человек с двенадцатью сервисами таймауты в HTTP-клиенте и бюджет повторов в библиотеке обычно дают 80 % эффекта за 5 % сложности. Про эксплуатационную сторону — Kubernetes в треке devops.

Порядок внедрения, если начинать с нуля

Дешёвое и обязательное — сверху, дорогое и опциональное — снизу. Пункты 1–4 закрывают большую часть аварий класса «упала мелочь, лёг продукт» и делаются за спринт; 5–7 нужны, когда трафик вырос настолько, что усиление стало заметным; без 8 всё, начиная с 4, остаётся гипотезой.

  1. Таймауты на всех сетевых вызовах — явные, посчитанные, покомпонентные.
  2. Дедлайн, проброшенный через всю цепочку, с локальными потолками.
  3. Таблица критичности, согласованная с продуктом.
  4. Фолбэки для необязательного — вместе с метрикой деградации и SLI на полноту.
  5. Бюджет повторов и джиттер; повторяет только один уровень.
  6. Лимиты конкурентности на каждую зависимость; отбрасывание нагрузки с приоритетами и отказ по истёкшему дедлайну.
  7. Размыкатели там, где нужен именно рубильник, а не сглаживание.
  8. Учения: проверить всё сделанное и вернуться к пункту 3 с находками.

Типичные ошибки

  • Таймаут «из головы». 30 секунд, потому что «так было в примере». Считается из L_max / λ_пик и p99 зависимости, берётся минимум.
  • Таймаут вместо дедлайна. Каждый уровень ждёт своё, суммарное время растёт вниз по цепочке, база держит соединения ради ответа, который наверху уже отменили.
  • Повторы на каждом уровне. Три уровня по три попытки — двадцатисемикратное усиление точно в момент перегрузки. Без джиттера к этому добавляется синхронизация клиентов: все возвращаются одновременно и добивают сервис, который начал вставать.
  • Повтор неидемпотентной операции после таймаута. Двойные списания — классика этого пункта.
  • Размыкатель на редком трафике. Пять запросов в окне дают около десяти ложных размыканий в сутки. Ставьте минимальное число вызовов или не ставьте размыкатель. И не путайте его с выбросом узла из балансировки: болеет один экземпляр — отключается вся зависимость.
  • Фолбэк без метрики. Тихая деградация неделями, обнаруживается по падению конверсии.
  • Фолбэк сложнее основного пути. Резервный маршрут не прогрет, не протестирован и падает первым.
  • Деградация вместо исправления. Фолбэк скрывает симптом; если он включён третью неделю, проблема не решена, а спрятана. Отдельный SLI на полноту не даёт этому случиться незаметно.
  • Общий пул на все зависимости. Одна медленная зависимость выедает ресурс у всех остальных.
  • Отсутствие таблицы критичности. Каждый инженер сам угадывает, что важно, и угадывает по-разному в разных сервисах.

Мини-итог

  • Деградация — заранее принятое решение о том, чем жертвовать (полнота, свежесть, точность, скорость, охват). Отказ — то же решение, принятое случайно.
  • Самый большой рычаг — вынести звено из обязательной цепочки: 905 минут в месяц против 47,5 на том же наборе сервисов. Купить это девятками нельзя: три сервиса по 99,99 % дают 60,5 минуты и стоят на два порядка дороже.
  • Деградированный ответ считается успехом только при отдельном SLI на полноту, иначе ущерб просто переезжает из графика доступности в отчёт о выручке.
  • Таймаут вычисляется, а не угадывается: W_max = L_max / λ. При 500 rps и пуле 200 любой таймаут выше 400 мс — обещание исчерпать пул. Дефолты библиотек (Go http.Client, requests, JDBC socketTimeout) означают бесконечность.
  • Дедлайн важнее таймаута: он передаётся вниз, ограничивается локальным потолком, и работа без остатка бюджета не начинается вовсе.
  • Повтор умножает нагрузку в момент перегрузки: три уровня по три попытки дают ×27. Бюджет 10 % на уровень превращает это в ×1,33. Джиттер обязателен: без него клиенты синхронизируются.
  • Размыкатель экономит три порядка занятых слотов, но бинарен и требует статистики: на 0,5 rps он даёт около десяти ложных размыканий в сутки. Непрерывная замена — самоограничение клиента по формуле max(0, (requests − K·accepts)/(requests + 1)), двадцать строк кода.
  • Фолбэк обязан быть проще основного пути и обязан быть виден в метриках. Тихая деградация — отдельный класс отказа.
  • Граница инженерного решения проходит по таблице критичности: что имеет право не показаться пользователю, решает продукт, а инженер обеспечивает и измеряет. Столбец «проверено учением» отличает описание системы от описания намерений.

Источники

Что дальше

Мы разобрали противоядия: таймаут, дедлайн, бюджет повторов, размыкатель, переборки, отбрасывание нагрузки. Каждое из них — ответ на конкретный механизм отказа. Теперь стоит посмотреть на сами механизмы: почему очередь растёт нелинейно у порога насыщения, как безобидный всплеск превращается в получасовую аварию и почему система иногда не возвращается в норму даже после того, как исходная причина исчезла.

Как ломаются распределённые системы: каскады и насыщение

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов