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

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

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

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

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

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

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

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

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

Connection conn = dataSource.getConnection();
try {
    conn.setAutoCommit(false);
    accountRepo.withdraw(from, amount);
    accountRepo.deposit(to, amount);
    conn.commit();
} catch (Exception e) {
    conn.rollback();
    throw e;
} finally {
    conn.close();
}

@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 работает через прокси.

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

Вызов → [Прокси: открыть транзакцию] → ваш метод → [Прокси: commit / rollback]

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

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

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

@Service
public class OrderService {

    public void processBatch(List<Order> orders) {
        orders.forEach(this::processOne);   // вызов через this — мимо прокси
    }

    @Transactional
    public void processOne(Order order) {
        // транзакция НЕ откроется, потому что вызвали через 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 {

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

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

Правило простое: @Transactional ставьте только на public-методы. По той же причине не сработает аннотация на final-методе или final-классе — прокси не может его переопределить.

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

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

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

Если внутри транзакции что-то пошло не так и это требует отката — исключение нужно пробросить дальше.

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

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

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

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

Это сюрприз для многих. Два способа исправить:

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

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

Propagation (распространение) отвечает на вопрос: метод вызвали внутри уже идущей транзакции — присоединиться к ней или открыть свою? Режимов семь, на практике важны три.

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

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

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

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

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) { ... }
}

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

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());   // один сбойный не рушит весь пакет
        }
    }
}

Работает поверх JDBC и PostgreSQL (через SAVEPOINT).

Остальные четыре — кратко

  • MANDATORY — требует уже открытую транзакцию, иначе ошибка. «Меня нельзя вызывать вне транзакции».
  • SUPPORTS — есть транзакция, присоединюсь; нет — выполнюсь без неё.
  • NOT_SUPPORTED — приостановить транзакцию и выполниться без неё.
  • NEVER — если транзакция есть, бросить ошибку.

Isolation — как транзакции видят друг друга

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

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

Уровней четыре плюс DEFAULT (берётся из настроек базы — в PostgreSQL это 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 отключает отслеживание изменений — не сравнивает объекты «до и после» и не делает лишних запросов;
  • пул соединений может направить запрос на read-реплику, если она настроена.

Дешёвое улучшение: ставьте readOnly = true на любой метод, который только читает.

Несколько баз или база плюс Kafka — почему «одной транзакцией» не выйдет

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

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

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

Коротко

  • Транзакция — группа операций с базой по принципу «всё или ничего»: либо commit, либо rollback.
  • @Transactional снимает ручную обвязку: Spring сам открывает, коммитит и откатывает транзакцию.
  • Работает через прокси — вызов должен идти через обёртку, иначе транзакции не будет.
  • Не сработает: вызов через this (self-invocation), private/final-методы, проглоченное try/catch исключение.
  • Ставьте @Transactional только на public-методы.
  • По умолчанию откат — только на RuntimeException/Error; на checked-исключения транзакция коммитится. Чинится через rollbackFor.
  • Propagation: REQUIRED (по умолчанию, присоединиться/открыть), REQUIRES_NEW (отдельная транзакция), NESTED (savepoint).
  • Isolation меняйте осознанно; на высоких уровнях код должен уметь повторять операцию при конфликте.
  • readOnly = true — для методов, которые только читают; дешёвый прирост с JPA.
  • Несколько баз / база + очередь одной транзакцией не оборачивают — используют Outbox и Saga.

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

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