сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Spring и Hibernate

Spring Data и транзакции

Транзакции: где Spring подставляет ножку

Поставил подменный менеджер транзакций, который печатает каждое открытие, коммит и откат, и уронил метод двумя разными исключениями. RuntimeException - в логе ROLLBACK. Обычное проверяемое Exception - в логе COMMIT. Транзакция закоммитилась несмотря на ошибку.

Это дефолт, о который спотыкаются в проде: деньги списались, письмо не ушло, а откат не случился, потому что исключение оказалось проверяемым. Вторая классика собеса - вызов через this, где транзакции не оказывается вовсе.

// Формулировки: «когда @Transactional откатывает, а когда нет?», «что такое REQUIRES_NEW?», «откуда берётся UnexpectedRollbackException?».

Репозитории и где проходит граница транзакции

Spring Data убирает шаблонный код доступа к базе: ты объявляешь интерфейс репозитория, а реализацию генерируют. Запрос собирается прямо из имени метода - findByEmailAndStatus превращается в условие по двум полям; сложное пишут в @Query; постраничную выдачу даёт параметр Pageable.

@Transactional работает через прокси - обёртку, которой Spring подменяет твой объект при создании: она открывает транзакцию до вызова метода, коммитит после и откатывает при ошибке. Значит, действуют все прокси-правила: перехват работает на публичном методе бина, вызванном СНАРУЖИ. Я это проверил на живом контексте - при вызове через this в логе не появилось ни открытия, ни коммита.

// Границу транзакции обычно держат на сервисном методе, а не в репозитории. Транзакцией должна владеть бизнес-операция целиком, чтобы несколько запросов к базе были неделимы - иначе половина операции закоммитится, а половина нет.

запрос из имени метода
findByEmailAndStatus - условие собирается по названию
@Transactional
прокси открывает, коммитит и откатывает транзакцию вокруг метода

Что откатывает транзакцию, а что нет

Замер целиком. Метод упал с IllegalStateException - в логе ROLLBACK. Метод упал с обычным Exception - в логе COMMIT. Тот же метод с настройкой @Transactional(rollbackFor = Exception.class) - снова ROLLBACK.

Правило такое: по умолчанию откат происходит только на непроверяемых исключениях, то есть наследниках RuntimeException и Error. Проверяемое исключение - то, которое компилятор требует объявить или поймать - считается штатным исходом, и транзакция коммитится. Дефолт этот достался Spring по наследству от старого стандарта серверных компонентов, логики в нём для сегодняшнего кода немного, поэтому его просто надо помнить.

Propagation отвечает на вопрос «что делать, если транзакция уже открыта». REQUIRED, значение по умолчанию, присоединяется к текущей - в моём замере вложенный вызов НЕ создал вторую транзакцию, лог показал одно открытие на оба метода. REQUIRES_NEW приостанавливает текущую и открывает независимую: у меня в логе появилось второе открытие, вложенный метод закоммитился сам, и лишь потом завершился внешний. Так делают аудит и логи, которые должны пережить откат основной операции.

@Transactional void a() { throw new IllegalStateException(); }  // ROLLBACK
@Transactional void b() throws Exception { throw new Exception(); }  // COMMIT (!)

@Transactional(rollbackFor = Exception.class)
void c() throws Exception { throw new Exception(); }             // ROLLBACK
rollbackFor
откатывать и на проверяемых исключениях, а не только на непроверяемых
REQUIRES_NEW
приостановить текущую транзакцию и открыть независимую

Пометка rollback-only: когда «поймал и продолжил» не спасает

Тут я проверил два похожих случая, и они ведут себя ПО-РАЗНОМУ. Случай первый: метод поймал собственное исключение и пошёл дальше. Результат - обычный COMMIT, никаких сюрпризов. Случай второй: метод позвал ДРУГОЙ транзакционный бин, тот упал, а внешний метод исключение поймал и продолжил. Результат - UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only.

Разница в том, кто пометил транзакцию. Когда падает вложенный транзакционный метод, присоединившийся к той же транзакции, прокси не может откатить её частями - и помечает всю транзакцию как «только откат». Внешний метод об этом не знает, доходит до конца, и на попытке коммита получает отказ. Твой собственный try-catch внутри одного метода такой пометки не ставит.

// Практический вывод: ловить исключения от соседних транзакционных бинов и делать вид, что всё хорошо, нельзя. Нужна операция, которая переживёт падение соседа - вызывай её с REQUIRES_NEW, тогда у неё будет своя транзакция и своя судьба.

// поймал СВОЁ исключение внутри метода:
//   BEGIN ... COMMIT, ошибок нет

// поймал исключение ВЛОЖЕННОГО транзакционного бина:
//   BEGIN ... ! помечено rollback-only ... ROLLBACK
//   UnexpectedRollbackException: Transaction rolled back
//   because it has been marked as rollback-only
rollback-only
пометка «эту транзакцию можно только откатить»
UnexpectedRollbackException
коммит отклонён, потому что транзакция уже помечена на откат

Как отвечать: «Почему транзакция не откатилась, хотя вылетело исключение?»

Первым делом смотрю на тип исключения. По умолчанию Spring откатывает только на непроверяемых - на RuntimeException и Error, а проверяемое считается штатным исходом и транзакция коммитится. Я это проверял с подменным менеджером транзакций: то же самое падение с RuntimeException дало ROLLBACK, а с Exception - COMMIT. Лечится настройкой rollbackFor = Exception.class. Второй частый случай - исключение поймали внутри и проглотили: прокси про ошибку не узнал и честно закоммитил. Третий - вызов через this: транзакции не было вовсе, в логе при таком вызове вообще пусто, откатывать нечего. И отдельно держу в голове обратную ситуацию: если упал вложенный транзакционный метод, а я поймал его исключение, транзакция уже помечена только на откат, и мой коммит упадёт с UnexpectedRollbackException.

Почему это сильный ответ: назван главный корень и три реальных сценария вместо одного, причём последний - обратный случай, где ошибка вылезает именно из-за перехвата; видно, что человек это ловил.

На чём валят

  • Ждать отката на проверяемом исключении. По умолчанию его нет - нужен rollbackFor.
  • @Transactional на приватном методе или на вызове через this. Транзакции не будет, и в логе не появится ни строчки.
  • Поймать исключение соседнего транзакционного бина и продолжить. Транзакция уже помечена на откат, коммит упадёт с UnexpectedRollbackException.
  • Ставить REQUIRES_NEW «на всякий случай». Это два соединения с базой на один запрос и риск исчерпать пул.
  • Звать чужой сервис по сети внутри транзакции. Соединение с базой простаивает всё время ожидания, под нагрузкой пул кончается.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 19, остальные разбираются в тренажёре.

  1. #spring_data_tx1 / 5
    На какие исключения @Transactional откатывает транзакцию ПО УМОЛЧАНИЮ?
    A)На исключения — и проверяемые, и непроверяемые — транзакция откатывается автоматически
    B)Только на RuntimeException и Error; на проверяемые (checked) — нет
    C)Только на проверяемые (checked) исключения; RuntimeException фиксирует (commit) изменения как успех
    D)Ни на какие: откат нужно запускать вручную вызовом метода rollback внутри catch-блока
    показать ответ и разбор
    +B)Только на RuntimeException и Error; на проверяемые (checked) — нет

    // разбор: Важнейшая ловушка: @Transactional по умолчанию откатывает ТОЛЬКО на unchecked-исключениях (RuntimeException и Error). Если из транзакционного метода вылетает CHECKED-исключение (например, IOException, ваше бизнес-checked), транзакция по умолчанию КОММИТИТСЯ — изменения сохранятся, что часто неожиданно. Чтобы откатывать и на checked, указывают @Transactional(rollbackFor=Exception.class); обратное — noRollbackFor. Это поведение — источник трудноуловимых багов «почему сохранилось, хотя была ошибка».

  2. #spring_data_tx2 / 5
    Почему @Transactional может «не сработать» при вызове метода из того же класса (self-invocation)?
    A)Потому что аннотацию @Transactional разрешено ставить лишь на методы контроллеров, но не на сервисы
    B)Потому что Spring применяет @Transactional лишь к первому вызванному методу за всё время работы приложения
    C)Вызов идёт мимо прокси Spring — аннотация применяется только к внешним вызовам через бин
    D)Потому что при внутреннем вызове база данных не успевает открыть соединение до начала выполнения метода
    показать ответ и разбор
    +C)Вызов идёт мимо прокси Spring — аннотация применяется только к внешним вызовам через бин

    // разбор: @Transactional реализован через AOP-ПРОКСИ: контейнер оборачивает бин прокси, который открывает/коммитит транзакцию вокруг вызова. Но при ВНУТРЕННЕМ вызове this.otherMethod() исполнение идёт напрямую по ссылке this, МИНУЯ прокси, — значит транзакционный advice не срабатывает, @Transactional игнорируется. То же с @Async, @Cacheable. Обходы: вынести метод в отдельный бин (вызов через него пройдёт через прокси), self-inject свой прокси, или использовать TransactionTemplate/AspectJ-режим. Классический вопрос-ловушка на собесе.

  3. #spring_data_tx3 / 5
    Что задаёт propagation у @Transactional (например, REQUIRES_NEW)?
    A)Уровень изоляции транзакции — насколько параллельные транзакции видят незакоммиченные изменения друг друга
    B)Максимальное время, которое транзакция может выполняться, прежде чем будет принудительно откачена по таймауту
    C)Число повторных попыток выполнить транзакцию, если она завершилась откатом из-за конфликта блокировок
    D)Как метод ведёт себя относительно уже существующей транзакции (присоединиться/приостановить/новая)
    показать ответ и разбор
    +D)Как метод ведёт себя относительно уже существующей транзакции (присоединиться/приостановить/новая)

    // разбор: propagation определяет, как транзакционный метод соотносится с УЖЕ ИДУЩЕЙ транзакцией: REQUIRED (по умолчанию) — присоединиться к существующей или создать новую; REQUIRES_NEW — ПРИОСТАНОВИТЬ внешнюю и выполниться в СВОЕЙ независимой (её коммит/откат отдельный — удобно для аудита/логов, которые должны сохраниться даже при откате основной); SUPPORTS, MANDATORY, NESTED, NOT_SUPPORTED, NEVER — другие режимы. Не путать с isolation (видимость данных между транзакциями) и timeout. propagation — про вложенность/границы транзакций.

  4. #spring_data_tx4 / 5
    Что делает @Transactional(readOnly = true)?
    A)Помечает транзакцию как только для чтения — подсказка для оптимизаций (напр. Hibernate не делает dirty-check)
    B)Физически запрещает базе выполнять запросы записи, отклоняя INSERT/UPDATE на уровне драйвера
    C)Кэширует результат метода, чтобы повторные вызовы с теми же аргументами не обращались к базе данных
    D)Открывает соединение с репликой для чтения, обеспечивая свежие данные при каждом запросе
    показать ответ и разбор
    +A)Помечает транзакцию как только для чтения — подсказка для оптимизаций (напр. Hibernate не делает dirty-check)

    // разбор: readOnly=true — это ПОДСКАЗКА, что транзакция только читает. Главный практический эффект с Hibernate/JPA: пропускается dirty checking и авто-flush (Hibernate не отслеживает изменения сущностей и не пытается их сохранить) — меньше накладных и случайных апдейтов. JDBC-драйвер/СУБД тоже могут оптимизировать. Это НЕ жёсткая гарантия запрета записи (хотя некоторые СУБД её усиливают) и не про реплики сама по себе. Ставят на сервисные методы-запросы для производительности и защиты от нечаянных модификаций.

  5. #spring_data_tx5 / 5
    Как в Spring Data вернуть данные постранично?
    A)Загрузить весь результат в List и вручную вырезать нужный диапазон индексов уже в памяти приложения
    B)Принять Pageable и вернуть Page<T> (или Slice<T>)
    C)Добавить в SQL-запрос жёстко зашитые LIMIT и OFFSET с константами, одинаковыми для всех запросов
    D)Хранить смещение страницы в поле singleton-репозитория и увеличивать его при каждом новом вызове
    показать ответ и разбор
    +B)Принять Pageable и вернуть Page<T> (или Slice<T>)

    // разбор: Spring Data поддерживает пагинацию из коробки: метод репозитория принимает Pageable (PageRequest.of(page, size, Sort...)), а возвращает Page<T> — страницу с контентом плюс метаданные (общее число элементов/страниц, есть ли следующая). Page выполняет доп. count-запрос; если тотал не нужен — легче Slice<T> (знает только про наличие следующей страницы). Так постранично отдают большие выборки в API, не загружая всё в память. Сортировка тоже задаётся через Pageable/Sort.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.