Отказоустойчивость в микросервисной архитектуре: проектирование устойчивых взаимодействий

Проблема: Вы запустили запрос к зависимому сервису, тот ответил HTTP 500, и вся цепочка вызовов рухнула. Система из 20 микросервисов гарантированно даст сбой, если не спроектирована работа с ошибками. Стандартный try/catch здесь бесполезен — сетевые сбои, таймауты и перегруженные сервисы требуют иного подхода.

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


Паттерн #1: Интеллектуальные повторы с экспоненциальной отсрочкой

Простые линейные повторы под нагрузкой создают эффект «DDoS самому себе». Решение — адаптивная задержка между попытками:

javascript
async function fetchWithRetry(url, maxRetries = 3) {
  let attempt = 0;
  
  while (attempt < maxRetries) {
    try {
      const response = await axios.get(url, { timeout: 2000 });
      return response.data;
    } catch (err) {
      if (!isRetryableError(err)) throw err; // Повторяем только сетевые/5хх ошибки
      
      attempt++;
      const delay = Math.pow(2, attempt) * 100 + Math.random() * 100; // Экспоненциальная задержка + джиттер
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  throw new Error(`Max retries (${maxRetries}) exceeded`);
}

// Проверяем тип ошибки
function isRetryableError(err) {
  return err.code === 'ECONNABORTED' || 
         err.code === 'ECONNRESET' || 
         (err.response && err.response.status >= 500);
}

Почему экспоненциально и с джиттером?

  • Быстрый первый повтор: возможно, сбой временный
  • Увеличение интервалов снижает нагрузку на аварийный сервис
  • Случайная добавка (джиттер) предотвращает синхронизацию запросов от разных клиентов (эффект «толпы»)

Паттерн #2: Автоматический размыкатель цепи (Circuit Breaker)

При постоянных сбоях вызовы становятся бесполезными. Размыкатель отсекает трафик на период «реабилитации» сервиса:

javascript
const CircuitBreaker = require('opossum');

const breaker = new CircuitBreaker(async (url) => {
  const response = await axios.get(url, { timeout: 1000 });
  return response.data;
}, {
  timeout: 3000,          // Слишком долгий ответ ≈ ошибка
  errorThresholdPercentage: 50, // % ошибок для размыкания
  resetTimeout: 30000,     // Через 30 сек — полуоткрытое состояние
  rollingCountTimeout: 60000 // Окно статистики – 60 сек
});

// Использование
breaker.fire('https://api.inventory/items')
  .then(data => console.log(data))
  .catch(err => { 
    // Открытое состояние? Используем fallback
    if (breaker.opened) initiateFallback(); 
  });

Фазы работы:

  1. Замкнут (Closed): Запросы проходят
  2. Разомкнут (Open): Запросы немедленно отклоняются
  3. Полуоткрыт (Half-Open): Пропускается часть трафика для тестирования

Критичный нюанс: Не используйте единый брейкер для всего кластера зависимых сервисов. Сегментируйте их по:

  • Конечным точкам API
  • Типам ресурсов (чтение/запись)
  • Уровням критичности

Паттерн #3: Стратегические Fallbacks

Когда сервис недоступен — не всегда нужно показывать ошибку. Альтернативы:

  1. Кешированные данные: Вернуть последнюю успешную копию (с пометкой stale)
  2. Пустой ответ: Для неключевых данных (например, рекомендаций)
  3. Деградация функционала: Отключить второстепенные блоки UI
  4. Асинхронная очередь: Поместить запрос в очередь на фоновую обработку
javascript
async function getProductList() {
  try {
    return await breaker.fire('https://api.products');
  } catch (err) {
    if (breaker.opened) {
      const cached = cacheService.get('last-products');
      if (cached) return { ...cached, isStale: true };
    }
    return { items: [], fallback: true };
  }
}

Предостережение: Fallback тоже может выйти из строя. Вводите правила уровня инфраструктуры (например, rate limiter для кеш-сервиса).


Артефакты проектирования

Context Propagation
Передавайте обязательные метаданные цепочки вызовов (requestId, userId, deadline) чтобы:

  • Отслеживать сквозную трассировку
  • Автоматически отменять устаревшие запросы
javascript
// Установка дедлайна на уровне шлюза
axios.get('/api/order', {
  headers: { 
    'X-Request-Deadline': Date.now() + 3000 // Таймаут 3 сек
  }
});

// В микросервисе
if (Date.now() > parseInt(req.headers['x-request-deadline'])) {
  abortRequest(); // Прерываем долгий процесс
}

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

  • Явные контракты API (схемы ошибок)
  • Ожидаемую нагрузку (RPS)
  • Статусы здоровья в /ready и /live

Изоляция отказов
Используйте bulkheads — разделите ресурсы (пулы потоков, подключения к БД) по бизнес-доменам. Сбой в биллинге не должен парализовать аутентификацию.


Культурные артефакты

  • Метрики: Считайте не только ошибки (5xx), но и таймауты, открытия брейкеров, состояние fallback.
  • Постмортемы: Анализируйте триггеры сбоев — изменение в зависимом сервисе? Скачок трафика?
  • Хаос-инжиниринг: Планово «убивайте» POD в Kubernetes. Настоящая отказоустойчивость подтверждается в бою.

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