Введение

В этой статье я собираюсь показать вам лучший способ использования аннотации Spring Transactional.

Это один из лучших методов, которые я применял при разработке RevoGain, веб-приложения, которое позволяет вам рассчитать прибыль, полученную при торговле акциями, товарами или криптовалютами с помощью Revolut.

Аннотация Spring Transactional

Начиная с версии 1.0, Spring предлагал поддержку управления транзакциями на основе AOP, что позволяло разработчикам декларативно определять границы транзакций. Я знаю об этом, потому что читал руководство осенью 2004 года:

Очень скоро после этого, в версии 1.2, Spring добавил поддержку аннотации @Transactional, что еще больше упростило настройку границ транзакций бизнес-единиц работы.

Аннотация @Transactional содержит следующие атрибуты:

  • value и transactionManager — эти атрибуты могут быть использованы для предоставления ссылки на TransactionManager, которая будет использоваться при обработке транзакции для аннотированного блока

  • propagation — определяет, как границы транзакции распространяются на другие методы, которые будут вызваны прямо или косвенно из аннотированного блока. По умолчанию propagation задается как REQUIRED и значит, что она запускается, если еще нет ни одной транзакции. В противном случае текущая транзакция будет использована выполняющимся на данный момент методом.

  • timeout и timeoutString — определяют максимальное количество секунд, в течение которых текущему методу разрешено работать, прежде чем будет выброшено исключение TransactionTimedOutException

  • readOnly — определяет, является ли текущая транзакция доступной только для чтения или для записи.

  • rollbackFor и rollbackForClassName — определяют один или несколько классов Throwable, для которых текущая транзакция будет откатываться. По умолчанию транзакция откатывается, если возникает RuntimException или Error, но не откатывается, если возникает проверенное Exception.

  • noRollbackFor и noRollbackForClassName — определяют один или несколько классов Throwable, для которых текущая транзакция не будет откатываться. Обычно вы используете эти атрибуты для одного или нескольких классов RuntimException, для которых вы не хотите откатывать данную транзакцию.

К какому уровню относится аннотация Spring Transactional?

Аннотация @Transactional принадлежит к сервисному уровню (Service), потому что именно он отвечает за определение границ транзакций.

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

Для уровня DAO (Data Access Object) или репозитория требуется транзакции на уровне приложения, но она должна распространяться с сервисного уровня.

Лучший способ использования аннотации Spring Transactional

На сервисном уровне вы можете иметь как связанные, так и не связанные с базой данных службы. Если в конкретном сценарии использования необходимо их сочетать, например, когда нужно сделать парсинг заданного оператора, создать отчет и занести результаты в базу данных, то лучше всего, если транзакция с ней (БД) будет осуществлена как можно позже.

По этой причине можно использовать нетранзакционную шлюзовую службу, например, RevolutStatementService:

@Service
public class RevolutStatementService {
 
    @Transactional(propagation = Propagation.NEVER)
    public TradeGainReport processRevolutStocksStatement(
            MultipartFile inputFile,
            ReportGenerationSettings reportGenerationSettings) {
        return processRevolutStatement(
            inputFile,
            reportGenerationSettings,
            stocksStatementParser
        );
    }
     
    private TradeGainReport processRevolutStatement(
            MultipartFile inputFile,
            ReportGenerationSettings reportGenerationSettings,
            StatementParser statementParser
    ) {
        ReportType reportType = reportGenerationSettings.getReportType();
        String statementFileName = inputFile.getOriginalFilename();
        long statementFileSize = inputFile.getSize();
 
        StatementOperationModel statementModel = statementParser.parse(
            inputFile,
            reportGenerationSettings.getFxCurrency()
        );
        int statementChecksum = statementModel.getStatementChecksum();
        TradeGainReport report = generateReport(statementModel);
 
        if(!operationService.addStatementReportOperation(
            statementFileName,
            statementFileSize,
            statementChecksum,
            reportType.toOperationType()
        )) {
            triggerInsufficientCreditsFailure(report);
        }
 
        return report;
    }
}

Метод processRevolutStocksStatement нетранзакционный, и поэтому можно использовать стратегию Propagation.NEVER для обеспечения того, чтобы этот метод никогда не вызывался из активной транзакции.

Поэтому statementParser.parse и метод generateReport выполняются в нетранзакционном контексте, поскольку мы не хотим устанавливать соединение с базой данных и поддерживать его, когда нам нужно выполнить только обработку на уровне приложения.

Только operationService.addStatementReportOperation должна выполняться в транзакционном контексте, и по этой причине addStatementReportOperation использует аннотацию @Transactional:

@Service
@Transactional(readOnly = true)
public class OperationService {
 
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public boolean addStatementReportOperation(
        String statementFileName,
        long statementFileSize,
        int statementChecksum,
        OperationType reportType) {
         
        ...
    }
}

Обратите внимание, что addStatementReportOperation переопределяет уровень изоляции по умолчанию и указывает, что этот метод выполняется в транзакции базы данных SERIALIZABLE.

Стоит также отметить, что класс аннотирован @Transactional(readOnly = true), это значит, что по умолчанию все методы сервиса будут использовать эту настройку и выполняться в транзакции только для чтения, если только метод не переопределит транзакционные настройки с помощью своего собственного определения @ТгаnѕастіоnаІ.

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

Например, UserService использует тот же шаблон:

@Service
@Transactional(readOnly = true)
public class UserService implements UserDetailsService {
 
    @Override
    public UserDetails loadUserByUsername(String username)
        throws UsernameNotFoundException {
        ...
    }
     
    @Transactional
    public void createUser(User user) {
        ...
    }
}

В loadUserByUsername используется транзакция только для чтения, и поскольку мы используем Hibernate, Spring также выполняет некоторые оптимизации в режиме только для чтения.

С другой стороны, createUser должен записывать информацию в базу данных. Поэтому он переопределяет значение атрибута readOnly значением по умолчанию, заданным аннотацией @Transactional, которое соответствует readOnly=false, что делает транзакцию доступной для чтения и записи.

Еще одним большим преимуществом разделения методов "чтение-запись" и "только для чтения" является то, что мы можем направлять их на разные узлы базы данных, как объясняется в этой статье.

Таким образом, мы можем масштабировать трафик только для чтения, увеличивая количество узлов-реплик. Потрясающе, правда?

Заключение

Аннотация Spring Transactional очень удобна, когда речь идет об определении границ транзакций бизнес-методов.

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

Данный материал подготовили для будущих студентов нового потока курса «Разработчик на Spring Framework», а всех желающих приглашаем на бесплатный открытый урок на тему «Правильный DAO на Spring JDBC». На этом занятии рассмотрим, как использовать всю мощь нативного SQL и при этом написать безопасное, поддерживаемое и тестируемое DAO с использованием Spring JDBC.

Комментарии (17)


  1. Djeux
    01.02.2022 18:13
    +2

    Лучший способ - не использовать @Transactional


    1. MisterParser
      01.02.2022 21:03
      +2

      Почему?


      1. shalomman
        01.02.2022 21:59
        +2

        потомучто консистентность для слабаков


        1. levchick
          01.02.2022 22:30
          +3

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


          1. eleoleeye
            01.02.2022 22:49
            -2

            В каком-нибудь финтехе все в курсе как что работает


            1. Djeux
              02.02.2022 00:13
              +4

              Очень ошибочное мнение. Я встречал финтехи где повесили Transactional на parent класс контроллера и со спокойной душой пошли в прод. Ну а когда пришли веселые времена, унесли ноги.


              1. panzerfaust
                02.02.2022 08:05
                -2

                Какой-то это был жиденький финтех, если такой фокус не был обнаружен ни на ревью, ни в статическом анализаторе. Это, наверное, был один из тех финтехов, которые "растят джунов"? Вот, вырастили.


          1. panzerfaust
            02.02.2022 08:15
            -1

            более очевидный и понятный всем код

            Это тот код, который закреплен в гайдлайнах проекта. Аннотации - значит аннотации. Шаманские камлания - значит шаманские камлания.

            Ну и называть АОП магией в то время, как AspectJ в прошлом году отпраздновал 20-летний юбилей - ну это такое. Приемы АОП давно уже в джентльменском наборе любого разраба на JVM.


      1. rion3
        03.02.2022 15:09
        +1

        Есть принципиальный момент в том, что спринг для своей магии компоненты оборачивает в динамическую прокси из стандартной джава библиотеки, в которой не работает обычное наследование. Поэтому если в одном сервис классе публичные транзакционные методы вызывают друг друга, то аннотация может неявно неотработать. Явно вызывать TransactionTemplate от spring будет надёжней. Но для простых crud сервисов удобней использовать аннотацию из статьи.

        Также в spring есть тренд в отказе от аннотаций в пользу функционального декларативного стиля в веб модуле.


    1. Lezenford
      02.02.2022 12:58
      +1

      Это зависит от того, какой стек используем. Если вся логика лежит в БД на уровне хранимых процедур (старый финтех), то да, можно и лучше вообще не использовать, база сделает все сама. При наличии любой ОРМ уже так просто не отделаться. И если выбирать между аннотацией и явным созданием транзакции через sessonFactory - лучше аннотация. код становится сильно проще. Что, впрочем, не отменяет необходимости знать, что за магия там творится под капотом, чтобы не стрелять себе в ногу


  1. Bakuard
    02.02.2022 08:07
    -4

    Размещать управление транзакциями на сервисном слое, как мне кажется, характерная черта именно simple domain model. Что касается rich domain model, то почему бы не разместить слой транзакций в контроллерах?


    1. Lezenford
      02.02.2022 12:56
      +2

      Ничего не мешает работать с rich model на уровне сервиса. Контроллер не вмещает в себя функцию бизнес логики - его задача получить данные из сервиса и отдать клиенту, максимум - преобразовать в ДТО. Но консистентносить и наполненность должна обеспечиваться именно сервисом


      1. Bakuard
        03.02.2022 16:13

        Я пробовал подход с использованием rich domain model и размещением слоя транзакций исключительно в сервисах. По итогу большая часть сервисов выступала просто обёрткой над моделью, т.е. каждый метод такого сервиса просто вызовал какой-то один метод модели и помечался анотацией Transactional. Собственно все. Мне кажется, что в таких случаях сервисы - это лишние сущности.


        1. Lezenford
          03.02.2022 16:30
          +1

          Это зависит от размеров и сложности сервиса. Если у вас пара контроллеров да пара сущностей - то да, созданием прослоек можно пренебречь, но если приложение крупное, то создание этих оберток обосновано. В крупном приложении нужно строже придерживаться целевого назначения компонентов. То, что сейчас у вас всего 1 вызов в сервисе и сразу его возврат в контроллер, не значит что при решении новой задачи не появиться необходимость 2 вызовов, где транзакция нужна только для одного и тут внезапно придется вписывать слой сервисов. А вписывать его нужно целиком, чтобы не нарушать единообразия, а значит в какой то момент времени в ПР вместо добавления одного метода внезапно придется писать кучу вот таки пробрасывающих объектов, чтобы сохранить стилистику кода приложения. А при разработке приложения чуть сложнее, чем предоставление данных из BD в UI as is, такая необходимость возникнет гарантированно, а значит и сервисный слой будут закладывать еще на этапе проектирования архитектуры, даже если в первые пол года при разработке прототипа он окажется избыточным.

          Цель разбиения на слои не только в технической необходимости, но еще и в соблюдении единообразия проекта, чистоты кода, проектного code style, снижения сложности входа в проект новых разработчиков и т.д. Собственно это базовые принципы использования любого архитектурного паттерна с заявкой на то, что сервис будет функционировать и эксплуатироваться.


          1. Bakuard
            03.02.2022 19:29
            -1

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

            "Цель разбиения на слои не только в технической необходимости " - с этим никто и не спорит.


  1. gordeevart
    03.02.2022 13:05

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

    А тут можно подробнее? Время отклика увеличивается за счёт сериализации, проверки прав? Это критично? Каким образом усложняет я обработка ошибок?

    Спрашиваю, потому что не везде именно такая слоеная архитектура, и не всегда хочется тянуть spring на операционный уровень. Тогда возникает хороший вопрос, где следует ставить такие аннотации?


    1. levchick
      03.02.2022 13:24
      +1

      Чем шире scope конкретной транзакции, тем дольше она выполняется. Особенно если внутри нее есть еще запросы в другие сервисы / базы. Во время того, как транзакция открыта, соединение с БД не может быть возвращено назад в пул и использовано другими запросами. Рано или поздно упретесь в лимит открытых конекшенов. Если в рамках транзакции вы делаете лок (select for update) и держите ее открытой дольше чем необходимо для обновления стэйта сущности, которую лочите, то другие ресурсы тоже не смогут получить к ней доступ на запись и будут скапливаться в очередь, что может привести к приличной деградации. Ну и бонусом, всякие неприятные последствия для бизнес логики, если ваша транзакция откатится из-за какого-нибудь минорного исключения выше по стэку, который на суть бизнес операции не влияет никак.


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