@Transactional встречается в каждом сервисе, и почти каждый разработчик хоть раз ловил «почему не откатилось». На собесе разбирают механику прокси, распространение, правила отката и изоляцию.
Как это работает
@Transactional обрабатывает прокси (см. модуль про AOP). При вызове метода через прокси TransactionInterceptor:
- спрашивает
PlatformTransactionManager(для JPA этоJpaTransactionManager) о транзакции с нужными атрибутами; - тот берёт соединение из пула, выключает autocommit и привязывает соединение и EntityManager к текущему потоку (
TransactionSynchronizationManager, внутриThreadLocal); - вызывается метод; репозитории находят соединение через поток;
- при нормальном выходе commit, при подходящем исключении rollback.
Следствия: транзакция привязана к потоку, поэтому работа в другом потоке (@Async, CompletableFuture, параллельный стрим) идёт вне неё. Вызов через this, private-метод, final-метод не проходят через прокси.
Правила отката
По умолчанию откат при unchecked-исключениях (RuntimeException) и Error. На checked-исключениях (IOException, своё Exception) транзакция коммитится. Настраивается: @Transactional(rollbackFor = Exception.class) или noRollbackFor.
Если исключение поймали внутри метода и не пробросили, прокси о нём не знает и закоммитит.
Распространение (propagation)
| Значение | Поведение |
|---|---|
REQUIRED (по умолчанию) | присоединиться к текущей, иначе создать новую |
REQUIRES_NEW | приостановить текущую и открыть новую, отдельный коммит |
NESTED | savepoint внутри текущей: откат вложенной части не откатывает внешнюю (с JPA через JDBC savepoint, объекты в памяти не откатываются) |
SUPPORTS | в транзакции, если она есть, иначе без |
NOT_SUPPORTED | приостановить текущую и выполнить без транзакции |
MANDATORY | требует существующую, иначе исключение |
NEVER | исключение, если транзакция есть |
Ловушка REQUIRED: внутренний метод бросил RuntimeException, внешний его поймал и продолжил. Внутренний прокси уже пометил общую транзакцию как rollback-only, и при коммите внешнего получаем UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only.
REQUIRES_NEW нужен, например, для записи аудита или лога ошибок, которые должны сохраниться даже при откате основной транзакции. Он берёт второе соединение из пула, пока первое ждёт: при маленьком пуле и нагрузке это путь к дедлоку пула. И он, как и всё остальное, не работает при вызове через this.
readOnly
@Transactional(readOnly = true) подсказывает: Hibernate не делает dirty checking и flush (меньше работы и памяти), драйвер может пометить соединение только для чтения, маршрутизация может отправить запрос на реплику. Это не запрет записи на уровне Spring: просто изменения не будут сброшены автоматически.
Изоляция и таймаут
isolation = Isolation.REPEATABLE_READ меняет уровень изоляции для этой транзакции (по умолчанию уровень базы, в PostgreSQL READ COMMITTED). timeout в секундах ограничивает выполнение.
Где ставить @Transactional
- На методах сервисного слоя, где начинается бизнес-операция. Не в контроллерах и не на каждом методе репозитория.
- Транзакция должна быть короткой: никаких HTTP-вызовов, отправки писем и ожиданий внутри. Соединение и блокировки строк держатся до конца.
- Действия «после успешного коммита» (письмо, событие в Kafka) делают через
@TransactionalEventListener(phase = AFTER_COMMIT)или паттерн outbox.
Программный вариант: TransactionTemplate.execute(status -> ...), когда нужна транзакция на часть метода или нет прокси.
Частые ошибки
- Вызов
@Transactional-метода из того же класса. - Ожидание отката на checked-исключении.
- Проглоченное исключение внутри метода.
REQUIRES_NEWчерезthisи ожидание отдельной транзакции.- Долгий внешний вызов внутри транзакции.
Что спросят на собесе
- Как Spring реализует
@Transactional? Где хранится текущая транзакция? - При каких исключениях происходит откат по умолчанию?
- Чем
REQUIREDотличается отREQUIRES_NEWиNESTED? - Что такое
UnexpectedRollbackExceptionи откуда он берётся? - Что даёт
readOnly = true?