Вероятно, одной из наиболее часто используемых аннотаций Spring является @Transactional. Несмотря на ее популярность, иногда она используется неправильно, в результате чего получается не совсем то, что задумал инженер-программист.

В этой статье я собрал проблемы, с которыми лично сталкивался в проектах. Надеюсь, этот список поможет вам лучше понять транзакции и поспособствует устранению нескольких ваших замечаний.

1. Вызовы в пределах одного класса

@Transactional редко подвергается достаточному количеству тестов, и это приводит к тому, что какие-то проблемы не видны на первый взгляд. В результате вы можете столкнуться со следующим кодом:

Аннотация не работает в методе registerAccount:

public void registerAccount(Account acc) {
    createAccount(acc);

    notificationSrvc.sendVerificationEmail(acc);
}

@Transactional
public void createAccount(Account acc) {
    accRepo.save(acc);
    teamRepo.createPersonalTeam(acc);
}

В этом случае при вызове registerAccount() сохранение пользователя и создание команды не будут выполняться в одной транзакции. @Transactional работает на основе аспектно-ориентированного программирования. Поэтому обработка происходит, когда один бин вызывается из другого. В приведенном выше примере метод вызывается из того же класса, поэтому прокси не могут быть применены. Так происходит и с другими аннотациями, как, к примеру, @Cacheable.

Проблема может быть решена тремя основными способами:

  1. Самостоятельная инъекция (Self-inject)

  2. Создать еще один уровень абстракции

  3. Использовать TransactionTemplate в методе registerAccount(), обернув вызов createAccount()

Первый способ кажется менее очевидным, но таким образом мы избегаем дублирования логики, если @Transactional содержит параметры.

Аннотация работает в методе registerAccount:

@Service
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accRepo;
    private final TeamRepository teamRepo;
    private final NotificationService notificationSrvc;
    @Lazy private final AccountService self;

    public void registerAccount(Account acc) {
        self.createAccount(acc);

        notificationSrvc.sendVerificationEmail(acc);
    }

    @Transactional
    public void createAccount(Account acc) {
        accRepo.save(acc);
        teamRepo.createPersonalTeam(acc);
    }
}

Если вы используете Lombok, не забудьте добавить @Lazy в ваш lombok.config.

2. Обработка не всех исключений

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

Установите rollbackFor, если вам нужно откатиться назад в случае StripeException:

@Transactional(rollbackFor = StripeException.class)
public void createBillingAccount(Account acc) throws StripeException {
    accSrvc.createAccount(acc);

    stripeHelper.createFreeTrial(acc);
}

3. Уровни изоляции транзакций и распространение

Часто разработчики добавляют аннотации, не задумываясь о том, какого поведения они хотят добиться. Почти всегда по умолчанию используется уровень изоляции READ_COMMITED.

Понимание уровней изоляции необходимо для того, чтобы избежать ошибок, которые потом очень трудно отлаживать.

Например, если вы создаете отчеты, то можно выбрать разные данные на уровне изоляции по умолчанию, выполнив один и тот же запрос несколько раз в течение транзакции. Это происходит, когда параллельная транзакция фиксирует что-то в это время. Использование REPEATABLE_READ поможет избежать таких сценариев и сэкономить массу времени на поиск и устранение неисправностей.

Различные механизмы распространения помогают связать транзакции в нашей бизнес-логике. Например, если вам нужно запустить какой-то код в другой транзакции, а не во внешней, можно использовать распространение REQUIRES_NEW, которое приостанавливает внешнюю транзакцию, создает новую, а затем возобновляет внешнюю транзакцию.

4. Транзакции не блокируют данные

@Transactional
public List<Message> getAndUpdateStatuses(Status oldStatus, Status newStatus, int batchSize) {
    List<Message> messages = messageRepo.findAllByStatus(oldStatus, PageRequest.of(0, batchSize));
    
    messages.forEach(msg -> msg.setStatus(newStatus));

    return messageRepo.saveAll(messages);
}

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

Однако проблема в том, что ничто не мешает другому экземпляру приложения вызвать findAllByStatus одновременно с первым. В результате метод вернет одни и те же данные в обоих экземплярах, и их обработка будет произведена 2 раза.

Есть 2 способа избежать этой проблемы.

Выбрать для обновления (пессимистическая блокировка)

Select-for-update в PostgreSQL:

UPDATE message
SET status = :newStatus
WHERE id in (
   SELECT m.id FROM message m WHERE m.status = :oldStatus LIMIT :limit
   FOR UPDATE SKIP LOCKED)
RETURNING *

В приведенном выше примере, когда выполняется выбор, строки блокируются до конца обновления. Запрос возвращает все измененные строки.

Версионирование сущностей (оптимистическая блокировка)

Этот способ помогает избежать блокировки. Идея заключается в том, чтобы добавить столбец version к нашим сущностям. Таким образом, мы можем выбрать данные и затем обновить их только в том случае, если версия сущностей в базе данных совпадает с версией в приложении. В случае использования JPA можно применить аннотацию @Version.

5. Два разных источника данных

Например, мы создали новую версию хранилища данных, но все еще должны некоторое время поддерживать старую.

@Transactional
public void saveAccount(Account acc) {
    dataSource1Repo.save(acc);
    dataSource2Repo.save(acc);
}

Конечно, в этом случае только один save будет обрабатываться транзакционно, именно в том TransactionalManager, который используется по умолчанию.

Spring предоставляет здесь два варианта.

ChainedTransactionManager

1st TX Platform: begin
  2nd TX Platform: begin
    3rd Tx Platform: begin
    3rd Tx Platform: commit
  2nd TX Platform: commit <-- fail
  2nd TX Platform: rollback  
1st TX Platform: rollback

ChainedTransactionManager — это способ объявления нескольких источников данных, в которых, в случае исключения, откат будет происходить в обратном порядке. Таким образом, при наличии трех источников данных, если во время фиксации на втором произошла ошибка, то откат будет произведен только для первых двух. Третий уже зафиксировал изменения.

JtaTransactionManager

Этот менеджер позволяет использовать полностью поддерживаемые распределенные транзакции на основе двухфазной фиксации. Однако он делегирует управление бэкенд-провайдеру JTA. Это могут быть серверы Java EE или отдельные решения (Atomikos, Bitrionix и т.д.).

Заключение

Транзакции — непростая тема, и нередко возникают проблемы с их пониманием. Чаще всего они не полностью покрываются тестами, так что большинство ошибок можно заметить только при ревью кода. А если в продакшне случаются инциденты, найти первопричину всегда непросто.


Материал подготовлен нашим экспертом - Александром Коженковым и опубликован в преддверии старта курса «Highload Architect».

Всех желающих приглашаем на открытый урок «Репликация». На занятии мы:
- Рассмотрим принцип работы механизмов репликации с точки зрения синхронизации данных;
- Проанализируем проблемы асинхронной репликации и варианты их решения;
- Обсудим предназначение и потенциальные проблемы репликации вида master-master, а также рассмотрим преимущества и недостатки безмастерной репликации.

>> РЕГИСТРАЦИЯ