Вы написали @Transactional на методе — и кажется, всё должно работать само. Но бывает, что транзакция не откатывается при ошибке, или открывается там, где вы её не ожидали. Разберём как это устроено и где обычно ломается.
Как @Transactional вообще работает
Spring не «видит» аннотацию во время выполнения магически. Вместо этого он оборачивает ваш класс в прокси — специальный объект-обёртку. Когда кто-то снаружи вызывает метод с @Transactional, прокси перехватывает вызов, открывает транзакцию, вызывает оригинальный метод, и потом фиксирует или откатывает транзакцию.
Вызывающий код
↓
Прокси (открывает TX)
↓
Ваш метод
↓
Прокси (commit или rollback)
Отсюда вытекают все ограничения, которые кажутся странными, пока не знаешь об этом механизме.
Где ставить @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 AOP не видит приватные методы. Аннотация будет тихо проигнорирована. Метод должен быть
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) {
// транзакция НЕ откроется
}
}
Исправление — вынести метод в отдельный бин. Тогда вызов пройдёт через прокси:
@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 | Транзакция должна уже быть открыта, иначе — исключение. |
В большинстве случаев нужен REQUIRED. Остальные — для конкретных сценариев.
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());
}
}
}
@Transactional(propagation = Propagation.NESTED)
public void saveItem(Item item) { ... }
Откат savepoint'а при ошибке оставляет внешнюю транзакцию живой — остальные элементы продолжают обрабатываться.
readOnly = true
@Transactional(readOnly = true)
public List<OrderView> findOrdersByCustomer(long customerId) { ... }
Флаг readOnly даёт три эффекта:
- JDBC передаёт базе данных
SET TRANSACTION READ ONLY— PostgreSQL не создаёт снимок для write-операций. - Hibernate (JPA) отключает dirty checking и flush — немного меньше работы для CPU.
- Если настроен routing DataSource с read-репликой — соединение направляется туда автоматически.
Ставьте readOnly = true на все методы, которые только читают данные.
Почему checked-исключения не откатывают транзакцию
Это историческое решение Spring: по умолчанию транзакция откатывается только при RuntimeException и Error. Checked-исключения (IOException, SQLException и прочие) транзакцию не откатывают — она зафиксируется.
@Transactional
public void doWork() throws IOException {
repository.save(...);
if (someCondition) throw new IOException(); // транзакция зафиксируется, не откатится!
}
Два способа это исправить:
Вариант 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
Транзакция не передаётся в другой поток. Если метод запускает асинхронную операцию, она выполнится уже без транзакции:
@Transactional
public void mainOp() {
repository.save(...);
doSomethingAsync(); // выполнится в другом потоке, БЕЗ транзакции
}
@Async
@Transactional
public CompletableFuture<Void> doSomethingAsync() {
// это откроет СВОЮ новую транзакцию, не родительскую
}
Если нужно выполнить что-то после успешного коммита транзакции, используйте @TransactionalEventListener:
@Component
class OrderEventHandler {
@TransactionalEventListener // AFTER_COMMIT по умолчанию
public void onOrderCreated(OrderCreated event) {
emailService.send(...);
}
}
Слушатель вызовется только если транзакция зафиксировалась — без риска отправить письмо при откате.
@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);
}
Правильный подход — внешние вызовы вне транзакции, а изменения в базе данных — в коротких отдельных транзакциях:
public void processOrder(OrderId id) {
var order = loadOrder(id); // короткая TX, соединение освобождается
paymentGateway.charge(order.total()); // вне TX
deliveryService.scheduleDelivery(order); // вне TX
confirmOrder(id); // короткая TX
}
@Transactional
Order loadOrder(OrderId id) { return orderRepo.find(id); }
@Transactional
void confirmOrder(OrderId id) {
var order = orderRepo.find(id);
order.confirm();
orderRepo.save(order);
}
Транзакция должна жить секунды, а не минуты.
Коротко
@Transactionalработает через прокси — только наpublic-методах, только при вызове извне класса.- Self-invocation (вызов через
this) обходит прокси — транзакция не открывается. Решение — отдельный бин. - Ставьте на методах сервисного слоя, не на контроллерах и не на репозиториях.
REQUIRED— по умолчанию и почти всегда правильный выбор.REQUIRES_NEWберёт отдельное соединение из пула.- Checked-исключения не откатывают транзакцию — оборачивайте в RuntimeException или указывайте
rollbackFor. readOnly = true— на все методы, которые только читают данные.- Транзакция не передаётся в другой поток. Для действий после коммита —
@TransactionalEventListener. - Kafka, HTTP, Redis — вне транзакции. Для атомарности с Kafka — паттерн Outbox.
- Транзакция должна быть короткой: внешние вызовы — за её пределами.
Что почитать дальше
- Уровни изоляции транзакций в PostgreSQL — READ COMMITTED, REPEATABLE READ, SERIALIZABLE и аномалии.
- Блокировки в PostgreSQL — SELECT FOR UPDATE и другие виды блокировок.
- Connection pooling — HikariCP и PgBouncer — почему REQUIRES_NEW опасен при высокой нагрузке.
- Spring AOP — как прокси устроен под капотом.