Вопрос 19 · Раздел 11

Какие исключения по умолчанию вызывают rollback?

Spring делает rollback транзакции не для всех исключений. Есть чёткое правило.

Версии по языкам: English Russian Ukrainian

🟢 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):

  • IOException
  • SQLException
  • Любые пользовательские 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:

  1. Наиболее специфичный match побеждает (deepest in hierarchy)
  2. При одинаковой глубине — первое совпавшее правило
  3. 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)

  1. String typo in rollbackForClassName: @Transactional(rollbackForClassName = "BusinesException") — silently ignored. Spring не валидирует class name при старте. Exception выбрасывается, но rollback не происходит.

  2. Conflicting rules: rollbackFor = Exception.class + noRollbackFor = RuntimeException.class. Поведение зависит от rule matching order — наиболее специфичный match побеждает. RuntimeException глубже в иерархии → noRollbackFor для него побеждает → no rollback для RuntimeException, rollback для checked Exception.

  3. AOP ordering: Custom exception handler aspect может перехватить exception до TransactionInterceptor. TransactionInterceptor видит normal return → COMMIT. Решение: @Order для контроля порядка аспектов.

  4. @Async с checked exceptions: @Async метод имеет отдельный transaction context. Checked exception не прокидывается в caller. Caller’s transaction не знает об ошибке → COMMIT. Future object хранит exception — нужно проверить future.get().

  5. 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. При нарушении бизнес-правила exception бросался, но транзакция делала 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

  1. Единая политика: rollbackFor = Exception.class на всех write-методах — baseline. Никаких исключений.
  2. Minimize checked exceptions: Перевести бизнес-исключения на RuntimeException hierarchy. Меньше boilerplate, корректный rollback по умолчанию.
  3. Never swallow exceptions: Если поймали — setRollbackOnly() или ретрансляция.
  4. Test rollback behavior: Integration tests должны проверять что данные действительно откатились.
  5. Monitor rollback rate: Высокий % = проблема в валидации, не в транзакциях.
  6. Use noRollbackFor selectively: Только для исключений, которые реально означают “это не ошибка, продолжай”.

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

Обязательно знать:

  • 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? — Наиболее специфичный match побеждает (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 exceptions]]
  • [[16. Что такое аннотация @Transactional]]