Spring Data и транзакции
Поставил подменный менеджер транзакций, который печатает каждое открытие, коммит и откат, и уронил метод двумя разными исключениями. 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, остальные разбираются в тренажёре.
- На какие исключения @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. Это поведение — источник трудноуловимых багов «почему сохранилось, хотя была ошибка».
- Почему @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-режим. Классический вопрос-ловушка на собесе.
- Что задаёт propagation у @Transactional (например, REQUIRES_NEW)?A)Уровень изоляции транзакции — насколько параллельные транзакции видят незакоммиченные изменения друг другаB)Максимальное время, которое транзакция может выполняться, прежде чем будет принудительно откачена по таймаутуC)Число повторных попыток выполнить транзакцию, если она завершилась откатом из-за конфликта блокировокD)Как метод ведёт себя относительно уже существующей транзакции (присоединиться/приостановить/новая)
показать ответ и разбор
+D)Как метод ведёт себя относительно уже существующей транзакции (присоединиться/приостановить/новая)// разбор: propagation определяет, как транзакционный метод соотносится с УЖЕ ИДУЩЕЙ транзакцией: REQUIRED (по умолчанию) — присоединиться к существующей или создать новую; REQUIRES_NEW — ПРИОСТАНОВИТЬ внешнюю и выполниться в СВОЕЙ независимой (её коммит/откат отдельный — удобно для аудита/логов, которые должны сохраниться даже при откате основной); SUPPORTS, MANDATORY, NESTED, NOT_SUPPORTED, NEVER — другие режимы. Не путать с isolation (видимость данных между транзакциями) и timeout. propagation — про вложенность/границы транзакций.
- Что делает @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-драйвер/СУБД тоже могут оптимизировать. Это НЕ жёсткая гарантия запрета записи (хотя некоторые СУБД её усиливают) и не про реплики сама по себе. Ставят на сервисные методы-запросы для производительности и защиты от нечаянных модификаций.
- Как в 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.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.