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

Вы написали @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 даёт три эффекта:

  1. JDBC передаёт базе данных SET TRANSACTION READ ONLY — PostgreSQL не создаёт снимок для write-операций.
  2. Hibernate (JPA) отключает dirty checking и flush — немного меньше работы для CPU.
  3. Если настроен 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 — как прокси устроен под капотом.