@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:

  1. Connection.setReadOnly(true) — драйвер PostgreSQL передаёт это серверу как SET TRANSACTION READ ONLY.
  2. Hibernate / JPA пропускает dirty-checking и flush — экономия CPU.
  3. 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, не на репозитории.
  • [ ] Метод с @Transactionalpublic.
  • [ ] @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-паттерн.