← Назад к списку
ТехническаяJava и KotlinSenior

Как работает @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-исключение по умолчанию не откатывает транзакцию
  • Ловят исключение внутри метода и удивляются, почему данные закоммитились

ИП Кочкин Алексей Сергеевич · ИНН 390509026279 · ОГРНИП 325390000030973 · jiniys2005@yandex.ru