ГлавнаяБлог › @Transactional в Spring: как это работает под капотом

@Transactional в Spring: как это работает под капотом

Прокси — вот и весь фокус

@Transactional — это не магия внутри метода, это магия вокруг него. Spring не трогает байткод твоего класса — он оборачивает бин в прокси (JDK dynamic proxy, если есть интерфейс, или CGLIB-наследник, если только класс). Прокси перехватывает вызов, открывает транзакцию через PlatformTransactionManager, вызывает твой реальный метод, а на выходе коммитит или роллбэкает.

Отсюда следует главное правило: транзакция начинается только тогда, когда вызов метода проходит через прокси, то есть снаружи объекта.

Представь охранника на входе в здание (прокси). Если ты входишь через главную дверь — тебя проверяют, записывают время входа/выхода (транзакция). Но если ты уже внутри здания и идёшь из одного кабинета в другой по внутреннему коридору (вызов метода this.method() внутри того же класса) — охранник тебя не видит. Никакой проверки.

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, минуя прокси. Транзакции не будет вообще.

Решения: вынести транзакционный метод в отдельный бин, либо инжектить self через ApplicationContext.getBean(), либо использовать AspectJ weaving вместо proxy-based AOP (но это отдельная настройка и накладные расходы).

Propagation — кто кому вложен

По умолчанию — REQUIRED: если транзакция уже есть, метод просто в неё вливается; если нет — создаётся новая. Это покрывает 90% случаев, но на собесе часто спрашивают про два других:

  • REQUIRES_NEW — текущая транзакция подвешивается, стартует новая, независимая. Пригодится для логирования аудита, которое должно закоммититься даже если основная операция упадёт.
  • NESTED — создаёт savepoint внутри существующей транзакции (работает только с JDBC-драйверами, которые это поддерживают). Откат внутренней части не тащит за собой всю транзакцию.
Если внутренний метод с REQUIRES_NEW бросит исключение и оно перехватится во внешнем методе — что случится с внешней транзакцией?
Ничего страшного — внешняя транзакция продолжится и закоммитится, так как это отдельная физическая транзакция. Упадёт только внутренняя.

Почему rollback иногда не срабатывает

По умолчанию Spring делает rollback только на unchecked-исключениях (RuntimeException и Error). Если метод бросает checked-исключение — транзакция закоммитится, даже если внутри было исключение.

@Transactional(rollbackFor = CustomCheckedException.class)
public void doWork() throws CustomCheckedException {
    ...
}
Ещё одна классическая ошибка — перехватить исключение внутри транзакционного метода и "проглотить" его (try/catch без rethrow). Spring не увидит исключения — транзакция закоммитится с частично применёнными изменениями, если ты не выставил флаг вручную через 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, зависит от провайдера.

Ещё разборы