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

Важно знать

  • 2PC через JTA/XA в нашем стеке запрещён — Kafka не поддерживает XA, не масштабируется, transaction coordinator — single point of failure, плохо живёт в Kubernetes.
  • ChainedTransactionManager Spring — best-effort, не атомарность. Failure между commit-ами двух DS оставляет inconsistency без recovery.
  • Альтернатива 1: Saga с локальными транзакциями + compensation — стандарт UCP для cross-service flow.
  • Альтернатива 2: Outbox + idempotent consumer для event-driven синхронизации без rollback'ов.
  • Альтернатива 3: Modular monolith — несколько Bounded Context в одном PG, локальный @Transactional работает.
  • «Но мне нужна immediate consistency между двумя сервисами» — обычно означает либо неверную границу BC, либо реальную EC, которую надо задекларировать.

Двухфазный коммит — старая идея из мира enterprise application servers (JBoss, WebLogic), которая в эпоху Kafka и Kubernetes плохо работает. UCP делает явный отказ: никаких JTA-конфигов в Spring, никаких XADataSource, никаких ChainedTransactionManager.

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

R-DIST-TX-X1: причины запрета.

Kafka не поддерживает XA

Наш main broker — Kafka. Apache Kafka не реализует XA-протокол. Это фундаментально: невозможно сделать атомарный «commit в PostgreSQL + send в Kafka» через distributed transaction. Любая попытка построить такое — самообман.

То же касается Redis, ClickHouse, ElasticSearch, S3 — почти весь современный data stack не поддерживает XA. Старые JMS-брокеры (ActiveMQ, IBM MQ) и старые RDBMS (Oracle, DB2) поддерживают, но мы не на них.

2PC synchronous block — не масштабируется

Two-phase commit держит synchronous lock на участвующих ресурсах между фазой prepare и фазой commit. Если в саге 4 шага через 2PC — все 4 ресурса залочены на длительность всей операции (десятки/сотни миллисекунд при сетевых hops).

Под нагрузкой это становится bottleneck-ом: throughput падает в разы, latency растёт, deadlock между перекрывающимися 2PC становится регулярным.

Transaction coordinator — single point of failure

XA требует transaction coordinator — отдельный сервис, который ведёт state каждой distributed transaction (prepare-ready, committed, aborted). Если координатор упал в момент COMMITTED prepare, не отправленный COMMIT — все участвующие ресурсы остаются в IN_DOUBT состоянии до восстановления координатора. Это знаменитые «hanging XID» в Oracle.

В Kubernetes с rolling restart, частыми migration-ами, network partition — coordinator-сервис ломается часто.

Сложно operate в Kubernetes

Конфиг JTA + XA datasources + transaction recovery store + правильный transaction-log-dir на persistent volume + recovery после рестарта — это десятки часов настройки и отладки на проде, плюс новый класс инцидентов, которые команда раньше не видела.

Сложность не покрывается выгодой — потому что есть рабочие альтернативы.

ChainedTransactionManager — не вариант

R-DIST-TX-X3: Spring предоставляет ChainedTransactionManager для multi-datasource — выглядит как distributed transaction, но это best-effort sequence, не атомарность.

// ОПАСНО — выглядит атомарно, но не атомарно
@Bean
public PlatformTransactionManager transactionManager(
    @Qualifier("dataSource1") PlatformTransactionManager tm1,
    @Qualifier("dataSource2") PlatformTransactionManager tm2
) {
    return new ChainedTransactionManager(tm1, tm2);
}

Как это работает:

  1. Begin tx1, begin tx2.
  2. Бизнес-логика делает write в обе DS.
  3. Commit tx2 (вложенный).
  4. Commit tx1 (внешний).

Что ломается:

  • Если commit tx2 прошёл, а commit tx1 упал — у DS2 уже committed данные, у DS1 — rollback. Inconsistency, recovery нет.
  • Никаких prepare-фаз, никакого rollback'а уже-committed tx2.
  • Документация Spring сама помечает класс @Deprecated since Spring 5.3.

Использование ChainedTransactionManager для multi-datasource — анти-паттерн. Либо один PG (modular monolith), либо saga (cross-service).

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

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

R-DIST-TX-1: стандарт UCP для cross-service flow. Каждый шаг — локальная @Transactional в одном PG, compensation на сбое. Подробнее — Saga.

[order-service]    [payment-service]   [inventory-service]
@Transactional     @Transactional       @Transactional
  ↓                  ↓                    ↓
local commit       local commit         local commit

При сбое: orchestrator вызывает refundPayment, cancelOrder — semantic compensation

Свойства:

  • Каждый шаг ACID локально.
  • Eventual consistency между сервисами.
  • Recovery через saga_<name> state-таблицу.
  • Масштабируется горизонтально.

2. Outbox + idempotent consumer

R-DIST-TX-2: для event-driven sync без rollback'ов. Write commits в БД + INSERT в outbox атомарно (одна локальная транзакция в PG), relay публикует в Kafka. Подробнее — Outbox.

@Transactional (одна PG-транзакция)
  ↓
INSERT order + INSERT outbox_event
  ↓ commit
[outbox-relay async] → Kafka
                       ↓
                       receiver (idempotent через processed_event)

Подходит когда compensation не нужна: «опубликовали OrderConfirmed, downstream обновил read-model или отправил уведомление». Если downstream упал — retry в Kafka, receiver идемпотентен.

3. Modular monolith

R-DIST-TX-3: для tight coupling — один процесс, один PG, локальный @Transactional.

@Component
@Transactional
public class CreateOrderHandler {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;        // same process
    private final InventoryService inventoryService;    // same process

    public Order handle(CreateOrderCommand command) {
        var order = orderRepository.save(...);
        paymentService.charge(order.id(), command.amount());
        inventoryService.reserve(order.id(), command.items());
        return order;
    }
}

Все три операции в одной PG-транзакции — ACID локально. Никаких саг, никаких compensation, никакого distributed-сложности. Деплой один.

Когда команда вырастает или нужна независимая масштабируемость — разделяем на сервисы, тогда появляются саги. До этого порога monolith проще и надёжнее.

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

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

  1. Неверная граница BC — две операции, требующие immediate consistency, скорее всего относятся к одному Bounded Context. Объединить в один сервис.
  2. Реальная eventual consistency — реально immediate не нужна, нужна быстрая (<1s) EC + декларация в API. См. Eventual consistency.
  3. Read-your-writes — клиенту нужно сразу увидеть свой результат, но не нужна атомарность с другим сервисом. Решается специальным endpoint-ом из write-side.

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

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
JTA / XA / 2PC для cross-serviceR-DIST-TX-X1saga с локальными транзакциями
JtaTransactionManager в @ConfigurationR-DIST-TX-X1стандартный JpaTransactionManager
XADataSource для PG + KafkaR-DIST-TX-X1outbox
ChainedTransactionManager multi-DSR-DIST-TX-X3один PG (modular monolith) или saga
@Transactional поверх HTTP-вызова другого сервисаR-DIST-TX-X1outbox + saga
«Distributed transaction через Try-Cancel-Confirm руками»R-DIST-TX-X1стандартизованная saga

Куда дальше

  • Distributed Patterns → раздел 7. Distributed transactions — нормативные формулировки.
  • Saga — главная альтернатива 2PC.
  • Outbox + Inbox — атомарный «commit + publish» через локальную транзакцию.
  • Eventual consistency — что делать с требованием «immediate».
  • Когда нужны распределённые паттерны — modular monolith как альтернатива.
  • Архитектурный выбор: монолит или микросервисы — критерии разделения.