Как Spring работает с транзакциями

Spring отделяет прикладной код от конкретного transaction API. Общий контракт задаёт PlatformTransactionManager, а declarative-вариант обычно добавляет transaction interceptor через proxy.

caller
→ Spring proxy
→ TransactionInterceptor
→ transaction manager
→ target method
→ commit или rollback

Начало вызова

Interceptor читает настройки @Transactional:

  • propagation;
  • isolation;
  • timeout;
  • read-only;
  • rollback rules;
  • имя transaction manager.

Затем transaction manager либо присоединяется к существующей транзакции, либо начинает новую. Для JDBC он получает connection и выключает auto-commit; для JPA координирует EntityManager и resource transaction либо JTA.

Ресурс привязывается к текущему потоку. Поэтому repository внутри того же thread получает connection/EntityManager текущей транзакции без передачи его аргументом.

Завершение

метод вернулся
→ flush при необходимости
→ commit
 
исключение требует rollback
→ rollback
→ исходное исключение выходит наружу

По умолчанию declarative transaction откатывается на RuntimeException и Error. Для checked exception правило задают явно:

@Transactional(rollbackFor = IOException.class)
public void importFile(Path path) throws IOException {
    // ...
}

Если исключение перехвачено и метод вернулся нормально, interceptor не узнает о сбое. Реальный случай: Кейс исключение было проглочено внутри транзакции.

Почему нужен proxy-вызов

@Service
class ImportService {
 
    public void start() {
        saveBatch(); // self-invocation
    }
 
    @Transactional
    public void saveBatch() {
        // ...
    }
}

Внутренний вызов this.saveBatch() не проходит через Spring proxy, поэтому новая transaction advice для этого метода обычно не запускается.

Решения:

  • вынести transaction boundary в другой bean;
  • вызывать публичный use case через proxy снаружи;
  • использовать TransactionTemplate, если граница действительно должна находиться внутри алгоритма.

Propagation

РежимПоведение
REQUIREDприсоединиться или создать
REQUIRES_NEWприостановить текущую и создать независимую
NESTEDsavepoint внутри физической транзакции, если manager поддерживает
SUPPORTSучаствовать, если транзакция есть
MANDATORYтребовать существующую
NOT_SUPPORTEDвыполнить без транзакции
NEVERзавершиться ошибкой при наличии транзакции

NESTED не означает ещё одну независимую database transaction. Это обычно savepoint. REQUIRES_NEW требует отдельного физического transaction/resource и может исчерпать connection pool при неудачном использовании.

Подробнее: Propagation.

Programmatic transaction

TransactionTemplate полезен, когда граница является частью явного control flow:

OrderView view = transactionTemplate.execute(status -> {
    Order order = repository.findById(id)
            .orElseThrow();
    return mapper.toView(order);
});

Прямой вызов PlatformTransactionManager нужен реже. Programmatic API не «хуже» declarative; он просто делает границу частью кода и увеличивает связанность с инфраструктурой.

Isolation и read-only

isolation применяется при создании новой транзакции и должен поддерживаться конкретным manager/database. Он не исправляет неверный бизнес-инвариант автоматически: см. Изоляция транзакций.

readOnly = true — подсказка transaction infrastructure/provider и полезное описание намерения. Нельзя считать её универсальным запретом на SQL UPDATE.

Транзакция и внешняя система

Database transaction не включает обычный HTTP-вызов или отправку сообщения в другую систему:

UPDATE в БД
→ внешний вызов прошёл
→ локальный commit упал

Rollback вернёт локальную БД, но не отменит удалённый эффект. Нужны idempotency, outbox, saga/compensation или другой согласованный protocol.

Вопросы для собеседования

Почему @Transactional не сработал на private method?

Обычный proxy intercepts внешний method call. Private method не является такой точкой входа, а self-invocation proxy обходит.

NESTED и REQUIRES_NEW одинаковы?

Нет. NESTED обычно использует savepoint текущей physical transaction, REQUIRES_NEW создаёт независимую.

Что произойдёт, если поймать exception?

Если не выбросить его наружу и не пометить transaction rollback-only, proxy увидит нормальное завершение и может сделать commit.

Открывает ли transaction controller?

Только если именно вызов controller method проходит через настроенный transaction proxy. Обычно boundary держат в application service.

Откатит ли Spring HTTP-вызов?

Нет.

Связанные заметки