Аннотация @Transactional — один из тех инструментов в Spring, которые начинают использовать почти автоматически. Она выглядит как простое и надёжное решение: обернул метод — и все операции внутри либо выполняются полностью, либо откатываются.
На старте этого обычно достаточно. Но как только код начинает жить в продакшене, появляются странные эффекты: транзакции не откатываются, изменения частично сохраняются, а иногда аннотация будто бы вообще игнорируется.
Проблема здесь не в Spring. Проблема в том, что @Transactional — это конкретный механизм с довольно жёсткими правилами. И если их не учитывать, система начинает вести себя непредсказуемо.
На старте этого обычно достаточно. Но как только код начинает жить в продакшене, появляются странные эффекты: транзакции не откатываются, изменения частично сохраняются, а иногда аннотация будто бы вообще игнорируется.
Проблема здесь не в Spring. Проблема в том, что @Transactional — это конкретный механизм с довольно жёсткими правилами. И если их не учитывать, система начинает вести себя непредсказуемо.
Как Spring управляет транзакциями
Важно понять одну ключевую вещь: Spring не «встраивает» транзакции в метод напрямую. Он делает это через прокси.
Когда ты помечаешь бин аннотацией @Transactional, Spring создаёт обёртку (proxy), которая перехватывает вызовы методов. Именно в этот момент:
• Открывается транзакция перед выполнением метода
• Происходит commit, если всё прошло успешно
• или rollback, если возникла ошибка
Это объясняет поведение, которое на первый взгляд кажется странным.
Например, если метод вызывает другой метод внутри того же класса, прокси в этом не участвует. Вызов происходит напрямую, и никакой транзакции для второго метода не будет, даже если он тоже помечен @Transactional.
Когда ты помечаешь бин аннотацией @Transactional, Spring создаёт обёртку (proxy), которая перехватывает вызовы методов. Именно в этот момент:
• Открывается транзакция перед выполнением метода
• Происходит commit, если всё прошло успешно
• или rollback, если возникла ошибка
Это объясняет поведение, которое на первый взгляд кажется странным.
Например, если метод вызывает другой метод внутри того же класса, прокси в этом не участвует. Вызов происходит напрямую, и никакой транзакции для второго метода не будет, даже если он тоже помечен @Transactional.
Это классическая ловушка, в которую попадают даже опытные разработчики.
Где @Transactional ломается на практике
Есть несколько ситуаций, в которых аннотация просто не будет работать так, как ожидается. Их не так много, но каждая регулярно встречается в реальных проектах:
• Вызов метода внутри того же класса (self-invocation)
• Метод не public
• Бин не управляется Spring
• Транзакция ожидается, но не создана из-за конфигурации прокси
• Исключение не приводит к rollback
• Обработка исключения в try-catch приводит к «тихому» rollback-only состоянию
Последние два пункта особенно коварны.
По умолчанию Spring откатывает транзакцию только при RuntimeException. Если в коде выбрасывается checked exception, транзакция будет закоммичена:
• Вызов метода внутри того же класса (self-invocation)
• Метод не public
• Бин не управляется Spring
• Транзакция ожидается, но не создана из-за конфигурации прокси
• Исключение не приводит к rollback
• Обработка исключения в try-catch приводит к «тихому» rollback-only состоянию
Последние два пункта особенно коварны.
По умолчанию Spring откатывает транзакцию только при RuntimeException. Если в коде выбрасывается checked exception, транзакция будет закоммичена:
Чтобы изменить это поведение, нужно явно указать:
Но даже если ты всё сделал правильно с точки зрения rollback, можно столкнуться с менее очевидной проблемой: rollback-only состоянием транзакции.
Типичный сценарий: один транзакционный метод вызывает другой, внутри которого происходит ошибка. Исключение перехватывается через try-catch, и снаружи кажется, что всё обработано.
Типичный сценарий: один транзакционный метод вызывает другой, внутри которого происходит ошибка. Исключение перехватывается через try-catch, и снаружи кажется, что всё обработано.
Несмотря на то что исключение перехвачено, транзакция уже помечена как rollback-only. И при попытке commit снаружи ты получишь неожиданный UnexpectedRollbackException.
Это один из самых неприятных сценариев, потому что визуально код выглядит корректно, а ошибка проявляется только на этапе завершения транзакции.
Это один из самых неприятных сценариев, потому что визуально код выглядит корректно, а ошибка проявляется только на этапе завершения транзакции.
Когда транзакции начинают конфликтовать
До определённого момента кажется, что транзакции — это просто защита от ошибок. Но как только появляются параллельные запросы, становится важен ещё один аспект — изоляция.
Представим простой сценарий: списание денег с баланса.
Представим простой сценарий: списание денег с баланса.
Если два запроса выполняются одновременно, оба могут прочитать один и тот же баланс и оба успешно его изменить. В итоге деньги спишутся дважды.
На уровне кода всё выглядит корректно. Проблема возникает из-за того, как транзакции видят данные друг друга.
Здесь уже приходится думать не только про @Transactional, но и про:
• Уровень изоляции
• Блокировки (например, SELECT FOR UPDATE)
• или optimistic locking
И это как раз тот момент, когда «просто аннотации» перестаёт хватать.
На уровне кода всё выглядит корректно. Проблема возникает из-за того, как транзакции видят данные друг друга.
Здесь уже приходится думать не только про @Transactional, но и про:
• Уровень изоляции
• Блокировки (например, SELECT FOR UPDATE)
• или optimistic locking
И это как раз тот момент, когда «просто аннотации» перестаёт хватать.
Взаимодействие транзакций между собой
Ещё один слой сложности появляется, когда один транзакционный метод вызывает другой.
По умолчанию используется стратегия REQUIRED: если транзакция уже есть, то она продолжается, если нет — создаётся новая.
Но иногда это поведение не подходит.
Хороший пример: логирование ошибок.
По умолчанию используется стратегия REQUIRED: если транзакция уже есть, то она продолжается, если нет — создаётся новая.
Но иногда это поведение не подходит.
Хороший пример: логирование ошибок.
Если saveOrder() падает, вся транзакция откатывается. И вместе с ней откатывается и logError(), потому что он выполняется в той же транзакции.
В итоге ошибка была, но в базе её следа нет.
Решение: запустить логирование в отдельной транзакции:
В итоге ошибка была, но в базе её следа нет.
Решение: запустить логирование в отдельной транзакции:
На этом этапе часто возникает логичный вопрос: если REQUIRES_NEW такой удобный, почему бы не использовать его везде?
На практике это плохая идея. Каждая новая транзакция — это отдельное соединение с базой и отдельный жизненный цикл. При активном использовании это может привести к:
• Нехватке соединений в пуле
• Росту количества блокировок и даже deadlock-сценариям
• Ухудшению производительности из-за лишних commit/rollback
Кроме того, избыточное дробление транзакций может нарушить целостность бизнес-логики: операции, которые должны быть атомарными, начинают жить отдельно друг от друга.
Поэтому REQUIRES_NEW — это точечный инструмент. Он отлично подходит для изолированных действий (логирование, аудит, отправка служебных записей), но использовать его «на всякий случай» — почти всегда плохое решение.
На практике это плохая идея. Каждая новая транзакция — это отдельное соединение с базой и отдельный жизненный цикл. При активном использовании это может привести к:
• Нехватке соединений в пуле
• Росту количества блокировок и даже deadlock-сценариям
• Ухудшению производительности из-за лишних commit/rollback
Кроме того, избыточное дробление транзакций может нарушить целостность бизнес-логики: операции, которые должны быть атомарными, начинают жить отдельно друг от друга.
Поэтому REQUIRES_NEW — это точечный инструмент. Он отлично подходит для изолированных действий (логирование, аудит, отправка служебных записей), но использовать его «на всякий случай» — почти всегда плохое решение.
Как не наступать на одни и те же грабли
Есть несколько практических правил, которые сильно снижают вероятность проблем. Их немного, но они закрывают большую часть типичных багов:
• Размещать @Transactional на уровне сервисов, а не контроллеров
• Не вызывать транзакционные методы внутри того же класса
• Явно задавать rollbackFor, если есть checked exceptions
• Использовать REQUIRES_NEW для независимых операций
• Не держать транзакции во время внешних вызовов (API, очереди, файловые операции)
• Избегать слишком длинных транзакций
• Размещать @Transactional на уровне сервисов, а не контроллеров
• Не вызывать транзакционные методы внутри того же класса
• Явно задавать rollbackFor, если есть checked exceptions
• Использовать REQUIRES_NEW для независимых операций
• Не держать транзакции во время внешних вызовов (API, очереди, файловые операции)
• Избегать слишком длинных транзакций
@Transactional — это мощный инструмент, но он не прощает поверхностного использования.
На уровне «повесил аннотацию — работает» всё действительно выглядит просто. Но как только появляется нагрузка, параллельность и сложная бизнес-логика, начинают всплывать детали: прокси, propagation, rollback-правила, изоляция.
И именно в этих деталях кроется разница между кодом, который «обычно работает», и системой, которая стабильно ведёт себя в продакшене.
Хотите узнать больше? Изучите другие статьи из разделов:
На уровне «повесил аннотацию — работает» всё действительно выглядит просто. Но как только появляется нагрузка, параллельность и сложная бизнес-логика, начинают всплывать детали: прокси, propagation, rollback-правила, изоляция.
И именно в этих деталях кроется разница между кодом, который «обычно работает», и системой, которая стабильно ведёт себя в продакшене.
Хотите узнать больше? Изучите другие статьи из разделов: