← назад к разделу

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

Вопрос не «упадёт ли?», а «что произойдёт с остальной системой, когда это случится?».

Паттерны отказоустойчивости не предотвращают сбои — они не дают одному сбою каскадно уронить всё вокруг.

Retry — повторить при временной ошибке

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

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

Решение — повторять с нарастающей задержкой (Exponential Backoff):

Попытка 1: сразу
Попытка 2: через 1 сек
Попытка 3: через 2 сек
Попытка 4: через 4 сек
Попытка 5: через 8 сек (не больше 30 сек)
import pRetry from 'p-retry';

const result = await pRetry(() => paymentClient.charge(request), {
  retries: 4,          // всего 5 попыток
  minTimeout: 1_000,
  factor: 2,
  maxTimeout: 30_000,
  shouldRetry: (error) => error instanceof RetryableError,
});

Jitter — чтобы не долбить сервис одновременно

Все сто клиентов получили ошибку в один момент. Все ждут одну секунду. Все ретраят одновременно. Сервис снова падает. Это называется «эффект толпы» (thundering herd).

Jitter добавляет случайный разброс к задержке — клиенты рассредоточиваются во времени:

import { setTimeout as sleep } from 'node:timers/promises';

const delay = Math.min(maxDelay, baseDelay * multiplier ** attempt);
const jitter = Math.floor(Math.random() * (delay / 2));
await sleep(delay + jitter);

Не все запросы безопасно повторять

  • Безопасно: операции, которые можно повторить без побочных эффектов — GET, PUT с фиксированным ключом, DELETE по идентификатору.
  • Опасно: POST без ключа идемпотентности. Двойная отправка может создать два заказа.

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

Circuit Breaker — «автомат» на случай падения сервиса

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

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

diagram

Три состояния:

  • CLOSED — нормальная работа. Запросы проходят, считаем процент ошибок в скользящем окне.
  • OPEN — выключатель сработал. Запросы мгновенно отклоняются без ожидания. Ждём таймаут.
  • HALF_OPEN — пропускаем несколько пробных запросов. Успешны — переходим в CLOSED. Нет — обратно в OPEN.

Пример конфигурации (opossum):

import CircuitBreaker from 'opossum';

const paymentBreakerOptions: CircuitBreaker.Options = {
  rollingCountTimeout: 10_000,    // скользящее окно подсчёта ошибок
  errorThresholdPercentage: 50,   // 50% ошибок → OPEN
  resetTimeout: 30_000,           // через 30 сек → HALF_OPEN
  volumeThreshold: 10,            // минимум вызовов до срабатывания
  errorFilter: (e) => e instanceof OrderNotFoundError, // бизнес-ответ — не сбой
};
const chargeBreaker = new CircuitBreaker(
  (userId: number, amount: Money) => paymentClient.charge(userId, amount.toKopecks()),
  paymentBreakerOptions,
);

chargeBreaker.fallback(() => {
  throw new ServiceTemporarilyUnavailableError('Payment service');
});

export const charge = (userId: number, amount: Money): Promise<PaymentResult> =>
  chargeBreaker.fire(userId, amount);

Важный нюанс: OrderNotFoundError (404) — это бизнес-ответ, не сбой. Если Circuit Breaker будет считать 404 ошибкой, то при серии «заказ не найден» он заблокирует все запросы к платёжному шлюзу. Поэтому errorFilter — обязательная настройка.

Timeout — не ждать вечно

Сервис не отвечает. Без таймаута запрос висит бесконечно, удерживая сокет и память. Сто таких висящих запросов — и всё останавливается.

Любой сетевой вызов должен ограничиваться по времени:

const chargeWithTimeout = new CircuitBreaker(
  (userId: number, amount: Money) => paymentClient.charge(userId, amount.toKopecks()),
  { timeout: 5_000 }, // TimeoutError после 5 секунд
);
const response = await fetch('https://payments.example.com/charge', {
  method: 'POST',
  body: JSON.stringify(request),
  signal: AbortSignal.timeout(5_000),
});

Это касается не только HTTP. Любой вызов к базе данных, Redis, gRPC, очереди сообщений — везде должен быть таймаут.

Как сочетаются Retry, Circuit Breaker и Timeout

Три паттерна работают вместе, и порядок важен:

Circuit Breaker → Retry → Timeout → Внешний сервис
  • Circuit Breaker — снаружи. Если цепь разомкнута, не тратим время на ретраи.
  • Retry — в середине. Повторяет при временном сбое.
  • Timeout — внутри. Каждая отдельная попытка ограничена по времени.

Bulkhead — изоляция ресурсов

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

Bulkhead — по аналогии с переборками в корабле. Каждый отсек герметичен: затопление одного не топит остальные.

diagram

Два варианта реализации:

Semaphore Isolation — ограничение числа одновременных вызовов к каждой внешней системе. В Node.js нет пулов потоков для I/O — все вызовы идут через единый событийный цикл, поэтому семафор и есть основной инструмент изоляции: отдельный лимит конкурентности на каждую систему (p-limit или опция capacity в opossum).

Worker Pool Isolation — отдельный пул worker_threads (например, piscina) для тяжёлых CPU-задач. Зависшая CPU-работа не блокирует событийный цикл. Overhead на передачу данных между потоками.

import pLimit from 'p-limit';

const paymentLimit = pLimit(20);   // не больше 20 одновременных вызовов
const inventoryLimit = pLimit(20);
const insuranceLimit = pLimit(10);

const charge = (req: ChargeRequest): Promise<PaymentResult> =>
  paymentLimit(() => paymentClient.charge(req));

Для сетевых вызовов с таймаутами достаточно семафора. Пул worker_threads нужен для CPU-тяжёлой работы, которая иначе заблокирует событийный цикл.

Fallback — запасной ответ

Если основной путь недоступен — используем запасной. Не всегда нужен идеальный ответ, иногда «приемлемый» лучше, чем ошибка.

Три варианта запасного ответа:

Значение по умолчанию — когда точные данные недоступны, отдаём разумную замену:

const rateBreaker = new CircuitBreaker(
  (currency: string) => exchangeRateClient.getRate(currency),
  breakerOptions,
);

rateBreaker.fallback(async (currency: string) =>
  (await exchangeRateCache.getLatest(currency)) ?? ExchangeRate.fallback(currency));

export const getRate = (currency: string): Promise<ExchangeRate> =>
  rateBreaker.fire(currency);

Кэшированный ответ — при сбое отдаём последние известные данные:

const productsBreaker = new CircuitBreaker(async (category: string) => {
  const products = await catalogClient.getProducts(category);
  await productCache.put(category, products);
  return products;
}, breakerOptions);

productsBreaker.fallback(async (category: string) =>
  (await productCache.get(category)) ?? []);

Упрощённая логика — вместо персонализированных рекомендаций отдаём популярные товары:

const recommendationsBreaker = new CircuitBreaker(
  (userId: number) => recommendationClient.getPersonalized(userId),
  breakerOptions,
);

recommendationsBreaker.fallback(() =>
  productRepository.findTopByPopularity(10));

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

Rate Limiter — защита от перегрузки

Внешний API ограничивает вас: 100 запросов в секунду. Превышение — бан на пять минут. Или ваш собственный сервис получает всплеск трафика — нужно защититься.

const register = (request: PaymentRegisterDto): Promise<PaymentResponse> =>
  paymentApiLimiter.schedule(() => paymentClient.register(request));
import Bottleneck from 'bottleneck';

const paymentApiLimiter = new Bottleneck({
  reservoir: 50,                    // 50 запросов
  reservoirRefreshAmount: 50,
  reservoirRefreshInterval: 1_000,  // на каждую секунду
});

Основные алгоритмы:

  • Fixed Window — счётчик сбрасывается в начале каждого окна. Простой, но на границе окон может пропустить вдвое больше лимита.
  • Sliding Window — скользящее окно, более точное ограничение.
  • Token Bucket — токены накапливаются с постоянной скоростью. Позволяет кратковременные всплески, если есть накопленные токены.
  • Leaky Bucket — запросы обрабатываются с постоянной скоростью. Сглаживает всплески, но добавляет задержку.

Dead Letter Queue — что делать с необрабатываемыми сообщениями

Сообщение в Kafka не обрабатывается: битые данные, неизвестный формат, бизнес-валидация не проходит. Бесконечные повторы бессмысленны — сообщение «отравлено» и блокирует обработку следующих.

Dead Letter Queue (DLQ, очередь мёртвых писем) — после нескольких неудачных попыток сообщение перекладывается в отдельную очередь для разбора вручную.

diagram
const MAX_ATTEMPTS = 3;

const isRetryable = (e: unknown): boolean =>
  !(e instanceof ValidationError || e instanceof SyntaxError);

await consumer.run({
  eachMessage: async ({ topic, message }) => {
    try {
      await handle(message);
    } catch (e) {
      const attempts = Number(message.headers?.attempts ?? 0) + 1;
      if (attempts >= MAX_ATTEMPTS || !isRetryable(e)) {
        await producer.send({
          topic: `${topic}.dlq`,
          messages: [{
            key: message.key,
            value: message.value,
            headers: { ...message.headers, error: (e as Error).message },
          }],
        });
        return;
      }
      await producer.send({
        topic,
        messages: [{
          key: message.key,
          value: message.value,
          headers: { ...message.headers, attempts: String(attempts) },
        }],
      });
    }
  },
});

Что делать с DLQ:

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

Как паттерны работают вместе

На практике паттерны комбинируются. Полный стек для одного внешнего вызова выглядит так (порядок снаружи → внутрь):

  1. Rate Limiter — если лимит исчерпан, не тратим ресурсы дальше.
  2. Bulkhead — если пул заполнен, не создаём новые вызовы.
  3. Circuit Breaker — если сервис недоступен, мгновенный отказ или fallback.
  4. Retry — если вызов упал, повторяем с задержкой.
  5. Timeout — каждая попытка ограничена по времени.
const paymentLimiter = new Bottleneck({
  reservoir: 100,
  reservoirRefreshAmount: 100,
  reservoirRefreshInterval: 1_000,
});

const paymentBulkhead = pLimit(25);

const callWithRetry = (req: ChargeRequest) =>
  pRetry(() => paymentClient.charge(req, { timeout: 5_000 }), {
    retries: 2,
    minTimeout: 1_000,
    factor: 2,
  });

const paymentBreaker = new CircuitBreaker(callWithRetry, {
  rollingCountTimeout: 10_000,
  errorThresholdPercentage: 50,
  resetTimeout: 30_000,
});

export const charge = (req: ChargeRequest): Promise<PaymentResult> =>
  paymentLimiter.schedule(() =>
    paymentBulkhead(() => paymentBreaker.fire(req)));

Коротко

  • Retry + Exponential Backoff — переживаем кратковременные сбои; Jitter предотвращает одновременный шквал повторов.
  • Circuit Breaker — не тратим ресурсы на недоступный сервис; три состояния: CLOSED / OPEN / HALF_OPEN.
  • Timeout — любой сетевой вызов без таймаута — потенциальная утечка ресурсов.
  • Bulkhead — изолируем ресурсы; один проблемный сервис не блокирует остальные.
  • Fallback — деградируем, но не падаем; не всегда применим (платёж — честная ошибка, не «примерный» ответ).
  • Rate Limiter — защищаем от перегрузки себя и внешние API.
  • DLQ — не теряем и не зацикливаемся на «отравленных» сообщениях.
  • Порядок обёртки: Rate Limiter → Bulkhead → Circuit Breaker → Retry → Timeout.

Что почитать дальше

  • Распределённые паттерны — Saga и Outbox для согласованности данных между сервисами.
  • Apache Kafka — DLQ и идемпотентный потребитель в деталях.