💳 Раздел 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 {

    @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 vs MySQL

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

Сравнение механики ROLLBACK:

PostgreSQL (Логический откат O(1)):
  1. Сервер получает команду ROLLBACK (XACT_ABORT).
  2. Записывает 2 бита в Commit Log (pg_xact / CLOG): статус = 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 Trap)

Самая коварная проблема отката на уровне 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 миллионов операций физического восстановления, нагружая CPU и диск.

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

Ответ: При откате транзакции поле @Version у Java-объектов в памяти не возвращается к исходному значению. Если сущность имела версию 1, Hibernate при подготовке к коммиту инкрементирует ее в памяти до 2. Если затем на фазе выполнения оператора происходит ошибка и транзакция откатывается:

  • В БД версия осталась равной 1.
  • В Java-объекте в поле @Version уже записана цифра 2.
  • Если попытаться сохранить этот же Java-объект в следующей транзакции, Hibernate выполнит UPDATE ... WHERE id = ? AND version = 2 и не найдет ни одной строки, что приведет к ложному выбросу OptimisticLockException (StaleObjectStateException). Объект после отката должен быть вычитан из базы данных заново.

4. Что произойдет, если внутри блока @Transactional перехватить исключение через try-catch, но ничего не предпринять?

Ответ: Произойдет тихий коммит транзакции (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, а внешний перехватил ошибку и попытался закоммититься.
  3. Программный откат: TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
  4. Sequence / Auto-Increment: Никогда не откатываются при ROLLBACK, образуя разрывы в нумерации.
  5. L1 Cache Inconsistency: Сущности в памяти JVM сохраняют мутации после отката, требуется em.clear().

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

  • ❌ «Rollback возвращает счетчики Sequence обратно к прежним значениям» (Счетчики не транзакционны, образуются gaps).
  • ❌ «Если перехватить RuntimeException в try-catch внутри сервиса, транзакция все равно откатится» (Если исключение не покинуло метод и не вызван setRollbackOnly, Spring выполнит commit).
  • ❌ «После отката транзакции объект Java автоматически вернет свои старые значения полей» (Состояние объектов в куче JVM не откатывается).

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