Як налаштувати rollback для checked винятків
За замовчуванням у Spring Framework анотація @Transactional НЕ відкочує транзакцію в разі виникнення перевірюваних винятків (Checked Exceptions, тобто нащадків java.lang.Excepti...
🟢 Junior Level
За замовчуванням у Spring Framework анотація @Transactional НЕ відкочує транзакцію в разі виникнення перевірюваних винятків (Checked Exceptions, тобто нащадків java.lang.Exception, крім RuntimeException). При виникненні checked-винятку Spring за замовчуванням виконує COMMIT.
Щоб змусити Spring відкочувати транзакцію при перевірюваних винятках, використовується атрибут rollbackFor.
Суть у 30 секундах
Головний промисловий стандарт (Best Practice):
@Transactional(rollbackFor = Exception.class)
Вказівка rollbackFor = Exception.class повідомляє перехоплювачу Spring AOP:
«Відкочуй транзакцію в разі виникнення абсолютно будь-якого винятку (
Exception), включно зIOException,SQLExceptionта будь-якими власними перевірюваними бізнес-винятками».
package com.example.service;
import com.example.exception.InsufficientFundsException; // Checked exception: extends Exception
import com.example.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
// БЕЗПЕЧНА КОНФІГУРАЦІЯ:
@Transactional(rollbackFor = Exception.class)
public void withdraw(Long accountId, BigDecimal amount) throws InsufficientFundsException {
var account = accountRepository.findById(accountId).orElseThrow();
if (account.getBalance().compareTo(amount) < 0) {
// Завдяки rollbackFor = Exception.class транзакція гарантовано ВІДКОТИТЬСЯ!
throw new InsufficientFundsException("Недостатньо коштів на рахунку");
}
account.setBalance(account.getBalance().subtract(amount));
accountRepository.save(account);
}
}
Варіанти використання rollbackFor
- Для всіх перевірюваних винятків (рекомендовано):
@Transactional(rollbackFor = Exception.class) - Для конкретного перевірюваного винятку:
@Transactional(rollbackFor = InsufficientFundsException.class) - Для кількох конкретних винятків:
@Transactional(rollbackFor = {IOException.class, InsufficientFundsException.class})
🟡 Middle Level
Три архітектурні стратегії роботи з Checked Exceptions
Якщо в кодовій базі присутні перевірювані винятки, Senior Engineer обирає одну з трьох стратегій:
Стратегія 1: Явний rollbackFor (Збереження Checked Exceptions)
-> @Transactional(rollbackFor = Exception.class)
-> Плюси: не ламає наявний код та сигнатури throws.
-> Мінуси: легко забути вказати атрибут над новим методом.
Стратегія 2: Рефакторинг на RuntimeException (Сучасний стандарт)
-> Замінити: public class BusinessException extends Exception
-> На: public class BusinessException extends RuntimeException
-> Плюси: Spring відкочує з коробки; сигнатури чисті без throws; сумісно зі Stream API.
-> Мінуси: вимагає рефакторингу кодової бази.
Стратегія 3: Програмний відкат (setRollbackOnly)
-> TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
-> Плюси: працює при ручному перехопленні помилок у блоках try-catch.
-> Мінуси: зв'язування коду з внутрішнім API Spring Framework.
Приклад програмного відкату в блоці catch
Якщо метод зобов’язаний перехопити перевірюваний виняток, записати лог і повернути об’єкт відповіді клієнту, але при цьому базу даних необхідно повернути до вихідного стану:
package com.example.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.io.IOException;
@Slf4j
@Service
public class DocumentExportService {
@Transactional
public boolean processAndArchiveDocument(Long docId) {
updateDocumentStatus(docId, "PROCESSING");
try {
writePdfToNetworkDisk(docId); // кидає checked IOException
return true;
} catch (IOException ex) {
log.error("Мережевий диск недоступний: {}", ex.getMessage());
// Програмно виставляємо прапорець відкату:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return false; // Повертаємо false клієнту, транзакція буде відкочена!
}
}
}
🔴 Senior Level
Глобальне налаштування rollbackFor для всього проєкту
Щоб не дублювати rollbackFor = Exception.class над сотнями сервісних методів і виключити людський фактор (коли розробник забув дописати атрибут), Senior-інженери застосовують один із двох глобальних підходів:
Варіант А: Кастомна мета-анотація (чисто та типобезпечно)
У Spring анотації є компонованими (Meta-Annotations):
package com.example.annotation;
import org.springframework.core.annotation.AliasFor;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Transactional(rollbackFor = Exception.class) // Базове правило: відкат на ВСЕ!
public @interface Tx {
@AliasFor(annotation = Transactional.class, attribute = "readOnly")
boolean readOnly() default false;
@AliasFor(annotation = Transactional.class, attribute = "propagation")
Propagation propagation() default Propagation.REQUIRED;
@AliasFor(annotation = Transactional.class, attribute = "timeout")
int timeout() default -1;
}
Тепер у сервісах використовується лаконічна анотація @Tx, яка за замовчуванням гарантує відкат для всіх винятків!
Варіант Б: Перевизначення біна TransactionInterceptor
Налаштування глобального правила в конфігурації Spring:
package com.example.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.interceptor.NameMatchTransactionAttributeSource;
import org.springframework.transaction.interceptor.RollbackRuleAttribute;
import org.springframework.transaction.interceptor.RuleBasedTransactionAttribute;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.util.Collections;
@Configuration
public class GlobalTransactionConfig {
@Bean
public TransactionInterceptor transactionInterceptor(PlatformTransactionManager txManager) {
RuleBasedTransactionAttribute defaultRule = new RuleBasedTransactionAttribute();
// Додаємо глобальне правило відкату для всіх Exception:
defaultRule.setRollbackRules(Collections.singletonList(new RollbackRuleAttribute(Exception.class)));
NameMatchTransactionAttributeSource source = new NameMatchTransactionAttributeSource();
source.addTransactionalMethod("*", defaultRule);
return new TransactionInterceptor(txManager, source);
}
}
Алгоритм Rule Matching при композиції правил
Коли на методі поєднуються rollbackFor та noRollbackFor:
@Transactional(
rollbackFor = Exception.class,
noRollbackFor = EntityNotFoundBusinessException.class
)
public void syncCatalog() throws Exception { ... }
- Spring обчислює дистанцію за ієрархією спадкування (
depth) через рекурсивний виклик: \(\text{depth}(TargetClass, CurrentClass) = \begin{cases} 0, & \text{якщо } TargetClass == CurrentClass \\ 1 + \text{depth}(TargetClass.superclass), & \text{інакше} \end{cases}\) - Якщо викинуто
EntityNotFoundBusinessException, глибина для правилаnoRollbackForдорівнює0(точний збіг). - Глибина для
rollbackFor = Exception.classдорівнює2(EntityNotFoundBusinessException$\rightarrow$BusinessException$\rightarrow$Exception). - Правило з мінімальною глибиною (
depth = 0) перемагає: транзакція комітиться, незважаючи на наявність загальногоrollbackFor = Exception.class!
4 Tricky Questions
1. У чому різниця між rollbackFor = Exception.class та rollbackFor = Throwable.class?
Відповідь:
Exception.classохоплює всі перевірювані винятки (Checked Exceptions) та всі неперевірювані (RuntimeException). Проте він не охоплює прямих нащадків класуThrowable, які не успадковуютьException(хоча на практиці в Java безпосередньо наслідуватиThrowableкатегорично не рекомендується).Throwable.classє коренем усієї ієрархії помилок у JVM (Throwable$\rightarrow$ExceptionтаError). ВказівкаrollbackFor = Throwable.classгарантує абсолютний відкат у разі викидання взагалі будь-якого об’єкта, який у принципі можна викинути операторомthrowв Java. На практиціrollbackFor = Exception.classдостатньо в 99.9% випадків, оскількиErrorуже включено до правил відкату за замовчуванням.
2. Що станеться, якщо батьківський метод з rollbackFor = Exception.class викличе дочірній метод з noRollbackFor = IOException.class (обидва методи Propagation.REQUIRED), і дочірній метод викине IOException?
Відповідь: Транзакція відкотиться (ROLLBACK)! Механіка:
- Обидва методи виконуються в межах однієї й тієї самої фізичної транзакції.
- Коли дочірній метод викидає
IOException, йогоTransactionInterceptorбачить локальне правилоnoRollbackFor = IOException.class. Тому дочірній інтерцептор не викликаєsetRollbackOnly(). - Виняток прокидається вище й вилітає з батьківського методу.
- Батьківський
TransactionInterceptorперехоплюєIOExceptionта звіряє його зі своїми правилами. У батька сконфігурованоrollbackFor = Exception.class. ОскількиIOException instanceof Exception, батьківський інтерцептор ініціюєconnection.rollback().
3. Чому використання rollbackForClassName = "MyException" вважається прихованим джерелом багів у продакшені?
Відповідь:
Тому що Spring не перевіряє валідність рядка імені класу під час компіляції або запуску застосунку.
Зв’язування відбувається ліниво за збігом підрядка імені класу в рантаймі. Якщо розробник припуститься оддруку (наприклад, rollbackForClassName = "IOExcepshon"), Spring не викине помилку ініціалізації контексту. При викиданні справжнього IOException строкове правило не спрацює, застосується дефолтна політика, і транзакція буде закомічена замість відкату. Завжди слід використовувати строго типізовані посилання на літерали класів: rollbackFor = IOException.class.
4. Чому в сучасній архітектурі на Spring Boot 3+ рекомендується взагалі відмовитися від Checked Exceptions на користь RuntimeException або Result-типів?
Відповідь:
- Несумісність із функціональним стилем: Стандартні функціональні інтерфейси Java 8+ (
Function,Consumer,Supplier, Stream API) не оголошуютьthrows CheckedException. Спроба викинути checked-виняток усередині.map()або.filter()вимагає громіздких обгорток уtry-catchабоsneakyThrows. - Ризик дефолтного коміту: При використанні checked-винятків будь-яка забута анотація
rollbackForпризводить до часткового збереження некоректних даних у БД. - Чистота сигнатур:
RuntimeExceptionдозволяє відокремити бізнес-логіку від контрактів інтерфейсів, централізовано обробляючи помилки на рівні@RestControllerAdvice.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«За замовчуванням Spring не відкочує транзакцію при перевірюваних винятках (
Checked Exceptions), вважаючи їх штатними альтернативними результатами бізнес-логіки. Щоб налаштувати відкат, використовують атрибут@Transactional(rollbackFor = Exception.class). В enterprise-проєктах рекомендується або стандартизувати цей атрибут через власну мета-анотацію, або повністю відмовитися від checked-винятків на користьRuntimeException, для яких Spring виконує відкат автоматично».
Чек-лист ключових понять
- Розв’язання проблеми:
@Transactional(rollbackFor = Exception.class). - Програмна альтернатива:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(). - Правило переваги: При конфлікті
rollbackForтаnoRollbackForперемагає правило з найменшою глибиною спадкування до цільового класу (depth). - Типобезпека: Категорично уникати
rollbackForClassNameчерез ризик одруків у рядках. - Modern Best Practice: Міграція бізнес-винятків на ієрархію
RuntimeException.
Червоні прапорці (чого в жодному разі не можна говорити)
- ❌ «Якщо оголосити throws MyCheckedException у сигнатурі методу, Spring автоматично зрозуміє, що потрібен відкат» (Оголошення в throws ніяк не впливає на AOP-проксі, транзакція закомітиться).
- ❌ «rollbackForClassName перевіряється Spring Boot під час старту застосунку» (Перевірка відбувається ліниво в рантаймі, помилка в рядку призводить до тихого коміту).
- ❌ «noRollbackFor скасовує відкат навіть для RuntimeException» (Тільки якщо вказаний у noRollbackFor клас розташований ближче в ієрархії, ніж правила відкату).