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