Які винятки за замовчуванням викликають 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:
- Найбільш специфічний збіг перемагає (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 в
rollbackForClassName:@Transactional(rollbackForClassName = "BusinesException")— silently ignored. Spring не валідує class name при старті. Exception викидається, але rollback не відбувається. -
Conflicting rules:
rollbackFor = Exception.class+noRollbackFor = RuntimeException.class. Поведінка залежить від rule matching order — найбільш специфічний збіг перемагає. 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. При порушенні бізнес-правила виняток кидався, але транзакція робила 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? — Найбільш специфічний збіг перемагає (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 винятків]]
- [[16. Що таке анотація @Transactional]]