💳 Раздел 11 · Вопрос #20

Как настроить 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

  1. Для всех проверяемых исключений (рекомендуется):
    @Transactional(rollbackFor = Exception.class)
    
  2. Для конкретного проверяемого исключения:
    @Transactional(rollbackFor = InsufficientFundsException.class)
    
  3. Для нескольких конкретных исключений:
    @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 { ... }
  1. Spring вычисляет расстояние по иерархии наследования (depth) через рекурсивный вызов: \(\text{depth}(TargetClass, CurrentClass) = \begin{cases} 0, & \text{если } TargetClass == CurrentClass \\ 1 + \text{depth}(TargetClass.superclass), & \text{иначе} \end{cases}\)
  2. Если выброшено EntityNotFoundBusinessException, глубина для правила noRollbackFor равна 0 (точное совпадение).
  3. Глубина для rollbackFor = Exception.class равна 2 (EntityNotFoundBusinessException $\rightarrow$ BusinessException $\rightarrow$ Exception).
  4. Правило с минимальной глубиной (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)! Механика:

  1. Оба метода выполняются в одной и той же физической транзакции.
  2. Когда дочерний метод выбрасывает IOException, его TransactionInterceptor видит локальное правило noRollbackFor = IOException.class. Поэтому дочерний интерцептор не вызывает setRollbackOnly().
  3. Исключение пробрасывается выше и вылетает из родительского метода.
  4. Родительский 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-типов?

Ответ:

  1. Несовместимость с функциональным стилем: Стандартные функциональные интерфейсы Java 8+ (Function, Consumer, Supplier, Stream API) не объявляют throws CheckedException. Попытка выбросить checked-исключение внутри .map() или .filter() требует уродливых оберток в try-catch или sneakyThrows.
  2. Риск дефолтного коммита: При использовании checked-исключений любая забытая аннотация rollbackFor приводит к частичному сохранению неконсистентных данных в БД.
  3. Чистота сигнатур: RuntimeException позволяет отделить бизнес-логику от контрактов интерфейсов, централизованно обрабатывая ошибки на уровне @RestControllerAdvice.

🎯 Шпаргалка для интервью

30-секундный ответ

«По умолчанию Spring не откатывает транзакцию при проверяемых исключениях (Checked Exceptions), считая их штатными исходами бизнес-логики. Чтобы настроить откат, используют атрибут @Transactional(rollbackFor = Exception.class). В enterprise-проектах рекомендуется либо стандартизировать этот атрибут через собственную мета-аннотацию, либо отказаться от checked-исключений в пользу RuntimeException, для которых Spring выполняет откат автоматически».

Чек-лист ключевых понятий

  1. Решение проблемы: @Transactional(rollbackFor = Exception.class).
  2. Программная альтернатива: TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
  3. Правило победы: При конфликте rollbackFor и noRollbackFor побеждает правило с наименьшей глубиной наследования до целевого класса (depth).
  4. Типобезопасность: Категорически избегать rollbackForClassName из-за риска опечаток в строках.
  5. Modern Best Practice: Миграция бизнес-исключений на иерархию RuntimeException.

Красные флаги (чего ни в коем случае нельзя говорить)

  • ❌ «Если объявить throws MyCheckedException в сигнатуре метода, Spring автоматически поймет, что нужен откат» (Объявление в throws никак не влияет на AOP-прокси, транзакция закоммитится).
  • ❌ «rollbackForClassName проверяется Spring Boot при старте приложения» (Проверка происходит лениво в рантайме, опечатка приводит к тихому коммиту).
  • ❌ «noRollbackFor отменяет откат даже для RuntimeException» (Только если указанный в noRollbackFor класс ближе в иерархии, чем правила отката).

Ссылки на связанные темы