@Transactional в Spring: как это работает под капотом
Прокси — вот и весь фокус
@Transactional — это не магия внутри метода, это магия вокруг него. Spring не трогает байткод твоего класса — он оборачивает бин в прокси (JDK dynamic proxy, если есть интерфейс, или CGLIB-наследник, если только класс). Прокси перехватывает вызов, открывает транзакцию через PlatformTransactionManager, вызывает твой реальный метод, а на выходе коммитит или роллбэкает.
Отсюда следует главное правило: транзакция начинается только тогда, когда вызов метода проходит через прокси, то есть снаружи объекта.
Self-invocation — классическая ловушка
@Service
public class OrderService {
public void placeOrder(Order o) {
// это вызов через 'this', прокси не участвует
saveOrder(o);
}
@Transactional
public void saveOrder(Order o) {
repository.save(o);
}
}
Если placeOrder вызывается снаружи и внутри дёргает saveOrder, аннотация @Transactional на saveOrder будет проигнорирована — вызов идёт через this, минуя прокси. Транзакции не будет вообще.
ApplicationContext.getBean(), либо использовать AspectJ weaving вместо proxy-based AOP (но это отдельная настройка и накладные расходы).Propagation — кто кому вложен
По умолчанию — REQUIRED: если транзакция уже есть, метод просто в неё вливается; если нет — создаётся новая. Это покрывает 90% случаев, но на собесе часто спрашивают про два других:
- REQUIRES_NEW — текущая транзакция подвешивается, стартует новая, независимая. Пригодится для логирования аудита, которое должно закоммититься даже если основная операция упадёт.
- NESTED — создаёт savepoint внутри существующей транзакции (работает только с JDBC-драйверами, которые это поддерживают). Откат внутренней части не тащит за собой всю транзакцию.
Почему rollback иногда не срабатывает
По умолчанию Spring делает rollback только на unchecked-исключениях (RuntimeException и Error). Если метод бросает checked-исключение — транзакция закоммитится, даже если внутри было исключение.
@Transactional(rollbackFor = CustomCheckedException.class)
public void doWork() throws CustomCheckedException {
...
}
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().Уровни изоляции — не забывай про БД
@Transactional(isolation = Isolation.READ_COMMITTED) задаёт уровень изоляции на старте транзакции. Но реальное поведение зависит от того, что поддерживает СУБД: PostgreSQL по умолчанию READ COMMITTED, MySQL InnoDB — REPEATABLE READ. Аннотация в Spring — это запрос к драйверу, а не гарантия — если БД не поддерживает уровень, будет либо ошибка, либо тихий даунгрейд до ближайшего доступного.
readOnly — не просто оптимизация
@Transactional(readOnly = true) отключает dirty checking в Hibernate (не нужно сравнивать снимки сущностей при flush) и может дать хинт драйверу на маршрутизацию к read-реплике. Но если внутри такого метода вызвать save() — получишь либо игнор, либо exception, зависит от провайдера.