Какие исключения по умолчанию вызывают rollback?
Spring делает rollback транзакции не для всех исключений. Есть чёткое правило.
🟢 Junior Level
Spring делает rollback транзакции не для всех исключений. Есть чёткое правило.
Правило по умолчанию
Spring автоматически откатывает транзакцию только для:
- RuntimeException (NullPointerException, IllegalArgumentException, и т.д.)
- Error (OutOfMemoryError, StackOverflowError, и т.д.)
Это Unchecked Exceptions — те, которые не обязаны быть объявлены в throws.
Механизм: TransactionInterceptor перехватывает исключение и проверяет: если тип = RuntimeException или Error → setRollbackOnly(). Если тип = Exception (но не RuntimeException) → commit, как если бы метод завершился нормально.
Что НЕ вызывает откат
Checked Exceptions — наследники Exception (но не RuntimeException):
IOExceptionSQLException- Любые пользовательские
extends Exception
Если такие исключения вылетают из @Transactional метода — Spring сделает COMMIT.
Пример
@Transactional
public void process() {
throw new RuntimeException("fail"); // ROLLBACK ✅
}
@Transactional
public void process2() throws IOException {
throw new IOException("fail"); // COMMIT ❌ — данные сохранятся!
}
Аналогия
Представьте сигнализацию: RuntimeException = пожар (всё бросаем и эвакуируемся = rollback). Checked Exception = плановая проверка (продолжаем работу = commit).
Как изменить поведение
@Transactional(rollbackFor = Exception.class) // Rollback для ВСЕХ исключений
public void process() throws IOException { ... }
🟡 Middle Level
Почему Spring выбрал такую политику?
Архитектурное решение из философии EJB:
- RuntimeException — непредвиденная системная ошибка или баг. Состояние системы неопределённо → нужно откатить.
- Checked Exception — ожидаемая бизнес-ситуация (например,
InsufficientFundsException). Разработчик должен обработать и, возможно, закоммитить изменения.
Поведение не менялось с Spring 2.x и одинаково в Spring Boot 3.x. Но в Spring 6+ с Ahead-of-Time компиляцией могут быть нюансы с exception type resolution — тестируйте.
Полная классификация
| Тип исключения | Пример | Rollback по умолчанию? |
|---|---|---|
RuntimeException |
NullPointerException, IllegalArgumentException | ✅ Да |
Error |
OutOfMemoryError, StackOverflowError | ✅ Да |
| Checked Exception | IOException, SQLException | ❌ Нет |
| Custom checked | BusinessException extends Exception |
❌ Нет |
| Spring DataAccessException | DataIntegrityViolationException | ✅ Да (это RuntimeException) |
// Почему commit: BusinessException наследуется от Exception (не от RuntimeException). // Spring видит checked exception → считает «нормальным завершением» → commit. // Результат: частично сохранённые данные, хотя бизнес-логика «упала».
Как настроить rollback для Checked Exceptions
// Для всех Exception
@Transactional(rollbackFor = Exception.class)
public void businessMethod() throws MyCheckedException { ... }
// Для конкретного исключения
@Transactional(rollbackFor = IOException.class)
public void readFile() throws IOException { ... }
// Для нескольких исключений
@Transactional(rollbackFor = {IOException.class, MyBusinessException.class})
public void process() throws IOException, MyBusinessException { ... }
// Исключить конкретный RuntimeException из отката
@Transactional(noRollbackFor = MyCustomRuntimeException.class)
public void processWithExceptionHandling() { ... }
Проблема с Catch-блоками
Если вы поймали исключение внутри метода — Spring об этом не узнает:
@Transactional
public void save() {
try {
repository.save(user);
throw new RuntimeException();
} catch (Exception e) {
log.error("Error occurred");
// ТРАНЗАКЦИЯ БУДЕТ ЗАКОММИЧЕНА! Spring не видит исключения.
}
}
Чтобы транзакция откатилась, исключение должно вылететь за границы прокси-метода.
Распространённые ошибки
| Ошибка | Что происходит | Как исправить |
|---|---|---|
| Не знать дефолтную политику | COMMIT при checked exception | rollbackFor = Exception.class |
| Catch и swallow | Spring видит normal return → COMMIT | Ретрансляция или setRollbackOnly() |
| Wrong exception hierarchy | BusinessException extends Exception — нет rollback |
Наследовать от RuntimeException |
| AOP ordering | Custom aspect ловит exception до TransactionInterceptor | Проверить порядок аспектов |
@Async метод |
Exception не прокидывается в caller’s transaction | Отдельный transaction context |
Когда НЕ использовать rollback
| Ситуация | Почему | Альтернатива |
|---|---|---|
| Partial success в batch | Один failed item ≠ весь batch failed | Обработать, продолжить, commit rest |
| Бизнес-валидация | Ожидаемая ситуация, данные валидны до ошибки | noRollbackFor = ValidationException.class |
| Graceful degradation | Система должна работать в degraded mode | Не rollback, а fallback логика |
Сравнение подходов к обработке
| Подход | Плюсы | Минусы | Когда использовать |
|---|---|---|---|
| RuntimeException (default) | Автоматический rollback, минимум кода | Не для ожидаемых бизнес-ошибок | Баги, системные ошибки |
rollbackFor = Exception.class |
Все исключения = rollback | Может rollback-ить то, что не нужно | Когда любая ошибка = invalid state |
Программный setRollbackOnly() |
Условный rollback | Boilerplate, Spring coupling | Complex business rules |
🔴 Senior Level
Rollback Rule Resolution — Spring Internals
// RuleBasedTransactionAttribute.rollbackOn()
@Override
public boolean rollbackOn(Throwable ex) {
// Check custom rollback rules first
for (RollbackRuleAttribute rule : rollbackRules) {
int depth = rule.getDepth(ex);
if (depth >= 0) {
return rule instanceof RollbackRuleAttribute;
}
}
// Default: rollback on RuntimeException and Error
return (ex instanceof RuntimeException || ex instanceof Error);
}
Метод getDepth() обходит иерархию исключений:
public int getDepth(Throwable ex) {
return getDepth(ex.getClass(), 0);
}
private int getDepth(Class<?> exceptionClass, int depth) {
if (exceptionClass.getName().contains(this.exceptionName)) {
return depth; // Match found
}
if (exceptionClass == Throwable.class) {
return -1; // No match, stop searching
}
return getDepth(exceptionClass.getSuperclass(), depth + 1);
}
String-Based Rollback Rules
@Transactional(rollbackForClassName = {"MyBusinessException"})
public void process() { ... }
Spring резолвит имя класса в рантайме:
// RollbackRuleAttribute constructor with String
public RollbackRuleAttribute(String exceptionPattern) {
this.exceptionName = exceptionPattern;
// НЕ валидируется при старте — резолвится лениво
}
Risk: Опечатка в имени класса → правило молча не сработает → нет rollback.
Combining rollbackFor и noRollbackFor
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = {NotFoundException.class, ValidationException.class}
)
public void process() {
// Exception → rollback
// NotFoundException → NO rollback (commit despite exception)
// ValidationException → NO rollback
// RuntimeException → rollback (подкласс Exception)
}
Rule matching order:
- Наиболее специфичный match побеждает (deepest in hierarchy)
- При одинаковой глубине — первое совпавшее правило
- Default rules (RuntimeException, Error) проверяются последними
Spring DataAccessException Hierarchy
Spring оборачивает все database exceptions в иерархию DataAccessException:
DataAccessException (RuntimeException)
├── DataIntegrityViolationException — FK violation, unique constraint
├── DuplicateKeyException — Duplicate key
├── CannotAcquireLockException — Lock timeout/deadlock
├── QueryTimeoutException — Query timeout
├── CannotSerializeTransactionException — Serialization failure
└── ... many more
Все они — RuntimeException → trigger rollback по умолчанию. Поэтому exception translation важен:
@Repository // Enables exception translation
public class UserRepositoryImpl {
// SQLException → DataAccessException (RuntimeException) → rollback
}
Edge Cases (минимум 3)
-
String typo in
rollbackForClassName:@Transactional(rollbackForClassName = "BusinesException")— silently ignored. Spring не валидирует class name при старте. Exception выбрасывается, но rollback не происходит. -
Conflicting rules:
rollbackFor = Exception.class+noRollbackFor = RuntimeException.class. Поведение зависит от rule matching order — наиболее специфичный match побеждает. RuntimeException глубже в иерархии → noRollbackFor для него побеждает → no rollback для RuntimeException, rollback для checked Exception. -
AOP ordering: Custom exception handler aspect может перехватить exception до
TransactionInterceptor. TransactionInterceptor видит normal return → COMMIT. Решение:@Orderдля контроля порядка аспектов. -
@Asyncс checked exceptions:@Asyncметод имеет отдельный transaction context. Checked exception не прокидывается в caller. Caller’s transaction не знает об ошибке → COMMIT. Future object хранит exception — нужно проверитьfuture.get(). -
Wrapped exceptions:
CompletionExceptionоборачивает исходное checked exception. Rollback rule ищет match поCompletionException(RuntimeException) → rollback. Но исходное checked exception может не совпасть с правилом.
Catch-Block Problem — Deep Analysis
@Transactional
public void problematicMethod() {
try {
repository.save(entity);
throw new DataAccessException("something went wrong") {};
} catch (DataAccessException e) {
log.error("Failed", e);
// EXCEPTION CAUGHT — TransactionInterceptor never sees it
// Transaction WILL BE COMMITTED
}
}
Решения:
// Solution 1: Ретрансляция
@Transactional
public void method1() {
try {
repository.save(entity);
throw new RuntimeException("fail");
} catch (RuntimeException e) {
log.error("Failed", e);
throw e; // Interceptor увидит
}
}
// Solution 2: setRollbackOnly
@Transactional
public void method2() {
try {
repository.save(entity);
throw new IOException("fail");
} catch (IOException e) {
log.error("Will rollback", e);
TransactionAspectSupport.currentTransactionStatus()
.setRollbackOnly();
}
}
// Solution 3: Wrap in RuntimeException
@Transactional
public void method3() {
try {
repository.save(entity);
throw new IOException("fail");
} catch (IOException e) {
throw new RuntimeException("Wrapped", e); // Trigger rollback
}
}
Performance Numbers
| Операция | Время |
|---|---|
| Rollback rule resolution (cached) | ~0.5 μs |
setRollbackOnly() call |
~1 μs |
| Exception type check (instanceof chain) | ~0.1 μs |
| Actual DB rollback | 1-5 ms (PostgreSQL O(1)), 5-50 ms (InnoDB O(N)) |
Memory Implications
- Rollback rules хранятся в
TransactionAttribute— один объект на метод - При большом количестве правил (rollbackForClassName с опечатками) — wasted memory на бесполезных правилах
- Exception objects в стеке — могут быть большими (stack trace). Long stack traces = memory pressure при frequent rollbacks.
Thread Safety
Rollback rules immutable — thread-safe. TransactionSynchronizationManager хранит состояние в ThreadLocal — конкурентные вызовы изолированы.
Production War Story
Сервис обработки заказов использовал BusinessException extends Exception для “ожидаемых” ошибок валидации. @Transactional без rollbackFor. При нарушении бизнес-правила exception бросался, но транзакция делала COMMIT — полусозданный заказ оставался в базе. Клиент видел ошибку, но заказ висел в “висячем” состоянии. Решение: rollbackFor = Exception.class на всех write-методах + интеграционные тесты.
Monitoring
Debug logging:
logging:
level:
org.springframework.transaction.interceptor: TRACE
Micrometer:
@Aspect
@Component
public class TransactionMetrics {
private final MeterRegistry registry;
@AfterThrowing(pointcut = "@annotation(Transactional)", throwing = "ex")
public void onException(Throwable ex) {
registry.counter("transaction.exception", "type", ex.getClass().getSimpleName())
.increment();
}
}
Actuator:
GET /actuator/metrics/transaction.exception
Highload Best Practices
- Единая политика:
rollbackFor = Exception.classна всех write-методах — baseline. Никаких исключений. - Minimize checked exceptions: Перевести бизнес-исключения на RuntimeException hierarchy. Меньше boilerplate, корректный rollback по умолчанию.
- Never swallow exceptions: Если поймали —
setRollbackOnly()или ретрансляция. - Test rollback behavior: Integration tests должны проверять что данные действительно откатились.
- Monitor rollback rate: Высокий % = проблема в валидации, не в транзакциях.
- Use
noRollbackForselectively: Только для исключений, которые реально означают “это не ошибка, продолжай”.
🎯 Шпаргалка для интервью
Обязательно знать:
- Spring rollback-ит только для RuntimeException и Error — checked exceptions → commit
- DataAccessException (все подклассы) — RuntimeException → rollback по умолчанию
- @Transactional(rollbackFor = Exception.class) — rollback для ВСЕХ исключений
- If exception caught inside method — Spring не видит → commit, нужен setRollbackOnly() или rethrow
- rollbackForClassName (string-based) — risky: typo silently ignored, no validation at startup
- noRollbackFor исключает конкретные исключения из rollback (например, ValidationException)
Частые уточняющие вопросы:
- Почему Spring выбрал такую политику? — RuntimeException = баг (rollback), Checked = ожидаемая бизнес-ситуация
- Что будет при conflicting rollbackFor + noRollbackFor? — Наиболее специфичный match побеждает (deepest in hierarchy)
- Как работает rollback rule resolution? — RuleBasedTransactionAttribute.rollbackOn() обходит иерархию исключений
- Почему @Async не propagates checked exception? — Отдельный transaction context, exception в Future
Красные флаги (НЕ говорить):
- “Все исключения вызывают rollback” — только RuntimeException и Error
- “Поймал exception = Spring сделает rollback” — Spring не видит caught exceptions
- “rollbackForClassName = надёжный способ” — typo молча игнорируется
Связанные темы:
- [[18. Что такое rollback в транзакциях]]
- [[20. Как настроить rollback для checked exceptions]]
- [[16. Что такое аннотация @Transactional]]