Транзакции в Spring¶
Под словами «транзакции в Spring» смешивают три разные вещи, их лучше сразу разделять:
- Локальная транзакция одного ресурса - одна БД,
@Transactional. Самый надёжный и дешёвый путь. - Глобальная транзакция через JTA/XA - атомарный commit нескольких ресурсов по протоколу 2PC.
- Координация без 2PC - Outbox, Saga, идемпотентность, компенсации.
Warning
@Transactional не решает распределённую согласованность между микросервисами. Он даёт локальную транзакцию внутри
одного процесса и обычно одного менеджера транзакций. Для нескольких ресурсов или сервисов нужны отдельные паттерны
(см. Распределённые транзакции).
Страницы¶
- эта страница - механизм транзакций,
@Transactional, propagation, isolation, тесты, как выбрать подход - Блокировки, retry и идемпотентность -
@Lockи@Versionв Spring Data, Spring Retry,@Retryable, идемпотентные операции - Программное управление и потоки -
TransactionTemplate, действия после коммита, маршрутизация DataSource, транзакции в нескольких потоках - Распределённые транзакции в Spring - XA в Spring, Saga, Outbox, TCC, распределённые блокировки
Как Spring ведёт транзакцию¶
Абстракция и менеджеры транзакций¶
Spring даёт одну абстракцию транзакций поверх разных стратегий. Код пишется одинаково, а реальную работу делает менеджер транзакций.
| Менеджер | Что ведёт |
|---|---|
DataSourceTransactionManager |
Локальная JDBC-транзакция одной БД |
JpaTransactionManager |
Локальная транзакция через JPA / Hibernate, по умолчанию в Spring Data JPA |
JtaTransactionManager |
Глобальная транзакция через JTA/XA (см. XA в Spring) |
TransactionSynchronizationManager - хранилище транзакционного контекста текущего потока: соединение,
ресурсы, синхронизации, флаг read-only. Всё лежит в ThreadLocal. Отсюда два следствия:
- обычная (imperative) транзакция Spring привязана к потоку
- транзакция сама не перетекает в другой поток:
@Async,ExecutorService,CompletableFuture, parallel stream работают без неё (см. Транзакции и потоки)
Реактивный стек - отдельный мир. Там транзакцию ведёт TransactionalOperator, а контекст живёт в контексте
Reactor-конвейера, а не в ThreadLocal. Imperative @Transactional и реактивная модель - разные вещи.
@Transactional¶
@Transactional - метаданные: аннотация только описывает транзакционную семантику. Работать она начинает,
когда включена инфраструктура (@EnableTransactionManagement, в Spring Boot включается сама). Spring перехватывает
вызов через AOP-прокси.
Вызов метода
> TransactionInterceptor перехватывает
> PlatformTransactionManager.getTransaction()
> DataSource.getConnection()
> connection.setAutoCommit(false)
> выполнение бизнес-логики
> нет исключений: manager.commit()
> RuntimeException или Error: manager.rollback()
> connection.setAutoCommit(true), возврат соединения в пул
Значения по умолчанию:
- propagation -
REQUIRED - isolation -
DEFAULT, уровень самой БД - read-write
- откат по
RuntimeExceptionиError, по checked-исключениям нет - timeout - как у нижележащей системы
Атрибуты: propagation, isolation, timeout, readOnly, rollbackFor, noRollbackFor, а также
value / transactionManager - какой менеджер использовать, если их несколько.
Self-invocation - главная ловушка. Вызов @Transactional-метода из того же класса идёт через this, мимо прокси,
и транзакция не создаётся.
@Service
public class OrderService {
public void processOrder(Order order) {
this.saveOrder(order); // вызов через this, не через прокси - транзакции НЕ будет
}
@Transactional
public void saveOrder(Order order) {
repository.save(order);
}
}
Решения:
- вынести метод в другой бин - самый понятный вариант
- внедрить самого себя:
@Lazy private OrderService self - управлять транзакцией программно через
TransactionTemplate(см. Программное управление) - AspectJ weaving вместо прокси: перехватывается любой вызов
Видимость методов. В proxy-режиме транзакционный метод должен быть public. С Spring 6 для class-based прокси
(CGLIB) работают и protected или package-private методы, а для interface-based прокси метод должен быть public и
объявлен в интерфейсе. Spring советует ставить аннотацию на методы конкретного класса, а не интерфейса.
Итого: транзакционная граница - на public-методе сервиса, который вызывают снаружи. Важная семантика не
должна зависеть от внутренних вызовов this.method().
Правила отката. По умолчанию откат только по RuntimeException и Error. Если бизнес-ошибка оформлена
checked-исключением, а откат ожидается, его надо указать явно. Иначе код визуально «падает», а данные уже
закоммичены.
@Transactional(rollbackFor = Exception.class)
public void riskyOperation() throws IOException { ...}
readOnly = true - подсказка инфраструктуре и слою хранения, а не запрет на запись.
- Hibernate переводит сессию в режим без flush и dirty checking - реальный выигрыш на чтении
- Spring выставляет соединению
setReadOnly(true), драйвер PostgreSQL открывает транзакцию какREAD ONLY, и запись в ней падает - по флагу можно маршрутизировать запросы на реплику (см. Маршрутизацию DataSource)
@Transactional(readOnly = true)
public List<Order> findAllOrders() {
return orderRepository.findAll();
}
Это оптимизация, а не механизм безопасности. Гарантию «не писать» дают права в БД и дисциплина кода.
timeout - максимальное время транзакции в секундах, при превышении откат.
Propagation¶
Propagation - что делать, если транзакционный метод вызвали внутри уже идущей транзакции. Ключ к пониманию - разница между логической транзакцией (область одного метода) и физической (реальная транзакция БД).
| Propagation | Есть внешняя транзакция | Нет внешней | Когда использовать |
|---|---|---|---|
REQUIRED (по умолчанию) |
Присоединяется | Создаёт новую | Обычный случай: один use case - одна транзакция |
REQUIRES_NEW |
Приостанавливает внешнюю, создаёт новую | Создаёт новую | Аудит, который должен сохраниться даже при откате основной |
NESTED |
Создаёт savepoint | Создаёт новую | Частичный откат внутри общей транзакции |
SUPPORTS |
Присоединяется | Работает без транзакции | Чтение, где транзакция необязательна |
NOT_SUPPORTED |
Приостанавливает внешнюю | Без транзакции | Тяжёлые вычисления без БД |
MANDATORY |
Присоединяется | Бросает исключение | Метод ОБЯЗАН вызываться внутри транзакции |
NEVER |
Бросает исключение | Без транзакции | Метод НЕЛЬЗЯ вызывать внутри транзакции |
На практике важны REQUIRED, REQUIRES_NEW и NESTED. Остальные нужны, когда явно фиксируешь контракт: транзакция
обязательна или запрещена. В JTA те же значения, кроме NESTED, называются TxType (см.
Атрибуты транзакции).
REQUIRED - внутренний метод входит в ту же физическую транзакцию. Если внутренний метод упал и пометил
транзакцию rollback-only, а внешний перехватил исключение и продолжил, то на коммите внешний получит
UnexpectedRollbackException.
REQUIRES_NEW - всегда новая независимая физическая транзакция.
- внешняя приостановлена, внутренняя коммитится или откатывается сама по себе, её блокировки освобождаются сразу
- нужно второе соединение из пула: под нагрузкой легко исчерпать пул
- если обе транзакции трогают одни и те же строки - риск дедлока
- злоупотребление ведёт к скрытым проблемам производительности и неожиданной согласованности
NESTED - не новая транзакция, а savepoint внутри текущей.
- откат вложенной части откатывает только до savepoint, внешняя продолжается
- коммит вложенной части не окончательный, итог решает внешняя транзакция
- работает через JDBC-savepoint'ы, для операций через JPA / Hibernate не поддерживается
- не путать с «маленькой независимой транзакцией» - это
REQUIRES_NEW
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order);
try {
auditService.logAction("ORDER_PLACED", order.getId()); // аудит сохранится даже при откате заказа
} catch (Exception e) {
// логируем, но основную транзакцию не откатываем
}
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAction(String action, Long entityId) {
auditRepo.save(new AuditEntry(action, entityId));
}
}
Isolation¶
Isolation - какие аномалии конкурентного доступа разрешены: dirty read, non-repeatable read, phantom read, serialization anomaly. Что это за аномалии и как PostgreSQL реализует уровни через MVCC - в Архитектуре Postgres.
Isolation.DEFAULT - уровень самой БД. В PostgreSQL по умолчанию READ COMMITTED. Уровень - это договор с
конкретной БД, а не абстрактная теория.
| Уровень | Что даёт | Особенности PostgreSQL |
|---|---|---|
READ_UNCOMMITTED |
Допускает dirty read | Ведёт себя как READ COMMITTED. На практике не нужен |
READ_COMMITTED |
Нет dirty read, возможны non-repeatable read и фантомы | SELECT видит снимок на начало запроса, а не транзакции. Подходит для большинства CRUD |
REPEATABLE_READ |
Повторное чтение стабильно | Фантомов нет (строже стандарта), но write skew и другие аномалии сериализации возможны |
SERIALIZABLE |
Как будто транзакции шли по очереди | Под конкуренцией транзакции откатываются с ошибкой сериализации - нужен retry |
Warning
Метод с REQUIRED, который присоединился к внешней транзакции, тихо игнорирует свои isolation, timeout и
readOnly и наследует их от внешней. @Transactional(isolation = SERIALIZABLE) на внутреннем методе не даёт
SERIALIZABLE. Нужен REQUIRES_NEW или другая граница транзакции. Чтобы конфликт не проходил молча, у менеджера
транзакций включают validateExistingTransaction = true.
Тестирование транзакций¶
Что проверять: путь коммита, путь отката, self-invocation, поведение propagation, конфликты и retry, дедлоки и таймауты блокировок. Наличие аннотации на методе ничего не доказывает.
@Transactional на тесте - каждый тест откатывается после завершения.
@SpringBootTest
@Transactional
class OrderServiceTest {
@Test
void shouldCreateOrder() {
orderService.placeOrder(new OrderRequest(...));
assertThat(orderRepo.count()).isEqualTo(1); // данные видны внутри теста - та же транзакция
// после теста - автоматический ROLLBACK
}
}
Warning
Транзакция теста прячет баги: LazyInitializationException не воспроизведётся, а код, который ушёл в другой
поток, тестовую транзакцию не видит. Для интеграционных тестов используй @Commit или чисти данные вручную.
Testcontainers - настоящие БД вместо H2:
@Testcontainers
@SpringBootTest
class XATransactionTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8");
@Test
void shouldCommitAcrossDatabases() {
// XA-тест с двумя настоящими БД
}
}
Как выбрать¶
Один сервис, одна БД?
-> локальная транзакция + @Transactional
Один сервис, несколько БД или ресурсов?
-> XA / 2PC (Atomikos, Narayana)
-> или Outbox, если допустима eventual consistency
Микросервисы, синхронная координация?
-> Saga (оркестрация) + Outbox
-> TCC для операций с резервированием
Микросервисы, асинхронная координация?
-> Saga (хореография) + Event Sourcing
-> Outbox + CDC (Debezium)
| Сценарий | Механизм | Компромисс |
|---|---|---|
| CRUD одной сущности | @Transactional + optimistic lock |
Просто, retry при конфликтах |
| Финансовая операция в одной БД | @Transactional + pessimistic lock |
Строгая согласованность, throughput ниже |
| Два ресурса в одном сервисе | XA / 2PC | Атомарно, latency сильно выше |
| Микросервисы, нужна согласованность | Saga (оркестрация) + Outbox | Сложная реализация, eventual consistency |
| Микросервисы на событиях | Хореография + Event Sourcing | Максимальная развязка, отладка тяжелее |
| Задачи по расписанию в кластере | ShedLock или распределённая блокировка | Просто, один запуск на кластер |
| Ресурс под высокой конкуренцией | Распределённая блокировка (Redis, ZooKeeper) | Координация, риск единой точки отказа |
Анти-паттерны:
- распределённая транзакция через REST - атомарности нет, retry без идемпотентности опасен
- долгие XA-транзакции - держат ресурсы и вызывают каскад таймаутов
SERIALIZABLE«на всякий случай» - убивает throughput, бери минимально нужный уровень- retry без идемпотентности - дубли операций
- pessimistic lock через границы микросервисов - блокировки не пересекают процессы
- блокировки в разном порядке - дедлоки, блокируй в детерминированном порядке, например по id
Мониторинг:
- что смотреть: число и длительность транзакций, частота откатов и retry, время ожидания блокировок, зависшие (in-doubt) XA-транзакции, размер outbox-таблицы, consumer lag в Kafka
- чем: Micrometer + Prometheus / Grafana, Spring Boot Actuator (
/actuator/metrics),pg_stat_activity,SHOW ENGINE INNODB STATUS, журналы Atomikos
Итого:
@Transactional- локальная абстракция транзакций, а не решение распределённой согласованности.- Контекст imperative-транзакции привязан к потоку. Параллелизму нужны отдельные транзакционные границы.
REQUIREDприсоединяется к существующей транзакции,REQUIRES_NEW- новая физическая,NESTED- savepoint.- Self-invocation ломает
@Transactionalв proxy-режиме. - Isolation - договор с конкретной БД: в PostgreSQL
READ UNCOMMITTED=READ COMMITTED, аREPEATABLE READстроже стандарта по фантомам. - Optimistic locking - для масштаба, pessimistic - для частых и дорогих конфликтов.
- Retry нужен почти всегда, но только для безопасно повторяемых операций.
- XA/2PC нужен редко и дорого стоит. Применим, только если ресурсы умеют XA и нужна строгая атомарность между ними.
Порядок изучения темы:
- Локальные транзакции:
@Transactional, откат, propagation, isolation. - Привязка к потоку, прокси, self-invocation, программное управление.
- Блокировки: optimistic, pessimistic, savepoint'ы, дедлоки, retry.
- Высокая конкуренция: короткие транзакции, пулы соединений, contention.
- XA, JTA, 2PC.
- Распределённые паттерны: outbox, saga, идемпотентность, компенсации.
Без локальной модели распределённую обычно понимают неправильно.