Перейти к содержанию

Транзакции в Spring

Под словами «транзакции в Spring» смешивают три разные вещи, их лучше сразу разделять:

  1. Локальная транзакция одного ресурса - одна БД, @Transactional. Самый надёжный и дешёвый путь.
  2. Глобальная транзакция через JTA/XA - атомарный commit нескольких ресурсов по протоколу 2PC.
  3. Координация без 2PC - Outbox, Saga, идемпотентность, компенсации.

Warning

@Transactional не решает распределённую согласованность между микросервисами. Он даёт локальную транзакцию внутри одного процесса и обычно одного менеджера транзакций. Для нескольких ресурсов или сервисов нужны отдельные паттерны (см. Распределённые транзакции).

Страницы


Как 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 - максимальное время транзакции в секундах, при превышении откат.

@Transactional(timeout = 5)
public void longRunningOperation() { ...}

Propagation

Propagation - что делать, если транзакционный метод вызвали внутри уже идущей транзакции. Ключ к пониманию - разница между логической транзакцией (область одного метода) и физической (реальная транзакция БД).

@Transactional(propagation = Propagation.REQUIRED)
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.

@Transactional(isolation = Isolation.READ_COMMITTED)

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

Итого:

  1. @Transactional - локальная абстракция транзакций, а не решение распределённой согласованности.
  2. Контекст imperative-транзакции привязан к потоку. Параллелизму нужны отдельные транзакционные границы.
  3. REQUIRED присоединяется к существующей транзакции, REQUIRES_NEW - новая физическая, NESTED - savepoint.
  4. Self-invocation ломает @Transactional в proxy-режиме.
  5. Isolation - договор с конкретной БД: в PostgreSQL READ UNCOMMITTED = READ COMMITTED, а REPEATABLE READ строже стандарта по фантомам.
  6. Optimistic locking - для масштаба, pessimistic - для частых и дорогих конфликтов.
  7. Retry нужен почти всегда, но только для безопасно повторяемых операций.
  8. XA/2PC нужен редко и дорого стоит. Применим, только если ресурсы умеют XA и нужна строгая атомарность между ними.

Порядок изучения темы:

  1. Локальные транзакции: @Transactional, откат, propagation, isolation.
  2. Привязка к потоку, прокси, self-invocation, программное управление.
  3. Блокировки: optimistic, pessimistic, savepoint'ы, дедлоки, retry.
  4. Высокая конкуренция: короткие транзакции, пулы соединений, contention.
  5. XA, JTA, 2PC.
  6. Распределённые паттерны: outbox, saga, идемпотентность, компенсации.

Без локальной модели распределённую обычно понимают неправильно.