lenec ru

← все посты

Circuit breaker pattern в Node.js: opossum, fallback strategies, monitoring

17K

Вы разбили монолит на микросервисы, каждый со своей базой данных. Теперь простой заказ превращается в проблему: списать деньги, зарезервировать товар, создать доставку — три сервиса, три базы. Если третий шаг упадёт, как откатить первые два? Распределённые транзакции в стиле 2PC блокируют ресурсы, медленны и ломаются при сетевых сбоях. Saga pattern — это практичная альтернатива.

Проблема: ACID-транзакции невозможны в микросервисах

В монолите вы оборачиваете всё в BEGIN TRANSACTION — либо всё фиксируется, либо откатывается атомарно. В микросервисах каждый сервис владеет своей базой. Попытки использовать 2PC (two-phase commit) наталкиваются на реальность: фаза «prepare» держит блокировки, координатор — единая точка отказа, современные брокеры (Kafka, RabbitMQ) не поддерживают XA-протокол. CAP-теорема: при разделении сети выбираете либо доступность, либо консистентность. Большинство систем выбирают доступность и eventual consistency.

Saga pattern: компенсирующие транзакции

Saga — это последовательность локальных транзакций T1, T2, ..., Tn. Каждая Ti фиксируется в своей базе. Если Ti падает, выполняются компенсирующие транзакции C(i-1), ..., C1 в обратном порядке.

Ключевое: компенсация — не откат базы данных, а новая бизнес-операция. Если вы списали 1000₽, компенсация — это возврат 1000₽ (новая запись), а не удаление исходной.

Пример saga для заказа:

T1: CreateOrder → T2: ChargePayment → T3: ReserveInventory → T4: CreateShipment

Если T3 падает: C2: RefundPayment → C1: CancelOrder

Консистентность eventual: между шагами система в промежуточном состоянии. Клиент видит заказ «processing» несколько секунд.

Choreography: event-driven подход

Нет центрального координатора. Каждый сервис реагирует на события и публикует свои:

// Order Service
orderBus.on('ORDER_CREATED', async (order) => {
  try {
    await paymentClient.charge(order.userId, order.total);
    eventBus.emit('PAYMENT_COMPLETED', { orderId: order.id });
  } catch (err) {
    eventBus.emit('PAYMENT_FAILED', { orderId: order.id });
  }
});

// Inventory Service
eventBus.on('PAYMENT_COMPLETED', async ({ orderId }) => {
  try {
    await inventory.reserve(orderId);
    eventBus.emit('INVENTORY_RESERVED', { orderId });
  } catch (err) {
    eventBus.emit('INVENTORY_FAILED', { orderId });
  }
});

// Compensation
eventBus.on('INVENTORY_FAILED', async ({ orderId }) => {
  await paymentClient.refund(orderId);
  await orderRepo.cancel(orderId);
});

Sequence diagram:

Client → Order → Payment → Inventory
                    ↓ FAIL
         Order ← Payment (refund)
Client ← Order (409)

Плюсы: нет единой точки отказа, сервисы слабо связаны, легко добавить подписчика.

Минусы: workflow неявный, отладка требует distributed tracing, риск event storm, сложно гарантировать порядок компенсаций.

Правило: choreography для 2–3 шагов с линейным флоу.

Orchestration: центральный координатор

Orchestrator знает весь workflow, вызывает сервисы по очереди и запускает компенсации при сбоях:

class OrderSagaOrchestrator {
  constructor(redis, orderClient, paymentClient, inventoryClient) {
    this.redis = redis;
    this.steps = [
      { name: 'createOrder', 
        execute: (ctx) => orderClient.create(ctx.orderData), 
        compensate: (ctx) => orderClient.cancel(ctx.orderId) },
      { name: 'chargePayment', 
        execute: (ctx) => paymentClient.charge(ctx.userId, ctx.total),
        compensate: (ctx) => paymentClient.refund(ctx.paymentId) },
      { name: 'reserveInventory', 
        execute: (ctx) => inventoryClient.reserve(ctx.orderId),
        compensate: (ctx) => inventoryClient.release(ctx.reservationId) }
    ];
  }

  async executeSaga(sagaId, orderData) {
    const context = { sagaId, orderData, completedSteps: [] };
    await this.redis.set(`saga:${sagaId}`, JSON.stringify(context));

    try {
      for (const step of this.steps) {
        const result = await step.execute(context);
        Object.assign(context, result);
        context.completedSteps.push(step.name);
        await this.redis.set(`saga:${sagaId}`, JSON.stringify(context));
      }
      return { status: 'COMPLETED', orderId: context.orderId };
    } catch (err) {
      await this.compensate(context);
      return { status: 'COMPENSATED', error: err.message };
    }
  }

  async compensate(context) {
    const completed = [...context.completedSteps].reverse();
    for (const stepName of completed) {
      const step = this.steps.find(s => s.name === stepName);
      if (step.compensate) {
        try {
          await step.compensate(context);
        } catch (err) {
          // Критично: компенсация упала — DLQ для ручной обработки
          await this.redis.lpush('saga:failed-compensations', 
            JSON.stringify({ sagaId: context.sagaId, step: stepName }));
        }
      }
    }
  }
}

Плюсы: весь workflow в одном месте, явный контроль компенсаций, retry-логика, таймауты, состояние персистится.

Минусы: потенциальное узкое горло, сервисы связаны с orchestrator.

Реализация на Node.js: пример с заказом

const express = require('express');
const Redis = require('ioredis');
const { OrderSagaOrchestrator } = require('./saga');

const app = express();
const redis = new Redis();
const saga = new OrderSagaOrchestrator(redis, orderClient, paymentClient, inventoryClient);

app.post('/orders', async (req, res) => {
  const sagaId = `saga-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  const result = await saga.executeSaga(sagaId, req.body);
  
  if (result.status === 'COMPLETED') {
    res.status(201).json({ orderId: result.orderId, sagaId });
  } else {
    res.status(409).json({ error: result.error, sagaId });
  }
});

app.get('/admin/sagas', async (req, res) => {
  const keys = await redis.keys('saga:*');
  const sagas = await Promise.all(keys.map(k => redis.get(k)));
  res.json(sagas.map(JSON.parse));
});

Критичные паттерны:

  • Idempotency: используйте sagaId как idempotency key в downstream-сервисах
  • Transactional Outbox: событие и изменение БД фиксируются атомарно
  • Dead Letter Queue: упавшие компенсации в DLQ для ручной обработки
  • Мониторинг: алертите, если saga висит >30 секунд

Choreography vs Orchestration: когда что использовать

КритерийChoreographyOrchestration
Шаги2–34+
ФлоуЛинейныйВетвления, условия
OwnershipРазные командыОдна команда
ОтладкаDistributed tracingЦентрализованные логи
СвязанностьСлабая (события)Сильная (команды)

Для сложных workflow используйте готовые движки: Temporal (Go/TypeScript/Python), AWS Step Functions, Camunda. Они персистят состояние, автоматически ретраят шаги и предоставляют UI для мониторинга.

Вывод

Saga pattern — это признание реальности распределённых систем. Отказы нормальны, сеть ненадёжна, консистентность eventual. Вместо иллюзии ACID вы получаете явные компенсации, персистентное состояние и возможность восстановления.

Начинайте с choreography для простых флоу. Переходите на orchestration при условиях, параллельных ветках или нужде в аудируемости. Делайте каждый шаг идемпотентным. Используйте transactional outbox для надёжной публикации событий. Мониторьте длительность saga и застрявшие компенсации. Некоторые сбои требуют ручного вмешательства — проектируйте систему так, чтобы они всплывали явно.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.