Как работает @Transactional в Spring? В каких случаях аннотация молча не сработает?
Короткий ответ
- Работает через AOP-прокси вокруг бина, а не магически
- Прокси открывает транзакцию до метода и коммитит/откатывает после
- Self-invocation (this.method()) идёт мимо прокси — транзакции нет
- По умолчанию rollback только на unchecked-исключениях
- private и final методы не проксируются
- Propagation управляет вложенностью: REQUIRED, REQUIRES_NEW, NESTED
- Исключение, проглоченное catch, не вызовет rollback
@Transactional — это прокси вокруг бина; все его «молчаливые отказы» объясняются тем, что вызов не прошёл через прокси или исключение не того типа.
Как сказать вслух
пример ответаSpring оборачивает бин в прокси: перед вызовом метода прокси открывает транзакцию, после — коммитит, а при исключении откатывает. Отсюда все классические грабли. Если метод вызывает другой метод того же класса напрямую, вызов идёт мимо прокси, и аннотация игнорируется. Приватные методы не проксируются. И по умолчанию откат происходит только на runtime-исключениях — checked-исключение тихо закоммитит транзакцию, если явно не указать rollbackFor.
Подробный ответ
Основной ответ
@Transactional реализован через Spring AOP: на этапе пост-процессинга бин оборачивается в прокси (JDK dynamic proxy по интерфейсу или CGLIB-подкласс). Перехватчик берёт соединение, выключает autocommit, связывает транзакцию с потоком (ThreadLocal), после метода коммитит, при исключении решает, откатывать ли: по умолчанию rollback только для RuntimeException и Error, checked-исключения коммитят (меняется через rollbackFor). Поэтому не работают: self-invocation (this.inner() — прямой вызов без прокси), private/final/static методы, вызовы из конструктора, проглоченные исключения, и методы, выполняемые в другом потоке (@Async, новые потоки — транзакция привязана к ThreadLocal). Propagation.REQUIRED присоединяется к текущей транзакции, REQUIRES_NEW подвешивает её и открывает новую, NESTED использует savepoint. С виртуальными потоками и пулами соединений важно не держать транзакции долгими.
Ключевые моменты
- Прокси-механика. Транзакция начинается только при входе «снаружи» через прокси; внутренние вызовы — обычные вызовы Java.
- Правила rollback. Unchecked → rollback, checked → commit по умолчанию; rollbackFor = Exception.class меняет поведение.
- Propagation. REQUIRES_NEW для аудита/логов, которые должны выжить при откате основной; NESTED — частичный откат через savepoint.
- Потоки. Транзакционный контекст — ThreadLocal; @Async-метод или ручной поток транзакцию не унаследует.
Практический контекст
Это вопрос на «чинил ли кандидат транзакции в бою». Типичные инциденты: данные частично сохранились, потому что сервис ловил исключение и логировал; «транзакционный» метод вызывался из того же класса; долгие транзакции исчерпали пул соединений. Лечение self-invocation — вынести метод в другой бин или внедрить self-ссылку. Интервьюер также ценит понимание связки @Transactional с LazyInitializationException и границами сессии Hibernate.
Пример кода
@Service
public class OrderService {
public void process(Order o) {
// ОШИБКА: вызов через this идёт мимо прокси —
// @Transactional на save() не сработает
this.save(o);
}
@Transactional
public void save(Order o) { /* ... */ }
}
// Решение: вынести save() в отдельный бин
// или вызывать process() -> orderTxService.save(o)Частые ошибки
- Вешают @Transactional на private-метод или вызывают транзакционный метод через this
- Не знают, что checked-исключение по умолчанию не откатывает транзакцию
- Ловят исключение внутри метода и удивляются, почему данные закоммитились