Перейти к содержанию

Распределённые транзакции в Spring

См. Распределённые транзакции.

Какие бывают подходы

Подход Что это Когда
A. Локальная транзакция @Transactional + одна БД Всегда сначала пытайся свести use case к этому
B. XA / JTA / 2PC Атомарный commit нескольких XA-ресурсов Две XA-БД или БД + JMS в одном контролируемом окружении
C. Best effort 1PC Две локальные транзакции подряд Только если бизнес готов к рассогласованию и есть сверка или компенсация
D. Outbox / Inbox Данные и событие в одной локальной транзакции, публикация отдельно Самый практичный вариант для микросервисов: надёжная eventual consistency
E. Saga / Process Manager Шаги с компенсациями Операция реально распределена между сервисами, XA невозможен или дорог
F. TCC Try-Confirm-Cancel, резервирование ресурсов Финансовые и ресурсные сценарии

Итого: главный вопрос не «знаю ли я XA», а «умею ли я выбрать между XA, Outbox и Saga».


XA в Spring

Как XA и 2PC работают на уровне JTA, какие у 2PC проблемы и когда XA оправдан - в XA и 2PC в JTA.

JtaTransactionManager - менеджер Spring поверх JTA. Модель контекста в потоке остаётся, но координатором становится глобальный менеджер, а XA-ресурсы подключаются к общей транзакции. Выбор TM - это не просто зависимость, а платформа обработки сбоев и наблюдаемости глобальных транзакций.

Note

Spring Boot 3 убрал встроенную поддержку Atomikos и Bitronix. Atomikos подключают через собственный стартер com.atomikos:transactions-spring-boot3-starter, Narayana - через dev.snowdrop:narayana-spring-boot-starter. Bitronix заброшен, для новых проектов не подходит. В исходной заметке был артефакт org.springframework.boot:spring-boot-starter-jta-narayana - в Boot 3 его нет.

Atomikos - конфигурация вручную:

@Configuration
public class XAConfig {

    @Bean
    public UserTransactionManager atomikosTransactionManager() {
        UserTransactionManager utm = new UserTransactionManager();
        utm.setForceShutdown(false);
        return utm;
    }

    @Bean
    public UserTransactionImp userTransaction() throws SystemException {
        UserTransactionImp ut = new UserTransactionImp();
        ut.setTransactionTimeout(300);
        return ut;
    }

    @Bean
    public JtaTransactionManager transactionManager(UserTransactionManager atm, UserTransaction ut) {
        return new JtaTransactionManager(ut, atm);
    }

    @Bean
    public DataSource orderDataSource() {
        AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
        ds.setUniqueResourceName("orderDB");
        ds.setXaDataSourceClassName("org.postgresql.xa.PGXADataSource");
        Properties props = new Properties();
        props.put("serverName", "localhost");
        props.put("databaseName", "orders");
        ds.setXaProperties(props);
        ds.setPoolSize(10);
        return ds;
    }

    @Bean
    public DataSource inventoryDataSource() {
        AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
        ds.setUniqueResourceName("inventoryDB");
        ds.setXaDataSourceClassName("com.mysql.cj.jdbc.MysqlXADataSource");
        Properties props = new Properties();
        props.put("url", "jdbc:mysql://localhost:3306/inventory");
        ds.setXaProperties(props);
        ds.setPoolSize(10);
        return ds;
    }
}

Narayana - transaction manager из мира JBoss / WildFly:

narayana:
  transaction-manager-id: "1"
  default-timeout: 300
  log-dir: ./narayana-logs

XA + JMS - одна транзакция на БД и очередь:

@Bean
public ActiveMQXAConnectionFactory xaConnectionFactory() {
    return new ActiveMQXAConnectionFactory("tcp://localhost:61616");
}

@Bean
public AtomikosConnectionFactoryBean atomikosConnectionFactory() {
    AtomikosConnectionFactoryBean bean = new AtomikosConnectionFactoryBean();
    bean.setUniqueResourceName("jmsXA");
    bean.setXaConnectionFactory(xaConnectionFactory());
    bean.setPoolSize(10);
    return bean;
}

@Service
public class OrderService {
    @Transactional   // JTA-транзакция покрывает и БД, и JMS
    public void placeOrder(Order order) {
        orderRepository.save(order);                    // БД
        jmsTemplate.convertAndSend("orders", order);    // JMS: упадёт отправка - откатится и БД
    }
}

Без 2PC: Saga, Outbox, Event Sourcing, TCC

Saga - последовательность локальных транзакций, у каждой есть компенсирующая транзакция. Нужна, когда XA не подходит: микросервисы, разные БД, облачные managed-сервисы.

Хореография - сервисы обмениваются событиями через брокер:

OrderService     -> событие ORDER_CREATED
PaymentService   -> слушает ORDER_CREATED -> списывает деньги -> PAYMENT_COMPLETED
InventoryService -> слушает PAYMENT_COMPLETED -> резервирует товар -> INVENTORY_RESERVED
ShippingService  -> слушает INVENTORY_RESERVED -> создаёт доставку

Ошибка в InventoryService:
InventoryService -> INVENTORY_FAILED
PaymentService   -> слушает INVENTORY_FAILED -> возврат денег (компенсация)
OrderService     -> слушает PAYMENT_REFUNDED -> отмена заказа (компенсация)
  • Плюсы: слабая связанность, каждый шаг простой
  • Минусы: трудно отследить общий прогресс, возможны циклические зависимости, отладка тяжёлая

Оркестрация - центральный координатор ведёт сагу:

public class OrderSagaOrchestrator {
    public void execute(Order order) {
        SagaExecution saga = SagaExecution.start();
        try {
            saga.step("payment",
                    () -> paymentService.charge(order),        // действие
                    () -> paymentService.refund(order));       // компенсация
            saga.step("inventory",
                    () -> inventoryService.reserve(order),
                    () -> inventoryService.release(order));
            saga.step("shipping",
                    () -> shippingService.schedule(order),
                    () -> shippingService.cancel(order));
        } catch (SagaStepException e) {
            saga.compensate();   // откатывает выполненные шаги в обратном порядке
        }
    }
}
  • Плюсы: последовательность видна, проще тестировать
  • Минусы: оркестратор - центральная точка, связанность выше

Фреймворки: Axon Framework, Eventuate Tram, MicroProfile LRA, Temporal, Apache Camel Saga.


Transactional Outbox - как атомарно обновить БД и отправить событие: событие пишется в таблицу outbox той же БД в той же локальной транзакции, а отдельный процесс публикует его в брокер.

@Transactional
public void placeOrder(Order order) {
    orderRepository.save(order);
    outboxRepository.save(new OutboxEvent("OrderCreated", objectMapper.writeValueAsString(order)));
    // одна локальная транзакция - атомарно
}
// отдельный процесс (Debezium CDC или polling) читает outbox, публикует в Kafka, помечает или удаляет запись

Реализации: Debezium (CDC по WAL или binlog), простой планировщик с polling, Spring Integration.


Event Sourcing + CQRS - храним не текущее состояние, а последовательность событий.

public class Account {
    private List<DomainEvent> changes = new ArrayList<>();

    public void debit(BigDecimal amount) {
        if (balance.compareTo(amount) < 0)
            throw new InsufficientFundsException();
        apply(new MoneyDebited(id, amount, Instant.now()));
    }

    private void apply(DomainEvent event) {
        when(event);          // меняем состояние
        changes.add(event);   // копим незакоммиченные изменения
    }

    private void when(DomainEvent event) {
        if (event instanceof MoneyDebited e)
            this.balance = this.balance.subtract(e.amount());
    }
}
  • Плюсы: полный аудит, воспроизводимость, события - естественная единица согласованности в распределённой системе
  • Сложности: eventual consistency read-моделей, snapshot'ы ради производительности, эволюция схемы событий

TCC (Try-Confirm-Cancel) - двухфазный протокол без координатора уровня БД.

Try:     каждый сервис резервирует ресурс, но не коммитит
Confirm: каждый сервис подтверждает резерв
Cancel:  каждый сервис снимает резерв
// PaymentService
public PaymentReservation tryCharge(Order order) {
    return new PaymentReservation(order.getAmount(), PENDING);   // hold на счёте, без списания
}

public void confirmCharge(PaymentReservation reservation) {
    reservation.setStatus(CONFIRMED);   // фактическое списание
}

public void cancelCharge(PaymentReservation reservation) {
    reservation.setStatus(CANCELLED);   // снять hold
}

Отличие от Saga: после Try ресурсы только зарезервированы, а в саге каждый шаг коммитится полностью. Confirm и Cancel обязаны быть идемпотентными.


Распределённые блокировки

Нужны, когда блокировки строк одной БД недостаточно: несколько экземпляров приложения работают с общим ресурсом.

Redis (Redisson):

RLock lock = redissonClient.getLock("order:" + orderId);
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {   // ждём до 5 с, TTL блокировки 30 с
    try {
        processOrder(orderId);
    } finally {
        lock.unlock();                         // только если блокировку взяли
    }
}

Redlock - алгоритм блокировки на большинстве (N/2+1) из N независимых нод Redis. Мартин Клеппман показал, что он небезопасен при рассинхроне часов и паузах GC. Для критичных данных нужны fencing tokens: монотонный номер блокировки, который проверяет сам ресурс. В Redisson RedissonRedLock помечен deprecated.

ZooKeeper (Curator):

InterProcessMutex lock = new InterProcessMutex(curatorClient, "/locks/order/" + orderId);
if (lock.acquire(5, TimeUnit.SECONDS)) {
    try {
        processOrder(orderId);
    } finally {
        lock.release();
    }
}

ZooKeeper даёт более строгие гарантии, чем Redis: консенсус, эфемерные ноды пропадают вместе с сессией клиента. Но медленнее и требует отдельного кластера.

Через БД:

// ShedLock - задача по расписанию выполняется на одном экземпляре кластера
@SchedulerLock(name = "reportGeneration", lockAtMostFor = "PT30M")
@Scheduled(cron = "0 0 * * * *")
public void generateReport() { ...}

// advisory lock PostgreSQL - лёгкая блокировка по числовому ключу, без блокировки строк
@Query(value = "SELECT pg_try_advisory_lock(:key)", nativeQuery = true)
boolean tryAdvisoryLock(@Param("key") long key);

@Query(value = "SELECT pg_advisory_unlock(:key)", nativeQuery = true)
boolean advisoryUnlock(@Param("key") long key);

Warning

Pessimistic lock не пересекает границу процесса: блокировка строки в БД одного сервиса не защищает данные другого.


Модели согласованности

CAP - при разделении сети (Partition) приходится выбирать между Consistency и Availability.

  • CP: ZooKeeper, etcd, HBase - жертвуют доступностью
  • AP: Cassandra, CouchDB - жертвуют строгой согласованностью

Выбор не бинарный, это спектр.

ACID и BASE:

ACID BASE
Atomicity Basically Available
Consistency Soft state
Isolation Eventually consistent
Durability

BASE - явный компромисс распределённых систем: данные временно расходятся, но приходят к согласованности.

Модели от строгой к слабой: strict → linearizability → sequential → causal → eventual.

Главный вопрос - какой уровень нужен бизнесу: банковскому балансу - linearizability, ленте соцсети хватит eventual. См. Data Consistency.