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

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

вызов извне → через обёртку вызов через this → мимо обёртки контроллер прокси Spring BEGIN — открыть транзакцию ваш бин OrderService processBatch() без аннотации @Transactional processOne() this.processOne() COMMIT / ROLLBACK метод отработал — commit BEGIN и COMMIT не сработали

Транзакцию открывает не метод, а обёртка вокруг бина. Вызов снаружи попадает в неё и получает BEGIN и COMMIT; вызов того же метода изнутри соседнего идёт напрямую — обёртка о нём не узнаёт, и транзакции нет.

Обязательно

Что такое транзакция

Представьте перевод денег: списать со счёта A и зачислить на счёт B. Это две операции с базой, но для бизнеса они должны быть неделимы: либо обе прошли, либо обе не прошли. Если списание прошло, а зачисление упало — деньги пропали.

Транзакция — это группа операций с базой данных, которая выполняется по принципу «всё или ничего». Внутри транзакции:

  • если всё прошло успешно — изменения фиксируются (commit);
  • если случилась ошибка — все изменения откатываются (rollback), как будто их не было.

Без транзакции каждый INSERT/UPDATE сохраняется сам по себе, и при сбое в середине вы остаётесь с частично записанными данными.

Зачем нужна @Transactional

Раньше транзакциями управляли руками: открыть, в конце закоммитить, в catch откатить, в finally закрыть соединение. Длинно, и легко что-нибудь забыть.

void transfer(AccountId from, AccountId to, Money amount) throws SQLException {
    try (Connection conn = dataSource.getConnection()) {
        conn.setAutoCommit(false);              // иначе каждый запрос сам себе транзакция
        try {
            accountRepo.withdraw(from, amount);
            accountRepo.deposit(to, amount);
            conn.commit();
        } catch (SQLException e) {
            conn.rollback();
            throw e;
        }
    }
}

Обратите внимание, сколько здесь обязательного: без setAutoCommit(false) транзакции не будет вовсе, rollback() сам бросает SQLException, поэтому просто написать его в catch нельзя, а соединение надо закрыть при любом исходе — это делает try со скобками.

@Transactional убирает всю эту обвязку. Вы помечаете метод аннотацией, а Spring сам открывает транзакцию перед методом, коммитит после успешного завершения и откатывает при ошибке.

@Service
public class TransferService {

    @Transactional
    public void transfer(AccountId from, AccountId to, Money amount) {
        accountRepo.withdraw(from, amount);
        accountRepo.deposit(to, amount);
    }
}

Если внутри transfer вылетит исключение — оба изменения откатятся автоматически.

Аннотацию можно поставить и на класс: тогда она действует на все публичные методы. Аннотация на методе при этом не складывается с классовой, а переопределяет её целиком — у метода берутся только его собственные атрибуты, а не смесь. Поэтому частый приём «@Transactional(readOnly = true) на классе сервиса, а на пишущих методах обычная @Transactional» работает именно так, как выглядит: пишущий метод получает транзакцию с умолчаниями, а не read-only плюс что-то.

Как это работает: прокси

Все ловушки растут из одного корня: @Transactional работает через прокси.

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

Такую обёртку можно собрать руками на чистой Java — Spring делает то же самое автоматически. Смотрите, вокруг каких вызовов появились BEGIN и COMMIT:

живой пример

import java.lang.reflect.Proxy;

public class ProxyDemo {

    interface Orders {
        void processBatch();
        void processOne(String id);
    }

    static class OrderService implements Orders {
        public void processBatch() {
            processOne("A-1");
        }
        public void processOne(String id) {
            System.out.println("    сохраняю заказ " + id);
        }
    }

    static Orders wrap(Orders target) {
        return (Orders) Proxy.newProxyInstance(
                Orders.class.getClassLoader(),
                new Class<?>[]{Orders.class},
                (proxy, method, args) -> {
                    System.out.println("  BEGIN  " + method.getName());
                    Object result = method.invoke(target, args);
                    System.out.println("  COMMIT " + method.getName());
                    return result;
                });
    }

    public static void main(String[] args) {
        Orders orders = wrap(new OrderService());

        System.out.println("processOne через прокси:");
        orders.processOne("A-9");

        System.out.println("processBatch через прокси:");
        orders.processBatch();
    }
}
Запустить

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

В выводе видно главное. У processOne, вызванного снаружи, есть и BEGIN, и COMMIT. А у того же processOne, вызванного изнутри processBatch, их нет: заказ сохранился, но обёртка о вызове не узнала. Всё держится на том, что вызов проходит через прокси — мимо него транзакции не будет.

Ловушка 1: вызов через this (self-invocation)

Самая частая — и это ровно то, что напечатал пример выше. Когда метод вызывает другой метод того же класса через this (явно или неявно: processOne(...) без префикса — это тоже this), вызов идёт напрямую, минуя прокси. Аннотация на втором методе не сработает.

Решение: вынести метод с @Transactional в отдельный бин и вызывать его как зависимость — тогда вызов пойдёт через прокси.

@Service
public class OrderService {
    private final OrderProcessor processor;   // другой бин

    public void processBatch(List<Order> orders) {
        orders.forEach(processor::processOne);   // через прокси — работает
    }
}

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

Прокси может обернуть только то, что видит снаружи. На private-методе @Transactional молча игнорируется — без ошибки, что особенно коварно.

@Service
public class OrderService {

    @Transactional   // игнорируется — метод private
    private void save(Order order) { ... }
}

Правило простое: @Transactional ставьте на методы, видимые снаружи класса. Со Spring 6 (Boot 3) прокси на основе наследования (CGLIB) перехватывает protected и пакетно-видимые методы, а прокси по интерфейсу — только public. private не перехватит никогда — как и метод в final-классе или final-метод: прокси наследуется от класса и не может их переопределить. Проще не выяснять тонкости, а держать транзакционные методы public.

Ловушка 3: проглоченное исключение

Откат происходит, только если исключение вылетает из метода наружу, до прокси. Если вы поймали его внутри в try/catch и не пробросили дальше — Spring о сбое не узнает и закоммитит.

@Transactional
public void process(Order order) {
    try {
        repo.save(order);
        externalCall();   // здесь упало
    } catch (Exception e) {
        log.warn("ошибка", e);   // поймали и проглотили — отката НЕ будет
    }
}

Нужен откат — исключение надо пробросить дальше.

Ловушка 4: транзакция живёт в потоке

Вся механика выше держится ещё на одном допущении: текущая транзакция хранится в ThreadLocal того потока, который её открыл. Отсюда следствия, которые иначе выглядят как отдельные загадки. Метод с @Async работает в другом потоке, поэтому не продолжает транзакцию вызывающего, а открывает свою: откат снаружи не отменит то, что он сделал. Задача в своём ExecutorService, слушатель события в фазе AFTER_COMMIT, код в реактивной цепочке — то же самое. И наоборот: если в транзакционном методе раздать работу по потокам, ни один из них транзакции не увидит, а соединение останется у исходного.

Откат по умолчанию: только unchecked

Даже когда исключение вылетает наружу, откат происходит не на любое. По умолчанию Spring откатывает транзакцию только на RuntimeException и Error (так называемые unchecked-исключения).

На checked-исключение (которое надо объявлять в throws — например IOException) транзакция по умолчанию коммитится. Логика Spring такая: checked-исключение — это часть «нормального» сценария, а не сбой.

исключение из метода ROLLBACK RuntimeException, Error COMMIT checked, например IOException

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

Всё правило — это буквально два catch. Вот они на чистой Java, вместе с третьим случаем из прошлого раздела:

живой пример

import java.io.IOException;

public class RollbackDemo {

    interface Body {
        void run() throws Exception;
    }

    static void inTransaction(String name, Body body) {
        System.out.println("BEGIN " + name);
        try {
            body.run();
            System.out.println("  COMMIT");
        } catch (RuntimeException | Error e) {
            System.out.println("  ROLLBACK: " + e.getMessage());
        } catch (Exception checked) {
            System.out.println("  COMMIT, хотя вылетело " + checked.getClass().getSimpleName());
        }
    }

    public static void main(String[] args) {
        inTransaction("unchecked", () -> {
            throw new IllegalStateException("товара нет на складе");
        });
        inTransaction("checked", () -> {
            throw new IOException("файл недоступен");
        });
        inTransaction("проглоченное", () -> {
            try {
                throw new IllegalStateException("товара нет на складе");
            } catch (RuntimeException swallowed) {
                System.out.println("  поймали и промолчали");
            }
        });
    }
}
Запустить

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

Три запуска — три разных итога, и только первый откатывается. Два способа это исправить:

  • явно указать, на что откатывать: @Transactional(rollbackFor = Exception.class);
  • в своём коде бросать наследников RuntimeException — тогда откат работает «из коробки».

Propagation — что делать с уже открытой транзакцией

Метод log сервиса аудита вызвали внутри placeOrder, где транзакция уже открыта. Писать в неё же, и тогда откат заказа унесёт и запись аудита, или открыть свою? На этот вопрос отвечает propagation (распространение). Режимов семь, на практике важны три.

REQUIRED BEGIN внешней метод внутри неё один COMMIT REQUIRES_NEW внешняя на паузе своя BEGIN свой COMMIT внешняя дальше NESTED BEGIN внешней savepoint откат к точке общий COMMIT

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

REQUIRED — по умолчанию

«Есть транзакция — присоединись к ней; нет — открой новую». Всё выполняется в одной транзакции с общим commit/rollback. Это поведение по умолчанию: пока вы не написали propagation явно, метод работает именно так.

@Transactional   // REQUIRED
public void placeOrder(Order order) {
    orderRepo.save(order);
    auditService.log(order);   // если log тоже @Transactional — та же транзакция
}

Следствие: если в конце что-то упадёт, откатится всё, включая запись аудита.

REQUIRES_NEW — всегда своя транзакция

Цена у него выше, чем кажется. Приостановленная транзакция не отпускает своё соединение с базой, а новая берёт второе: на время вызова один бизнес-метод держит два соединения из пула. Если так обрабатывать список в цикле при пуле на десять соединений, десять параллельных запросов займут по два и встанут намертво: каждый держит первое соединение и ждёт второго, которого больше нет. Это классическая взаимная блокировка на пуле, и лечится она не размером пула, а отказом от REQUIRES_NEW внутри цикла. Правило: REQUIRES_NEW для одного независимого действия за запрос, а не для каждого элемента пакета.

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

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

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

Нужно для независимых побочных действий — аудит, журнал, уведомление: «основное дело провалилось, но факт попытки должен остаться».

Таймаут и что нельзя делать внутри транзакции

У транзакции есть предохранитель, про который вспоминают после первой аварии: @Transactional(timeout = 5) ограничивает её пятью секундами. Отсчёт идёт от начала транзакции, а проверяется он на обращениях к базе, и по истечении времени запрос прерывается с TransactionTimedOutException. Это единственная штатная защита от «висящей» транзакции, которая держит блокировки и не даёт соседям работать. То же самое задаётся глобально: spring.transaction.default-timeout=10s.

Дальше правило, которое стоит выучить раньше всех режимов propagation: внутри транзакции не ходят по сети. Вызов чужого HTTP-сервиса, отправка в очередь, запрос в поисковый движок — всё это занимает сотни миллисекунд, а иногда минуты, и всё это время транзакция удерживает соединение из пула и блокировки на изменённых строках. Пул на десять соединений при чужом сервисе, который стал отвечать за две секунды, исчерпывается мгновенно, и ложится ваш сервис, хотя проблема была у соседа.

// так не надо: HTTP внутри транзакции
@Transactional
public void placeOrder(Order order) {
    repo.save(order);
    paymentClient.charge(order);        // держим соединение и блокировки всё это время
}

// так надо: транзакция коротка, внешний вызов снаружи
public void placeOrder(Order order) {
    orderService.saveNew(order);        // @Transactional внутри, миллисекунды
    paymentClient.charge(order);        // сеть уже вне транзакции
}

Когда внешнее действие обязано произойти ровно при успешной фиксации, его привязывают к событию фазы AFTER_COMMIT (см. Spring Events), а когда оно должно произойти гарантированно — пишут задание в ту же базу и досылают отдельным процессом, то есть Outbox, о котором ниже.

Дополнительно: при первом чтении можно пропустить

Глубже: NESTED — точка сохранения внутри транзакциирасширенное

Создаёт savepoint (точку сохранения) внутри текущей транзакции. Если вложенный кусок упал — откатывается только до точки сохранения, а не вся транзакция.

@Transactional
public void processBatch(List<Order> orders) {
    for (var order : orders) {
        try {
            processor.tryProcess(order);   // NESTED
        } catch (Exception e) {
            log.warn("пропущен: {}", order.id());   // один сбойный не рушит весь пакет
        }
    }
}

Здесь важно, что вложенный метод именно NESTED. Будь у него обычный REQUIRED, перехваченное исключение всё равно пометило бы общую транзакцию как rollback-only, и на коммите внешнего метода вылетело бы UnexpectedRollbackException — пакет упал бы целиком.

Это и есть самый частый источник UnexpectedRollbackException, и встречают его обычно без всякого NESTED. Сценарий такой: внешний метод в транзакции зовёт внутренний бин, тот тоже @Transactional с обычным REQUIRED, внутри падает, внешний ловит исключение и спокойно продолжает работу. Кажется, что ошибка обработана. Но REQUIRED не открыл новую транзакцию, он присоединился к той же, и его исключение уже пометило её как rollback-only; когда внешний метод дойдёт до конца, коммитить будет нечего, и наружу вылетит UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only. Лечится это на уровне замысла: если сбой внутреннего действия не должен рушить общее, оно должно быть в своей транзакции (REQUIRES_NEW) или в savepoint (NESTED); если должен — исключение не ловят.

Работает NESTED там, где менеджер транзакций умеет savepoint: DataSourceTransactionManager (JDBC поверх PostgreSQL) умеет, а JpaTransactionManager по умолчанию бросит NestedTransactionNotSupportedException — savepoint откатил бы соединение, но не кеш сущностей JPA.

Глубже: остальные четыре режима propagationрасширенное

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

Глубже: когда прокси не помогает: TransactionTemplateрасширенное

Три ловушки выше — это ограничения прокси, и у всех них есть общий обход: управлять транзакцией явно, без аннотации. Для этого есть TransactionTemplate.

@Service
public class ImportService {
    private final TransactionTemplate tx;

    ImportService(PlatformTransactionManager manager) {
        this.tx = new TransactionTemplate(manager);
    }

    public void importAll(List<Row> rows) {
        for (List<Row> chunk : partition(rows, 500)) {
            tx.executeWithoutResult(status -> repo.saveAll(chunk));   // своя транзакция на пачку
        }
    }
}

Где он нужен: транзакция на часть метода (долгое чтение файла снаружи, запись внутри), транзакция внутри лямбды или цикла, транзакция в фоновом потоке или в @PostConstruct, где прокси ещё нет, и транзакция в коде, который сам не бин. status.setRollbackOnly() внутри отмечает откат без исключения, а атрибуты (таймаут, propagation, readOnly) задаются на самом шаблоне. Цена — явный код вместо аннотации, поэтому по умолчанию остаётся @Transactional, а шаблон берут там, где границу транзакции надо провести не по границе метода.

Глубже: isolation — как транзакции видят друг другарасширенное

Isolation (уровень изоляции) определяет, насколько параллельные транзакции «видят» незавершённые изменения друг друга. Чем выше уровень — тем меньше странных эффектов от конкуренции, но тем дороже по производительности.

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

Сам эффект выглядит так: транзакция дважды читает остаток на счёте, между чтениями сосед его списал, и второй SELECT возвращает другое число. На READ_COMMITTED это норма, на REPEATABLE_READ второе чтение увидит то же, что первое. Уровней четыре плюс DEFAULT (берётся из настроек базы — в PostgreSQL это READ_COMMITTED). Но искать в PostgreSQL четыре разных поведения бесполезно: там их три, READ_UNCOMMITTED работает ровно как READ_COMMITTED. Для большинства задач хватает значения по умолчанию, а менять уровень нужно осознанно — детали про уровни в статье про ACID в PostgreSQL.

Важно: на высоких уровнях (например SERIALIZABLE) база при конфликте конкурентных транзакций может одну из них отклонить с ошибкой. Поэтому такой код должен уметь повторять операцию (retry), иначе будут случайные сбои под нагрузкой.

Глубже: readOnly = true — пометка «только чтение»расширенное

Если метод только читает и ничего не пишет, отметьте транзакцию как read-only:

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

Зачем это нужно (особенно с JPA/Hibernate):

  • Hibernate отключает отслеживание изменений — не сравнивает объекты «до и после» и не делает лишних запросов;
  • если в приложении настроено переключение на реплику для чтения (обычно через AbstractRoutingDataSource), именно флаг readOnly подсказывает ему, что запрос можно отправить туда.

И одна оговорка, из-за которой этот флаг иногда роняет прод. Если работа идёт не через JPA, а через JDBC или jOOQ, readOnly — не подсказка, а запрет: Spring выставляет соединению режим «только чтение», PostgreSQL переводит транзакцию в READ ONLY, и любая запись внутри такого метода падает с ошибкой. Так что помечайте методы, которые действительно только читают, а не те, которые «читают и заодно обновляют счётчик».

Глубже: несколько баз или база плюс Kafka — почему «одной транзакцией» не получитсярасширенное

Иногда хочется обернуть в одну транзакцию две базы или базу и отправку сообщения в очередь. Технически в Spring есть механизмы для этого, но на практике так не делают:

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

Правильный подход для таких случаев — паттерн Outbox (записать в ту же базу таблицу «исходящих», а отдельный процесс уже разошлёт сообщения) и Saga. Подробно — в Distributed Patterns.

Коротко

  • Транзакция — группа операций с базой по принципу «всё или ничего»; @Transactional снимает ручную обвязку: Spring сам открывает, коммитит и откатывает.
  • Работает через прокси: вызов должен идти через обёртку. Мимо неё идут вызов через this, private- и final-методы — транзакции там просто нет.
  • Проглоченное в try/catch исключение до прокси не доходит, и транзакция коммитится.
  • По умолчанию откат — только на RuntimeException/Error; на checked-исключения транзакция коммитится. Чинится через rollbackFor.
  • Propagation: REQUIRED (по умолчанию), REQUIRES_NEW (отдельная транзакция для аудита), NESTED (savepoint, и только там, где менеджер его умеет).
  • Isolation меняйте осознанно и с повтором операции при конфликте; readOnly = true — на методы, которые действительно только читают (через JDBC и jOOQ это не подсказка, а запрет на запись); две базы или база с очередью — это Outbox и Saga, а не одна транзакция.
  • Транзакция живёт в ThreadLocal: @Async, свой пул потоков и фаза AFTER_COMMIT её не продолжают, а открывают новую; @Transactional на классе переопределяется аннотацией на методе целиком.
  • timeout (или spring.transaction.default-timeout) — единственная защита от висящей транзакции; по сети внутри транзакции не ходят: чужой тормоз выносит пул соединений.
  • REQUIRES_NEW держит два соединения сразу и в цикле даёт взаимную блокировку на пуле; TransactionTemplate нужен там, где граница транзакции не совпадает с границей метода.
  • UnexpectedRollbackException чаще всего приходит так: внутренний REQUIRED-метод упал, внешний поймал исключение, а транзакция уже помечена rollback-only.

Что пощупать

Границы транзакций в учебном сервисе каталога расставлены по одному правилу: транзакция на публичный метод сервиса, чтение с readOnly, изменение состояния только через методы сущности внутри транзакции. Код лежит в практикуме remodov/marketplace-system, стартовый сервис services/catalog-starter, тесты на H2.

Код: product.

Сделаем сами

Ветка step-02-read-endpoint — сценарий чтения с границей транзакции вынут, условие по ссылке в TASK.md.

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

  • ACID и уровни изоляции в PostgreSQL — что реально гарантируют уровни isolation.
  • Spring Data JPA — как транзакции связаны с сессией JPA и проблемой OSIV.
  • Spring Events — @TransactionalEventListener и события, привязанные к фазам транзакции.
  • Spring AOP — как аннотации вроде @Transactional превращают бин в прокси.