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¶
- Клиент вызывает метод сервиса, а на самом деле его прокси.
- Контейнер через интерцептор (AOP) видит аннотацию
@Transactionalи смотрит атрибут, по умолчаниюREQUIRED:- активной транзакции нет - создаёт новую в
TransactionManager - транзакция уже открыта, например сервис вызван из другого
@Transactional, - входит в неё, новую не создаёт
- активной транзакции нет - создаёт новую в
- Внутри метода код впервые трогает
EntityManagerилиDataSource. Контейнер берёт подключение из пула и привязывает его к текущей транзакции. - Весь дальнейший код в этом потоке видит ту же транзакцию: тот же
EntityManager, другие DAO, вложенные сервисы. - Метод завершился без исключения - интерцептор сообщает
TransactionManager, что всё в порядке:- транзакцию открыл этот метод (корневая) - commit
- метод вошёл в чужую транзакцию - коммита нет, он будет при выходе из внешнего метода
- Из метода вылетело 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, хотя бы закоммитить его в правильный момент.
- На commit TM делает
prepare(xid)только XA-участникам, например БД. - Все ответили OK - TM коммитит не-XA ресурс одной фазой.
- Получилось - 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();
}
}
}