@Transactional — это одна короткая аннотация, за которой стоит много неочевидной механики. Пока всё работает, об этом можно не думать. Когда перестаёт — выясняется, что аннотация ведёт себя не так, как кажется. Разберём с нуля: что такое транзакция, как Spring её включает и где самые частые грабли.
Что такое транзакция
Представьте перевод денег: списать со счёта A и зачислить на счёт B. Это две операции с базой, но для бизнеса они должны быть неделимы: либо обе прошли, либо обе не прошли. Если списание прошло, а зачисление упало — деньги пропали.
Транзакция — это группа операций с базой данных, которая выполняется по принципу «всё или ничего». Внутри транзакции:
- если всё прошло успешно — изменения фиксируются (commit);
- если случилась ошибка — все изменения откатываются (rollback), как будто их не было.
Без транзакции каждый INSERT/UPDATE сохраняется сам по себе, и при сбое в середине вы остаётесь с частично записанными данными.
Зачем нужна @Transactional
Раньше транзакциями управляли руками: открыть, в конце закоммитить, в catch откатить, в finally закрыть соединение. Это длинно и легко ошибиться — забыть rollback, забыть закрыть соединение.
Connection conn = dataSource.getConnection();
try {
conn.setAutoCommit(false);
accountRepo.withdraw(from, amount);
accountRepo.deposit(to, amount);
conn.commit();
} catch (Exception e) {
conn.rollback();
throw e;
} finally {
conn.close();
}
@Transactional убирает всю эту обвязку. Вы помечаете метод аннотацией, а Spring сам открывает транзакцию перед методом, коммитит после успешного завершения и откатывает при ошибке.
@Service
public class TransferService {
@Transactional
public void transfer(AccountId from, AccountId to, Money amount) {
accountRepo.withdraw(from, amount);
accountRepo.deposit(to, amount);
}
}
Если внутри transfer вылетит исключение — оба изменения откатятся автоматически.
Как это работает: прокси
Чтобы понять ловушки, надо знать главное: @Transactional работает через прокси.
Прокси — это объект-обёртка. Когда вы помечаете бин аннотацией, Spring подкладывает в контейнер не сам ваш объект, а обёртку вокруг него. Обёртка перехватывает вызовы методов: перед методом открывает транзакцию, после — коммитит или откатывает, а сам метод вызывает «внутри».
Вызов → [Прокси: открыть транзакцию] → ваш метод → [Прокси: commit / rollback]
Это удобно, но рождает несколько ловушек: всё держится на том, что вызов проходит через прокси. Если вызов идёт мимо обёртки — транзакции не будет.
Ловушка 1: вызов через this (self-invocation)
Самая частая. Когда метод вызывает другой метод того же класса через this (явно или неявно), вызов идёт напрямую, минуя прокси. Аннотация на втором методе не сработает.
@Service
public class OrderService {
public void processBatch(List<Order> orders) {
orders.forEach(this::processOne); // вызов через this — мимо прокси
}
@Transactional
public void processOne(Order order) {
// транзакция НЕ откроется, потому что вызвали через this
}
}
Решение: вынести метод с @Transactional в отдельный бин и вызывать его как зависимость — тогда вызов пойдёт через прокси.
@Service
public class OrderService {
private final OrderProcessor processor; // другой бин
public void processBatch(List<Order> orders) {
orders.forEach(processor::processOne); // через прокси — работает
}
}
Ловушка 2: private-методы
Прокси может обернуть только то, что видит снаружи. На private-методе @Transactional молча игнорируется — без ошибки, что особенно коварно.
@Service
public class OrderService {
public void process(Order order) {
save(order);
}
@Transactional // игнорируется — метод private
private void save(Order order) { ... }
}
Правило простое: @Transactional ставьте только на public-методы. По той же причине не сработает аннотация на final-методе или final-классе — прокси не может его переопределить.
Ловушка 3: проглоченное исключение
Откат происходит, только если исключение вылетает из метода наружу, до прокси. Если вы поймали его внутри в try/catch и не пробросили дальше — Spring о сбое не узнает и закоммитит.
@Transactional
public void process(Order order) {
try {
repo.save(order);
externalCall(); // здесь упало
} catch (Exception e) {
log.warn("ошибка", e); // поймали и проглотили — отката НЕ будет
}
}
Если внутри транзакции что-то пошло не так и это требует отката — исключение нужно пробросить дальше.
Откат по умолчанию: только unchecked
Даже когда исключение вылетает наружу, откат происходит не на любое. По умолчанию Spring откатывает транзакцию только на RuntimeException и Error (так называемые unchecked-исключения).
На checked-исключение (которое надо объявлять в throws — например IOException) транзакция по умолчанию коммитится. Логика Spring такая: checked-исключение — это часть «нормального» сценария, а не сбой.
@Transactional
public void process() throws IOException {
repo.save(...);
throw new IOException("..."); // транзакция КОММИТНЕТСЯ, не откатится
}
Это сюрприз для многих. Два способа исправить:
- явно указать, на что откатывать:
@Transactional(rollbackFor = Exception.class); - в своём коде бросать наследников
RuntimeException— тогда откат работает «из коробки».
Propagation — что делать с уже открытой транзакцией
Propagation (распространение) отвечает на вопрос: метод вызвали внутри уже идущей транзакции — присоединиться к ней или открыть свою? Режимов семь, на практике важны три.
REQUIRED — по умолчанию
«Есть транзакция — присоединись к ней; нет — открой новую». Всё выполняется в одной транзакции с общим commit/rollback. Это поведение по умолчанию, его получают 90% методов.
@Transactional // REQUIRED
public void placeOrder(Order order) {
orderRepo.save(order);
auditService.log(order); // если log тоже @Transactional — та же транзакция
}
Следствие: если в конце что-то упадёт, откатится всё, включая запись аудита.
REQUIRES_NEW — всегда своя транзакция
Текущая транзакция приостанавливается, открывается новая и независимая. Она коммитится сама по себе, после чего исходная транзакция продолжается.
@Transactional
public void process(Order order) {
orderRepo.save(order);
auditService.log(order); // REQUIRES_NEW — отдельная транзакция
throw new RuntimeException("..."); // основная откатится,
// но запись аудита уже сохранена
}
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(Order order) { ... }
}
Нужно для независимых побочных действий — аудит, журнал, уведомление: «основное дело провалилось, но факт попытки должен остаться».
NESTED — точка сохранения внутри транзакции
Создаёт savepoint (точку сохранения) внутри текущей транзакции. Если вложенный кусок упал — откатывается только до точки сохранения, а не вся транзакция.
@Transactional
public void processBatch(List<Order> orders) {
for (var order : orders) {
try {
processor.tryProcess(order); // NESTED
} catch (Exception e) {
log.warn("пропущен: {}", order.id()); // один сбойный не рушит весь пакет
}
}
}
Работает поверх JDBC и PostgreSQL (через SAVEPOINT).
Остальные четыре — кратко
MANDATORY— требует уже открытую транзакцию, иначе ошибка. «Меня нельзя вызывать вне транзакции».SUPPORTS— есть транзакция, присоединюсь; нет — выполнюсь без неё.NOT_SUPPORTED— приостановить транзакцию и выполниться без неё.NEVER— если транзакция есть, бросить ошибку.
Isolation — как транзакции видят друг друга
Isolation (уровень изоляции) определяет, насколько параллельные транзакции «видят» незавершённые изменения друг друга. Чем выше уровень — тем меньше странных эффектов от конкуренции, но тем дороже по производительности.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(AccountId from, AccountId to, Money amount) { ... }
Уровней четыре плюс DEFAULT (берётся из настроек базы — в PostgreSQL это READ_COMMITTED). На старте достаточно знать, что для большинства задач хватает значения по умолчанию, а менять уровень нужно осознанно — детали про сами уровни в статье про ACID в PostgreSQL.
Одно практическое предупреждение: на высоких уровнях (например SERIALIZABLE) база при конфликте конкурентных транзакций может одну из них отклонить с ошибкой. Поэтому такой код должен уметь повторять операцию (retry), иначе будут случайные сбои под нагрузкой.
readOnly = true — пометка «только чтение»
Если метод только читает и ничего не пишет, отметьте транзакцию как read-only:
@Transactional(readOnly = true)
public List<Order> findByCustomer(CustomerId id) {
return repo.findByCustomerId(id);
}
Зачем это нужно (особенно с JPA/Hibernate):
- Hibernate отключает отслеживание изменений — не сравнивает объекты «до и после» и не делает лишних запросов;
- пул соединений может направить запрос на read-реплику, если она настроена.
Дешёвое улучшение: ставьте readOnly = true на любой метод, который только читает.
Несколько баз или база плюс Kafka — почему «одной транзакцией» не выйдет
Иногда хочется обернуть в одну транзакцию две базы или базу и отправку сообщения в очередь. Технически в Spring есть механизмы для этого, но на практике так не делают:
- это медленно — нужна синхронная координация между системами;
- это хрупко — если одна из систем зависнет, транзакция останется в неопределённом состоянии;
- многие облачные базы такой режим не поддерживают.
Правильный подход для таких случаев — паттерн Outbox (записать в ту же базу таблицу «исходящих», а отдельный процесс уже разошлёт сообщения) и Saga. Подробно — в Distributed Patterns.
Коротко
- Транзакция — группа операций с базой по принципу «всё или ничего»: либо commit, либо rollback.
@Transactionalснимает ручную обвязку: Spring сам открывает, коммитит и откатывает транзакцию.- Работает через прокси — вызов должен идти через обёртку, иначе транзакции не будет.
- Не сработает: вызов через
this(self-invocation),private/final-методы, проглоченноеtry/catchисключение. - Ставьте
@Transactionalтолько наpublic-методы. - По умолчанию откат — только на
RuntimeException/Error; на checked-исключения транзакция коммитится. Чинится черезrollbackFor. - Propagation:
REQUIRED(по умолчанию, присоединиться/открыть),REQUIRES_NEW(отдельная транзакция),NESTED(savepoint). - Isolation меняйте осознанно; на высоких уровнях код должен уметь повторять операцию при конфликте.
readOnly = true— для методов, которые только читают; дешёвый прирост с JPA.- Несколько баз / база + очередь одной транзакцией не оборачивают — используют Outbox и Saga.
Что почитать дальше
- ACID и уровни изоляции в PostgreSQL — что реально гарантируют уровни isolation.
- Spring Data JPA — как транзакции связаны с сессией JPA и проблемой OSIV.
- Spring Events —
@TransactionalEventListenerи события, привязанные к фазам транзакции. - Spring AOP — как аннотации вроде
@Transactionalпревращают бин в прокси.