Опирается на правила:
R-DIST-TX-X1…R-DIST-TX-X3иR-DIST-TX-1…R-DIST-TX-3из Distributed Patterns Style Guide → раздел 7. Distributed transactions — что НЕ делать.
Важно знать
- 2PC/XA в Node-стеке недоступен: ни
pg, ниkafkajsXA-протокол не реализуют — не городить координатор руками (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 между сервисами»
Это требование почти всегда означает одно из трёх:
-
Неверная граница BC — две операции, требующие immediate consistency, скорее всего относятся к одному Bounded Context.
ProductиInventoryв одном PG = одинDataSource.transaction. -
Реальная eventual consistency — immediate не нужна, нужна быстрая (<1с) EC + декларация в OpenAPI (
@ApiOperation({ description: 'Read-проекция, задержка до 2 секунд' })). Клиент не замечает разницы при правильном UX. -
Read-your-writes — клиенту нужно сразу увидеть свой результат, но атомарность с другим сервисом не требуется. Решается: вернуть version-токен в response, клиент передаёт его в следующем read — сервис ждёт пока read-model нагонит (
version >= token).
«Хочу 2PC» — симптом, не requirement.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| 2PC/XA для cross-service через любые координаторы | R-DIST-TX-X1 | saga с локальными DataSource.transaction |
Два DataSource в одном use case с последовательными commit'ами | R-DIST-TX-X2 | один PG (modular monolith) или saga |
typeorm-transactional поверх двух datasource | R-DIST-TX-X3 | один PG или saga |
Последовательные QueryRunner.commitTransaction() по двум DS | R-DIST-TX-X3 | outbox + saga |
@Transactional() поверх HTTP-вызова другого сервиса | R-DIST-TX-X1 | outbox + 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 как альтернатива микросервисам.