Опирается на правила: R-DIST-TX-X1R-DIST-TX-X3 и R-DIST-TX-1R-DIST-TX-3 из Distributed Patterns Style Guide → раздел 7. Distributed transactions — что НЕ делать.

Важно знать

  • 2PC/XA в Node-стеке недоступен: ни pg, ни kafkajs XA-протокол не реализуют — не городить координатор руками (R-DIST-TX-X1).
  • Два DataSource в одном use case — иллюзия атомарности. Падение между commit'ами оставляет inconsistency без recovery (R-DIST-TX-X2).
  • typeorm-transactional поверх двух datasource или последовательные QueryRunner.commitTransaction() — best-effort, не атомарность (R-DIST-TX-X3).
  • Альтернатива 1: Saga с локальными DataSource.transaction + compensation — стандарт UCP для cross-service flow (R-DIST-TX-1).
  • Альтернатива 2: Outbox + idempotent consumer — атомарный commit + publish через одну PG-транзакцию (R-DIST-TX-2).
  • Альтернатива 3: Modular monolith — несколько NestJS-модулей в одном процессе с одним PG, локальная DataSource.transaction работает (R-DIST-TX-3).
  • «Мне нужна immediate consistency между сервисами» — почти всегда означает неверную границу BC или реальную eventual consistency, которую достаточно задекларировать.

Двухфазный коммит — идея из мира enterprise application servers (JBoss, WebLogic). В Node-экосистеме её нет не из-за идеологии, а потому что инфраструктура не поддерживает: pg-driver не реализует XA, kafkajs тоже, Node.js-координатора XA в природе не существует. Попытки построить 2PC руками — это ручная реализация чужих ошибок.

Почему 2PC/XA не подходит

Kafka не XA, Node-драйверы тоже

R-DIST-TX-X1: Наш main broker — Kafka. Apache Kafka XA-протокол не реализует. Это фундаментально: «атомарный commit в PG + send в Kafka» через distributed transaction невозможен — не «сложно», а именно невозможен.

Из популярного Node-стека XA не поддерживает никто: kafkajs, pg, ioredis, @elastic/elasticsearch. Старые JMS-брокеры (IBM MQ, ActiveMQ) поддерживают, но мы не на них.

2PC держит lock на всех участниках

Two-phase commit удерживает synchronous lock на участвующих ресурсах между фазой prepare и фазой commit. Четыре сервиса в одной saga через 2PC — четыре ресурса залочены на всё время сетевых round-trip'ов (десятки–сотни мс). Под нагрузкой это bottleneck: throughput падает, latency растёт, deadlock между перекрывающимися 2PC становится регулярным.

Transaction coordinator — single point of failure

XA требует coordinator — отдельный сервис с персистентным state каждой distributed transaction. Если coordinator упал между фазой prepare и commit — участники остаются в IN_DOUBT до его восстановления. В Kubernetes с rolling restart и network partition coordinator ломается часто.

В Node нет battle-tested XA-coordinator. Написанный руками будет воспроизводить именно эти сценарии.

Multi-DataSource commit-цепочки — не вариант

R-DIST-TX-X2 и R-DIST-TX-X3: подключить два DataSource к одному use case и сделать последовательные commit'ы выглядит как решение — это best-effort, не атомарность.

// ОПАСНО — выглядит атомарно, но не атомарно
const queryRunnerOrder = orderDataSource.createQueryRunner();
const queryRunnerPayment = paymentDataSource.createQueryRunner();
await queryRunnerOrder.startTransaction();
await queryRunnerPayment.startTransaction();

await queryRunnerOrder.manager.save(order);
await queryRunnerPayment.manager.save(payment);

await queryRunnerPayment.commitTransaction(); // commit 1 прошёл
await queryRunnerOrder.commitTransaction();   // commit 2 упал → payment committed, order нет
// inconsistency без recovery-плана

То же касается typeorm-transactional (@Transactional() decorator) поверх двух datasource: декоратор открывает транзакцию в каждом, но commit'ит последовательно — тот же сценарий.

Альтернативы — что использовать вместо

1. Saga с локальными транзакциями

R-DIST-TX-1: стандарт UCP для cross-service flow. Каждый шаг — локальная DataSource.transaction в одном PG, orchestrator управляет state, compensation на сбое.

// OrderSagaOrchestrator — отдельный @Injectable, не handler
@Injectable()
export class OrderSagaOrchestrator {
  constructor(private readonly dataSource: DataSource) {}

  async advance(sagaId: string, event: SagaEvent): Promise<void> {
    await this.dataSource.transaction(async (m) => {
      const saga = await m.findOneOrFail(OrderSagaEntity, { where: { sagaId } });

      if (event.type === 'ORDER_RESERVED' && saga.currentStep === 1) {
        await m.update(OrderSagaEntity, { sagaId }, {
          status: 'PAYMENT_REQUESTED',
          currentStep: 2,
        });
        await m.insert(OutboxEventEntity, {
          eventId: randomUUID(),
          topic: 'payment.commands',
          payload: JSON.stringify(new RequestPaymentCommand(sagaId, saga.orderId, saga.amount)),
        });
      }

      if (event.type === 'PAYMENT_FAILED') {
        await m.update(OrderSagaEntity, { sagaId }, { status: 'COMPENSATING' });
        await m.insert(OutboxEventEntity, {
          eventId: randomUUID(),
          topic: 'inventory.commands',
          payload: JSON.stringify(new CancelReservationCommand(sagaId, saga.orderId)),
        });
      }
    });
  }
}
[order-service]     [payment-service]    [inventory-service]
DataSource.tx        DataSource.tx         DataSource.tx
    ↓                    ↓                     ↓
local commit         local commit           local commit

При сбое: orchestrator → CancelReservationCommand, RefundPaymentCommand

Свойства: каждый шаг ACID локально, eventual consistency между сервисами, recovery через order_saga-таблицу, горизонтально масштабируется.

2. Outbox + idempotent consumer

R-DIST-TX-2: для event-driven sync без rollback'ов. Write + INSERT в outbox атомарно в одной локальной DataSource.transaction, relay публикует в Kafka.

// CreateOrderHandler — одна PG-транзакция
await this.dataSource.transaction(async (m) => {
  const order = m.create(OrderEntity, {
    orderId: command.orderId,
    customerId: command.customerId,
    status: 'CONFIRMED',
  });
  await m.save(order);

  await m.insert(OutboxEventEntity, {
    eventId: randomUUID(),
    aggregateId: order.orderId,
    topic: 'order.events',
    eventType: 'OrderConfirmed',
    payload: JSON.stringify({ orderId: order.orderId, customerId: order.customerId }),
  });
});
// relay async публикует в Kafka через FOR UPDATE SKIP LOCKED

Подходит когда compensation не нужна: «опубликовали OrderConfirmed, downstream обновил read-model или уведомил Customer». Downstream идемпотентен через processed_event + .orIgnore().

3. Modular monolith

R-DIST-TX-3: tight coupling — несколько BC-модулей Nest в одном процессе с одним PG. Одна DataSource.transaction покрывает все операции.

@Injectable()
export class CreateOrderHandler {
  constructor(
    private readonly dataSource: DataSource,
    private readonly inventoryService: InventoryService,  // тот же процесс
    private readonly paymentService: PaymentService,      // тот же процесс
  ) {}

  async handle(command: CreateOrderCommand): Promise<Order> {
    return this.dataSource.transaction(async (m) => {
      const order = m.create(OrderEntity, {
        orderId: command.orderId,
        customerId: command.customerId,
        status: 'PENDING',
      });
      await m.save(order);
      await this.inventoryService.reserve(m, order.orderId, command.productId, command.quantity);
      await this.paymentService.charge(m, order.orderId, command.amount);
      order.status = 'CONFIRMED';
      return m.save(order);
    });
  }
}

InventoryService и PaymentService принимают EntityManager — все три операции в одной транзакции, ACID локально, никакой distributed-сложности. Разделяем на сервисы тогда, когда независимая масштабируемость или SLO реально требуют — не раньше.

«Мне нужна immediate consistency между сервисами»

Это требование почти всегда означает одно из трёх:

  1. Неверная граница BC — две операции, требующие immediate consistency, скорее всего относятся к одному Bounded Context. Product и Inventory в одном PG = один DataSource.transaction.

  2. Реальная eventual consistency — immediate не нужна, нужна быстрая (<1с) EC + декларация в OpenAPI (@ApiOperation({ description: 'Read-проекция, задержка до 2 секунд' })). Клиент не замечает разницы при правильном UX.

  3. Read-your-writes — клиенту нужно сразу увидеть свой результат, но атомарность с другим сервисом не требуется. Решается: вернуть version-токен в response, клиент передаёт его в следующем read — сервис ждёт пока read-model нагонит (version >= token).

«Хочу 2PC» — симптом, не requirement.

Что запрещено

АнтипаттернПравилоЧто взамен
2PC/XA для cross-service через любые координаторыR-DIST-TX-X1saga с локальными DataSource.transaction
Два DataSource в одном use case с последовательными commit'амиR-DIST-TX-X2один PG (modular monolith) или saga
typeorm-transactional поверх двух datasourceR-DIST-TX-X3один PG или saga
Последовательные QueryRunner.commitTransaction() по двум DSR-DIST-TX-X3outbox + saga
@Transactional() поверх HTTP-вызова другого сервисаR-DIST-TX-X1outbox + saga
«Distributed transaction руками» через try/catch rollback цепочкиR-DIST-TX-X1стандартизованная saga с compensation

Куда дальше

  • Distributed Patterns → раздел 7. Distributed transactions — нормативные формулировки правил.
  • Saga — главная альтернатива 2PC, orchestrator + state-machine в NestJS.
  • Outbox + Inbox — атомарный «commit + publish» через одну PG-транзакцию.
  • Eventual consistency — что делать с требованием «immediate».
  • Idempotency — dedup на receiver-стороне, processed_event + .orIgnore().
  • Compensation — semantic state-change, audit trail, DLQ при сбое.
  • Когда нужны распределённые паттерны — modular monolith как альтернатива микросервисам.