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

Когда приложение разбивают на несколько сервисов, исчезает главное удобство монолита — единая транзакция базы данных. Раньше одна обёртка dataSource.transaction() гарантировала: либо заказ создан, деньги списаны и склад зарезервирован — либо ничего из этого. В микросервисах каждый сервис — отдельная база, отдельный процесс, отдельная сеть. Откатить чужую транзакцию нельзя. Сеть может упасть в самый неудобный момент.

Эта статья разбирает паттерны, которые решают именно эту проблему — один за другим, от простых к сложным.

Two-Phase Commit (2PC) — двухфазная фиксация

Проблема. Нужно, чтобы три сервиса либо все зафиксировали изменения, либо все откатились. Как это скоординировать?

Идея. Вводится координатор, который управляет процессом из двух фаз:

  1. Prepare (голосование): координатор спрашивает каждого участника «готов зафиксировать?». Участник выполняет всю работу — блокирует ресурсы, пишет в журнал — но не фиксирует. Отвечает «да» или «нет».
  2. Commit или Rollback (решение): если все ответили «да» — координатор говорит «фиксируй». Если хоть один ответил «нет» — все откатываются.
diagram

Почему 2PC редко используют в микросервисах:

  • Между фазами ресурсы заблокированы. Если координатор упал — участники висят с заблокированными строками.
  • Координатор — единая точка отказа. Его падение между фазами оставляет систему в неопределённом состоянии.
  • Два сетевых раунда — задержка растёт с каждым участником.

Когда подходит: несколько баз данных одного вендора в рамках одной инфраструктуры (XA-транзакции). Для микросервисов через сеть — почти никогда.

Three-Phase Commit (3PC) — почему не спасает

3PC добавляет промежуточную фазу PRE_COMMIT, чтобы участники могли самостоятельно принять решение, если координатор пропадёт. Три сетевых раунда вместо двух — ещё больше задержка. И главное: 3PC не решает проблему сетевого раздела (network partition). Если участник получил PRE_COMMIT, потерял связь, зафиксировал — а другой участник в это время откатился — данные рассогласованы.

На практике проблемы 2PC решают не через 3PC, а через SAGA.

SAGA — цепочка компенсируемых шагов

Проблема. Нужно скоординировать действия нескольких сервисов, но без блокировок и единого координатора на уровне базы.

Идея. Вместо одной большой транзакции — цепочка локальных транзакций. Каждый шаг фиксируется самостоятельно. Если шаг N упал — выполняются компенсирующие транзакции для уже выполненных шагов в обратном порядке.

Шаг 1: Создать заказ            → Компенсация: Отменить заказ
Шаг 2: Списать деньги           → Компенсация: Вернуть деньги
Шаг 3: Зарезервировать на складе → Компенсация: Снять резерв

Ключевое отличие от 2PC: между шагами система находится во временно несогласованном состоянии. Это называется eventual consistency — в конечном счёте всё придёт к согласованности, но не мгновенно.

Оркестрация

Центральный оркестратор знает последовательность шагов и какую компенсацию вызвать при ошибке.

interface SagaStep<C> {
  name: string;
  action: (ctx: C) => Promise<StepResult>;
  compensation: (ctx: C) => Promise<void>;
}

export class CreateOrderSaga {

  async execute(request: CreateOrderRequest): Promise<OrderResult> {
    const ctx = new SagaContext(request);
    const completedSteps: SagaStep<SagaContext>[] = [];

    const steps: SagaStep<SagaContext>[] = [
      {
        name: 'create-order',
        action: (c) => this.orderService.create(c.request),
        compensation: (c) => this.orderService.cancel(c.orderId),
      },
      {
        name: 'charge-payment',
        action: (c) => this.paymentService.charge(c.userId, c.total),
        compensation: (c) => this.paymentService.refund(c.paymentId),
      },
      {
        name: 'reserve-inventory',
        action: (c) => this.inventoryService.reserve(c.orderId, c.items),
        compensation: (c) => this.inventoryService.releaseReservation(c.orderId),
      },
    ];

    for (const step of steps) {
      try {
        const result = await step.action(ctx);
        ctx.apply(result);
        completedSteps.push(step);
      } catch (e) {
        await this.compensate(ctx, completedSteps);
        throw new SagaError(`Step failed: ${step.name}`, { cause: e });
      }
    }
    return ctx.toResult();
  }
}

Плюсы: логика в одном месте, легко отслеживать состояние, просто отлаживать.

Минусы: оркестратор знает про все сервисы — может разрастись в класс, который делает всё на свете.

Хореография

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

diagram
// PaymentService реагирует на событие (kafkajs)
await consumer.subscribe({ topic: 'order-events' });

await consumer.run({
  eachMessage: async ({ message }) => {
    const event: OrderCreatedEvent = JSON.parse(message.value!.toString());
    try {
      const result = await paymentService.charge(event.userId, event.total);
      await eventPublisher.publish(
        new PaymentChargedEvent(event.orderId, result.paymentId));
    } catch (e) {
      await eventPublisher.publish(
        new PaymentFailedEvent(event.orderId, (e as Error).message));
    }
  },
});

Плюсы: сервисы слабо связаны, развиваются независимо.

Минусы: логику саги трудно отследить — она размазана по сервисам. Сложно понять, в каком состоянии находится процесс.

Оркестрация или хореография — что выбрать

Оркестрация лучше, когда шагов больше трёх-четырёх, есть ветвления («если оплата частичная — другой путь»), важна единая точка мониторинга.

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

Важно о компенсациях

Компенсация — это не откат базы данных. refund() — это новая бизнес-операция, которая приводит систему к эквиваленту отмены. Деньги возвращаются отдельной транзакцией, а не отменой списания.

Компенсация обязана быть идемпотентной — если вызвать её дважды, результат должен быть одинаковым:

async refund(paymentId: string): Promise<void> {
  const payment = await this.paymentRepository.findOneByOrFail({ id: paymentId });
  if (payment.status === PaymentStatus.REFUNDED) {
    return; // уже возвращено — ничего не делаем
  }
  await this.paymentGateway.refund(payment.gatewayId);
  payment.markRefunded();
  await this.paymentRepository.save(payment);
}

Transactional Outbox — атомарная запись данных и событий

Проблема. Типичная ошибка: сохранили заказ в базу, потом отправили событие в Kafka. Между этими двумя операциями процесс упал — заказ есть, событие потеряно. Обратная ситуация тоже плоха: событие ушло, а транзакция откатилась.

// Опасный код — не атомарно!
await orderRepository.save(order); // транзакция зафиксирована
await producer.send(orderCreated); // процесс упал — событие потеряно

Решение. Событие сохраняется в ту же базу данных, в той же транзакции, что и бизнес-данные — в отдельную таблицу outbox_events. Отдельный фоновый процесс (relay) периодически читает эту таблицу и публикует события в брокер.

diagram
CREATE TABLE outbox_events (
    id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type VARCHAR(255) NOT NULL,
    aggregate_id   VARCHAR(255) NOT NULL,
    event_type     VARCHAR(255) NOT NULL,
    payload        JSONB NOT NULL,
    created_at     TIMESTAMP NOT NULL DEFAULT now(),
    published_at   TIMESTAMP,
    retry_count    INT DEFAULT 0
);
// Бизнес-код: заказ и событие в одной транзакции (TypeORM)
async createOrder(cmd: CreateOrderCommand): Promise<Order> {
  return this.dataSource.transaction(async (manager) => {
    const order = await manager.save(newOrder(cmd));
    await this.outboxPublisher.save(manager, 'Order', order.id,
      'OrderCreated', new OrderCreatedPayload(order));
    return order;
  });
}

// Relay: периодически отправляет необработанные события
@Interval(500)
async publishPending(): Promise<void> {
  const events = await this.outboxRepository.findUnpublished(100);
  for (const event of events) {
    try {
      await this.producer.send({
        topic: topicFor(event),
        messages: [{ key: event.aggregateId, value: event.payload }],
      });
      event.markPublished();
    } catch {
      event.incrementRetryCount();
    }
    await this.outboxRepository.save(event);
  }
}

At-least-once — почему получатель должен быть идемпотентным

Outbox гарантирует at-least-once delivery: событие будет отправлено хотя бы один раз. Relay может упасть после отправки, но до того, как пометил строку published_at — тогда при следующем запуске событие уйдёт повторно. Поэтому потребители событий обязаны быть идемпотентными (см. раздел Idempotent Consumer ниже).

Polling vs CDC

Альтернатива polling-relay — Change Data Capture (CDC): внешний сервис читает журнал базы (WAL) и публикует изменения в Kafka напрямую. Но polling-relay в коде сервиса проще и надёжнее:

  • Контракт события — в коде, а не в схеме таблицы. Изменение структуры outbox_events не ломает потребителей.
  • CDC — отдельный кластер с собственным жизненным циклом и мониторингом. Сломался — отдельная команда, отдельная процедура.
  • Висящий CDC-коннектор накапливает WAL до отказа диска. Polling-relay таких рисков не создаёт.

Опрос каждые 200–500 мс — задержка, неотличимая для бизнеса, и снимает целый класс операционных проблем.

Event Sourcing — история как источник истины

Проблема. Таблица orders хранит текущий статус: status = 'CONFIRMED'. Но мы не знаем, когда заказ был создан, когда оплачен, был ли он до этого в другом статусе. История потеряна.

Идея. Хранить не текущее состояние, а последовательность событий, которые к нему привели. Текущее состояние вычисляется воспроизведением всех событий.

OrderCreated → PaymentReceived → ItemReserved → OrderConfirmed
diagram

Агрегат восстанавливается из событий:

export class Order {

  private id!: string;
  private status!: OrderStatus;
  private readonly uncommittedEvents: DomainEvent[] = [];
  private version = 0;

  static fromEvents(events: DomainEvent[]): Order {
    const order = new Order();
    for (const event of events) {
      order.apply(event);
      order.version++;
    }
    return order;
  }

  private apply(event: DomainEvent): void {
    switch (event.type) {
      case 'OrderCreated':
        this.id = event.orderId;
        this.status = OrderStatus.CREATED;
        break;
      case 'PaymentReceived':
        this.status = OrderStatus.PAID;
        break;
      case 'OrderConfirmed':
        this.status = OrderStatus.CONFIRMED;
        break;
      default:
        throw new Error(`Unknown event: ${(event as { type: string }).type}`);
    }
  }

  confirm(): void {
    if (this.status !== OrderStatus.PAID) {
      throw new Error(`Can only confirm PAID orders, current=${this.status}`);
    }
    this.raise({ type: 'OrderConfirmed', orderId: this.id, confirmedAt: new Date() });
  }

  private raise(event: DomainEvent): void {
    this.apply(event);
    this.uncommittedEvents.push(event);
  }
}

Проекции для чтения

Event Sourcing разделяет запись и чтение. Данные пишутся в Event Store (только добавление). Для чтения строятся проекции — таблицы, оптимизированные под конкретные запросы. Это естественно сочетается с CQRS.

export class OrderSummaryProjection {
  async onOrderCreated(event: OrderCreated): Promise<void> {
    await this.repository.save(
      new OrderSummary(event.orderId, 'CREATED', event.createdAt));
  }
  async onOrderConfirmed(event: OrderConfirmed): Promise<void> {
    await this.repository.updateStatus(event.orderId, 'CONFIRMED');
  }
}

Когда подходит Event Sourcing

Подходит, когда нужна полная история изменений (финансы, аудит, расчёты с продавцами), возможность посмотреть состояние на любой момент времени, или сложная доменная логика с множеством переходов статусов.

Избыточен для простых справочников, CRUD без сложной логики, проектов без требований к истории.

Подводные камни

  • Задержка проекций. Проекция обновляется асинхронно — пользователь может не увидеть свои изменения сразу.
  • Версионирование событий. Событие OrderCreated_v1 не содержит поля currency. В _v2 оно появилось. Нужна стратегия миграции старых событий при чтении.
  • Размер Event Store. Агрегат с тысячами событий — воспроизведение занимает время. Решение — снимки (снэпшоты): периодически сохранять текущее состояние, воспроизводить только от последнего снимка.

Idempotent Consumer — защита от дублей

Проблема. В распределённой системе сообщения приходят повторно: ретраи брокера, перебалансировка группы потребителей, гарантии at-least-once. Обработчик должен давать одинаковый результат при повторной обработке.

Решение. Хранить таблицу обработанных событий. Перед обработкой проверить — не обрабатывали ли уже это событие.

async process<T>(eventId: string, handler: () => Promise<T>): Promise<void> {
  await this.dataSource.transaction(async (manager) => {
    const alreadyProcessed = await manager.existsBy(ProcessedEvent, { eventId });
    if (alreadyProcessed) {
      return; // уже обработали — пропускаем
    }
    await handler();
    await manager.save(new ProcessedEvent(eventId, new Date()));
  });
}

await consumer.subscribe({ topic: 'payment-events' });

await consumer.run({
  eachMessage: async ({ message }) => {
    const event: PaymentChargedEvent = JSON.parse(message.value!.toString());
    await idempotentProcessor.process(event.eventId, async () => {
      const order = await orderRepository.findOneByOrFail({ id: event.orderId });
      order.markPaid(event.paymentId);
      await orderRepository.save(order);
    });
  },
});
CREATE TABLE processed_events (
    event_id     VARCHAR(255) PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL DEFAULT now()
);

-- Очистка старых записей
DELETE FROM processed_events
WHERE processed_at < now() - INTERVAL '7 days';

Тот же принцип работает для REST API — через заголовок Idempotency-Key. Клиент генерирует уникальный ключ, сервер проверяет, не обрабатывался ли уже запрос с таким ключом:

@Post('payments')
async charge(
  @Headers('idempotency-key') idempotencyKey: string,
  @Body() request: ChargeRequest,
): Promise<PaymentResult> {
  return this.idempotentProcessor.processOrReturn(idempotencyKey, () =>
    this.paymentService.charge(request));
}

Distributed Lock — защита от конкурентного доступа

Проблема. Два экземпляра одного сервиса одновременно обрабатывают один заказ. Оба списывают деньги — пользователь платит дважды.

Решение. Распределённая блокировка через Redis: только один экземпляр может держать блокировку на один ресурс одновременно.

async executeWithLock<T>(
  lockKey: string,
  ttlMs: number,
  action: () => Promise<T>,
): Promise<T> {
  let lock: Lock;
  try {
    lock = await this.redlock.acquire([lockKey], ttlMs);
  } catch {
    throw new LockAcquisitionError(`Cannot acquire lock: ${lockKey}`);
  }
  try {
    return await action();
  } finally {
    await lock.release();
  }
}

async processOrder(orderId: number): Promise<void> {
  await this.lockService.executeWithLock(
    `order-processing:${orderId}`,
    30_000,
    async () => {
      const order = await this.orderRepository.findOneByOrFail({ id: orderId });
      order.confirm();
      await this.orderRepository.save(order);
    });
}

Если Redis недоступен — можно использовать SELECT FOR UPDATE в базе данных:

async findByIdForUpdate(manager: EntityManager, id: number): Promise<Order | null> {
  return manager.getRepository(Order)
    .createQueryBuilder('order')
    .setLock('pessimistic_write')
    .setOnLocked('skip_locked')
    .where('order.id = :id', { id })
    .getOne();
}

Подводные камни блокировок:

  • Взаимная блокировка. Два процесса блокируют ресурсы в разном порядке и ждут друг друга. Решение — всегда блокировать в одном порядке (например, по ID).
  • Зависшая блокировка. Процесс взял блокировку, упал, не отпустил. Решение — TTL (автоматическое снятие по таймауту).
  • Раздвоение в Redis. Redis перешёл на нового мастера, а старый ещё жив — два процесса держат одну блокировку. Решение — Redlock (блокировка сразу на нескольких независимых нодах Redis).

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

Реальная система не использует один паттерн в изоляции. Они складываются в цепочку:

diagram

OrderService создаёт заказ и сохраняет событие через Outbox — атомарно в одной транзакции. Polling Relay периодически читает таблицу и публикует в Kafka. PaymentService обрабатывает через Idempotent Consumer — дубли безопасны. При конкурентном доступе — Distributed Lock. Весь процесс координирует SAGA с компенсациями, если что-то пошло не так.

Коротко

  • 2PC — строгая согласованность через двухфазный протокол; подходит только в рамках одной инфраструктуры, для микросервисов через сеть не применяют.
  • SAGA — цепочка локальных транзакций с компенсирующими шагами; оркестрация (центральный координатор) или хореография (события между сервисами).
  • Компенсация — это новая бизнес-операция, а не откат базы; обязана быть идемпотентной.
  • Transactional Outbox — событие и бизнес-данные в одной транзакции; relay доставляет в брокер с гарантией at-least-once.
  • Event Sourcing — хранение истории событий вместо текущего состояния; восстановление через воспроизведение; сочетается с CQRS.
  • Idempotent Consumer — таблица обработанных событий защищает от дублей при повторной доставке.
  • Distributed Lock — Redis-блокировка (Redlock через node-redlock) или SELECT FOR UPDATE защищают от конкурентного доступа.

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

  • CQRS — паттерн разделения команд и запросов; естественно сочетается с Event Sourcing.
  • Гексагональная архитектура — как изолировать SAGA-оркестратор от инфраструктуры.