@Transactional — самая частая аннотация в Spring-приложениях и самый частый источник тонких багов. Эта статья — про то, что обычно неочевидно: propagation, self-invocation, что реально делает readOnly, когда rollback срабатывает.
Правила пронумерованы кодами PG-TX-NNN — на них ссылается скилл ucp-pattern-review (Spring-side) и ucp-pg-runtime-review (DB-side).
1. Где ставить @Transactional
PG-TX-001 Ставь на бизнес-методах service-уровня (UseCase / UseCaseHandler), не на репозиториях.
// правильно
@Component
class CreateOrderUseCaseHandler {
@Transactional
public OrderId handle(CreateOrderUseCase uc) {
var customer = customerRepository.findById(uc.customerId());
var order = orderFactory.create(customer, uc.items());
orderRepository.save(order);
outboxRepository.publishEvent(new OrderCreated(order.id()));
return order.id();
}
}
Граница транзакции = граница бизнес-операции. Один UseCase = одна транзакция (R-HND-2 в use-case-pattern). На репозитории @Transactional избыточно.
PG-TX-002 Не ставь @Transactional на контроллере — контроллер это HTTP, а не бизнес-операция. Если запрос делает несколько UseCase, каждый со своей TX.
2. Self-invocation — главная грабля
PG-TX-010 Spring @Transactional работает через AOP proxy. Вызов метода ВНУТРИ ТОГО ЖЕ КЛАССА не проходит через прокси, и @Transactional не срабатывает.
@Component
class OrderService {
public void processBatch(List<OrderId> ids) {
for (var id : ids) {
processOne(id); // НЕ открывает транзакцию!
}
}
@Transactional
public void processOne(OrderId id) {
// внутри без TX, потому что вызвано из processBatch() — не через прокси
}
}
PG-TX-011 Решение — вынести метод с @Transactional в отдельный бин:
@Component
class OrderService {
private final OrderProcessor processor;
public void processBatch(List<OrderId> ids) {
for (var id : ids) {
processor.processOne(id); // через DI — через прокси — TX открывается
}
}
}
@Component
class OrderProcessor {
@Transactional
public void processOne(OrderId id) { ... }
}
PG-TX-012 Альтернатива — через TransactionTemplate или @Self reference, но это хак. Чистый путь — отдельный бин.
PG-TX-013 Метод с @Transactional должен быть public. Spring AOP proxy не видит package-private/protected/private. На private — ничего не открывается, тихо.
3. Propagation — что выбирать
PG-TX-020 Propagation.REQUIRED (default) — почти всегда правильно. Если транзакция уже есть — присоединиться к ней. Если нет — открыть новую.
PG-TX-021 Propagation.REQUIRES_NEW — приостанавливает текущую TX, открывает новую. Полезно для:
- Аудит-логов, которые должны зафиксироваться даже если основная TX откатилась.
- Записи в outbox/event log, которая идёт независимо от бизнес-результата.
- Idempotency-trail — лог попыток.
@Transactional
public void processPayment(...) {
auditService.logAttempt(...); // должен записаться даже если processPayment упал
paymentGateway.charge(...);
}
@Component
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAttempt(...) { ... }
}
PG-TX-022 REQUIRES_NEW берёт ОТДЕЛЬНОЕ соединение из пула. Текущее остаётся занятым (transaction suspended). На high-throughput это удваивает потребление пула.
PG-TX-023 Propagation.NESTED — savepoint внутри транзакции. Откат внутреннего savepoint оставляет внешнюю TX живой. PostgreSQL поддерживает savepoints, JDBC — тоже. Полезно для:
- Опциональных шагов, где сбой одного не должен откатывать все.
@Transactional
public void importBatch(List<Item> items) {
for (var item : items) {
try {
saveItem(item); // NESTED — savepoint
} catch (Exception e) {
log.warn("skip {}: {}", item, e);
}
}
}
@Transactional(propagation = Propagation.NESTED)
public void saveItem(Item i) { ... }
PG-TX-024 Propagation.MANDATORY — обязана быть открытая TX, иначе exception. Полезно, когда метод НЕ должен вызываться без транзакции (например, помощник, ожидающий что вызывающий уже открыл TX).
PG-TX-025 Propagation.NEVER / Propagation.NOT_SUPPORTED — почти не нужны в нормальном коде. Если в каком-то месте они кажутся правильными, скорее всего архитектура странная.
4. readOnly = true — что реально делает
PG-TX-030 @Transactional(readOnly = true) делает три вещи в Spring:
Connection.setReadOnly(true)— драйвер PostgreSQL передаёт это серверу какSET TRANSACTION READ ONLY.- Hibernate / JPA пропускает dirty-checking и flush — экономия CPU.
- Routing DataSource (если настроен — см. Connection pool §6) направляет на read-replica.
PG-TX-031 На уровне jOOQ — почти ничего. jOOQ не имеет понятия dirty checking. setReadOnly(true) просто проброшено на JDBC. Если использует роутинг — поедет на реплику.
PG-TX-032 Используй readOnly = true для query-методов:
@Transactional(readOnly = true)
public List<OrderView> findOrdersByCustomer(long customerId) { ... }
PG-TX-033 Не используй readOnly = true на методах, которые могут открыть транзакцию для write через nested call. Контракт нарушится.
5. rollbackFor — что откатывает
PG-TX-040 По умолчанию Spring откатывает только RuntimeException и Error. Checked exceptions НЕ откатывают транзакцию — это исторический Spring-default.
@Transactional
public void doWork() throws IOException {
repo.save(...);
if (cond) throw new IOException(); // commit пройдёт! Spring не откатит
}
PG-TX-041 Если код может бросить checked, и ты хочешь откат — @Transactional(rollbackFor = Exception.class).
@Transactional(rollbackFor = Exception.class)
public void doWork() throws IOException {
repo.save(...);
if (cond) throw new IOException(); // теперь rollback
}
PG-TX-042 Лучшее решение — не бросать checked exceptions из transactional-методов. Оборачивай в свои runtime-exceptions:
@Transactional
public void doWork() {
try {
externalCall();
} catch (IOException e) {
throw new ExternalCallFailedException(e); // RuntimeException
}
}
Кода больше, но контракт честный — все ошибки runtime, default rollback работает корректно.
PG-TX-043 noRollbackFor редко полезен. Возможный случай: бизнес-исключение, которое возвращается клиенту как 400 BAD REQUEST, но изменения в БД нужно сохранить. Обычно это запах архитектуры — лучше разделить операцию на две.
6. Транзакции и async
PG-TX-050 @Async метод запускается в другом потоке. Текущая транзакция НЕ передаётся.
@Transactional
public void mainOp() {
repo.save(...);
asyncSomething(); // выполнится в другом потоке, БЕЗ TX, после return mainOp()
}
@Async
@Transactional
public CompletableFuture<Void> asyncSomething() { ... } // открывает СВОЮ TX
PG-TX-051 CompletableFuture.runAsync(() -> repo.save(...)) без @Async — не транзакционно. Сохранение либо упадёт (нет тред-локального TX), либо использует свою auto-commit транзакцию.
7. @PostConstruct / события
PG-TX-060 @PostConstruct-метод выполняется до полной инициализации бина — @Transactional не работает на нём. Если нужно что-то сделать в БД при старте, используй ApplicationReadyEvent:
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initData() { ... }
PG-TX-061 @TransactionalEventListener — слушатель, привязанный к фазе транзакции (AFTER_COMMIT по умолчанию). Полезно для action-after-commit паттерна.
@Component
class OrderEventHandler {
@TransactionalEventListener // вызовется только после успешного COMMIT
public void onOrderCreated(OrderCreated event) {
emailService.send(...);
}
}
В UseCase-паттерне это идиоматичный путь публиковать события «после save», без гонки.
8. Транзакция и внешние ресурсы
PG-TX-070 @Transactional не покрывает Kafka, HTTP, Redis, файлы. Внутри транзакции kafkaTemplate.send(...) отправит сообщение, и если транзакция откатилась — сообщение всё равно ушло.
PG-TX-071 Используй Outbox-паттерн (см. Распределённые паттерны). В одной транзакции с бизнес-данными пиши в outbox-таблицу. Отдельный @Scheduled-relay читает outbox и шлёт в Kafka.
@Transactional
public OrderId createOrder(CreateOrderCommand cmd) {
var order = orderRepo.save(new Order(...));
outboxRepo.save(new OutboxEvent("OrderCreated", order.id(), payload));
return order.id();
// если упадёт после save и до save outbox — обе строки откатятся вместе
}
PG-TX-072 Spring @Transactional и Spring Kafka transactions — это разные транзакции. Связать их сложно (chained transactions / JTA / 2PC). На практике — Outbox.
9. Длительность транзакции
PG-TX-080 Транзакция должна жить секунды, не минуты.
Анти-паттерн:
@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);
}
// 5 сек открытая транзакция — connection занят, autovacuum заблокирован
PG-TX-081 Решение — разделить:
public void processOrder(OrderId id) {
var order = orderRepo.find(id); // отдельная короткая TX
paymentGateway.charge(order.total());
deliveryService.scheduleDelivery(order);
confirmOrder(id); // отдельная короткая TX
}
@Transactional
private void confirmOrder(OrderId id) {
var order = orderRepo.find(id);
order.confirm();
orderRepo.save(order);
}
С учётом self-invocation confirmOrder должен быть в отдельном бине (см. PG-TX-011).
PG-TX-082 На практике — Saga с компенсациями для долгих процессов. Каждый шаг — отдельная короткая транзакция, общая консистентность через компенсирующие действия.
10. Антипаттерны
PG-TX-090 @Transactional на private/protected/package-private методе — игнорируется (PG-TX-013).
PG-TX-091 @Transactional на методе, вызываемом из того же класса — не работает (PG-TX-010).
PG-TX-092 @Transactional вокруг внешнего HTTP/Kafka/S3 — соединение из пула удерживается всё время вызова.
PG-TX-093 rollbackFor = Exception.class глобально на каждом методе — лечение симптома, лучше переписать на runtime-exceptions.
PG-TX-094 @Transactional на контроллере — TX живёт до сериализации response в JSON, лишние операции в TX.
PG-TX-095 kafkaTemplate.send(...) внутри @Transactional — событие уйдёт даже при rollback. Нужен Outbox.
PG-TX-096 REQUIRES_NEW под high-load без понимания — удваивает потребление пула.
PG-TX-097 Propagation.SERIALIZABLE без retry на CannotSerializeTransactionException.
Чек-лист на ревью кода
- [ ]
@Transactionalна бизнес-методе UseCase/Handler, не на репозитории. - [ ] Метод с
@Transactional—public. - [ ]
@Transactionalметод не вызывается из того же класса напрямую (вынесен в отдельный бин). - [ ] Query-методы —
@Transactional(readOnly = true). - [ ] Не оборачивать HTTP/Kafka/S3 в
@Transactional(или вынести в отдельный шаг). - [ ] Если бросаются checked exceptions —
rollbackFor = Exception.classили конвертация в runtime. - [ ] Kafka events — через Outbox-таблицу, не
kafkaTemplate.sendвнутри TX. - [ ] Длительность транзакции — секунды.
- [ ]
REQUIRES_NEW— только для аудита/логов; учитывает удвоение пула. - [ ]
@TransactionalEventListenerдля after-commit действий.
Связанные
- Блокировки —
SELECT FOR UPDATEобязан быть внутри@Transactional. - Уровни изоляции — Spring
Isolation.SERIALIZABLE+ retry. - Connection pool — длинная TX = занятый connection.
- WAL — длинная TX = блокировка autovacuum.
- Распределённые паттерны — Outbox-паттерн.