Распределённые транзакции в 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:
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.