Как настроить rollback для checked exceptions
По умолчанию в Spring Framework аннотация @Transactional НЕ откатывает транзакцию при возникновении проверяемых исключений (Checked Exceptions, т.е. наследников java.lang.Except...
🟢 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.Isolation;
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 класс ближе в иерархии, чем правила отката).