Введение
В этой статье я собираюсь показать вам лучший способ использования аннотации 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)
Bakuard
02.02.2022 08:07-4Размещать управление транзакциями на сервисном слое, как мне кажется, характерная черта именно simple domain model. Что касается rich domain model, то почему бы не разместить слой транзакций в контроллерах?
Lezenford
02.02.2022 12:56+2Ничего не мешает работать с rich model на уровне сервиса. Контроллер не вмещает в себя функцию бизнес логики - его задача получить данные из сервиса и отдать клиенту, максимум - преобразовать в ДТО. Но консистентносить и наполненность должна обеспечиваться именно сервисом
Bakuard
03.02.2022 16:13Я пробовал подход с использованием rich domain model и размещением слоя транзакций исключительно в сервисах. По итогу большая часть сервисов выступала просто обёрткой над моделью, т.е. каждый метод такого сервиса просто вызовал какой-то один метод модели и помечался анотацией Transactional. Собственно все. Мне кажется, что в таких случаях сервисы - это лишние сущности.
Lezenford
03.02.2022 16:30+1Это зависит от размеров и сложности сервиса. Если у вас пара контроллеров да пара сущностей - то да, созданием прослоек можно пренебречь, но если приложение крупное, то создание этих оберток обосновано. В крупном приложении нужно строже придерживаться целевого назначения компонентов. То, что сейчас у вас всего 1 вызов в сервисе и сразу его возврат в контроллер, не значит что при решении новой задачи не появиться необходимость 2 вызовов, где транзакция нужна только для одного и тут внезапно придется вписывать слой сервисов. А вписывать его нужно целиком, чтобы не нарушать единообразия, а значит в какой то момент времени в ПР вместо добавления одного метода внезапно придется писать кучу вот таки пробрасывающих объектов, чтобы сохранить стилистику кода приложения. А при разработке приложения чуть сложнее, чем предоставление данных из BD в UI as is, такая необходимость возникнет гарантированно, а значит и сервисный слой будут закладывать еще на этапе проектирования архитектуры, даже если в первые пол года при разработке прототипа он окажется избыточным.
Цель разбиения на слои не только в технической необходимости, но еще и в соблюдении единообразия проекта, чистоты кода, проектного code style, снижения сложности входа в проект новых разработчиков и т.д. Собственно это базовые принципы использования любого архитектурного паттерна с заявкой на то, что сервис будет функционировать и эксплуатироваться.
Bakuard
03.02.2022 19:29-1"То, что сейчас у вас всего 1 вызов в сервисе и сразу его возврат в контроллер, не значит что при решении новой задачи не появиться необходимость 2 вызовов, где транзакция нужна только для одного и тут внезапно придется вписывать слой сервисов. " - мне кажется имеет смысл, если метод не требующий транзакции выполняется достаточно долго, чтобы оказывать заметное влияние на время выполнения транзакции. Но честно говоря, я такие случаи встречал очень редко.
"Цель разбиения на слои не только в технической необходимости " - с этим никто и не спорит.
gordeevart
03.02.2022 13:05Не используйте ее на веб-уровне, поскольку это может увеличить время отклика транзакции базы данных и усложнить предоставление правильного сообщения об ошибке для определенной ситуации (например, согласованность, дедлок, получение блокировки, оптимистическая блокировка).
А тут можно подробнее? Время отклика увеличивается за счёт сериализации, проверки прав? Это критично? Каким образом усложняет я обработка ошибок?
Спрашиваю, потому что не везде именно такая слоеная архитектура, и не всегда хочется тянуть spring на операционный уровень. Тогда возникает хороший вопрос, где следует ставить такие аннотации?
levchick
03.02.2022 13:24+1Чем шире scope конкретной транзакции, тем дольше она выполняется. Особенно если внутри нее есть еще запросы в другие сервисы / базы. Во время того, как транзакция открыта, соединение с БД не может быть возвращено назад в пул и использовано другими запросами. Рано или поздно упретесь в лимит открытых конекшенов. Если в рамках транзакции вы делаете лок (select for update) и держите ее открытой дольше чем необходимо для обновления стэйта сущности, которую лочите, то другие ресурсы тоже не смогут получить к ней доступ на запись и будут скапливаться в очередь, что может привести к приличной деградации. Ну и бонусом, всякие неприятные последствия для бизнес логики, если ваша транзакция откатится из-за какого-нибудь минорного исключения выше по стэку, который на суть бизнес операции не влияет никак.
В целом, для небольших проектов не под нагрузкой все эти моменты едва-ли будут ощутимы. Однако это очень частая проблема при росте. И большой вопрос, стоит ли такую бомбу замедленного действия закладывать со старта.
Djeux
Лучший способ - не использовать
@Transactional
MisterParser
Почему?
shalomman
потомучто консистентность для слабаков
levchick
Я думаю, что изначально комментатор имел ввиду, что при работе с такой важной вещью как транзакционность, лучше полагаться на более очевидный и понятный всем код, нежели чем на AOP магию. Не зная особенностей работы последней, можно очень здорово стрельнуть себе в
ногуголову, особенно в каком-нибудь финтехе.eleoleeye
В каком-нибудь финтехе все в курсе как что работает
Djeux
Очень ошибочное мнение. Я встречал финтехи где повесили Transactional на parent класс контроллера и со спокойной душой пошли в прод. Ну а когда пришли веселые времена, унесли ноги.
panzerfaust
Какой-то это был жиденький финтех, если такой фокус не был обнаружен ни на ревью, ни в статическом анализаторе. Это, наверное, был один из тех финтехов, которые "растят джунов"? Вот, вырастили.
panzerfaust
Это тот код, который закреплен в гайдлайнах проекта. Аннотации - значит аннотации. Шаманские камлания - значит шаманские камлания.
Ну и называть АОП магией в то время, как AspectJ в прошлом году отпраздновал 20-летний юбилей - ну это такое. Приемы АОП давно уже в джентльменском наборе любого разраба на JVM.
rion3
Есть принципиальный момент в том, что спринг для своей магии компоненты оборачивает в динамическую прокси из стандартной джава библиотеки, в которой не работает обычное наследование. Поэтому если в одном сервис классе публичные транзакционные методы вызывают друг друга, то аннотация может неявно неотработать. Явно вызывать TransactionTemplate от spring будет надёжней. Но для простых crud сервисов удобней использовать аннотацию из статьи.
Также в spring есть тренд в отказе от аннотаций в пользу функционального декларативного стиля в веб модуле.
Lezenford
Это зависит от того, какой стек используем. Если вся логика лежит в БД на уровне хранимых процедур (старый финтех), то да, можно и лучше вообще не использовать, база сделает все сама. При наличии любой ОРМ уже так просто не отделаться. И если выбирать между аннотацией и явным созданием транзакции через sessonFactory - лучше аннотация. код становится сильно проще. Что, впрочем, не отменяет необходимости знать, что за магия там творится под капотом, чтобы не стрелять себе в ногу