Какие исключения по умолчанию вызывают rollback
В Spring Framework транзакция по умолчанию откатывается (ROLLBACK) далеко не при каждом исключении. Существует жесткое архитектурное разделение:
🟢 Junior Level
В Spring Framework транзакция по умолчанию откатывается (ROLLBACK) далеко не при каждом исключении. Существует жесткое архитектурное разделение:
Правило по умолчанию в 30 секундах
Spring автоматически инициирует ROLLBACK только при возникновении непроверяемых исключений (Unchecked Exceptions):
java.lang.RuntimeExceptionи любые его подклассы (NullPointerException,IllegalArgumentException,IllegalStateExceptionи т.д.).java.lang.Errorи любые его подклассы (OutOfMemoryError,StackOverflowErrorи т.д.).
Все проверяемые исключения (Checked Exceptions, т.е. прямые наследники java.lang.Exception, такие как IOException, SQLException, ClassNotFoundException или ваши кастомные extends Exception) по умолчанию НЕ вызывают откат транзакции — Spring выполняет COMMIT!
Исключение покинуло @Transactional метод:
|
v
Является ли объект
RuntimeException или Error?
/ \
ДА НЕТ (Checked Exception)
/ \
[ ROLLBACK ] [ COMMIT ]
(Данные стерты) (Данные зафиксированы в БД!)
Наглядный пример поведения по умолчанию
package com.example.service;
import com.example.entity.Account;
import com.example.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
public class DefaultRollbackService {
private final AccountRepository accountRepository;
// СЦЕНАРИЙ 1: RuntimeException -> произойдет ROLLBACK
@Transactional
public void debitWithRuntimeException(Long id, BigDecimal amount) {
Account account = accountRepository.findById(id).orElseThrow();
account.setBalance(account.getBalance().subtract(amount));
accountRepository.save(account);
// Непроверяемое исключение: Spring перехватит его и выполнит ROLLBACK!
throw new IllegalStateException("Системная ошибка!");
}
// СЦЕНАРИЙ 2: Checked Exception -> произойдет COMMIT! (ОПАСНОСТЬ)
@Transactional
public void debitWithCheckedException(Long id, BigDecimal amount) throws IOException {
Account account = accountRepository.findById(id).orElseThrow();
account.setBalance(account.getBalance().subtract(amount));
accountRepository.save(account);
// Проверяемое исключение: Spring считает это штатным завершением и выполнит COMMIT!
// Списание средств ЗАФИКСИРУЕТСЯ в базе данных, несмотря на выброс ошибки!
throw new IOException("Сбой записи чека на диск");
}
}
🟡 Middle Level
Историческая философия Spring: почему именно так
Такое поведение было заложено Родом Джонсоном в Spring 1.x и унаследовано от спецификации EJB (Enterprise Java Beans):
RuntimeExceptionсчитается технической катастрофой или программной ошибкой (баг в коде, нарушение контрактов, недоступность БД). Состояние памяти скомпрометировано, нормальное продолжение работы невозможно $\rightarrow$ требуется безусловный откат (ROLLBACK).Checked Exceptionрассматривается архитектурой Java как штатный альтернативный бизнес-результат работы метода (например,InsufficientFundsExceptionилиUserAlreadyExistsException). Предполагается, что вызывающий код осознанно обрабатывает такие исходы, а предыдущие действия (например, запись счетчика попыток) должны сохраниться $\rightarrow$ выполняется фиксация (COMMIT).
💡 Современный статус: В современной Java-разработке концепция checked exceptions считается устаревшей и избыточной. Большинство библиотек и фреймворков используют исключительно
RuntimeException. Однако дефолтное поведение Spring@Transactionalостается неизменным ради стопроцентной обратной совместимости.
Классификация исключений и реакция Spring
| Класс исключения | Тип в Java | Реакция Spring по умолчанию | Что происходит с данными |
|---|---|---|---|
NullPointerException |
RuntimeException |
❌ ROLLBACK | Изменения отменяются |
IllegalArgumentException |
RuntimeException |
❌ ROLLBACK | Изменения отменяются |
DataIntegrityViolationException |
RuntimeException |
❌ ROLLBACK | Изменения отменяются |
OutOfMemoryError |
Error |
❌ ROLLBACK | Откат (если JVM еще жива) |
IOException |
Checked Exception |
COMMIT | Данные сохраняются в БД! |
SQLException |
Checked Exception |
⚠️ См. ниже | Транслируется в Runtime |
CustomException extends Exception |
Checked Exception |
COMMIT | Данные сохраняются в БД! |
Почему ошибки БД (SQLException) все же откатывают транзакцию
Возникает резонный вопрос: java.sql.SQLException — это проверяемое исключение (Checked Exception). Почему же тогда ошибки базы данных (дубликат ключа, нарушение внешнего ключа) вызывают откат?
- В Spring Framework работает компонент Exception Translation (через
@RepositoryиPersistenceExceptionTranslationPostProcessor). - Spring перехватывает низкоуровневые
SQLExceptionи автоматически оборачивает их в иерархиюorg.springframework.dao.DataAccessException(DuplicateKeyException,CannotAcquireLockException). - Поскольку
DataAccessExceptionявляется прямым наследникомRuntimeException, срабатывает стандартное правило отката!
🔴 Senior Level
Внутренний механизм RuleBasedTransactionAttribute
Определение необходимости отката инкапсулировано в классе ядра RuleBasedTransactionAttribute (метод rollbackOn):
// Spring Framework: org.springframework.transaction.interceptor.RuleBasedTransactionAttribute
@Override
public boolean rollbackOn(Throwable ex) {
RollbackRuleAttribute winner = null;
int deepest = Integer.MAX_VALUE;
// 1. Поиск наиболее специфичного совпадения среди кастомных правил
if (this.rollbackRules != null) {
for (RollbackRuleAttribute rule : this.rollbackRules) {
int depth = rule.getDepth(ex);
if (depth >= 0 && depth < deepest) {
deepest = depth;
winner = rule;
}
}
}
// Если найдено кастомное правило — побеждает оно
if (winner != null) {
return !(winner instanceof NoRollbackRuleAttribute);
}
// 2. ДЕФОЛТНОЕ ПРАВИЛО (если кастомных правил нет или они не подошли):
return (ex instanceof RuntimeException || ex instanceof Error);
}
Алгоритм вычисления глубины (getDepth) при конфликтующих правилах
Когда в аннотации одновременно указаны rollbackFor и noRollbackFor, Spring вычисляет расстояние наследования:
@Transactional(
rollbackFor = Exception.class, // глубина для NPE = 2 (NPE -> RuntimeException -> Exception)
noRollbackFor = RuntimeException.class // глубина для NPE = 1 (NPE -> RuntimeException)
)
public void processOrder() {
throw new NullPointerException();
}
Результат: Метод getDepth() вернет глубину 1 для правила noRollbackFor и глубину 2 для правила rollbackFor. Победит правило с наименьшей глубиной (наиболее специфичное к выброшенному классу)! Транзакция будет закоммичена!
Опасность строковых правил: rollbackForClassName
Spring позволяет задавать имена классов исключений строками:
@Transactional(rollbackForClassName = "Com.Mycompany.BusinesException") // ОПЕЧАТКА!
- Spring не валидирует имена классов при компиляции и старте приложения (ленивое связывание по подстроке имени).
- Если разработчик допустил опечатку, правило никогда не совпадет (
depth = -1). - При выбросе этого checked-исключения Spring молча применит дефолтное правило и закоммитит транзакцию!
- Senior Best Practice: Всегда использовать типобезопасные классы:
rollbackFor = BusinessException.class.
4 Tricky Questions
1. Что произойдет, если проверяемое исключение IOException будет обернуто в CompletionException или UndeclaredThrowableException?
Ответ:
Транзакция откатится (ROLLBACK), даже если разработчик не указывал атрибут rollbackFor.
Причина: Spring AOP Proxy перехватывает исключение, которое физически вылетает из вызова метода. CompletionException (из CompletableFuture) и UndeclaredThrowableException (из Java Dynamic Proxy) являются прямыми наследниками RuntimeException. Перехватчик анализирует класс выброшенного исключения верхнего уровня. Поскольку ex instanceof RuntimeException == true, Spring безусловно инициирует откат транзакции.
2. Как разрешится конфликт правил, если на методе объявлено rollbackFor = Exception.class, а метод выбросит NumberFormatException при наличии noRollbackFor = IllegalArgumentException.class?
Ответ:
Транзакция будет закоммичена (COMMIT)!
Механизм: Алгоритм RuleBasedTransactionAttribute ищет ближайшего общего предка в дереве наследования классов:
NumberFormatExceptionнаследуетIllegalArgumentException(глубина 1 в правилеnoRollbackFor).NumberFormatExceptionнаследуетIllegalArgumentException$\rightarrow$RuntimeException$\rightarrow$Exception(глубина 3 в правилеrollbackFor). Spring выбирает правило с минимальной глубиной (depth = 1). Поскольку побеждает правилоnoRollbackFor, откат блокируется, и транзакция коммитится.
3. Если перехватить RuntimeException внутри метода через блок catch, залогировать его и выйти из метода без проброса ошибки, что произойдет с транзакцией?
Ответ: Произойдет тихий коммит (Silent Commit)! AOP-прокси перехватывает исключения только тогда, когда они вылетают за границы метода сервиса. Если исключение было поймано и «проглочено» внутри метода:
TransactionInterceptorникогда не узнает о возникновении ошибки.- Прокси считает, что целевой метод завершился успешно.
- Вызывается
connection.commit(). Все изменения, внесенные до возникновения ошибки, навсегда сохранятся в базе данных. Если откат все же необходим, разработчик обязан вызватьTransactionAspectSupport.currentTransactionStatus().setRollbackOnly()или повторно пробросить исключение.
4. Почему OutOfMemoryError входит в список исключений, вызывающих откат по умолчанию, и гарантирован ли реальный откат в базе при его возникновении?
Ответ:
Error включен в условие отката (ex instanceof Error), так как символизирует фатальную нестабильность системы, при которой фиксация транзакции недопустима.
Однако реальный физический откат НЕ гарантирован:
- При
OutOfMemoryErrorвиртуальная машина JVM находится в состоянии критического истощения памяти. - Попытка перехватчика Spring вызвать
connection.rollback()требует создания новых объектов в куче JVM (вызовы JDBC-драйвера, обработка сокетов). Если куча исчерпана полностью, сам вызовrollback()упадет с повторнымOutOfMemoryError. - В этом случае соединение аварийно разрывается на уровне TCP-сокета. Защиту данных обеспечивает сама СУБД: сервер БД обнаруживает разрыв клиентской сессии и автоматически откатывает незавершенную транзакцию по журналу WAL/Undo Log.
🎯 Шпаргалка для интервью
30-секундный ответ
«По умолчанию в Spring Framework аннотация
@TransactionalвыполняетROLLBACKтолько при возникновенииRuntimeException(непроверяемых исключений) иError. Проверяемые исключения (Checked Exceptions) считаются штатными альтернативными исходами бизнес-логики и приводят кCOMMIT. Чтобы транзакция откатывалась при любых исключениях, необходимо явно указывать@Transactional(rollbackFor = Exception.class)».
Чек-лист ключевых понятий
- Дефолт отката:
RuntimeExceptionиError. - Дефолт коммита: Checked
Exception(IOException,SQLExceptionи прямые наследникиException). - DataAccessException: Все исключения Spring Data/JPA являются
RuntimeException, поэтому автоматически вызывают откат. - Разрешение конфликтов: Побеждает правило с наименьшей глубиной наследования (
getDepth()). - Swallowed Exceptions: Если поймать
RuntimeExceptionвcatchбез проброса, произойдет коммит.
Красные флаги (чего ни в коем случае нельзя говорить)
- ❌ «Spring откатывает транзакцию при любых исключениях в Java» (Checked exceptions по умолчанию коммитятся!).
- ❌ «SQLException по умолчанию не вызывает откат, потому что это Checked Exception» (Spring оборачивает его в
DataAccessException, который являетсяRuntimeException). - ❌ «Если указать rollbackFor = Exception.class, то Error перестанет откатываться» (Error откатывается всегда, если не запрещен явно через
noRollbackFor).