Деградация вместо отказа: таймауты, ретраи, размыкатели
Товарная страница интернет-магазина собирает данные из шести источников: шлюз, каталог, цены, рекомендации, отзывы, персонализация. В 03:40 сервис рекомендаций уходит в GC-паузу на девяносто секунд. Товарная страница отдаёт 500. Не «страница без блока рекомендаций» — страница целиком, вместе с ценой, кнопкой «купить» и корзиной.
Разбор наутро занимает четыре минуты: рекомендации ходили синхронно, без таймаута, в том же обработчике. Правка занимает сорок строк. А до правки компания месяцами платила за доступность блока «с этим товаром покупают» так, как будто это платёжный шлюз.
Это не история про невнимательность, а про отсутствующее решение: никто никогда не садился и не отвечал на вопрос «что мы показываем пользователю, если рекомендаций нет». Когда решения нет, система принимает его сама — и принимает худшее из возможных: отказ целиком.
Эта глава — про то, как принимать такие решения заранее и во что они обходятся. Каталог самих паттернов подробно разобран в главе про устойчивость трека архитектурных паттернов; здесь нас интересует другое — арифметика (сколько минут доступности покупает каждый рычаг и почему таймаут вычисляется, а не угадывается), цена (второй код-путь, который никто не тестирует) и граница, за которой инженерное решение упирается в продуктовое.
Деградация — это заранее принятое решение о том, чем жертвовать
Деградация — это спроектированный частичный ответ: система сознательно отдаёт меньше, чем умеет, чтобы не отдать ничего.
Ключевое слово — «спроектированный»: отказ тоже частичный ответ, просто выбранный случайно. У деградации пять измерений, и почти всегда полезно перебрать все пять, а потом выбрать самое дешёвое.
| Измерение | Что урезаем | Пример | Чем платит пользователь |
|---|---|---|---|
| Полнота | часть ответа | страница без блока рекомендаций | не видит части функциональности |
| Свежесть | актуальность данных | цена из кэша пятиминутной давности | видит устаревшее |
| Точность | качество вычисления | общий топ вместо персонального | получает хуже подобранное |
| Скорость | сроки исполнения | заказ принят, обработка в очередь | ждёт дольше |
| Охват | доля пользователей | новые регистрации закрыты, старые работают | часть пользователей не обслужена |
Последнюю строку принято стыдливо не обсуждать, а это самая честная форма деградации: если ёмкости хватает на 70 % трафика, вы обслужите либо 70 % пользователей хорошо, либо 100 % пользователей плохо. Второе обычно хуже — про это в главе про ёмкость и нагрузку.
(ошибка, таймаут, отказ)"] --> Q1{"Ответ пользователю
осмыслен без неё?"} Q1 -->|нет| CRIT["Критическая зависимость.
Честная ошибка и понятный текст.
Деградировать нечего —
работать надо с доступностью"] Q1 -->|да| Q2{"Есть приемлемо
устаревшие данные?"} Q2 -->|да| STALE["Отдать из кэша,
пометить, обновить в фоне"] Q2 -->|нет| Q3{"Есть дешёвая
грубая замена?"} Q3 -->|да| FB["Статический топ,
значение по умолчанию"] Q3 -->|нет| Q4{"Операцию можно
отложить?"} Q4 -->|да| ASYNC["Принять в очередь,
подтвердить позже"] Q4 -->|нет| OMIT["Опустить блок.
Ответ без него"] STALE --> M["Во всех ветках:
метрика деградации,
признак в трейсе,
отражение в SLI"] FB --> M ASYNC --> M OMIT --> M
Нижний узел — не украшение. Деградация, которую не видно в метриках, за неделю превращается в тихую поломку: сервис «зелёный», а половина пользователей месяц видит пустой блок. Об этом ниже отдельно.
Арифметика: сколько минут покупает вынос звена из цепочки
Вернёмся к товарной странице. Если запрос обязан пройти через 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 мс на этом сервисе — арифметическое обещание исчерпать пул при насыщении. Отсюда правило: таймаут выбирают из двух ограничений сразу и берут минимум.
- Сверху — сколько система переживёт:
W_max = L_max / λ_пик. - Снизу — сколько нужно здоровому вызову: обычно 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
Двадцатикратная разница — и она получена настройкой, а не переписыванием кода. Это, пожалуй, лучшее соотношение пользы к усилию во всей главе.
задержка 70 мс (джиттер) A->>B: попытка 2, потолок 400 мс B--xA: таймаут Note over A: бюджет повторов исчерпан,
размыкатель открылся A-->>G: деградированный ответ
degraded=recommendations G-->>U: 200 + страница без блока
заголовок X-Degraded: recommendations Note over G,B: остаток дедлайна 1230 мс не потрачен —
отказ произошёл быстро и осознанно
Размыкатель: полезен, но переоценён
Размыкатель (circuit breaker) — счётчик отказов с тремя состояниями. Пока доля ошибок ниже порога, он пропускает трафик; превысила — начинает отказывать мгновенно, не трогая нижестоящий сервис; спустя паузу пропускает несколько пробных запросов.
при ≥ 20 запросах в окне Open --> HalfOpen: прошло 30 с HalfOpen --> Closed: 5 из 5 пробных успешны HalfOpen --> Open: любой пробный неуспешен Closed --> Closed: успех — счётчик скользит note right of Open Отказ за доли миллисекунды: слоты пула свободны, нижний сервис не получает нагрузки. В HalfOpen пропускаем единицы запросов — слишком много пробных роняет сервис повторно end note
Арифметика выгоды та же, через закон Литтла. Сервис на 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). Когда входящий поток превышает ёмкость, единственный честный выбор — кого не обслужить. Приёмов три.
- Приоритеты запросов. Пометьте трафик классами: платёж и вход — критично; лента и рекомендации — можно сбрасывать первыми. Без приоритетов вы сбрасываете случайно, то есть с равной вероятностью роняете оформление заказа.
- Отказ по возрасту, а не по очереди. Если запрос ждёт в очереди дольше своего дедлайна, его исполнение бесполезно — отбрасывайте. Иначе система тратит всю ёмкость на работу, которую никто не ждёт. Обработка очереди в порядке LIFO под перегрузкой звучит кощунственно, но даёт больше уложившихся в дедлайн ответов, чем FIFO (см. AWS Builders’ Library, «Using load shedding to avoid overload»).
- Быстрый отказ на входе. Отклонить за 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, остаётся гипотезой.
- Таймауты на всех сетевых вызовах — явные, посчитанные, покомпонентные.
- Дедлайн, проброшенный через всю цепочку, с локальными потолками.
- Таблица критичности, согласованная с продуктом.
- Фолбэки для необязательного — вместе с метрикой деградации и SLI на полноту.
- Бюджет повторов и джиттер; повторяет только один уровень.
- Лимиты конкурентности на каждую зависимость; отбрасывание нагрузки с приоритетами и отказ по истёкшему дедлайну.
- Размыкатели там, где нужен именно рубильник, а не сглаживание.
- Учения: проверить всё сделанное и вернуться к пункту 3 с находками.
Типичные ошибки
- Таймаут «из головы». 30 секунд, потому что «так было в примере». Считается из
L_max / λ_пики p99 зависимости, берётся минимум. - Таймаут вместо дедлайна. Каждый уровень ждёт своё, суммарное время растёт вниз по цепочке, база держит соединения ради ответа, который наверху уже отменили.
- Повторы на каждом уровне. Три уровня по три попытки — двадцатисемикратное усиление точно в момент перегрузки. Без джиттера к этому добавляется синхронизация клиентов: все возвращаются одновременно и добивают сервис, который начал вставать.
- Повтор неидемпотентной операции после таймаута. Двойные списания — классика этого пункта.
- Размыкатель на редком трафике. Пять запросов в окне дают около десяти ложных размыканий в сутки. Ставьте минимальное число вызовов или не ставьте размыкатель. И не путайте его с выбросом узла из балансировки: болеет один экземпляр — отключается вся зависимость.
- Фолбэк без метрики. Тихая деградация неделями, обнаруживается по падению конверсии.
- Фолбэк сложнее основного пути. Резервный маршрут не прогрет, не протестирован и падает первым.
- Деградация вместо исправления. Фолбэк скрывает симптом; если он включён третью неделю, проблема не решена, а спрятана. Отдельный SLI на полноту не даёт этому случиться незаметно.
- Общий пул на все зависимости. Одна медленная зависимость выедает ресурс у всех остальных.
- Отсутствие таблицы критичности. Каждый инженер сам угадывает, что важно, и угадывает по-разному в разных сервисах.
Мини-итог
- Деградация — заранее принятое решение о том, чем жертвовать (полнота, свежесть, точность, скорость, охват). Отказ — то же решение, принятое случайно.
- Самый большой рычаг — вынести звено из обязательной цепочки: 905 минут в месяц против 47,5 на том же наборе сервисов. Купить это девятками нельзя: три сервиса по 99,99 % дают 60,5 минуты и стоят на два порядка дороже.
- Деградированный ответ считается успехом только при отдельном SLI на полноту, иначе ущерб просто переезжает из графика доступности в отчёт о выручке.
- Таймаут вычисляется, а не угадывается:
W_max = L_max / λ. При 500 rps и пуле 200 любой таймаут выше 400 мс — обещание исчерпать пул. Дефолты библиотек (Gohttp.Client,requests, JDBCsocketTimeout) означают бесконечность. - Дедлайн важнее таймаута: он передаётся вниз, ограничивается локальным потолком, и работа без остатка бюджета не начинается вовсе.
- Повтор умножает нагрузку в момент перегрузки: три уровня по три попытки дают ×27. Бюджет 10 % на уровень превращает это в ×1,33. Джиттер обязателен: без него клиенты синхронизируются.
- Размыкатель экономит три порядка занятых слотов, но бинарен и требует статистики: на 0,5 rps он даёт около десяти ложных размыканий в сутки. Непрерывная замена — самоограничение клиента по формуле
max(0, (requests − K·accepts)/(requests + 1)), двадцать строк кода. - Фолбэк обязан быть проще основного пути и обязан быть виден в метриках. Тихая деградация — отдельный класс отказа.
- Граница инженерного решения проходит по таблице критичности: что имеет право не показаться пользователю, решает продукт, а инженер обеспечивает и измеряет. Столбец «проверено учением» отличает описание системы от описания намерений.
Источники
- Michael Nygard. Release It!, 2nd ed., Pragmatic Bookshelf, 2018 — исходный каталог: таймауты, размыкатели, переборки, устойчивые точки соединения.
- Google. Site Reliability Engineering, ch. 21: Handling Overload — приоритеты запросов, клиентское самоограничение, формула adaptive throttling.
- Google. Site Reliability Engineering, ch. 22: Addressing Cascading Failures — почему повторы добивают систему и как из этого выходить.
- Marc Brooker. Timeouts, retries, and backoff with jitter, AWS Builders’ Library; там же исходный Exponential Backoff And Jitter со сравнением стратегий разброса на модели.
- Jacob Gabrielson. Avoiding fallback in distributed systems, AWS Builders’ Library.
- David Yanacek. Using load shedding to avoid overload, AWS Builders’ Library.
- gRPC. A6: client retries — бюджет повторов как часть протокола.
- Envoy. Circuit breaking и outlier detection.
- Netflix. concurrency-limits и Performance Under Load — адаптивные лимиты вместо фиксированных.
- resilience4j (Java), Polly (.NET), gobreaker (Go) — рабочие реализации. Hystrix с 2018 года в режиме поддержки, новые проекты на нём не начинают.
- IETF. RFC 5861: HTTP Cache-Control Extensions for Stale Content —
stale-while-revalidateиstale-if-error. - Nathan Bronson et al. Metastable Failures in Distributed Systems, HotOS 2021 — почему система не встаёт после устранения причины.
Что дальше
Мы разобрали противоядия: таймаут, дедлайн, бюджет повторов, размыкатель, переборки, отбрасывание нагрузки. Каждое из них — ответ на конкретный механизм отказа. Теперь стоит посмотреть на сами механизмы: почему очередь растёт нелинейно у порога насыщения, как безобидный всплеск превращается в получасовую аварию и почему система иногда не возвращается в норму даже после того, как исходная причина исчезла.