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

Java Transaction API

JTA (Jakarta Transactions) - API Jakarta EE для управления транзакциями. Транзакцию ведёт контейнер (сервер приложений) через TransactionManager, код только объявляет её границы. - декларативно: @Transactional из jakarta.transaction на методе CDI-бина, в EJB - @TransactionAttribute (см. EJB) - программно: UserTransaction с begin(), commit(), rollback() - транзакция привязана к потоку - одна транзакция может охватить несколько ресурсов, например две БД или БД и JMS (см. Распределённые транзакции)

@Resource
UserTransaction utx;

public void transfer() throws Exception {
    utx.begin();
    try {
        // работа с EntityManager
        utx.commit();
    } catch (Exception e) {
        utx.rollback();
        throw e;
    }
}

Note

Логика ниже одинакова для jakarta.transaction.Transactional и Spring @Transactional: в обоих случаях метод оборачивает прокси или интерцептор (см. Транзакции в Spring).


Что происходит при вызове @Transactional

  1. Клиент вызывает метод сервиса, а на самом деле его прокси.
  2. Контейнер через интерцептор (AOP) видит аннотацию @Transactional и смотрит атрибут, по умолчанию REQUIRED:
    • активной транзакции нет - создаёт новую в TransactionManager
    • транзакция уже открыта, например сервис вызван из другого @Transactional, - входит в неё, новую не создаёт
  3. Внутри метода код впервые трогает EntityManager или DataSource. Контейнер берёт подключение из пула и привязывает его к текущей транзакции.
  4. Весь дальнейший код в этом потоке видит ту же транзакцию: тот же EntityManager, другие DAO, вложенные сервисы.
  5. Метод завершился без исключения - интерцептор сообщает TransactionManager, что всё в порядке:
    • транзакцию открыл этот метод (корневая) - commit
    • метод вошёл в чужую транзакцию - коммита нет, он будет при выходе из внешнего метода
  6. Из метода вылетело unchecked (runtime) исключение - транзакция помечается rollback-only, и откат делает тот, кто её открыл (см. Откат).

Итого: все вызовы DAO и EntityManager внутри метода, включая вложенные сервисы с REQUIRED, идут в одной транзакции.


Атрибуты транзакции

Атрибут задаёт, что делать с транзакцией на входе в метод.

Атрибут Транзакции нет Транзакция есть
REQUIRED (по умолчанию) Создать новую Войти в неё
REQUIRES_NEW Создать новую Приостановить её, создать новую на время метода
MANDATORY Ошибка Войти в неё
SUPPORTS Работать без транзакции Войти в неё
NOT_SUPPORTED Работать без транзакции Приостановить её, работать с БД без транзакции
NEVER Работать без транзакции Ошибка
@ApplicationScoped
public class AuditService {

    @Transactional(Transactional.TxType.REQUIRES_NEW)   // своя транзакция, внешняя приостановлена
    public void log(String event) {
        // запись аудита сохранится, даже если внешняя транзакция откатится
    }
}

Note

В Spring атрибут называется propagation, и у него есть седьмое значение NESTED - вложенная транзакция через savepoint. В JTA его нет (см. Propagation).


Откат

Транзакция не удалась (конфликт блокировок, ошибка в коде) - её нужно откатить.

  • unchecked исключение (RuntimeException, Error) - откат
  • checked исключение - по умолчанию commit, откатывать нужно явно
@Transactional(rollbackOn = Exception.class)   // откат и на checked исключениях
public void importFile(Path file) throws IOException {
    // ...
}
  • dontRollbackOn - обратное: эти исключения транзакцию не откатывают
  • в Spring то же самое задаётся через rollbackFor и noRollbackFor

Rollback-only во вложенном вызове. Внутренний метод с REQUIRED бросил runtime-исключение - вся общая транзакция помечена rollback-only. Даже если внешний метод поймает исключение и завершится нормально, commit не пройдёт: JTA бросит RollbackException, Spring - UnexpectedRollbackException.


Где ставить @Transactional

На классе или на методе: - на классе - правило действует на все публичные методы класса - на методе - только на этот метод и перекрывает правило класса

@ApplicationScoped
@Transactional   // все методы класса - в транзакции REQUIRED
public class VehicleService {

    @Inject VehicleDao vehicleDao;
    @Inject AuditDao auditDao;

    public void createVehicle(VehicleDto dto) {
        Vehicle v = VehicleMapper.toEntity(dto, null);
        vehicleDao.save(v);                     // участвует в текущей транзакции
        auditDao.log("created " + v.getId());   // та же транзакция
    }

    @Transactional(Transactional.TxType.REQUIRES_NEW)   // перекрывает правило класса
    public void logSafely(String msg) {
        auditDao.log(msg);   // отдельная транзакция
    }
}

Warning

REQUIRES_NEW на logSafely() сработает, только если его вызывают снаружи, через прокси. Вызов this.logSafely() из createVehicle() пройдёт мимо перехватчика и останется в текущей транзакции. Метод с отдельной транзакцией выносят в другой бин, как AuditService в Атрибутах транзакции.

На каком слое: - Сервис - правильное место. Сервис собирает несколько DAO и интеграций в одну бизнес-операцию: create, update, delete. Это и есть атомарная единица работы, здесь её начинают, коммитят и откатывают - DAO - обычно без @Transactional. DAO выполняет запросы через EntityManager и участвует в транзакции, которую открыл сервис. Транзакции на каждом DAO-методе дробят операцию на куски и ломают атомарность - Контроллер - не ставить. Это слишком высоко: транзакция будет держаться вокруг веб-слоя, сетевых вызовов и сериализации ответа

Когда на DAO допустимо: - маленький pet-проект, где каждый DAO-метод - самостоятельная операция - отдельный метод, который осознанно идёт своей транзакцией, например аудит: REQUIRES_NEW - MANDATORY на DAO, чтобы жёстко требовать: транзакцию уже открыл сервис

См. Какой бин для какого слоя.

EJB. У @Stateless и @Singleton каждый метод по умолчанию идёт в транзакции REQUIRED (CMT), @Transactional не нужен (см. EJB). На CDI-сервисах без EJB транзакции включают @Transactional.

Чтение. В JTA нет стандартного readOnly, как @Transactional(readOnly = true) в Spring. Для чтения можно поставить SUPPORTS: метод войдёт в транзакцию, если она уже есть, и не откроет новую.

Note

Без транзакции persistence context живёт один вызов EntityManager: загруженные сущности сразу становятся detached, и ленивые связи потом не загрузятся (LazyInitializationException). Поэтому чтение с ленивыми связями держат внутри транзакции.

Итого: транзакция - часть бизнес-операции, её держат на сервисе. DAO - тонкий доступ к данным и участвует в транзакции сервиса.


TransactionSynchronizationRegistry

TransactionSynchronizationRegistry (TSR) - Это JTA-сервис, который даёт доступ к текущей транзакции потока и позволяет: 1) регистрировать колбэки на завершение транзакции (Synchronization) 2) хранить данные, привязанные к транзакции: putResource(), getResource() 3) узнавать статус транзакции: getTransactionStatus()

  • работает, только если на потоке есть активная JTA-транзакция. Внутри @Transactional контейнер её уже открыл, и TSR её видит
  • получают внедрением: @Inject TransactionSynchronizationRegistry txRegistry

Synchronization - интерфейс JTA с двумя колбэками, которые TransactionManager вызывает при завершении транзакции: - beforeCompletion() - перед коммитом, транзакция ещё активна. Исключение здесь откатывает транзакцию - afterCompletion(int status) - после завершения, status равен Status.STATUS_COMMITTED или Status.STATUS_ROLLEDBACK. На исход уже не влияет, исключения отсюда TM игнорирует, поэтому код оборачивают в try/catch

registerInterposedSynchronization(sync) - подписаться на события текущей транзакции. Контейнер запоминает объект Synchronization и вызовет его методы при завершении транзакции. - beforeCompletion() interposed-колбэка вызывается после обычных Synchronization (тех, что зарегистрированы через Transaction.registerSynchronization()), то есть ближе всего к коммиту ресурсов - afterCompletion() - наоборот, раньше обычных - поэтому логика в beforeCompletion() успевает повлиять на исход: если там упасть, коммит БД не произойдёт

Note

По спецификации из interposed beforeCompletion() можно работать с ресурсами (JDBC, коннекторы), но не с пользовательскими компонентами: методами бинов, таймерами. Interposed-синхронизации задуманы для системного кода, например JPA-провайдера. Вызов бина minioStorage в примере ниже на практике работает, но выходит за контракт.

См. Best-effort 2PC. В Spring тот же приём - TransactionSynchronization (см. Программное управление).


Блокировки

Когда транзакции параллельно работают с одним ресурсом, например строкой в БД, нужна стратегия конкурентного доступа. Их две: - Pessimistic - конфликт вероятен, поэтому данные блокируются сразу при чтении, остальные ждут - Optimistic - конфликт редок, поэтому ничего не блокируется, а конфликт проверяется при сохранении

См. MVCC в Postgres.


Pessimistic locking

Pessimistic locking - Это блокировка строк при чтении до конца транзакции. Исходим из того, что два пользователя с высокой вероятностью изменят одни и те же данные, поэтому блокируем сразу. - блокировка ставится при чтении с LockModeType.PESSIMISTIC_WRITE: база загружает строки и блокирует их. В Postgres это SELECT ... FOR UPDATE - снимается при commit или rollback - другая транзакция, которая хочет изменить эти строки или тоже взять блокировку, ждёт - обычное чтение без блокировки не ждёт: в Postgres его обслуживает MVCC - LockModeType.PESSIMISTIC_READ - разделяемая блокировка, в Postgres SELECT ... FOR SHARE: другие тоже могут читать с такой блокировкой, но не менять строку - подходит, когда изменение параллельно недопустимо, например банковские счета: защищает от потерянного обновления

@Transactional
public void transferMoney(Account from, Account to, BigDecimal amount) {
    // счета загружаются с блокировкой на запись
    Account accountFrom = entityManager.find(Account.class, from.getId(), LockModeType.PESSIMISTIC_WRITE);
    Account accountTo = entityManager.find(Account.class, to.getId(), LockModeType.PESSIMISTIC_WRITE);

    if (accountFrom.getBalance().compareTo(amount) >= 0) {
        accountFrom.setBalance(accountFrom.getBalance().subtract(amount));
        accountTo.setBalance(accountTo.getBalance().add(amount));
    } else {
        throw new InsufficientFundsException("Недостаточно средств.");
    }
}

Сценарий: 1) Транзакция A: пользователь A переводит деньги. Загружает оба счёта с PESSIMISTIC_WRITE, считает баланс, меняет его. Строки заблокированы до конца A. 2) Транзакция B: пользователь B переводит с того же счёта. Его find с блокировкой ждёт. 3) A делает commit, блокировка снимается. 4) B получает строку уже с новым балансом и продолжает. Транзакции выполняются по очереди.

Дедлок. Перевод A → B и одновременный B → A блокируют счета в разном порядке и ждут друг друга. Postgres находит дедлок и откатывает одну из транзакций. Защита - брать блокировки в одном порядке, например по возрастанию id.

Таймаут ожидания - подсказка jakarta.persistence.lock.timeout в миллисекундах. Без неё поток ждёт, пока блокировку не отпустят (см. Pessimistic locking в Spring).

Плюсы: - гарантирует, что данные не изменят параллельно - полезно при высокой вероятности конфликтов, например на балансах

Минусы: - дедлоки, если транзакции блокируют друг друга - при высокой конкуренции за одни и те же строки транзакции стоят в очереди, и система сильно замедляется

Итого: конфликт вероятен и дорог - блокируй при чтении, коротко и в одном порядке.


Optimistic locking

Optimistic locking - Это проверка конфликта при сохранении вместо блокировки. Исходим из того, что параллельных изменений мало, и даём транзакциям работать параллельно. - при чтении ничего не блокируется - у сущности есть версия (@Version, число или временная метка), при каждом обновлении она растёт - при сохранении JPA-провайдер (Hibernate) проверяет, что версия в БД не изменилась с момента чтения - проверка идёт при flush, обычно перед commit - версия изменилась - OptimisticLockException, транзакция откатывается

@Entity
public class Account {
    @Id
    private Long id;

    @Version
    private Long version;   // столбец версии, Hibernate увеличивает сам

    private BigDecimal balance;
}

@Transactional
public void transferMoney(Account from, Account to, BigDecimal amount) {
    Account accountFrom = entityManager.find(Account.class, from.getId());
    Account accountTo = entityManager.find(Account.class, to.getId());

    if (accountFrom.getBalance().compareTo(amount) >= 0) {
        accountFrom.setBalance(accountFrom.getBalance().subtract(amount));
        accountTo.setBalance(accountTo.getBalance().add(amount));
        // при flush Hibernate проверит версию
    } else {
        throw new InsufficientFundsException("Недостаточно средств.");
    }
}
-- что Hibernate отправляет при сохранении
UPDATE account
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
-- обновлено 0 строк -> версию кто-то изменил -> OptimisticLockException

Сценарий: 1) Транзакция A: пользователь A читает счёт, версия 1. 2) Транзакция B: пользователь B читает тот же счёт, тоже версия 1. 3) B меняет баланс и коммитит первой. Версия в БД стала 2. 4) A сохраняет изменения с условием version = 1, ни одна строка не обновилась - OptimisticLockException. 5) A откатывается. Её нужно повторить (retry): она перечитает счёт с версией 2 и новым балансом.

См. Retry при конфликтах и Optimistic locking в Spring.

Плюсы: - лучшая производительность, когда конфликты редки: блокировок на данные нет - подходит для систем с высокой конкуренцией, где одни и те же данные меняют редко - подходит для web и коротких запросов: блокировку БД нельзя держать между HTTP-запросами, например пока пользователь редактирует форму. Поэтому в микросервисах и web-формах это вариант по умолчанию

Минусы: - при конфликте транзакцию нужно повторять - на горячих строках, которые меняют постоянно (популярный счёт, общий счётчик), конфликтов много, и повторы съедают выигрыш. Там лучше pessimistic

Note

В исходной версии был минус «не подходит для баланса счёта, где важен порядок операций». Для баланса optimistic корректен: потерянного обновления не будет, проигравшая транзакция перечитает новый баланс. Проблема только в частоте конфликтов.

Итого: конфликт редок - не блокируй, проверяй версию при сохранении и повторяй при конфликте.


Pessimistic или optimistic

Pessimistic Optimistic
Когда блокирует При чтении, до конца транзакции Никогда, проверяет версию при записи
Что при конфликте Вторая транзакция ждёт Вторая транзакция падает с OptimisticLockException
Цена Ожидание, дедлоки, очередь Откаты и повторы
Когда брать Конфликты частые и дорогие: деньги, склад, слоты Конфликты редкие, чтение чаще записи, долгое редактирование в формах

Как выбрать: - конфликт редкий и важен throughput - optimistic - конфликт частый и ошибка недопустима - pessimistic - не знаешь - начинай с optimistic + retry

Под высокой конкуренцией часть ошибок нормальна: жертва дедлока, конфликт версий, ошибка сериализации на SERIALIZABLE. Обычно правильнее короткие транзакции + retry, чем ещё более тяжёлые блокировки.

Сценарий Обычно
Деньги Локальная транзакция, строгие инварианты, иногда pessimistic lock или SERIALIZABLE, обязательно idempotency key на внешних командах. Распределённая часть - Saga или Outbox, а не XA
Склад, места, слоты Pessimistic lock на короткий резерв или атомарный UPDATE с условием и версией, резерв с таймаутом, retry при конфликте
Счётчики и агрегаты Optimistic lock или атомарный UPDATE ... SET x = x + 1, аккуратный retry

Выбор зависит от характера конфликтов, а не от вкуса команды.


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

Одна JTA-транзакция может охватить несколько ресурсов. Если все они умеют XA, это настоящий 2PC. Если какой-то ресурс XA не умеет (файловое хранилище, HTTP-сервис), остаются компромиссы: Last Resource Commit, best-effort 2PC, Saga, Outbox, TCC. Общая теория - в Распределённых транзакциях.


XA и 2PC в JTA

XA - Это спецификация интерфейсов, через которые ресурс участвует в распределённой транзакции под управлением Transaction Manager (JTA). XA-совместимый ресурс - тот, у которого есть драйвер или адаптер (реализует XAResource), умеющий: - присоединиться к глобальной транзакции по её идентификатору XID - выполнить prepare: «я готов коммитить» - затем выполнить commit или rollback

Примеры XA-ресурсов: БД через XADataSource, JMS через XAConnectionFactory, иногда другие системы через JCA.

XA как стандарт X/Open - интерфейс между Transaction Manager (TM) и Resource Manager (RM):

Application Program (AP)
    ↕
Transaction Manager (TM)         - координатор: Narayana, Atomikos, TM сервера приложений
    ↕                ↕
Resource Manager 1   Resource Manager 2   - участники: PostgreSQL, MySQL, JMS
  (XAResource)         (XAResource)
  • TM - координатор: begin, prepare, commit, rollback
  • RM - система хранения или очередь, которая умеет XA
  • XAResource - интерфейс между TM и RM: подключение ресурса к транзакции (enlist), isSameRM для группировки
  • глобальная транзакция и ветви - у одной глобальной транзакции своя ветвь на каждый RM

Java-интерфейсы: - javax.transaction.xa.XAResource - контракт TM и RM, часть JDK - jakarta.transaction.UserTransaction - API для приложения - jakarta.transaction.TransactionManager - внутренний API TM - javax.sql.XADataSource - XA-совместимый DataSource

XA-2PC - двухфазный коммит, который TM проводит по протоколу XA.

Фаза 0 - работа внутри транзакции: 1) Приложение начинает транзакцию: UserTransaction.begin() или @Transactional. 2) Выполняет операции: INSERT и UPDATE в БД, отправку сообщения в JMS. 3) Когда код впервые трогает ресурс, TM получает его XAResource из пула и вызывает xa.start(xid) - присоединить ресурс к транзакции XID. 4) По окончании работы - xa.end(xid): ресурс закончил работу в транзакции.

До этого момента ничего гарантированно не закоммичено, всё «внутри транзакции».

Фаза 1 - PREPARE (голосование). Приложение просит commit (выход из @Transactional без ошибки), и TM вызывает xa.prepare(xid) у каждого ресурса. Ответы: - XA_OK - готов коммитить, изменения записаны в журнал ресурса - XA_RDONLY - ничего не менял, коммитить не нужно - исключение XAException - не готов, нужен откат

Хотя бы один не готов - TM откатывает всех. Смысл prepare: после него ресурс обязан суметь завершить транзакцию позже, даже если приложение или сервер упадёт.

Фаза 2 - COMMIT (решение). Все подготовились: TM записывает решение COMMIT в свой журнал и вызывает xa.commit(xid, false) у каждого подготовленного ресурса (false - не однофазный коммит). Решение откатить - xa.rollback(xid). Точка невозврата - запись COMMIT в журнал TM: даже если TM упадёт, при восстановлении он прочитает журнал и повторит COMMIT.

TM                     DB (XAResource)        JMS (XAResource)
begin
  ── start(xid) ──────►
  ── start(xid) ─────────────────────────────►
     ...работа...
  ── end(xid) ────────►
  ── end(xid) ───────────────────────────────►
  ── prepare(xid) ────►  XA_OK
  ── prepare(xid) ───────────────────────────►  XA_OK
  запись COMMIT в журнал TM
  ── commit(xid) ─────►
  ── commit(xid) ────────────────────────────►

Что происходит при падениях: - Приложение или сервер упал между prepare и commit. Классическая проблема 2PC: ресурсы подготовились и висят в ожидании решения. Поэтому TM ведёт журнал транзакций. После рестарта TM делает recovery: спрашивает у ресурсов подготовленные XID (xa.recover()) и доводит commit или rollback до конца. Это и есть настоящая надёжность XA - Ресурс упал на commit. TM всё равно знает решение и докоммитит, когда ресурс восстановится

Проблемы 2PC:

Проблема Что Последствия
Blocking TM упал после PREPARE, но до COMMIT - участники держат блокировки до восстановления TM Ресурсы недоступны, каскад таймаутов
Single point of failure TM - единая точка отказа Потеря журнала TM - неопределённое состояние
Latency Два синхронных раунда + fsync журналов у TM и каждого RM На порядок медленнее локальной транзакции
In-doubt transactions Участник в PREPARED не знает итога Ручное вмешательство или heuristic recovery
Heuristic exceptions RM решает commit или rollback сам, без TM HeuristicMixedException, данные могут разойтись

Оптимизации: - Read-only - RM, который ничего не писал, отвечает XA_RDONLY на prepare и выходит из протокола - One-phase commit - участник один: TM пропускает prepare и сразу коммитит, xa.commit(xid, true) - Last Resource Commit - один не-XA ресурс коммитится одной фазой в момент решения (см. ниже)

XA оправдан, когда одновременно: нужна строгая атомарность между несколькими ресурсами, ресурсы умеют XA, потеря latency и throughput допустима, эксплуатация готова к такой сложности. Типичный пример - legacy-монолит или интеграционная система в одном контролируемом окружении.

XA почти всегда плохая идея: микросервисы через HTTP или gRPC, облачные managed-сервисы без полной поддержки XA, Kafka, RabbitMQ, NoSQL, REST-побочные эффекты, высокая нагрузка и низкие задержки, долгие процессы с участием людей. 2PC координирует ресурсы, а не бизнес-процессы: долгие процессы, внешние API и сервисы без XA он не покрывает.

См. 2PC / XA и XA в Spring.


Last Resource Commit

LRCO (Last Resource Commit Optimization, Last Participant Support) - Это компромисс, когда кроме XA-ресурсов есть один не-XA ресурс, и TM разрешает коммитить его последним. Идея: раз ресурс не умеет prepare, хотя бы закоммитить его в правильный момент.

  1. На commit TM делает prepare(xid) только XA-участникам, например БД.
  2. Все ответили OK - TM коммитит не-XA ресурс одной фазой.
  3. Получилось - TM коммитит XA-ресурсы. Не получилось - откатывает их.
  • у last resource нет prepare: если после его коммита упал процесс или TM, а XA-commit не завершился, получится «внешний эффект есть, а БД нет» (или наоборот, зависит от порядка в реализации)
  • лучше, чем вручную два коммита подряд, но надёжности XA-2PC не даёт
  • зависит от платформы и TM, не всегда включено и рекомендовано. Применяют, чтобы поддержать «БД без XA + JMS XA» или наоборот
  • для MinIO почти не применяют: это HTTP-сервис, а не ресурс типа JDBC или JMS

Best-effort 2PC через Synchronization

Best-effort 2PC - Это координация БД с внешним ресурсом без XA: главная транзакция идёт в БД (JTA), а внешний ресурс подключается хуком к жизненному циклу транзакции через TSR. Это не XA и не настоящий 2PC на уровне TM. Best-effort - потому что у внешнего ресурса нет протокола prepare и rollback. - beforeCompletion() - попытка сделать внешний «коммит» - afterCompletion() - уборка или компенсация по результату - типичный случай - файловое хранилище MinIO или S3: это не XA-ресурс, настоящий XA-2PC с ним невозможен

Как работает (схема «БД → MinIO на коммите»): 1) В @Transactional-методе вставляешь vehicles в БД и регистрируешь Synchronization. 2) Контейнер готовится коммитить БД и вызывает beforeCompletion(). 3) В beforeCompletion() - minio.putObject(finalKey, bytes). MinIO упал - бросаешь RuntimeException, TM помечает транзакцию на откат, БД не коммитится. 4) afterCompletion(status): - STATUS_COMMITTED - лог успеха - откат - removeObjectQuietly(finalKey) на всякий случай и лог неудачи

@Inject
TransactionSynchronizationRegistry txRegistry;

@Transactional
public void importVehiclesTx(Long opId, List<VehicleImportItemDto> items, byte[] fileBytes,
                             String finalKey, String safeName, String contentType, long size) {

    final AtomicInteger importedCount = new AtomicInteger(0);

    txRegistry.registerInterposedSynchronization(new Synchronization() {
        @Override
        public void beforeCompletion() {
            // 2) перед коммитом БД - загрузить файл в MinIO, исключение откатит БД
            minioStorage.putObject(finalKey, fileBytes, contentType, Map.of("original-name", safeName));
        }

        @Override
        public void afterCompletion(int status) {
            try {
                if (status == Status.STATUS_COMMITTED) {
                    opLogger.markSuccess(opId, importedCount.get(), finalKey, safeName, contentType, size);
                    wsHub.broadcastText("refresh");
                    return;
                }
                // откат - убрать файл и записать неудачу без ключа файла
                minioStorage.removeObjectQuietly(finalKey);
                opLogger.markFailure(opId, importedCount.get(), null, safeName, contentType, size);
            } catch (Throwable ignored) {
                // afterCompletion не должен бросать
            }
        }
    });

    // 1) запись в БД
    for (VehicleImportItemDto item : items) {
        VehicleDto dto = VehicleImportItemDto.toEntity(item);
        vehicleService.checkUniqueVehicleName(dto.getName(), null);
        Coordinates coords = vehicleService.resolveCoordinatesForDto(dto);
        Vehicle v = VehicleDto.toEntity(dto, null);
        v.setCoordinates(coords);
        vehicleDao.save(v);
        importedCount.incrementAndGet();
    }
}

Правила, без которых схема не работает: - объект в MinIO писать только в beforeCompletion(). Тогда RuntimeException в бизнес-логике до коммита откатит БД, а MinIO останется нетронутым - при любом неуспехе в лог операции пишется fileObjectKey = null - afterCompletion() целиком в try/catch и не бросает: данные уже согласованы, а лог и WebSocket - best-effort. Исключение после коммита не должно ломать поток обработки - лог с REQUIRES_NEW: если упала сама БД, запись в лог тоже не пройдёт

Зачем AtomicInteger. importedCount нужен в двух местах: в цикле импорта (растёт после каждого vehicleDao.save) и в afterCompletion() (сколько успело обработаться к завершению транзакции). Synchronization здесь - анонимный класс, он захватывает локальные переменные метода, а по правилам Java захваченные переменные должны быть final или effectively final. Нужен контейнер: ссылка на него final, а значение внутри меняется. AtomicInteger здесь не для потокобезопасности, а удобная «коробка с числом». Альтернативы - final int[] {0} или свой holder-класс.

Гарантии: - MinIO недоступен в момент коммита - БД не закоммитится. Это сильное свойство - RuntimeException в бизнес-логике до коммита - откат БД, MinIO не тронут - откат БД по любой причине - файл удаляется в afterCompletion() - параллельные запросы изолированы: у каждого своя JTA-транзакция

Где ломается. У MinIO нет prepare, поэтому возможны крайние случаи: - MinIO принял объект в beforeCompletion(), а потом процесс умер до реального коммита БД, или сеть подвела на плохом тайминге. БД не закоммитилась, в MinIO остался лишний файл - afterCompletion() с откатом пытается убрать файл, но если приложение умерло раньше, уборка не выполнится - JPA-провайдер делает flush в своём beforeCompletion(), и порядок относительно вашего не гарантирован. Если flush упадёт уже после загрузки файла, будет откат, и файл уберёт afterCompletion()

В XA такие случаи закрывают журнал TM и recovery. В best-effort добавляют фоновую сверку (reconciliation job), которая находит файлы без записей в БД.

Где применяют: учебный 2PC и best-effort координация с внешним HTTP-ресурсом. В проде чаще уходят в Outbox или Saga.


Параллельный импорт: что происходит при сбоях

Ситуация Что будет Как обрабатывается
Два импорта одновременно пересекаются по уникальности, связям или read/write-зависимостям под SERIALIZABLE PostgreSQL отвечает could not serialize access ... The transaction might succeed if retried Транзакция откатывается целиком, afterCompletion() удаляет файл, в лог - ошибка и fileObjectKey = null. Согласованность БД и MinIO сохраняется. Часть запросов будет падать, поэтому нужен retry
Гонка на уникальном поле: два файла с одинаковым vehicle.name Срабатывает проверка checkUniqueVehicleName() или unique constraint в БД Исключение внутри транзакции - откат, файл удаляется, операция в истории - ОШИБКА без файла
MinIO недоступен или перегружен в момент коммита putObject() в beforeCompletion() бросает исключение, TM не коммитит БД БД откатывается, afterCompletion() пытается удалить файл, если он успел появиться, в лог - неудача и key = null. Это ключевое требование: «упал MinIO - БД откатывается»
БД закоммитилась, а потом упал WebSocket или логгер Данные уже согласованы afterCompletion() в try/catch и не бросает, побочные эффекты - best-effort
Много импортов одновременно пишут в таблицу истории импорта У каждой операции свой opId и UUID, обновления идут по своему ключу Конфликтов почти нет, только общая нагрузка на БД. opLogger.createStarted() вызывается вне importVehiclesTx, это отдельная операция в БД

Как связать БД с ресурсом без XA

Подход Как Плюсы Минусы С MinIO
XA-2PC Все ресурсы XA, TM делает prepare и commit. Примеры: БД + JMS, БД + БД Сильная атомарность, переживает многие сбои благодаря журналу TM Нужны XA-драйверы, сложнее настройка и администрирование, медленнее: лишние сетевые раунды, журналы, блокировки Нет: MinIO не XA
LRCO XA-ресурсы проходят prepare, не-XA коммитится последним Иногда удобно Не надёжно при падении между шагами, зависит от TM Практически нет
Best-effort 2PC Внешний ресурс в beforeCompletion(), уборка в afterCompletion() «Всё или ничего» при обычных сбоях, отказ ресурса откатывает БД Нет prepare у ресурса: редкие рассинхроны при падении процесса Да
Saga Каждый шаг коммитится локально, при ошибке выполняются компенсации Работает с любыми ресурсами Нет мгновенной атомарности, сложнее Да
Outbox / Inbox В транзакции БД сохраняются данные и запись «надо сделать во внешнем мире», воркер выполняет её с ретраями и идемпотентностью Самый популярный прод-подход без XA: очень надёжно при падениях, хорошо масштабируется Eventual consistency, а не мгновенная атомарность Да
TCC Ресурс умеет try (резерв), потом confirm или cancel Мощно Ресурс должен уметь резервировать Нет: MinIO не умеет «зарезервировать объект»

Note

Лаба с MinIO. В задании: «сохранение файлов должно быть реализовано транзакционно по отношению к вставке объектов в БД» и «2PC рекомендуется на уровне бизнес-логики». На защите ждут демонстрацию отказов, где при падении MinIO БД откатывается. Поэтому основное решение - best-effort 2PC, а не Outbox: - в Outbox нет атомарности «vehicles + файл»: сначала БД, потом, возможно позже, MinIO. Это eventual consistency - при падении MinIO в Outbox БД уже закоммичена. Объяснять, что «так в проде лучше», - риск не совпасть с критерием «транзакционно» - best-effort прямо показывает две фазы, выполняет «отказ MinIO - БД не коммитится» и «RuntimeException - откат, файла нет», легко демонстрируется в JMeter на параллельных транзакциях

Как упомянуть Outbox на защите: «Для MinIO нельзя сделать XA, поэтому мы сделали best-effort 2PC через beforeCompletion. В production часто используют Outbox или Saga для надёжности и ретраев, но это eventual consistency и не соответствует требованию "транзакционно прямо при импорте"». Если преподаватель скажет «не обязательно атомарно, главное не терять и надёжно» - тогда Outbox лучший выбор.


Retry при конфликтах

Под SERIALIZABLE и при дедлоках часть транзакций падает - это нормальное поведение, их нужно повторить. - повторять только конфликты БД: SQLSTATE 40001 (serialization_failure) и 40P01 (deadlock_detected). Отказ MinIO или ошибка валидации повтором не лечатся - в чистом Jakarta EE нет стандартной аннотации @Retry, как в Spring Retry. Есть @Retry из MicroProfile Fault Tolerance, если сервер его поддерживает, иначе цикл пишут руками - цикл стоит снаружи транзакции: каждая попытка - новый вызов @Transactional-метода другого бина, то есть новая JTA-транзакция. В Spring так же (см. Порядок @Retryable и @Transactional) - в TomEE, DBCP и JTA исключение БД часто приходит завёрнутым в XAException или SystemException, поэтому SQLSTATE ищут по цепочке причин - пауза между попытками растёт: 100, 200, 400 мс

@ApplicationScoped
public class VehicleImportService {

    private static final int SERIALIZABLE_RETRY_ATTEMPTS = 3;   // сколько раз повторяем

    @Inject
    private VehicleImportOperationLogger opLogger;

    @Inject
    private VehicleImportTxService txService;   // в нём @Transactional importVehiclesTx

    public void importVehicles(List<VehicleImportItemDto> items, byte[] rawFile,
                               String originalFileName, String contentType) {
        // подготовка: opUuid, safeName, ct, size, fileBytes, finalKey = "imports/" + opUuid + ext

        final Long opId = opLogger.createStarted(safeName, ct, size);   // лог «начато» - вне транзакции импорта

        // валидация до транзакции MinIO + БД
        List<VehicleImportErrors.RowError> errors = validateItems(items);
        if (!errors.isEmpty()) {
            opLogger.markFailure(opId, 0, null, safeName, ct, size);
            throw new VehicleValidationException("Validation failed", errors);
        }

        RuntimeException last = null;
        for (int attempt = 1; attempt <= SERIALIZABLE_RETRY_ATTEMPTS; attempt++) {
            try {
                // каждый вызов - новая JTA-транзакция, потому что метод другого бина с @Transactional
                txService.importVehiclesTx(opId, items, fileBytes, finalKey, safeName, ct, size);
                return;   // успех
            } catch (RuntimeException e) {
                last = e;
                if (isRetryableDbConflict(e) && attempt < SERIALIZABLE_RETRY_ATTEMPTS) {
                    sleepBackoff(attempt);
                    continue;
                }
                throw e;   // не повторяемая ошибка (например, упал MinIO) или попытки кончились
            }
        }
        throw last;   // сюда не дойдём
    }

    // SQLSTATE 40001 (serialization_failure) или 40P01 (deadlock_detected) где-то в цепочке причин
    private boolean isRetryableDbConflict(Throwable t) {
        Throwable cur = t;
        while (cur != null) {
            if (cur instanceof PSQLException psql) {
                String state = psql.getSQLState();
                if ("40001".equals(state) || "40P01".equals(state)) {
                    return true;
                }
            }
            cur = cur.getCause();
        }
        String msg = t.getMessage();   // запасной вариант: SQLSTATE потерялся, а текст остался
        return msg != null && msg.contains("could not serialize access");
    }

    private void sleepBackoff(int attempt) {
        long ms = 100L * (1L << (attempt - 1));   // 100, 200, 400 мс
        try {
            Thread.sleep(ms);
        } catch (InterruptedException ie) {
            Thread.currentThread().interrupt();
        }
    }
}