💳 Розділ 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 без прокидання або виклику setRollbackOnly, відбудеться коміт.

Червоні прапорці (чого в жодному разі не можна говорити)

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

Посилання на пов’язані теми