Опирается на правила:
PG-TX-001…PG-TX-082, антипаттерныPG-TX-090…PG-TX-097из PostgreSQL Style Guide → раздел Spring @Transactional.
Важно знать
- Ставь на бизнес-методах UseCase/Handler, не на репозиториях, не на контроллере.
- Self-invocation не работает — вызов через
this.method()идёт мимо AOP proxy.- Метод должен быть
public— на private/protected тихо не работает.REQUIRED(default) почти всегда;REQUIRES_NEW— отдельная TX + отдельное соединение.readOnly = true—setReadOnly+ skip flush + routing на read-replica.rollbackFor— Spring default откатывает толькоRuntimeException/Error. Checked не откатывают.- Лучше не бросать checked из transactional, оборачивать в runtime.
@Transactionalне покрывает Kafka/HTTP/Redis — Outbox.- TX должна жить секунды, не минуты — внешние HTTP вне транзакции.
@Transactional — самая частая аннотация и самый частый источник тонких багов. UCP формулирует — что обычно неочевидно: propagation, self-invocation, rollback, async.
Где ставить
PG-TX-001..002:
// ✓ — на UseCase/Handler
@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();
}
}
// ✗ — НЕ на репозитории (избыточно)
// ✗ — НЕ на контроллере (HTTP ≠ бизнес-операция)
Граница TX = граница бизнес-операции. Один UseCase = одна транзакция.
Self-invocation — главная грабля
PG-TX-010..013:
// ✗ — НЕ работает
@Component
class OrderService {
public void processBatch(List<OrderId> ids) {
for (var id : ids) {
processOne(id); // НЕ открывает транзакцию!
}
}
@Transactional
public void processOne(OrderId id) { ... }
}
Spring AOP proxy не перехватывает внутренние вызовы. Решение — отдельный бин:
@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) { ... }
}
Метод обязан быть public — Spring AOP не видит package-private/protected/private. На приватном — ничего не открывается тихо.
Propagation
PG-TX-020..025:
| Propagation | Когда |
|---|---|
REQUIRED (default) | Если TX есть — присоединиться. Иначе — открыть. Почти всегда. |
REQUIRES_NEW | Приостановить текущую, открыть новую. Аудит, outbox/event log. |
NESTED | Savepoint внутри TX. Опциональные шаги. |
MANDATORY | Должна быть открыта TX, иначе exception. Helper-методы. |
NEVER / NOT_SUPPORTED | Почти не нужны. |
REQUIRES_NEW
@Transactional
public void processPayment(...) {
auditService.logAttempt(...); // должен записаться даже если processPayment упал
paymentGateway.charge(...);
}
@Component
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAttempt(...) { ... }
}
REQUIRES_NEW берёт отдельное соединение из пула. Текущее остаётся занятым (suspended). На high-throughput удваивает потребление пула.
NESTED — savepoint
@Transactional
public void importBatch(List<Item> items) {
for (var item : items) {
try {
processor.saveItem(item); // NESTED — savepoint
} catch (Exception e) {
log.warn("skip {}: {}", item, e);
}
}
}
@Transactional(propagation = Propagation.NESTED)
public void saveItem(Item i) { ... }
Откат внутреннего savepoint оставляет внешнюю TX живой.
readOnly = true
PG-TX-030..033: три эффекта.
Connection.setReadOnly(true)→ JDBC передаёт PG:SET TRANSACTION READ ONLY.- JPA/Hibernate — skip dirty checking и flush (экономия CPU).
- Routing DataSource — направляет на read-replica (если настроено).
@Transactional(readOnly = true)
public List<OrderView> findOrdersByCustomer(long customerId) { ... }
На jOOQ — почти ничего (нет dirty checking). Просто JDBC setReadOnly.
rollbackFor
PG-TX-040..043: исторический Spring-default.
// ✗ — IOException НЕ откатит!
@Transactional
public void doWork() throws IOException {
repo.save(...);
if (cond) throw new IOException(); // commit пройдёт
}
Spring откатывает только RuntimeException и Error. Checked — нет.
Решения:
Вариант 1: rollbackFor
@Transactional(rollbackFor = Exception.class)
public void doWork() throws IOException { ... }
Вариант 2: оборачивать в runtime
@Transactional
public void doWork() {
try {
externalCall();
} catch (IOException e) {
throw new ExternalCallFailedException(e); // RuntimeException
}
}
Кода больше, но контракт честный. Все ошибки runtime, default rollback работает.
Транзакции и async
PG-TX-050..051:
// ✗ — текущая TX НЕ передаётся
@Transactional
public void mainOp() {
repo.save(...);
asyncSomething(); // выполнится в другом потоке, БЕЗ TX
}
@Async
@Transactional
public CompletableFuture<Void> asyncSomething() { ... } // открывает СВОЮ TX
CompletableFuture.runAsync(() -> repo.save(...)) без @Async — не транзакционно вообще.
@PostConstruct и события
PG-TX-060..061:
// ✗ — @PostConstruct до полной инициализации, @Transactional не работает
@PostConstruct
@Transactional
public void init() { ... }
// ✓ — ApplicationReadyEvent после инициализации
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initData() { ... }
@TransactionalEventListener
@Component
class OrderEventHandler {
@TransactionalEventListener // AFTER_COMMIT (default)
public void onOrderCreated(OrderCreated event) {
emailService.send(...);
}
}
Слушатель привязан к фазе TX. Полезно для action-after-commit без race.
Внешние ресурсы
PG-TX-070..072:
// ✗ — kafkaTemplate.send отправит, даже если TX откатилась
@Transactional
public OrderId createOrder(...) {
var order = orderRepo.save(...);
kafkaTemplate.send("orders.created", order); // ушло, rollback не вернёт
return order.id();
}
// ✓ — Outbox
@Transactional
public OrderId createOrder(...) {
var order = orderRepo.save(...);
outboxRepo.save(new OutboxEvent("OrderCreated", order.id(), payload));
return order.id();
// если упадёт — обе строки откатятся вместе
}
@Transactional не покрывает Kafka, HTTP, Redis, файлы. Используй Outbox.
Длительность
PG-TX-080..082: секунды, не минуты.
// ✗ — 5 сек открытая TX, connection занят
@Transactional
public void processOrder(OrderId id) {
var order = orderRepo.find(id);
paymentGateway.charge(order.total()); // HTTP, 3s
deliveryService.scheduleDelivery(order); // HTTP, 2s
order.confirm();
orderRepo.save(order);
}
// ✓ — внешние вызовы вне TX
public void processOrder(OrderId id) {
var order = orderRepo.find(id); // короткая TX
paymentGateway.charge(order.total());
deliveryService.scheduleDelivery(order);
confirmOrder(id); // короткая TX (отдельный бин!)
}
@Component
class ConfirmService {
@Transactional
public void confirmOrder(OrderId id) {
var order = orderRepo.find(id);
order.confirm();
orderRepo.save(order);
}
}
Для долгих процессов — Saga с компенсациями (см. Distributed → saga).
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
@Transactional на private/protected | PG-TX-090 | public |
Вызов @Transactional метода через this. | PG-TX-091 | отдельный бин |
@Transactional вокруг HTTP/Kafka/S3 | PG-TX-092 | вне TX |
rollbackFor = Exception.class глобально | PG-TX-093 | runtime exceptions |
@Transactional на контроллере | PG-TX-094 | на handler |
kafkaTemplate.send в TX | PG-TX-095 | Outbox |
REQUIRES_NEW без понимания пула | PG-TX-096 | оценить нагрузку |
SERIALIZABLE без retry | PG-TX-097 | @Retryable |
@Transactional на репозитории | PG-TX-001 | на UseCase |
Isolation.READ_COMMITTED явно | PG-IS-041 | не указывать (= default) |
Куда дальше
- PG → Spring @Transactional — нормативные формулировки.
- Уровни изоляции —
isolationпараметр. - Блокировки —
SELECT FOR UPDATEв@Transactional. - HikariCP — REQUIRES_NEW + пул.
- Kafka → outbox publishing — Outbox-паттерн.
- Distributed → saga — long processes.