← назад к разделу

@Transactional выглядит просто, пока всё работает. Когда перестаёт — оказывается, что под капотом много механики, которая не интуитивна. Эта статья разбирает реальную модель работы, чтобы можно было осознанно проектировать транзакционные границы.

Как работает: AOP proxy

@Transactional — это указание Spring создать прокси для бина. Прокси перехватывает вызовы аннотированных методов, оборачивает их в транзакцию.

Caller → [Proxy: openTransaction()] → RealService.doWork() → [Proxy: commit()/rollback()]

Из этой модели вытекают четыре ловушки, через которые проходит почти каждая команда.

Ловушка 1: Self-invocation

Вызов через this идёт в обход прокси:

@Service
public class OrderService {

    public void processBatch(List<Order> orders) {
        orders.forEach(this::processOne);  // ← прокси не задействован
    }

    @Transactional
    public void processOne(Order order) {
        // транзакция НЕ открывается, потому что вызов через this
    }
}

Решение: вынести в другой бин ИЛИ внедрить self-reference через ApplicationContext.getBean() ИЛИ переделать processBatch так, чтобы вызов шёл через прокси (контроллер вызывает напрямую processOne в цикле).

Ловушка 2: Private методы

Прокси не видит private методов — @Transactional на них игнорируется без ошибки.

@Service
public class OrderService {

    public void process(Order order) {
        save(order);
    }

    @Transactional   // ИГНОРИРУЕТСЯ
    private void save(Order order) { ... }
}

Правило: @Transactional — только на public методах. С Spring 6.x работает и на protected/package-private при определённых условиях, но public — единственный надёжный вариант.

Ловушка 3: Checked exceptions не откатывают по умолчанию

По умолчанию только RuntimeException и Error вызывают rollback. Checked exception (IOException, SQLException через wrapper) — commit'ит.

@Transactional
public void process() throws IOException {
    repo.save(...);
    throw new IOException("...");  // транзакция КОММИТНЕТСЯ
}

Решение: @Transactional(rollbackFor = Exception.class) или конкретный класс.

Прагматично: в новом коде стараться бросать RuntimeException (или его наследников). Это снимает большинство гочей.

Ловушка 4: Transaction propagation default'ы

@Transactional без параметров = propagation = REQUIRED. Это значит «используй существующую транзакцию или открой новую». Часто не то, что нужно.

Семь propagation modes

REQUIRED (default)

Используется существующая транзакция. Если нет — создаётся новая. Всё в одной транзакции, единый commit/rollback.

// Caller (transactional)
@Transactional
public void processOrderAndAudit(Order order) {
    orderService.process(order);   // в той же транзакции
    auditService.log(order);       // в той же транзакции
}

// Внутри auditService.log:
@Transactional   // propagation = REQUIRED, join существующую
public void log(Order order) { ... }

REQUIRES_NEW

Приостанавливает текущую транзакцию (если есть), открывает новую. После завершения новой — текущая возобновляется.

@Transactional
public void process(Order order) {
    orderRepo.save(order);
    auditService.log(order);  // ← REQUIRES_NEW: коммитнется независимо
    throw new RuntimeException("...");  // основная транзакция откатится,
                                         // но audit-запись уже сохранена
}

class AuditService {
    @Transactional(propagation = REQUIRES_NEW)
    public void log(Order order) { ... }
}

Применять для независимых side effects — аудит-логи, метрики, уведомления. Где «main task провалилась, но факт попытки должен остаться».

NESTED

Использует savepoint существующей транзакции. Rollback внутреннего откатывает только до savepoint, не всю транзакцию.

@Transactional
public void processBatch(List<Order> orders) {
    for (var order : orders) {
        try {
            tryProcess(order);
        } catch (Exception e) {
            log.warn("skipped: {}", order.id());
            // продолжаем с остальными
        }
    }
}

@Transactional(propagation = NESTED)
public void tryProcess(Order order) { repo.save(order); }

Поддерживается JDBC, не работает с JTA. С PostgreSQL — да, через SAVEPOINT.

MANDATORY

Должна быть существующая транзакция, иначе exception. Используется для методов, которые обязаны идти в чужой транзакции (например, нельзя вызвать вне транзакции, потому что состояние агрегата не закоммитится).

SUPPORTS

Если есть транзакция — присоединяемся, если нет — выполняемся без неё. Полезно для read-only операций, которым «всё равно».

NOT_SUPPORTED

Текущая транзакция приостанавливается, метод выполняется без транзакции. Полезно, когда нужно гарантированно не быть в транзакции (например, долгая job, не блокирующая запись).

NEVER

Если есть транзакция — exception. Гарантирует, что метод вызван вне транзакции.

Isolation levels

@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(AccountId from, AccountId to, Money amount) { ... }

Доступны 4 уровня + DEFAULT (берёт из datasource — обычно READ_COMMITTED в PostgreSQL).

Детально про уровни — в статье про ACID в PostgreSQL. Здесь важно одно: в Spring, если выбрали SERIALIZABLE, обязаны уметь retry:

@Retryable(
    retryFor = {ConcurrencyFailureException.class, CannotSerializeTransactionException.class},
    maxAttempts = 3,
    backoff = @Backoff(delay = 50, multiplier = 2.0)
)
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(AccountId from, AccountId to, Money amount) { ... }

Без retry — приложение случайно падает в проде при конкурентных транзакциях.

readOnly = true

Подсказка для драйвера + JPA, что транзакция read-only.

@Transactional(readOnly = true)
public List<Order> findByCustomer(CustomerId id) {
    return repo.findByCustomerId(id);
}

Эффекты:

  • PostgreSQL игнорирует (флаг есть, но эффекта мало).
  • Hibernate отключает dirty checking — не делает SELECT'ы для проверки изменений, не флашит изменения.
  • Connection pool может направить на read-replica (если настроено).

Рекомендация: ставить readOnly = true на любую транзакцию, которая не пишет. Это дешёвое улучшение производительности с JPA.

Rollback rules

Чтобы откатить транзакцию программно:

@Transactional
public void process(Order order) {
    if (validationFails(order)) {
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        return;   // выйдем без exception, но транзакция откатится
    }
    // ...
}

Однако чаще проще бросить RuntimeException. Программный rollback нужен, когда не хотите бросать исключение из «нормального» бизнес-сценария.

Distributed transactions: почему обычно нельзя

Два сценария:

Несколько БД через JTA + XA

Технически Spring поддерживает через JtaTransactionManager (Bitronix, Atomikos):

@Transactional
public void doMultipleDatabases() {
    db1.save(...);
    db2.save(...);
    // если упадёт — обе откатятся через 2PC
}

На практике — не делать так в production. Причины:

  • Performance: 2PC требует синхронной координации, latency ×2-3.
  • Хрупкость: одна из БД зависла → транзакция «в неопределённом состоянии», требует ручного разбора.
  • Облака не поддерживают XA нативно (AWS RDS, GCP Cloud SQL — XA-протоколы выключены).

Альтернатива — Saga + Outbox или Distributed Patterns Style Guide.

БД + Kafka в одной транзакции

Технически можно через ChainedTransactionManager, но это best-effort 2PC без гарантий. Реально нужен Outbox pattern: write в БД-таблицу outbox в той же транзакции, отдельный процесс публикует в Kafka.

Что нужно знать про @Transactional для UCP

В стеке Use Case Pattern транзакция обычно открывается внутри Handler'а, обёрнуто @Transactional. Use Case — pojo без аннотаций, Handler — @Service @Transactional.

@Service
@RequiredArgsConstructor
@Transactional
public class CreateOrderHandler implements UseCaseHandler<CreateOrder, OrderId> {

    private final OrderRepository orderRepo;
    private final EventPublisher events;

    @Override
    public OrderId handle(CreateOrder useCase) {
        var order = Order.create(useCase.toCommand());
        orderRepo.save(order);
        events.publish(new OrderCreatedEvent(order.id()));
        return order.id();
    }
}

Один Handler = одна транзакция = одна бизнес-операция. На этом уровне @Transactional — гарантия атомарности всей операции.

Что почитать дальше

  • ACID и уровни изоляции в PostgreSQL — какие реальные гарантии под уровнями isolation.
  • Spring Data JPA — взаимодействие транзакций с JPA-сессией, OSIV.
  • Spring Events — @TransactionalEventListener и события, привязанные к транзакции.
  • Distributed Patterns Style Guide — что делать вместо distributed transactions.