Вопрос 18 · Раздел 11

Что такое rollback в транзакциях?

Как кнопка "Отменить" (Ctrl+Z) в текстовом редакторе — всё, что вы сделали после последнего сохранения, возвращается назад.

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Rollback (откат) — это операция отмены всех изменений текущей транзакции и возврат базы данных к состоянию до её начала.

Механизм: СУБД использует undo log (журнал старых значений). При rollback каждая изменённая строка восстанавливается из undo log. В Spring TransactionInterceptor вызывает Connection.rollback(), который сигнализирует СУБД откатить текущую транзакцию.

Простой пример

BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
-- Что-то пошло не так!
ROLLBACK;  -- Оба изменения отменены, балансы вернулись к исходным

Аналогия

Как кнопка “Отменить” (Ctrl+Z) в текстовом редакторе — всё, что вы сделали после последнего сохранения, возвращается назад.

Зачем нужен

Rollback гарантирует атомарность (буква A в ACID): либо все изменения сохраняются, либо ни одного.

Rollback в Spring

Spring автоматически делает rollback, когда @Transactional метод выбрасывает исключение.

@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
    Account fromAcc = repo.findById(from);
    Account toAcc = repo.findById(to);
    fromAcc.setBalance(fromAcc.getBalance().subtract(amount));
    toAcc.setBalance(toAcc.getBalance().add(amount));
    repo.save(fromAcc);
    repo.save(toAcc);
    // Если здесь вылетит RuntimeException — всё автоматически откатится
}

По умолчанию

Spring откатывает только при RuntimeException и Error. Checked Exceptions (наследники Exception) не вызывают откат — Spring сделает COMMIT.

Почему Spring так решил: RuntimeException = программная ошибка (NPE, IAE), от которой невозможно восстановиться → rollback. Checked Exception = ожидаемая бизнес-ситуация (пользователь не найден), которую вызывающий может обработать → commit.

Когда НЕ стоит делать rollback

  1. Невосстановимые побочные эффекты — email уже отправлен, платёж уже прошёл через внешний API. Rollback БД не отменит внешнее действие.
  2. Долгие транзакции с частичным успехом — лучше записать partial result и обработать позже, чем потерять всё.
  3. Логирование/аудит — эти данные должны выжить даже при rollback основной транзакции (используйте REQUIRES_NEW).

🟡 Middle Level

Как работает Rollback на уровне БД

  1. Все изменения записываются в Undo Log
  2. При ROLLBACK СУБД читает Undo Log в обратном порядке
  3. Выполняются компенсирующие действия, восстанавливая старые значения
  4. Блокировки снимаются только после завершения отката

Rollback в Spring Framework

Автоматический (декларативный)

Происходит при выбросе исключения из @Transactional метода.

Программный (manual)

@Transactional
public void someMethod() {
    try {
        // логика
    } catch (Exception e) {
        TransactionAspectSupport.currentTransactionStatus()
            .setRollbackOnly();
    }
}

Состояние Rollback-Only

Если в цепочке вызовов REQUIRED → REQUIRED один метод выбросил RuntimeException, Spring помечает транзакцию флагом rollback-only.

Даже если внешний метод перехватит исключение и попытается завершиться успешно — при выходе Spring увидит флаг и выбросит UnexpectedRollbackException.

Частичный откат (Savepoints)

Через Propagation.NESTED можно откатить не всю транзакцию, а только её часть до savepoint.

Когда НЕ использовать rollback

Ситуация Почему Альтернатива
Компенсирующая транзакция (Saga) Данные нужны для обратной операции Явная компенсирующая операция
Audit logging Лог должен сохраниться даже при ошибке REQUIRES_NEW для логов
Частичный успех в batch Один failed item ≠ rollback всего batch Обработать ошибку, продолжить

Распространённые ошибки

Ошибка Что происходит Как исправить
Catch и swallow исключения Spring не видит исключение → COMMIT Перебросить или setRollbackOnly()
@Transactional без rollbackFor на checked exception COMMIT вместо ROLLBACK rollbackFor = Exception.class
Ожидание полного rollback в REQUIRED chain Вложенный метод уже выставил rollback-only Использовать REQUIRES_NEW для независимых операций
Rollback и последующее использование EntityManager Session в неконсистентном состоянии Не переиспользовать EntityManager после rollback

Сравнение: автоматический vs ручной rollback

Подход Плюсы Минусы Когда использовать
Автоматический (@Transactional) Чистый код, минимум boilerplate Только при выходе исключения Стандартные сценарии
Ручной (setRollbackOnly) Условный rollback, без выброса Boilerplate, coupling к Spring API Бизнес-логика с condition

🔴 Senior Level

Rollback Mechanics — Database Level

PostgreSQL Rollback Process

1. Transaction enters abort state
2. All tuple modifications marked as dead:
   - Inserted tuples: xmin = aborted txid → invisible to all future txns
   - Updated tuples: old version remains (xmax not set), new version marked aborted
   - Deleted tuples: deletion is undone (xmax cleared)
3. Row-level locks released
4. Transaction state changed to ABORTED
5. XLOG record written: XACT_ABORT

Key insight: В PostgreSQL MVCC rollback почти полностью логический — tuple versions помечаются невалидными через статус транзакции. Физическая очистка происходит позже через VACUUM.

InnoDB Rollback Process

1. Transaction enters rollback state
2. Undo log records read in reverse order
3. For each undo record:
   - Restore old value to data page
   - Release associated locks
   - Update undo log pointer
4. Transaction state → ROLLED BACK
5. Trx sys history updated

InnoDB rollback более физический — data pages реально восстанавливаются в предыдущее состояние через undo log.

Spring Rollback Decision Tree

// Simplified AbstractPlatformTransactionManager
private void processRollback(DefaultTransactionStatus status, Throwable ex) {
    // 1. Check if rollback is actually needed
    if (ex != null && !status.getTransactionAttribute().rollbackOn(ex)) {
        return;  // Don't rollback — will commit instead!
    }

    // 2. Check if this is a nested transaction (savepoint)
    if (status.hasSavepoint()) {
        status.rollbackToHeldSavepoint();  // Rollback to savepoint only
        return;
    }

    // 3. Check if this is a new transaction
    if (status.isNewTransaction()) {
        status.getTransactionManager().doRollback(status);
    }
    // 4. Participating in existing transaction
    else {
        status.setRollbackOnly();  // Can't actually rollback, just mark
    }
}

UnexpectedRollbackException — Full Analysis

@Transactional  // REQUIRED — starts Tx1
public void outer() {
    try {
        inner();  // REQUIRED — joins Tx1
    } catch (RuntimeException e) {
        // Exception caught, но Tx1 уже помечен rollback-only
    }
    // Spring tries to commit Tx1 → sees rollback-only flag
    // Throws UnexpectedRollbackException
}

@Transactional  // REQUIRED — joins Tx1
public void inner() {
    repo.save(entity);
    throw new RuntimeException();  // TransactionInterceptor → setRollbackOnly()
}

Flow:

  1. outer() starts physical transaction Tx1
  2. inner() joins Tx1 (same connection)
  3. inner() throws RuntimeException
  4. TransactionInterceptor catches, calls setRollbackOnly() on Tx1
  5. outer() catches exception, returns normally
  6. TransactionInterceptor tries to COMMIT Tx1
  7. TransactionManager sees rollback-only flag
  8. Throws UnexpectedRollbackException

Решения:

// Option 1: Не перехватывать — пусть прокидывается
@Transactional
public void outer() {
    inner();  // Если inner падает — outer тоже rollbacks
}

// Option 2: REQUIRES_NEW для независимых операций
@Transactional
public void outer() {
    try {
        independentService.doWork();  // REQUIRES_NEW — отдельная транзакция
    } catch (Exception e) {
        // Безопасно — independent tx уже обработан
    }
}

// Option 3: Перехватить и перебросить
@Transactional
public void outer() {
    try {
        inner();
    } catch (RuntimeException e) {
        throw new BusinessException("Failed", e);  // Всё равно вызывает rollback
    }
}

Edge Cases (минимум 3)

  1. Rollback и Sequence: SELECT nextval('seq') НЕ откатывается. Sequence advances независимо от rollback. После rollback sequence value уже увеличен — следующий вызов вернёт следующее значение. Это приводит к gaps в sequence.

  2. Rollback и Hibernate @Version: Версия сущности НЕ восстанавливается при rollback. Entity считается stale — при последующих операциях OptimisticLockException.

  3. Rollback и L1 Cache: После rollback Hibernate Persistence Context остаётся с dirty entities. Entity всё ещё в L1 cache, но состояние не соответствует БД. Spring создаёт новый EntityManager для следующей транзакции, но если вы вручную переиспользуете — будет проблема.

  4. Partial flush перед rollback: Hibernate мог сделать flush некоторых изменений до возникновения исключения. На уровне БД они будут откачены, но в Hibernate session они остаются как persisted.

Performance Numbers: Rollback vs Commit

Операция PostgreSQL InnoDB
Rollback latency O(1) — меняется статус транзакции O(N) — undo каждый change
Commit latency fsync WAL (~1-5 ms) fsync redo log (~1-5 ms)
Lock release Fast (in-memory) Fast (in-memory)
Connection cleanup ~10-50 μs ~10-50 μs

Rollback в PostgreSQL быстрее commit (не нужен fsync). В InnoDB — зависит от количества изменений.

Memory Implications

  • Undo log: Растёт пропорционально объёму изменений в транзакции. Долгая транзакция с большим rollback = большой undo log.
  • Hibernate L1 cache: После rollback entities остаются в cache — memory не освобождается до закрытия EntityManager.
  • Connection pool: Connection возвращается в пул только после завершения rollback. Долгий rollback = connection занят дольше.

Thread Safety

Rollback всегда происходит в том же потоке, где выполнялась транзакция. ThreadLocal состояние (TransactionSynchronizationManager) гарантирует изоляцию. @Async методы имеют отдельный transaction context — их rollback не влияет на caller.

Production War Story

Сервис обработки платежей: outer() вызывал inner() с REQUIRED. inner() падал с DataIntegrityViolationException. outer() ловил Exception, логировал, и продолжал — ожидая, что транзакция жива. Вместо этого Spring бросил UnexpectedRollbackException на commit. Все платежи падали с 500. Fix: inner() переведён на REQUIRES_NEW — его rollback не влияет на outer транзакцию.

Monitoring

Debug logging:

logging:
  level:
    org.springframework.transaction: DEBUG
    org.hibernate.SQL: DEBUG

Micrometer metrics:

@Bean
public TransactionSynchronization transactionMetrics(MeterRegistry registry) {
    return new TransactionSynchronization() {
        @Override
        public void afterCompletion(int status) {
            if (status == STATUS_ROLLED_BACK) {
                registry.counter("transaction.rollback").increment();
            }
        }
    };
}

Actuator:

GET /actuator/metrics/transaction.rollback
GET /actuator/metrics/transaction.commit

Highload Best Practices

  1. Минимальная длительность транзакции: Чем дольше транзакция, тем больше undo log и выше стоимость rollback.
  2. Batch processing с savepoints: Разбивайте большие batch-операции на чанки. При ошибке одного чанка — rollback только его.
  3. Avoid catch-and-swallow: Если перехватили Exception — явно вызывайте setRollbackOnly() или перебрасывайте.
  4. Use REQUIRES_NEW для side-effects: Логирование, нотификации — не должны блокировать основную транзакцию при rollback.
  5. Monitor rollback rate: Высокий % rollback = проблема в бизнес-логике или валидации, не в транзакциях.
  6. Test rollback behavior: Integration tests должны проверять что данные действительно откатились, а не просто что exception был выброшен.

🎯 Шпаргалка для интервью

Обязательно знать:

  • Rollback = отмена всех изменений транзакции, возврат к состоянию до BEGIN (через undo log)
  • Spring автоматически rollback-ит при RuntimeException и Error, checked exceptions → commit
  • Rollback-only flag: inner REQUIRED метод выбросил RuntimeException → вся транзакция помечена на откат
  • UnexpectedRollbackException: outer пытается commit-ить, но транзакция уже marked rollback-only
  • Savepoints (Propagation.NESTED) позволяют частичный rollback без отката всей транзакции
  • Sequence values (nextval) НЕ откатываются — это приводит к gaps в sequence

Частые уточняющие вопросы:

  • Что будет если поймать exception в @Transactional методе? — Spring не видит exception → commit, нужен setRollbackOnly()
  • Чем rollback в PostgreSQL отличается от InnoDB? — PG: логический (статус tx), InnoDB: физический (undo log reverse)
  • Когда rollback нежелателен? — Невосстановимые side effects (email, external API), partial success в batch
  • Как реализовать условный rollback? — TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()

Красные флаги (НЕ говорить):

  • “Rollback откатывает sequence” — nextval не транзакционный, gaps неизбежны
  • “Поймал exception = rollback” — Spring не видит caught exceptions
  • “После rollback EntityManager可以继续 использовать” — Session в неконсистентном состоянии

Связанные темы:

  • [[19. Какие исключения по умолчанию вызывают rollback]]
  • [[20. Как настроить rollback для checked exceptions]]
  • [[14. Что делает Propagation.NESTED]]
  • [[1. Расшифруйте каждую букву ACID]]