Вы написали @Transactional на методе — и кажется, всё должно работать само. Но бывает, что транзакция не откатывается при ошибке, или открывается там, где вы её не ожидали. Разберём, как это устроено и где обычно ломается.
Как @Transactional вообще работает
Spring не «видит» аннотацию во время выполнения магически. Вместо этого он оборачивает ваш класс в прокси — специальный объект-обёртку. Когда кто-то снаружи вызывает метод с @Transactional, прокси перехватывает вызов, открывает транзакцию, вызывает оригинальный метод, и потом фиксирует или откатывает транзакцию. Отсюда вытекают все ограничения, которые кажутся странными, пока не знаешь об этом механизме.
Где ставить @Transactional
Аннотация должна жить на методах сервисного слоя — там, где одна операция делает несколько связанных изменений в базе данных.
@Component
class CreateOrderHandler {
@Transactional
public OrderId handle(CreateOrderCommand cmd) {
var customer = customerRepository.findById(cmd.customerId());
var order = orderFactory.create(customer, cmd.items());
orderRepository.save(order);
outboxRepository.publishEvent(new OrderCreated(order.id()));
return order.id();
}
}
Здесь транзакция гарантирует: либо сохранился заказ и событие в outbox — либо ничего.
Три места, где её ставят зря.
Аннотация на контроллере — HTTP-запрос и бизнес-операция — разные вещи. Транзакция должна ограничивать бизнес-действие, а не HTTP-цикл.
Аннотация на репозитории — избыточно: у Spring Data транзакция уже есть на каждом методе, но нескольких операций она не объединит — для этого аннотация нужна выше.
Аннотация на private-методе — прокси не может переопределить приватный метод, аннотация будет тихо проигнорирована. Со Spring 6 (Boot 3) работают и protected, и пакетно-видимые методы — но только когда прокси строится по классу (CGLIB, и это умолчание Boot). Если бин проксируется по интерфейсу, перехватить можно лишь то, что в интерфейсе объявлено, а там всё public. И private не сработает никогда. Отсюда простое правило: держите транзакционные методы public, так меньше сюрпризов.
Главная ловушка: self-invocation
Это самая частая причина, почему @Transactional «не работает». Если метод вызывает другой метод того же класса через this, прокси не участвует — вызов идёт напрямую, в обход обёртки.
@Component
class OrderService {
public void processBatch(List<OrderId> ids) {
for (var id : ids) {
processOne(id); // вызов через this — прокси не задействован!
}
}
@Transactional
public void processOne(OrderId id) {
// транзакция НЕ откроется
}
}
Исправление — вынести метод в отдельный бин, тогда вызов пройдёт через прокси. Не хочется второго бина — тот же результат даёт TransactionTemplate, он открывает транзакцию явно и прокси не нужен; о нём в разделе про длинные транзакции. С отдельным бином это выглядит так:
@Component
class OrderService {
private final OrderProcessor processor;
public void processBatch(List<OrderId> ids) {
for (var id : ids) {
processor.processOne(id); // через DI — через прокси — транзакция открывается
}
}
}
@Component
class OrderProcessor {
@Transactional
public void processOne(OrderId id) { ... }
}
Режимы propagation
Propagation определяет, что делать, если транзакция уже открыта, когда вызывается метод с @Transactional.
| Режим | Поведение |
|---|---|
REQUIRED (по умолчанию) | Если транзакция есть — присоединиться. Если нет — открыть новую. |
REQUIRES_NEW | Приостановить текущую транзакцию и открыть новую, отдельную. |
NESTED | Создать точку сохранения (savepoint) внутри текущей транзакции. |
MANDATORY | Транзакция должна уже быть открыта, иначе — исключение. |
SUPPORTS | Есть транзакция — присоединиться, нет — работать без неё. |
NOT_SUPPORTED | Приостановить текущую и выполнить метод вне транзакции. |
NEVER | Транзакции быть не должно: если она есть — исключение. |
Разница между тремя первыми режимами — в том, сколько занято соединений и что переживёт откат внешней транзакции.
Три нижних режима встречаются реже, но каждый решает свою задачу. MANDATORY защищает метод, который обязан работать внутри чужой транзакции: репозиторный метод, меняющий половину агрегата, при прямом вызове из контроллера честно упадёт, а не запишет половину. NOT_SUPPORTED приостанавливает транзакцию на время долгого чтения или отчёта, освобождая соединение от роли участника. NEVER — прямой запрет, его ставят на методы, которые точно не должны попасть в транзакцию, например на вызов внешней службы. SUPPORTS почти всегда означает «автор не решил»: метод работает и так и так, а значит, гарантий не даёт никаких.
Три режима — три судьбы вложенного вызова: войти в ту же транзакцию, уехать на своё соединение или поставить точку сохранения внутри текущей. Откат внешней транзакции отменяет первый и третий случай и не трогает второй.
В большинстве случаев нужен REQUIRED.
UnexpectedRollbackException: поймали исключение, а коммит всё равно упал
Самая частая жалоба на @Transactional звучит так: «я же обработал ошибку, почему транзакция откатилась?» Причина в том, как устроен REQUIRED. Вложенный метод не открывает свою транзакцию — он присоединяется к внешней; когда внутри вылетает исключение, прокси вложенного метода помечает общую транзакцию как «только на откат». Внешний метод может это исключение поймать и спокойно продолжить работу, но на коммите Spring увидит метку и бросит:
org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only
Пример импорта пачки выше — ровно этот случай, если у saveItem оставить REQUIRED вместо NESTED: первый же плохой элемент пометит транзакцию, цикл честно доработает до конца, а вся пачка не запишется. Ошибка коварна тем, что показывает на коммит, то есть на место, где ничего не ломалось.
Лечится выбором, а не обходом. Если сбой одного элемента не должен рушить всё — вложенному методу нужна своя граница: NESTED (точка сохранения) или REQUIRES_NEW (отдельная транзакция и отдельное соединение). Если сбой должен рушить всё — исключение не ловят, а дают ему дойти до внешнего прокси. А вот вариант «поймать и сделать вид, что ничего не было» внутри одной транзакции не существует: решение принимает не тот, кто ловит, а тот, кто пометил.
REQUIRES_NEW — когда нужна отдельная транзакция
Типичный пример: журнал аудита. Запись в журнал должна сохраниться, даже если основная операция упала и откатилась.
@Transactional
public void processPayment(PaymentRequest req) {
auditService.logAttempt(req); // должен записаться даже при ошибке
paymentGateway.charge(req);
}
@Component
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAttempt(PaymentRequest req) { ... }
}
Важная деталь: REQUIRES_NEW берёт отдельное соединение из пула, а текущее остаётся занятым — одна операция держит сразу два. Под нагрузкой это не просто удвоенный расход. Представьте пул на десять соединений и десять одновременных внешних транзакций: все десять заняты, и каждая ждёт одиннадцатого соединения для вложенного вызова. Освободить его может только та транзакция, которая сама стоит в этой очереди. Приложение встаёт намертво и не по таймауту запроса, а насовсем. Поэтому пул должен быть заметно больше, чем число внешних транзакций, которые могут идти одновременно.
NESTED — откат части операции
Если нужно попробовать что-то и откатить только эту часть при ошибке, не трогая остальное:
@Transactional
public void importBatch(List<Item> items) {
for (var item : items) {
try {
processor.saveItem(item);
} catch (Exception e) {
log.warn("пропускаем {}: {}", item, e.getMessage());
}
}
}
@Component
class ItemProcessor {
@Transactional(propagation = Propagation.NESTED)
public void saveItem(Item item) { ... }
}
Откат savepoint'а при ошибке оставляет внешнюю транзакцию живой — остальные элементы продолжают обрабатываться.
Условие, о котором легко забыть: точку сохранения ставит не сам Spring, а JDBC-соединение под ним. С DataSourceTransactionManager (JDBC, jOOQ) соединение под рукой, и NESTED работает сразу. JpaTransactionManager вложенные транзакции тоже разрешает — он включает их сам при создании, — но до соединения ему надо добраться через диалект JPA-провайдера. У Hibernate с настроенным DataSource диалект соединение отдаёт, и всё работает; если провайдер его не отдаёт, прилетит NestedTransactionNotSupportedException с текстом «JpaDialect does not support savepoints».
isolation: уровень изоляции у той же аннотации
У @Transactional есть и атрибут isolation, он же BEGIN ISOLATION LEVEL в чистом SQL:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public Report buildReport(LocalDate day) { ... }
Две оговорки, из-за которых этот атрибут чаще не работает, чем работает. Первая: уровень нельзя поднять у вложенного REQUIRED-метода — он присоединяется к существующей транзакции и получает её уровень, молча. Нужен другой уровень — нужна отдельная транзакция. Вторая: подняв уровень, вы обязаны обработать ошибку сериализации (SQLSTATE 40001) и повторить транзакцию целиком, снаружи. Как именно — в статье про уровни изоляции; блокировки строк, которые часто нужны вместо повышения уровня, — в статье про блокировки.
readOnly = true
@Transactional(readOnly = true)
public List<OrderView> findOrdersByCustomer(long customerId) { ... }
Флаг readOnly даёт три эффекта:
- Spring зовёт у соединения
setReadOnly(true), а что с этим делать — решает драйвер. Драйвер PostgreSQL с настройками по умолчанию (readOnlyMode=transaction) открывает транзакцию какBEGIN READ ONLY, и случайныйUPDATEв отчётном методе падает с ошибкой сразу. Но если драйверу выставилиreadOnlyMode=ignore, он не сделает ничего — тогда флаг остаётся подсказкой для Hibernate, а на защиту от записи рассчитывать нельзя. - Hibernate (JPA) отключает dirty checking и flush — немного меньше работы для CPU.
- Если настроен routing DataSource с read-репликой — соединение направляется туда автоматически.
Почему checked-исключения не откатывают транзакцию
Это историческое решение Spring: по умолчанию транзакция откатывается только при RuntimeException и Error. Checked-исключения (IOException, SQLException и прочие) транзакцию не откатывают — она зафиксируется.
Всё правило умещается в один try — вот оно на чистой Java:
живой пример
import java.io.IOException;
import java.util.concurrent.Callable;
public class RollbackRuleDemo {
static void inTransaction(String method, Callable<Void> body) {
System.out.println("BEGIN " + method);
try {
body.call();
System.out.println("COMMIT " + method);
} catch (RuntimeException | Error e) {
System.out.println("ROLLBACK " + method + " <- " + e.getClass().getSimpleName());
} catch (Exception checked) {
System.out.println("COMMIT " + method + " <- " + checked.getClass().getSimpleName()
+ ", но откат правилом не предусмотрен");
}
}
public static void main(String[] args) {
inTransaction("saveReport", () -> { throw new IOException("файл не записался"); });
inTransaction("payOrder", () -> { throw new IllegalStateException("заказ уже оплачен"); });
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Метод упал — а в выводе COMMIT: данные записались наполовину, и никто этого не заметил.
Два способа это исправить:
Вариант 1 — явно указать rollbackFor:
@Transactional(rollbackFor = Exception.class)
public void doWork() throws IOException { ... }
Вариант 2 — обернуть в RuntimeException (чаще предпочтителен):
@Transactional
public void doWork() {
try {
externalCall();
} catch (IOException e) {
throw new ExternalCallFailedException(e); // RuntimeException
}
}
Второй вариант явнее выражает контракт: метод без checked-исключений в сигнатуре, ошибки — runtime, поведение предсказуемо.
Транзакция и async
Транзакция живёт в потоке: соединение и признак «транзакция открыта» Spring держит в ThreadLocal. В другом потоке этого нет:
живой пример
public class TxContextDemo {
static final ThreadLocal<String> CURRENT_TX = new ThreadLocal<>();
static void report(String where) {
String tx = CURRENT_TX.get();
System.out.println(where + ": " + (tx == null ? "транзакции нет" : "транзакция " + tx));
}
public static void main(String[] args) throws InterruptedException {
CURRENT_TX.set("tx-42");
report("поток запроса");
Thread worker = new Thread(() -> report("поток @Async"));
worker.start();
worker.join();
CURRENT_TX.remove();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Поэтому асинхронный метод открывает свою транзакцию, а не продолжает родительскую:
@Component
class OrderService {
private final Notifier notifier; // отдельный бин: вызов идёт через прокси
@Transactional
public void mainOp() {
orderRepo.save(...);
notifier.notifyAsync(); // уедет в другой поток, со своей транзакцией
}
}
@Component
class Notifier {
@Async
@Transactional
public void notifyAsync() { ... }
}
@Async работает через тот же механизм прокси, что и @Transactional: перехват случается только на входе в бин снаружи. Оставь мы notifyAsync в OrderService, вызов через this прошёл бы мимо обёртки — не уехал бы ни в какой другой поток и отработал бы прямо внутри транзакции mainOp.
Если нужно выполнить что-то после успешного коммита транзакции, используйте @TransactionalEventListener:
@Component
class OrderEventHandler {
@TransactionalEventListener // AFTER_COMMIT по умолчанию
public void onOrderCreated(OrderCreated event) {
emailService.send(...);
}
}
Слушатель вызовется только если транзакция зафиксировалась — без риска отправить письмо при откате.
Две вещи, которые в этом месте ломаются чаще всего.
Первая: слушатель AFTER_COMMIT работает уже без активной транзакции — она зафиксирована, соединение отпущено. Поэтому запись в базу прямо из такого слушателя молча не сохранится: Hibernate выполнит её вне транзакции и без коммита. Нужна запись — пометьте слушатель @Transactional(propagation = Propagation.REQUIRES_NEW), тогда у него будет своя, новая транзакция.
Вторая: событие должно быть опубликовано внутри транзакции. @TransactionalEventListener подписывается на момент её завершения, а если активной транзакции при публикации не было, слушателю не к чему привязаться, и он не вызовется вовсе — тихо, без ошибки. Так теряют уведомления, когда публикацию случайно выносят из транзакционного метода или зовут из планировщика. Если такое поведение нужно оставить рабочим, есть fallbackExecution = true — он разрешает вызов и без транзакции.
И общее ограничение: слушатель после коммита не может повлиять на результат. Упал он — транзакция уже записана, и повторять его придётся самому. Когда доставка обязана быть гарантированной, событие кладут в ту же базу в той же транзакции и публикуют отдельным процессом — это Outbox из следующего раздела.
@PostConstruct и транзакции
Ещё одна частая ошибка: поставить @Transactional на метод @PostConstruct. Это не работает — @PostConstruct вызывается до того, как Spring успевает обернуть бин в прокси.
// не работает
@PostConstruct
@Transactional
public void init() { ... }
// работает — этот метод вызовется когда Spring уже полностью готов
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initData() { ... }
Kafka, HTTP и другие внешние ресурсы
@Transactional управляет только базой данных. Отправка в Kafka, HTTP-запросы, запись в Redis — не входят в транзакцию. Если транзакция откатилась, уже отправленное сообщение в Kafka никуда не денется:
// так не надо: если TX откатится, сообщение уже ушло
@Transactional
public OrderId createOrder(CreateOrderCommand cmd) {
var order = orderRepo.save(...);
kafkaTemplate.send("orders.created", order); // не отменить при rollback
return order.id();
}
Решение — паттерн Outbox: сообщение сохраняется в ту же базу данных в той же транзакции, а отдельный процесс потом его публикует:
// так надо
@Transactional
public OrderId createOrder(CreateOrderCommand cmd) {
var order = orderRepo.save(...);
outboxRepo.save(new OutboxEvent("OrderCreated", order.id(), payload));
return order.id();
// если TX откатится — оба insert откатятся вместе
}
Длинные транзакции
Транзакция удерживает соединение с базой данных. Если внутри транзакции происходят долгие внешние вызовы (HTTP, сторонние API), соединение занято всё это время:
// плохо: соединение занято на 5+ секунд
@Transactional
public void processOrder(OrderId id) {
var order = orderRepo.find(id);
paymentGateway.charge(order.total()); // HTTP, ~3 сек
deliveryService.scheduleDelivery(order); // HTTP, ~2 сек
order.confirm();
orderRepo.save(order);
}
Правильный подход — внешние вызовы вне транзакции, а изменения в базе данных — в коротких отдельных транзакциях:
// оркестратор без @Transactional
public void processOrder(OrderId id) {
var order = orderStorage.load(id); // короткая TX, соединение освобождается
paymentGateway.charge(order.total()); // вне TX
deliveryService.scheduleDelivery(order); // вне TX
orderStorage.confirm(id); // короткая TX
}
// отдельный бин — вызов идёт через прокси
@Component
class OrderStorage {
@Transactional
public Order load(OrderId id) { return orderRepo.find(id); }
@Transactional
public void confirm(OrderId id) {
var order = orderRepo.find(id);
order.confirm();
orderRepo.save(order);
}
}
Короткие методы вынесены в отдельный класс не для красоты: рядом, в том же OrderService, вызов load(id) пошёл бы мимо прокси — та самая ловушка самовызова. Не хочется второго бина — тот же результат даёт TransactionTemplate: он открывает транзакцию явно, без прокси.
Транзакция должна жить секунды, а не минуты.
timeout: ограничить транзакцию сверху
У аннотации есть атрибут timeout — в секундах:
@Transactional(timeout = 5)
public void processOrder(OrderId id) { ... }
Работает он не так, как многие ждут. Spring переводит его в Statement.setQueryTimeout для запросов этой транзакции и проверяет остаток времени перед каждым следующим запросом — то есть он ограничивает работу с базой, а не метод целиком. Транзакция, которая пять секунд ждёт ответа чужого HTTP-сервиса между двумя запросами, этим таймаутом не прервётся. И ещё: драйвер ограничивает ожидание на своей стороне, а на стороне сервера запрос может продолжать работать; чтобы он точно прекратился, тот же предел ставят и в базе — statement_timeout на роль приложения.
Как проверить, что транзакция вообще открылась
Половина вопросов «почему не откатилось» снимается одной проверкой — а была ли транзакция. Два способа.
Включить журнал: уровень DEBUG у org.springframework.transaction печатает Creating new transaction, Participating in existing transaction, Initiating transaction commit — по этим строкам сразу видно, открылась транзакция или вызов прошёл мимо прокси и присоединяться было не к чему.
Спросить из кода: TransactionSynchronizationManager.isActualTransactionActive() возвращает true, только если транзакция действительно открыта, а getCurrentTransactionName() покажет, чей это метод. Строчку с такой проверкой ставят в подозрительный метод на время разбирательства — она отвечает на вопрос точнее любых рассуждений о прокси.
Коротко
- Всё держится на прокси: аннотация работает только при вызове извне класса, на
private-методах молчит, а вызов черезthisобходит обёртку — лечится отдельным бином илиTransactionTemplate. - Место аннотации — сервисный слой: не контроллер (он про HTTP-цикл) и не репозиторий (он не объединит несколько операций в одну).
REQUIRED— почти всегда правильный выбор;REQUIRES_NEWберёт второе соединение из пула,NESTEDставит точку сохранения и требуетDataSourceTransactionManager.- Откат по умолчанию только на
RuntimeExceptionиError— checked-исключение зафиксирует транзакцию; лечитсяrollbackForили обёрткой в runtime-исключение. readOnly = trueна читающих методах: база запретит запись, Hibernate не будет сверять изменения, соединение может уйти на реплику.- Транзакция не выходит за пределы своего потока и своей базы: после коммита —
@TransactionalEventListener, для Kafka — Outbox; внешние вызовы — за её границами, чтобы она жила секунды. - Поймали исключение из вложенного
REQUIRED-метода — транзакция уже помечена на откат, и коммит упадётUnexpectedRollbackException: нужна своя граница (NESTEDилиREQUIRES_NEW), а неtry/catch. - Слушатель
AFTER_COMMITработает без активной транзакции (запись из него требуетREQUIRES_NEW), а событие вне транзакции не вызовет его вовсе. timeoutограничивает запросы к базе, а не метод; на стороне сервера его дублируютstatement_timeout. Открылась ли транзакция, показывают журналorg.springframework.transactionнаDEBUGиTransactionSynchronizationManager.isActualTransactionActive().
Что почитать дальше
- Уровни изоляции транзакций в PostgreSQL — READ COMMITTED, REPEATABLE READ, SERIALIZABLE и аномалии.
- Блокировки в PostgreSQL — SELECT FOR UPDATE и другие виды блокировок.
- Connection pooling — HikariCP и PgBouncer — почему REQUIRES_NEW опасен при высокой нагрузке.
- Spring AOP — как прокси устроен под капотом.