Питання 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. Найбільш специфічний збіг перемагає (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 в rollbackForClassName: @Transactional(rollbackForClassName = "BusinesException") — silently ignored. Spring не валідує class name при старті. Exception викидається, але rollback не відбувається.

  2. Conflicting rules: rollbackFor = Exception.class + noRollbackFor = RuntimeException.class. Поведінка залежить від rule matching order — найбільш специфічний збіг перемагає. 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. При порушенні бізнес-правила виняток кидався, але транзакція робила 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? — Найбільш специфічний збіг перемагає (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 винятків]]
  • [[16. Що таке анотація @Transactional]]