@Transactional выглядит просто, пока всё работает. Когда перестаёт — оказывается, что под капотом много механики, которая не интуитивна. Эта статья разбирает реальную модель работы, чтобы можно было осознанно проектировать транзакционные границы.
Как работает: AOP proxy
@Transactional — это указание Spring создать прокси для бина. Прокси перехватывает вызовы аннотированных методов, оборачивает их в транзакцию.
Caller → [Proxy: openTransaction()] → RealService.doWork() → [Proxy: commit()/rollback()]
Из этой модели вытекают четыре ловушки, через которые проходит почти каждая команда.
Ловушка 1: Self-invocation
Вызов через this идёт в обход прокси:
@Service
public class OrderService {
public void processBatch(List<Order> orders) {
orders.forEach(this::processOne); // ← прокси не задействован
}
@Transactional
public void processOne(Order order) {
// транзакция НЕ открывается, потому что вызов через this
}
}
Решение: вынести в другой бин ИЛИ внедрить self-reference через ApplicationContext.getBean() ИЛИ переделать processBatch так, чтобы вызов шёл через прокси (контроллер вызывает напрямую processOne в цикле).
Ловушка 2: Private методы
Прокси не видит private методов — @Transactional на них игнорируется без ошибки.
@Service
public class OrderService {
public void process(Order order) {
save(order);
}
@Transactional // ИГНОРИРУЕТСЯ
private void save(Order order) { ... }
}
Правило: @Transactional — только на public методах. С Spring 6.x работает и на protected/package-private при определённых условиях, но public — единственный надёжный вариант.
Ловушка 3: Checked exceptions не откатывают по умолчанию
По умолчанию только RuntimeException и Error вызывают rollback. Checked exception (IOException, SQLException через wrapper) — commit'ит.
@Transactional
public void process() throws IOException {
repo.save(...);
throw new IOException("..."); // транзакция КОММИТНЕТСЯ
}
Решение: @Transactional(rollbackFor = Exception.class) или конкретный класс.
Прагматично: в новом коде стараться бросать RuntimeException (или его наследников). Это снимает большинство гочей.
Ловушка 4: Transaction propagation default'ы
@Transactional без параметров = propagation = REQUIRED. Это значит «используй существующую транзакцию или открой новую». Часто не то, что нужно.
Семь propagation modes
REQUIRED (default)
Используется существующая транзакция. Если нет — создаётся новая. Всё в одной транзакции, единый commit/rollback.
// Caller (transactional)
@Transactional
public void processOrderAndAudit(Order order) {
orderService.process(order); // в той же транзакции
auditService.log(order); // в той же транзакции
}
// Внутри auditService.log:
@Transactional // propagation = REQUIRED, join существующую
public void log(Order order) { ... }
REQUIRES_NEW
Приостанавливает текущую транзакцию (если есть), открывает новую. После завершения новой — текущая возобновляется.
@Transactional
public void process(Order order) {
orderRepo.save(order);
auditService.log(order); // ← REQUIRES_NEW: коммитнется независимо
throw new RuntimeException("..."); // основная транзакция откатится,
// но audit-запись уже сохранена
}
class AuditService {
@Transactional(propagation = REQUIRES_NEW)
public void log(Order order) { ... }
}
Применять для независимых side effects — аудит-логи, метрики, уведомления. Где «main task провалилась, но факт попытки должен остаться».
NESTED
Использует savepoint существующей транзакции. Rollback внутреннего откатывает только до savepoint, не всю транзакцию.
@Transactional
public void processBatch(List<Order> orders) {
for (var order : orders) {
try {
tryProcess(order);
} catch (Exception e) {
log.warn("skipped: {}", order.id());
// продолжаем с остальными
}
}
}
@Transactional(propagation = NESTED)
public void tryProcess(Order order) { repo.save(order); }
Поддерживается JDBC, не работает с JTA. С PostgreSQL — да, через SAVEPOINT.
MANDATORY
Должна быть существующая транзакция, иначе exception. Используется для методов, которые обязаны идти в чужой транзакции (например, нельзя вызвать вне транзакции, потому что состояние агрегата не закоммитится).
SUPPORTS
Если есть транзакция — присоединяемся, если нет — выполняемся без неё. Полезно для read-only операций, которым «всё равно».
NOT_SUPPORTED
Текущая транзакция приостанавливается, метод выполняется без транзакции. Полезно, когда нужно гарантированно не быть в транзакции (например, долгая job, не блокирующая запись).
NEVER
Если есть транзакция — exception. Гарантирует, что метод вызван вне транзакции.
Isolation levels
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(AccountId from, AccountId to, Money amount) { ... }
Доступны 4 уровня + DEFAULT (берёт из datasource — обычно READ_COMMITTED в PostgreSQL).
Детально про уровни — в статье про ACID в PostgreSQL. Здесь важно одно: в Spring, если выбрали SERIALIZABLE, обязаны уметь retry:
@Retryable(
retryFor = {ConcurrencyFailureException.class, CannotSerializeTransactionException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2.0)
)
@Transactional(isolation = Isolation.SERIALIZABLE)
public void transfer(AccountId from, AccountId to, Money amount) { ... }
Без retry — приложение случайно падает в проде при конкурентных транзакциях.
readOnly = true
Подсказка для драйвера + JPA, что транзакция read-only.
@Transactional(readOnly = true)
public List<Order> findByCustomer(CustomerId id) {
return repo.findByCustomerId(id);
}
Эффекты:
- PostgreSQL игнорирует (флаг есть, но эффекта мало).
- Hibernate отключает dirty checking — не делает SELECT'ы для проверки изменений, не флашит изменения.
- Connection pool может направить на read-replica (если настроено).
Рекомендация: ставить readOnly = true на любую транзакцию, которая не пишет. Это дешёвое улучшение производительности с JPA.
Rollback rules
Чтобы откатить транзакцию программно:
@Transactional
public void process(Order order) {
if (validationFails(order)) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return; // выйдем без exception, но транзакция откатится
}
// ...
}
Однако чаще проще бросить RuntimeException. Программный rollback нужен, когда не хотите бросать исключение из «нормального» бизнес-сценария.
Distributed transactions: почему обычно нельзя
Два сценария:
Несколько БД через JTA + XA
Технически Spring поддерживает через JtaTransactionManager (Bitronix, Atomikos):
@Transactional
public void doMultipleDatabases() {
db1.save(...);
db2.save(...);
// если упадёт — обе откатятся через 2PC
}
На практике — не делать так в production. Причины:
- Performance: 2PC требует синхронной координации, latency ×2-3.
- Хрупкость: одна из БД зависла → транзакция «в неопределённом состоянии», требует ручного разбора.
- Облака не поддерживают XA нативно (AWS RDS, GCP Cloud SQL — XA-протоколы выключены).
Альтернатива — Saga + Outbox или Distributed Patterns Style Guide.
БД + Kafka в одной транзакции
Технически можно через ChainedTransactionManager, но это best-effort 2PC без гарантий. Реально нужен Outbox pattern: write в БД-таблицу outbox в той же транзакции, отдельный процесс публикует в Kafka.
Что нужно знать про @Transactional для UCP
В стеке Use Case Pattern транзакция обычно открывается внутри Handler'а, обёрнуто @Transactional. Use Case — pojo без аннотаций, Handler — @Service @Transactional.
@Service
@RequiredArgsConstructor
@Transactional
public class CreateOrderHandler implements UseCaseHandler<CreateOrder, OrderId> {
private final OrderRepository orderRepo;
private final EventPublisher events;
@Override
public OrderId handle(CreateOrder useCase) {
var order = Order.create(useCase.toCommand());
orderRepo.save(order);
events.publish(new OrderCreatedEvent(order.id()));
return order.id();
}
}
Один Handler = одна транзакция = одна бизнес-операция. На этом уровне @Transactional — гарантия атомарности всей операции.
Что почитать дальше
- ACID и уровни изоляции в PostgreSQL — какие реальные гарантии под уровнями isolation.
- Spring Data JPA — взаимодействие транзакций с JPA-сессией, OSIV.
- Spring Events —
@TransactionalEventListenerи события, привязанные к транзакции. - Distributed Patterns Style Guide — что делать вместо distributed transactions.