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

Вы написали @Transactional на методе — и кажется, всё должно работать само. Но бывает, что транзакция не откатывается при ошибке, или открывается там, где вы её не ожидали. Разберём, как это устроено и где обычно ломается.

Как @Transactional вообще работает

Spring не «видит» аннотацию во время выполнения магически. Вместо этого он оборачивает ваш класс в прокси — специальный объект-обёртку. Когда кто-то снаружи вызывает метод с @Transactional, прокси перехватывает вызов, открывает транзакцию, вызывает оригинальный метод, и потом фиксирует или откатывает транзакцию. Отсюда вытекают все ограничения, которые кажутся странными, пока не знаешь об этом механизме.

Где ставить @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 6 (Boot 3) работают и protected, и пакетно-видимые методы — но только когда прокси строится по классу (CGLIB, и это умолчание Boot). Если бин проксируется по интерфейсу, перехватить можно лишь то, что в интерфейсе объявлено, а там всё public. И private не сработает никогда. Отсюда простое правило: держите транзакционные методы 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) {
        // транзакция НЕ откроется
    }
}

Исправление — вынести метод в отдельный бин, тогда вызов пройдёт через прокси. Не хочется второго бина — тот же результат даёт TransactionTemplate, он открывает транзакцию явно и прокси не нужен; о нём в разделе про длинные транзакции. С отдельным бином это выглядит так:

@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Транзакция должна уже быть открыта, иначе — исключение.
SUPPORTSЕсть транзакция — присоединиться, нет — работать без неё.
NOT_SUPPORTEDПриостановить текущую и выполнить метод вне транзакции.
NEVERТранзакции быть не должно: если она есть — исключение.

Разница между тремя первыми режимами — в том, сколько занято соединений и что переживёт откат внешней транзакции.

Три нижних режима встречаются реже, но каждый решает свою задачу. MANDATORY защищает метод, который обязан работать внутри чужой транзакции: репозиторный метод, меняющий половину агрегата, при прямом вызове из контроллера честно упадёт, а не запишет половину. NOT_SUPPORTED приостанавливает транзакцию на время долгого чтения или отчёта, освобождая соединение от роли участника. NEVER — прямой запрет, его ставят на методы, которые точно не должны попасть в транзакцию, например на вызов внешней службы. SUPPORTS почти всегда означает «автор не решил»: метод работает и так и так, а значит, гарантий не даёт никаких.

внешняя транзакция открыта — что сделает вложенный @Transactional REQUIRED BEGIN вложенный вызовCOMMITодно соединение,один коммит на всё REQUIRES_NEW BEGIN приостановленаROLLBACKCOMMIT · соед. 2второе соединениеиз пула; его коммитоткатом не вернуть NESTED BEGIN SAVEPOINT, откатCOMMITто же соединение,внешняя доживает

Три режима — три судьбы вложенного вызова: войти в ту же транзакцию, уехать на своё соединение или поставить точку сохранения внутри текущей. Откат внешней транзакции отменяет первый и третий случай и не трогает второй.

В большинстве случаев нужен REQUIRED.

UnexpectedRollbackException: поймали исключение, а коммит всё равно упал

Самая частая жалоба на @Transactional звучит так: «я же обработал ошибку, почему транзакция откатилась?» Причина в том, как устроен REQUIRED. Вложенный метод не открывает свою транзакцию — он присоединяется к внешней; когда внутри вылетает исключение, прокси вложенного метода помечает общую транзакцию как «только на откат». Внешний метод может это исключение поймать и спокойно продолжить работу, но на коммите Spring увидит метку и бросит:

org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only

Пример импорта пачки выше — ровно этот случай, если у saveItem оставить REQUIRED вместо NESTED: первый же плохой элемент пометит транзакцию, цикл честно доработает до конца, а вся пачка не запишется. Ошибка коварна тем, что показывает на коммит, то есть на место, где ничего не ломалось.

Лечится выбором, а не обходом. Если сбой одного элемента не должен рушить всё — вложенному методу нужна своя граница: NESTED (точка сохранения) или REQUIRES_NEW (отдельная транзакция и отдельное соединение). Если сбой должен рушить всё — исключение не ловят, а дают ему дойти до внешнего прокси. А вот вариант «поймать и сделать вид, что ничего не было» внутри одной транзакции не существует: решение принимает не тот, кто ловит, а тот, кто пометил.

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());
        }
    }
}

@Component
class ItemProcessor {
    @Transactional(propagation = Propagation.NESTED)
    public void saveItem(Item item) { ... }
}

Откат savepoint'а при ошибке оставляет внешнюю транзакцию живой — остальные элементы продолжают обрабатываться.

Условие, о котором легко забыть: точку сохранения ставит не сам Spring, а JDBC-соединение под ним. С DataSourceTransactionManager (JDBC, jOOQ) соединение под рукой, и NESTED работает сразу. JpaTransactionManager вложенные транзакции тоже разрешает — он включает их сам при создании, — но до соединения ему надо добраться через диалект JPA-провайдера. У Hibernate с настроенным DataSource диалект соединение отдаёт, и всё работает; если провайдер его не отдаёт, прилетит NestedTransactionNotSupportedException с текстом «JpaDialect does not support savepoints».

isolation: уровень изоляции у той же аннотации

У @Transactional есть и атрибут isolation, он же BEGIN ISOLATION LEVEL в чистом SQL:

@Transactional(isolation = Isolation.REPEATABLE_READ)
public Report buildReport(LocalDate day) { ... }

Две оговорки, из-за которых этот атрибут чаще не работает, чем работает. Первая: уровень нельзя поднять у вложенного REQUIRED-метода — он присоединяется к существующей транзакции и получает её уровень, молча. Нужен другой уровень — нужна отдельная транзакция. Вторая: подняв уровень, вы обязаны обработать ошибку сериализации (SQLSTATE 40001) и повторить транзакцию целиком, снаружи. Как именно — в статье про уровни изоляции; блокировки строк, которые часто нужны вместо повышения уровня, — в статье про блокировки.

readOnly = true

@Transactional(readOnly = true)
public List<OrderView> findOrdersByCustomer(long customerId) { ... }

Флаг readOnly даёт три эффекта:

  1. Spring зовёт у соединения setReadOnly(true), а что с этим делать — решает драйвер. Драйвер PostgreSQL с настройками по умолчанию (readOnlyMode=transaction) открывает транзакцию как BEGIN READ ONLY, и случайный UPDATE в отчётном методе падает с ошибкой сразу. Но если драйверу выставили readOnlyMode=ignore, он не сделает ничего — тогда флаг остаётся подсказкой для Hibernate, а на защиту от записи рассчитывать нельзя.
  2. Hibernate (JPA) отключает dirty checking и flush — немного меньше работы для CPU.
  3. Если настроен routing DataSource с read-репликой — соединение направляется туда автоматически.

Почему checked-исключения не откатывают транзакцию

Это историческое решение Spring: по умолчанию транзакция откатывается только при RuntimeException и Error. Checked-исключения (IOException, SQLException и прочие) транзакцию не откатывают — она зафиксируется.

Всё правило умещается в один try — вот оно на чистой Java:

живой пример

import java.io.IOException;
import java.util.concurrent.Callable;

public class RollbackRuleDemo {

    static void inTransaction(String method, Callable<Void> body) {
        System.out.println("BEGIN    " + method);
        try {
            body.call();
            System.out.println("COMMIT   " + method);
        } catch (RuntimeException | Error e) {
            System.out.println("ROLLBACK " + method + "  <- " + e.getClass().getSimpleName());
        } catch (Exception checked) {
            System.out.println("COMMIT   " + method + "  <- " + checked.getClass().getSimpleName()
                    + ", но откат правилом не предусмотрен");
        }
    }

    public static void main(String[] args) {
        inTransaction("saveReport", () -> { throw new IOException("файл не записался"); });
        inTransaction("payOrder", () -> { throw new IllegalStateException("заказ уже оплачен"); });
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Метод упал — а в выводе COMMIT: данные записались наполовину, и никто этого не заметил.

Два способа это исправить:

Вариант 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

Транзакция живёт в потоке: соединение и признак «транзакция открыта» Spring держит в ThreadLocal. В другом потоке этого нет:

живой пример

public class TxContextDemo {

    static final ThreadLocal<String> CURRENT_TX = new ThreadLocal<>();

    static void report(String where) {
        String tx = CURRENT_TX.get();
        System.out.println(where + ": " + (tx == null ? "транзакции нет" : "транзакция " + tx));
    }

    public static void main(String[] args) throws InterruptedException {
        CURRENT_TX.set("tx-42");
        report("поток запроса");

        Thread worker = new Thread(() -> report("поток @Async"));
        worker.start();
        worker.join();

        CURRENT_TX.remove();
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Поэтому асинхронный метод открывает свою транзакцию, а не продолжает родительскую:

@Component
class OrderService {
    private final Notifier notifier;   // отдельный бин: вызов идёт через прокси

    @Transactional
    public void mainOp() {
        orderRepo.save(...);
        notifier.notifyAsync();   // уедет в другой поток, со своей транзакцией
    }
}

@Component
class Notifier {
    @Async
    @Transactional
    public void notifyAsync() { ... }
}

@Async работает через тот же механизм прокси, что и @Transactional: перехват случается только на входе в бин снаружи. Оставь мы notifyAsync в OrderService, вызов через this прошёл бы мимо обёртки — не уехал бы ни в какой другой поток и отработал бы прямо внутри транзакции mainOp.

Если нужно выполнить что-то после успешного коммита транзакции, используйте @TransactionalEventListener:

@Component
class OrderEventHandler {
    @TransactionalEventListener   // AFTER_COMMIT по умолчанию
    public void onOrderCreated(OrderCreated event) {
        emailService.send(...);
    }
}

Слушатель вызовется только если транзакция зафиксировалась — без риска отправить письмо при откате.

Две вещи, которые в этом месте ломаются чаще всего.

Первая: слушатель AFTER_COMMIT работает уже без активной транзакции — она зафиксирована, соединение отпущено. Поэтому запись в базу прямо из такого слушателя молча не сохранится: Hibernate выполнит её вне транзакции и без коммита. Нужна запись — пометьте слушатель @Transactional(propagation = Propagation.REQUIRES_NEW), тогда у него будет своя, новая транзакция.

Вторая: событие должно быть опубликовано внутри транзакции. @TransactionalEventListener подписывается на момент её завершения, а если активной транзакции при публикации не было, слушателю не к чему привязаться, и он не вызовется вовсе — тихо, без ошибки. Так теряют уведомления, когда публикацию случайно выносят из транзакционного метода или зовут из планировщика. Если такое поведение нужно оставить рабочим, есть fallbackExecution = true — он разрешает вызов и без транзакции.

И общее ограничение: слушатель после коммита не может повлиять на результат. Упал он — транзакция уже записана, и повторять его придётся самому. Когда доставка обязана быть гарантированной, событие кладут в ту же базу в той же транзакции и публикуют отдельным процессом — это Outbox из следующего раздела.

@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);
}

Правильный подход — внешние вызовы вне транзакции, а изменения в базе данных — в коротких отдельных транзакциях:

// оркестратор без @Transactional
public void processOrder(OrderId id) {
    var order = orderStorage.load(id);              // короткая TX, соединение освобождается
    paymentGateway.charge(order.total());           // вне TX
    deliveryService.scheduleDelivery(order);        // вне TX
    orderStorage.confirm(id);                       // короткая TX
}
// отдельный бин — вызов идёт через прокси
@Component
class OrderStorage {

    @Transactional
    public Order load(OrderId id) { return orderRepo.find(id); }

    @Transactional
    public void confirm(OrderId id) {
        var order = orderRepo.find(id);
        order.confirm();
        orderRepo.save(order);
    }
}

Короткие методы вынесены в отдельный класс не для красоты: рядом, в том же OrderService, вызов load(id) пошёл бы мимо прокси — та самая ловушка самовызова. Не хочется второго бина — тот же результат даёт TransactionTemplate: он открывает транзакцию явно, без прокси.

Транзакция должна жить секунды, а не минуты.

timeout: ограничить транзакцию сверху

У аннотации есть атрибут timeout — в секундах:

@Transactional(timeout = 5)
public void processOrder(OrderId id) { ... }

Работает он не так, как многие ждут. Spring переводит его в Statement.setQueryTimeout для запросов этой транзакции и проверяет остаток времени перед каждым следующим запросом — то есть он ограничивает работу с базой, а не метод целиком. Транзакция, которая пять секунд ждёт ответа чужого HTTP-сервиса между двумя запросами, этим таймаутом не прервётся. И ещё: драйвер ограничивает ожидание на своей стороне, а на стороне сервера запрос может продолжать работать; чтобы он точно прекратился, тот же предел ставят и в базе — statement_timeout на роль приложения.

Как проверить, что транзакция вообще открылась

Половина вопросов «почему не откатилось» снимается одной проверкой — а была ли транзакция. Два способа.

Включить журнал: уровень DEBUG у org.springframework.transaction печатает Creating new transaction, Participating in existing transaction, Initiating transaction commit — по этим строкам сразу видно, открылась транзакция или вызов прошёл мимо прокси и присоединяться было не к чему.

Спросить из кода: TransactionSynchronizationManager.isActualTransactionActive() возвращает true, только если транзакция действительно открыта, а getCurrentTransactionName() покажет, чей это метод. Строчку с такой проверкой ставят в подозрительный метод на время разбирательства — она отвечает на вопрос точнее любых рассуждений о прокси.

Коротко

  • Всё держится на прокси: аннотация работает только при вызове извне класса, на private-методах молчит, а вызов через this обходит обёртку — лечится отдельным бином или TransactionTemplate.
  • Место аннотации — сервисный слой: не контроллер (он про HTTP-цикл) и не репозиторий (он не объединит несколько операций в одну).
  • REQUIRED — почти всегда правильный выбор; REQUIRES_NEW берёт второе соединение из пула, NESTED ставит точку сохранения и требует DataSourceTransactionManager.
  • Откат по умолчанию только на RuntimeException и Error — checked-исключение зафиксирует транзакцию; лечится rollbackFor или обёрткой в runtime-исключение.
  • readOnly = true на читающих методах: база запретит запись, Hibernate не будет сверять изменения, соединение может уйти на реплику.
  • Транзакция не выходит за пределы своего потока и своей базы: после коммита — @TransactionalEventListener, для Kafka — Outbox; внешние вызовы — за её границами, чтобы она жила секунды.
  • Поймали исключение из вложенного REQUIRED-метода — транзакция уже помечена на откат, и коммит упадёт UnexpectedRollbackException: нужна своя граница (NESTED или REQUIRES_NEW), а не try/catch.
  • Слушатель AFTER_COMMIT работает без активной транзакции (запись из него требует REQUIRES_NEW), а событие вне транзакции не вызовет его вовсе.
  • timeout ограничивает запросы к базе, а не метод; на стороне сервера его дублируют statement_timeout. Открылась ли транзакция, показывают журнал org.springframework.transaction на DEBUG и TransactionSynchronizationManager.isActualTransactionActive().

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