Які винятки за замовчуванням викликають 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без прокидання або викликуsetRollbackOnly, відбудеться коміт.
Червоні прапорці (чого в жодному разі не можна говорити)
- ❌ «Spring відкочує транзакцію при будь-яких винятках у Java» (Checked exceptions за замовчуванням призводять до коміту!).
- ❌ «SQLException за замовчуванням не викликає відкат, тому що це Checked Exception» (Spring перехоплює його й огортає в
DataAccessException, який єRuntimeException). - ❌ «Якщо вказати rollbackFor = Exception.class, то Error перестане відкочуватися» (Error відкочується завжди, якщо не заборонений явно через
noRollbackFor).