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

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

В Spring Framework транзакция по умолчанию откатывается (ROLLBACK) далеко не при каждом исключении. Существует жесткое архитектурное разделение:


🟢 Junior Level

В Spring Framework транзакция по умолчанию откатывается (ROLLBACK) далеко не при каждом исключении. Существует жесткое архитектурное разделение:

Правило по умолчанию в 30 секундах

Spring автоматически инициирует ROLLBACK только при возникновении непроверяемых исключений (Unchecked Exceptions):

  1. java.lang.RuntimeException и любые его подклассы (NullPointerException, IllegalArgumentException, IllegalStateException и т.д.).
  2. 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-прокси перехватывает исключения только тогда, когда они вылетают за границы метода сервиса. Если исключение было поймано и «проглочено» внутри метода:

  1. TransactionInterceptor никогда не узнает о возникновении ошибки.
  2. Прокси считает, что целевой метод завершился успешно.
  3. Вызывается 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)».

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

  1. Дефолт отката: RuntimeException и Error.
  2. Дефолт коммита: Checked Exception (IOException, SQLException и прямые наследники Exception).
  3. DataAccessException: Все исключения Spring Data/JPA являются RuntimeException, поэтому автоматически вызывают откат.
  4. Разрешение конфликтов: Побеждает правило с наименьшей глубиной наследования (getDepth()).
  5. Swallowed Exceptions: Если поймать RuntimeException в catch без проброса, произойдет коммит.

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

  • ❌ «Spring откатывает транзакцию при любых исключениях в Java» (Checked exceptions по умолчанию коммитятся!).
  • ❌ «SQLException по умолчанию не вызывает откат, потому что это Checked Exception» (Spring оборачивает его в DataAccessException, который является RuntimeException).
  • ❌ «Если указать rollbackFor = Exception.class, то Error перестанет откатываться» (Error откатывается всегда, если не запрещен явно через noRollbackFor).

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