Паттерны отказоустойчивости
В прошлом уроке вызов функции превратился в сетевой вызов, и это изменило всё: функция либо возвращает результат, либо бросает исключение, а сетевой вызов умеет третье состояние, «неизвестно». Запрос ушёл, ответа нет, и ты не знаешь, выполнилась операция или потерялась по дороге. Этот урок про инженерный ответ на «неизвестно»: таймауты и дедлайны, повторные попытки с джиттером, ключи идемпотентности, предохранитель и переборки. Пять паттернов, которые превращают каскадное падение в локальную неприятность.
Анатомия каскадного падения
Начнём с того, как умирают распределённые системы. Не от того, что какой-то сервис упал: упавший сервис это понедельник, к нему все готовы. Системы умирают от того, что какой-то сервис затормозил.
Сценарий на афише. Сервис рекомендаций («с этим спектаклем смотрят») обычно отвечает за 50 мс. Сегодня у него распухла база, и он стал отвечать за 30 секунд. Не падать, заметь: отвечать. Медленно.
Дальше физика. Страница сеанса ждёт рекомендации, её обработчик висит 30 секунд вместо 50 мс. Входящие запросы продолжают прибывать с той же скоростью, но обрабатываются в шестьсот раз медленнее, значит число одновременно висящих запросов растёт лавиной. Каждый висящий запрос держит ресурсы: соединение из пула базы, память, место в пуле HTTP-агента. Пул соединений исчерпывается за минуту. С этого момента все запросы приложения встают в очередь за соединениями, включая страницу оплаты, которой рекомендации не нужны вовсе. Балансировщик видит, что нода не отвечает, и переливает трафик на соседние, у которых та же беда, только теперь трафика больше. Через пять минут лежит вся афиша, а на дашборде у сервиса рекомендаций горит честный зелёный health check: процесс жив, порт открыт, просто медленно.
Запомни механику: медленный зависимый сервис опаснее мёртвого. Мёртвый отвечает ошибкой мгновенно, и вызывающий освобождает ресурсы. Медленный заставляет вызывающего копить висящие запросы, пока тот не захлебнётся сам. Отказ перетёк через сетевую границу, по дороге усилившись.
Вывод, на котором стоят все пять паттернов урока: отказы не изолируются сами, изоляцию надо проектировать. Дальше мы пройдём паттерны в порядке от обязательных к ситуативным.
Timeout и deadline propagation
Первое правило сетевого общежития: у каждого сетевого вызова есть таймаут. Без исключений. HTTP к соседнему сервису, запрос к базе, обращение к Redis, вызов платёжного API: всё ограничено по времени. Вызов без таймаута это согласие ждать вечно, а вечность, как мы видели, наступает быстро.
Подлость в том, что дефолты против тебя. fetch в Node без сигнала
будет ждать ответа неограниченно. Драйверы баз часто имеют таймаут
соединения, но не таймаут запроса. Поэтому таймауты это не «добавим,
когда прижмёт», а строка в код-ревью чек-листе: видишь сетевой вызов,
ищи таймаут.
В Node штатный механизм это AbortSignal, мы разбирали его в
уроке про события и сигналы:
const res = await fetch('http://recs.internal/api/for-session/42', {
signal: AbortSignal.timeout(300),
});
Но локальный таймаут это только полдела. Представь цепочку: gateway дал странице сеанса 1000 мс, страница сходила в каталог за 400 мс, потом пошла за рекомендациями с таймаутом 800 мс. Арифметика не сходится: у цепочки осталось 600 мс бюджета, а внутренний вызов готов ждать 800. Последние 200 мс работы никому не нужны, клиент уже получил ошибку и ушёл.
Поэтому зрелые системы оперируют не таймаутом, а
дедлайном:
не «ждать 800 мс», а «вся операция должна завершиться к 12:00:01.000».
Дедлайн назначается один раз на входе и прокидывается через всю
цепочку, обычно заголовком с абсолютным временем или остатком бюджета.
Каждый участник вычитает уже потраченное и передаёт остаток дальше. В
gRPC это родная механика (deadline у каждого вызова, прокидывается
автоматически), в HTTP договариваются заголовком руками:
// на входе в цепочку: бюджет всей операции
const deadline = Date.now() + 1000;
// в каждом исходящем вызове: остаток бюджета, не больше
const remaining = deadline - Date.now();
if (remaining <= 0) throw new DeadlineExceededError();
const res = await fetch(url, {
headers: { 'x-deadline': String(deadline) },
signal: AbortSignal.any([
AbortSignal.timeout(remaining), // остаток дедлайна
request.signal, // клиент закрыл вкладку, работать не для кого
]),
});
AbortSignal.any здесь не для красоты: он склеивает два повода
остановиться, исчерпание бюджета и уход клиента. Обрати внимание и на
проверку remaining <= 0 до вызова: если бюджет кончился, лучший
сетевой запрос это несделанный.
Антипаттерн, который ищется на ревью за минуту: внутренние таймауты
длиннее внешнего. Если gateway ждёт секунду, а сервис под ним готов
ждать базу три, то при деградации базы сервис прилежно доделывает
работу для клиента, который две секунды как получил 504. Умножь на
поток запросов, и получишь систему, занятую главным образом
производством никому не нужных ответов.
Retry, backoff и jitter
Сетевой вызов упал. Большой соблазн: попробовать ещё раз. Иногда это
правильно, иногда это способ устроить себе DDoS силами собственного
кода, и отличать одно от другого обязан каждый, кто пишет retry.
Сначала что повторять. Повторы имеют смысл только для
transient-ошибок:
обрыв соединения, 503, таймаут, lock timeout в базе. Повторять
логические ошибки (400, 404, 422) бессмысленно: невалидный
payload не станет валидным со второй попытки, он станет двумя записями
в логе вместо одной.
Теперь как. Наивный retry («упало, сразу пробуем снова, и так три раза») в распределённой системе работает усилителем отказа. Сервис рекомендаций захлебнулся под нагрузкой X. Все клиенты получили ошибку и тут же повторили: нагрузка 2X. Сервис перезапустился, и в первую же секунду в него прилетела синхронная волна повторов от всех клиентов сразу, это называется thundering herd. Сервис ложится опять, клиенты снова дружно повторяют, и система застревает в цикле «поднялся, забили, упал».
Лечение из двух половин. Exponential backoff: пауза между попытками растёт по степеням двойки (100 мс, 200, 400, 800), чтобы дать миру время починиться. И jitter: к паузе добавляется случайность, чтобы рассинхронизировать клиентов между собой. Без джиттера экспоненциальные волны остаются волнами, просто реже. Инженеры AWS в статье из ресурсов прогнали варианты на симуляции и пришли к выводу, который стоит запомнить: full jitter, пауза от нуля до экспоненциального потолка, почти всегда лучший выбор.
// full jitter по статье AWS: случайная пауза от 0 до потолка
const backoffMs = (attempt: number, baseMs = 100, capMs = 10_000) =>
Math.random() * Math.min(capMs, baseMs * 2 ** attempt);
В бою руками это не пишут, берут библиотеку. Для Node это cockatiel:
import { retry, handleWhen, ExponentialBackoff } from 'cockatiel';
const transient = handleWhen(
(err) => err instanceof FetchError && err.retriable,
);
const recsRetry = retry(transient, {
maxAttempts: 3,
backoff: new ExponentialBackoff(), // декоррелированный jitter из коробки
});
const recs = await recsRetry.execute(({ signal }) =>
fetchRecommendations(sessionId, signal),
);
И последний ограничитель, про который забывают чаще всего: retry budget. Каждая попытка это умножение трафика: три попытки на трёх уровнях цепочки дают до 27 запросов к нижнему сервису на один пользовательский. Бюджет повторов обрывает эту геометрию: повторов не больше N процентов от живого трафика, остальное честно падает. При здоровом downstream бюджет не расходуется вовсе, при деградации не даёт добить лежачего.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.