💳 Розділ 11 · Питання #18

Що таке rollback в транзакціях

Rollback є прямою реалізацією принципу атомарності (Atomicity в ACID):


🟢 Junior Level

Rollback (відкат транзакції) — це фундаментальна операція СУБД, яка скасовує всі зміни даних, виконані в межах поточної транзакції, та повертає базу даних до вихідного стану, в якому вона перебувала до виконання команди BEGIN / START TRANSACTION.

Суть у 30 секундах

Rollback є прямою реалізацією принципу атомарності (Atomicity в ACID):

«Усі операції всередині транзакції або фіксуються цілком і незворотно (COMMIT), або в разі виникнення найменшої помилки повністю скасовуються (ROLLBACK), ніби їх ніколи не існувало».

BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;

-- Сталася помилка (наприклад, збій зовнішнього сервісу або порушення check-constraint)
ROLLBACK; 
-- Баланси обох рахунків миттєво повернулися до початкових значень!

Автоматичний відкат у Spring Boot

У Spring Framework анотація @Transactional автоматично перехоплює винятки через AOP-проксі та делегує відкат менеджеру транзакцій:

package com.example.service;

import com.example.entity.Wallet;
import com.example.repository.WalletRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@RequiredArgsConstructor
public class PaymentProcessingService {

    private final WalletRepository walletRepository;

    @Transactional // За замовчуванням: відкат при RuntimeException та Error
    public void processPayment(Long walletId, BigDecimal amount) {
        Wallet wallet = walletRepository.findById(walletId).orElseThrow();
        
        wallet.setBalance(wallet.getBalance().subtract(amount));
        walletRepository.save(wallet);

        if (wallet.getBalance().compareTo(BigDecimal.ZERO) < 0) {
            // Викидання винятку автоматично тригерить connection.rollback()
            throw new IllegalStateException("Баланс гаманця не може бути від'ємним!");
        }
    }
}

Головне правило Spring за замовчуванням

⚠️ За замовчуванням Spring виконує ROLLBACK тільки в разі виникнення неперевірюваних винятків (RuntimeException) та фатальних системних помилок (Error). Якщо метод викине перевірюваний виняток (Checked Exception, наприклад IOException, SQLException або іншого спадкоємця java.lang.Exception), Spring вважатиме це штатним бізнес-результатом і виконає COMMIT!


🟡 Middle Level

Способи виклику Rollback у Spring

1. Декларативний відкат (через виняток)

Стандартний та найбільш декларативний підхід:

@Transactional(rollbackFor = Exception.class)
public void order() throws Exception { ... }

2. Програмний відкат (без викидання винятку)

Якщо бізнес-сценарій вимагає скасувати зміни в базі даних, але повернути клієнту зрозумілий об’єкт відповіді без помилки 500 Internal Server Error:

package com.example.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;

@Service
@RequiredArgsConstructor
public class SafeOrderService {

    private final ExternalPaymentGateway externalGateway;

    @Transactional
    public OrderResponse placeOrderSafely(OrderRequest request) {
        saveOrder(request);

        boolean paymentSuccess = externalGateway.charge(request.getAmount());
        if (!paymentSuccess) {
            // Програмно позначаємо транзакцію для відкату без викидання винятку:
            TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
            return new OrderResponse(false, "Платіж відхилено банком");
        }

        return new OrderResponse(true, "Успішно");
    }
}

Прапорець Rollback-Only та UnexpectedRollbackException

У ланцюжку методів із розповсюдженням Propagation.REQUIRED:

  1. Метод outer() викликає метод inner().
  2. Обидва методи виконуються в межах однієї фізичної транзакції.
  3. У методі inner() виникає RuntimeException.
  4. TransactionInterceptor внутрішнього методу перехоплює помилку й викликає transactionStatus.setRollbackOnly().
  5. Якщо метод outer() перехопить цей виняток через блок try-catch і спробує штатно завершитися, Spring під час виходу виявить прапорець isRollbackOnly() == true і примусово виконає відкат, викинувши коду, що викликає, виняток UnexpectedRollbackException.
[ outer() REQUIRED ] ───────────────────────────┐
     |                                           | (Єдина транзакція)
     v                                           |
[ inner() REQUIRED ]                             |
     |                                           |
     +-- Викидає RuntimeException!               |
     +-- TransactionStatus.setRollbackOnly() ────┘
     |
[ outer() перехопив помилку в catch і намагається зробити commit ]
     |
     v
Spring бачить прапорець Rollback-Only:
-> connection.rollback()
-> throw new UnexpectedRollbackException("Transaction marked as rollback-only");

🔴 Senior Level

Фізична реалізація Rollback у ядрах СУБД: PostgreSQL проти MySQL

Різні СУБД реалізують відкат транзакцій із принципово різною алгоритмічною складністю:

Порівняння механіки ROLLBACK:

PostgreSQL (Логічний відкат O(1)):
  1. Сервер отримує команду ROLLBACK (XACT_ABORT).
  2. Записує 2 біти в Commit Log (pg_xact / CLOG): статус = TRANSACTION_STATUS_ABORTED.
  3. Записує один запис у WAL (Write-Ahead Log) про скасування транзакції.
  4. Звільняє всі блокування рядків і таблиць в оперативній пам'яті.
  -> Час відкату: частки мілісекунди, НЕ ЗАЛЕЖИТЬ від обсягу оновлених рядків!
  -> Фізичне сміття (dead tuples) пізніше фоново утилізує процес autovacuum.

MySQL InnoDB (Фізичний відкат O(N)):
  1. Сервер переводить транзакцію у стан ROLLED BACK.
  2. Сканує ланцюжок записів в Undo Log У ЗВОРОТНОМУ ПОРЯДКУ.
  3. Для кожного зміненого рядка фізично відновлює старі значення
     у сторінках Clustered Index (B+Tree) у пам'яті та на диску!
  4. Знімає Next-Key Locks.
  -> Час відкату: пропорційний обсягу змін!
  -> Відкат транзакції на 10 млн рядків може заблокувати MySQL на кілька хвилин!

Поведінка сутностей у пам’яті JVM (Пастка Hibernate L1 Cache)

Найбільш підступна проблема відкату на рівні Java-застосунку:

СУБД скасовує стан рядків у базі даних, але віртуальна машина Java (JVM) НЕ ВІДНОВЛЮЄ значення полів об’єктів у купі (Heap)!

@Transactional
public void modifyAccount(Long accountId) {
    Account account = accountRepository.findById(accountId).orElseThrow();
    account.setBalance(BigDecimal.ZERO); // Мутація об'єкта в пам'яті JVM!
    
    // Припустимо, тут стався SQL-збій або викинуто виняток:
    throw new RuntimeException("Crash");
}

Що відбулося насправді:

  1. База даних виконала ROLLBACK: у таблиці на диску баланс залишився рівним, наприклад, 10 000.
  2. В об’єкті Java account поле balance все ще містить значення 0!
  3. Сесія Hibernate (PersistenceContext) після відкату перебуває в невалідному (брудному) стані.
  4. Якщо застосунок не знищить цей екземпляр EntityManager (наприклад, у довгих пакетних обробниках або фонових задачах), наступні операції можуть прочитати викривлені дані прямо з кешу L1.
  5. Senior Рішення: Після будь-якого непередбачуваного відкату сесія Hibernate має бути негайно закрита або примусово очищена викликом entityManager.clear().

4 Tricky Questions

1. Що відбувається зі значеннями генераторів автоінкременту (SEQUENCE у PostgreSQL / AUTO_INCREMENT у MySQL) під час ROLLBACK?

Відповідь: Лічильники автоінкременту та послідовностей НЕ відкочуються назад. Генератори sequence спроєктовані поза транзакційною моделлю MVCC заради високої продуктивності:

  • Запит nextval('seq') атомарно інкрементує лічильник через блокування спінлока в спільній пам’яті й одразу фіксує нове число.
  • Якщо транзакція, що отримала ID 105, відкочується, номер 105 втрачається назавжди. Наступна транзакція отримає номер 106.
  • У таблицях утворюються «прогалини» в ідентифікаторах (ID gaps: 1, 2, 5, 8). Це абсолютно нормальна й очікувана поведінка реляційних баз даних. Спроба зробити генератор послідовності строго неперервним вимагала б ексклюзивного блокування всієї таблиці до фіксації кожного запису, що повністю знищило б паралелізм.

2. Чому в PostgreSQL відкат мільйона рядків триває 1 мілісекунду, а в MySQL InnoDB може тривати кілька хвилин?

Відповідь:

  • У PostgreSQL відкат є суто логічним ($O(1)$). Сервер просто виставляє стан TRANSACTION_STATUS_ABORTED у кільцевому буфері pg_xact (CLOG) і записує подію XACT_ABORT у WAL. PostgreSQL фізично не торкається змінених сторінок таблиць. Усі нові версії рядків (dead tuples) просто стають невидимими для інших транзакцій за правилами MVCC і згодом очищаються процесом autovacuum.
  • У MySQL InnoDB відкат є фізичним компенсуючим процесом ($O(N)$). Рушій зобов’язаний послідовно розмотати сегмент Undo Log транзакції у зворотному порядку та фізично застосувати старі значення полів поверх змінених сторінок даних у буферному пулі та кластерному індексі. Якщо транзакція оновила 5 мільйонів рядків, рушій виконає 5 мільйонів операцій фізичного відновлення, створюючи величезне навантаження на процесор і дискову підсистему.

3. Як впливає ROLLBACK на версійність сутностей (@Version / Optimistic Locking) у Spring Data JPA?

Відповідь: Під час відкату транзакції поле @Version у Java-об’єктів у пам’яті не повертається до вихідного значення. Якщо сутність мала версію 1, Hibernate під час підготовки до коміту інкрементує її в пам’яті до 2. Якщо потім на етапі виконання SQL-оператора виникає збій і транзакція відкочується:

  • У БД версія залишилася рівною 1.
  • У Java-об’єкті в полі @Version уже зафіксована цифра 2.
  • Якщо спробувати зберегти цей самий Java-об’єкт у наступній транзакції, Hibernate виконає UPDATE ... WHERE id = ? AND version = 2 і не знайде жодного рядка, що спричинить хибний викид OptimisticLockException (StaleObjectStateException). Об’єкт після відкату необхідно вичитувати з бази даних наново.

4. Що станеться, якщо всередині методу @Transactional перехопити виняток через try-catch, але ніяк не повідомити Spring?

Відповідь: Відбудеться тихий коміт транзакції (Silent Commit) з частковим збереженням даних! Spring AOP Proxy перехоплює винятки лише тоді, коли вони вилітають за межі цільового методу. Якщо всередині методу виняток було перехоплено й приховано (catch (Exception e) { log.error("Error", e); }), Spring вважає, що метод завершився успішно. Інтерцептор викличе connection.commit(). Зміни, виконані до моменту збою, назавжди зафіксуються в базі даних, що призведе до грубого порушення цілісності бізнес-інваріантів.


🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

«Rollback — це операція скасування всіх змін поточної транзакції для гарантування принципу атомарності (ACID). У Spring @Transactional автоматично ініціює відкат тільки за умови виникнення RuntimeException та Error, тоді як перевірювані (checked) винятки за замовчуванням призводять до коміту. На рівні СУБД відкат у PostgreSQL відбувається миттєво ($O(1)$) шляхом позначення статусу транзакції як ABORTED у CLOG, а в MySQL InnoDB вимагає фізичного застосування журналу відкату Undo Log ($O(N)$). Критично пам’ятати: відкат у БД не повертає старі значення полів Java-об’єктів у купі JVM і не скасовує видані значення автоінкрементних Sequence».

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

  1. Поведінка Spring за замовчуванням: Відкат на RuntimeException + Error, коміт на checked Exception.
  2. UnexpectedRollbackException: Виникає, коли внутрішній метод із REQUIRED позначив транзакцію як rollback-only, а зовнішній метод перехопив помилку й спробував зафіксувати транзакцію (commit).
  3. Програмний відкат: TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
  4. Sequence / Auto-Increment: Ніколи не відкочуються під час ROLLBACK, спричиняючи дірки (gaps) у нумерації.
  5. L1 Cache Inconsistency: Об’єкти в пам’яті JVM зберігають мутації після відкату; сесія Hibernate стає брудною, потрібен entityManager.clear().

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

  • ❌ «Rollback повертає лічильники Sequence назад до колишніх значень» (Лічильники не є транзакційними, утворюються прогалини).
  • ❌ «Якщо перехопити RuntimeException у try-catch всередині сервісу, транзакція все одно відкотиться» (Якщо виняток не залишив межі проксі-методу й не викликано setRollbackOnly, Spring виконає коміт).
  • ❌ «Після відкату транзакції Java-об’єкт автоматично відновить свої старі значення полів» (Стан об’єктів у Heap JVM не контролюється СУБД і не відкочується).

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