Что такое 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:
- Метод
outer()вызывает методinner(). - Оба метода выполняются в одной физической транзакции.
- В методе
inner()происходитRuntimeException. TransactionInterceptorвнутреннего метода перехватывает ошибку и вызываетtransactionStatus.setRollbackOnly().- Если метод
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");
}
Что произошло:
- База данных выполнила
ROLLBACK: в таблице на диске баланс остался равным, например,10 000. - В объекте Java
accountполеbalanceпо-прежнему равно0! - Сессия Hibernate (
PersistenceContext) после отката находится в невалидном состоянии. - Если приложение не уничтожит этот экземпляр
EntityManager(например, в длинных пакетных обработчиках или фоновых задачах), последующие вызовы могут прочитать искаженные данные прямо из L1-кэша. - 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».
Чек-лист ключевых понятий
- Поведение Spring по умолчанию: Откат на
RuntimeException+Error, коммит на checkedException. - UnexpectedRollbackException: Возникает, когда внутренний
REQUIRED-метод пометил транзакциюrollback-only, а внешний перехватил ошибку и попытался закоммититься. - Программный откат:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(). - Sequence / Auto-Increment: Никогда не откатываются при
ROLLBACK, образуя разрывы в нумерации. - L1 Cache Inconsistency: Сущности в памяти JVM сохраняют мутации после отката, требуется
em.clear().
Красные флаги (чего ни в коем случае нельзя говорить)
- ❌ «Rollback возвращает счетчики Sequence обратно к прежним значениям» (Счетчики не транзакционны, образуются gaps).
- ❌ «Если перехватить RuntimeException в try-catch внутри сервиса, транзакция все равно откатится» (Если исключение не покинуло метод и не вызван
setRollbackOnly, Spring выполнит commit). - ❌ «После отката транзакции объект Java автоматически вернет свои старые значения полей» (Состояние объектов в куче JVM не откатывается).