Мы начнём изучение транзакций с нуля, шаг за шагом погружаясь в тему. Быстро рассмотрим проблему, которую решают транзакции. Далее посмотрим, как написать транзакцию на SQL. А потом разберёмся с управлением транзакциями в JDBC. Всё, что делает Spring, основано на JDBC. И вы сэкономите кучу времени при работе с аннотацией @Transactional
, если усвоите эти основы.
Данная статья является переводом и адаптацией англоязычной статьи. Я тщательно перевожу статью на русский, актуализирую устаревшие моменты, а также проверяю или пишу примеры кода, которые вы можете запустить самостоятельно.
– – – –
Spring Transaction Management: @Transactional In-Depth
Проблематика
Что такое транзакция и зачем она нужна проще объяснить на примере. Допустим, Вася переводит Пете 100 рублей. Для выполнения этой бизнес-функции нам потребуется три действия:
- Списать деньги с баланса Васи;
- Записать операцию перевода от Васи к Пете;
- Добавить денег на баланс Пете;
Представим, что мы списали деньги с баланса Васи, а потом произошла ошибка. В итоге на балансе у Васи нет денег, а у Пети они не появились. Приятная ситуация для банка, но неприятная для Васи и Пети.
Для решения таких проблем и существуют транзакции. Транзакция позволит нам объединить эти операции таким образом, что по итогу мы либо запишем все изменения, либо ничего. То есть объединить несколько различных действий в атомарную операцию.
Свойства транзакций ACID
Транзанкции обладют свойствами, которые называют ACID:
Атомарность (Atomicity) гарантирует, что никакая транзакция не будет зафиксирована в системе частично. Будут либо выполнены все операции, либо ни одной.
Согласованность (Consistency). Выполненая транзакция, сохраняет согласованность базы данных. Согласованность является более широким понятием, чем может показаться.
Например, в банковской системе может существовать требование равенства суммы, списываемой с одного счёта, сумме, зачисляемой на другой. Это бизнес-правило и оно не может быть гарантировано только проверками целостности базы данных. Это поведение должны учитывать программисты при написании кода транзакций. Если какая-либо транзакция произведёт списание, но не произведёт зачисления, то система останется в некорректном состоянии и свойство согласованности будет нарушено.
Изолированность (Isolation). Во время выполнения транзакции параллельные транзакции не должны оказывать влияние на её результат.
Изолированность обходится дорого, поэтому в реальных базах данных существуют режимы, не полностью изолирующие транзакцию. Об уровнях изоляции мы поговорим в отдельной статье.
Устойчивость (Durability). Независимо от проблем с оборудованием, изменения, сделанные успешно завершённой транзакцией, должны остаться сохранёнными после возвращения системы в работу.
Транзакции в SQL
Коротко рассмотрим, как сделать транзакцию в SQL. Для этого используют ключевые слова BEGIN
, COMMIT
, ROLLBACK
.
Возвращаясь к примеру с переводами, представим, что у нас есть 2 таблицы:
И мы хотим сделать перевод между первым и вторым пользователем 2. Других пользователей у нас в системе нет.
UPDATE person SET balance = (balance - 10) WHERE id = 1;
INSERT INTO transaction(person_from, person_to, amount) values (1, 3, 10);
UPDATE person SET balance = (balance + 10) WHERE id = 2;
Мы уменьшаем баланс первого пользователя, потом создаём запись в таблице переводов, но случайно ошибаемся и записываем перевод несуществующему третьему пользователю. После чего пытаемся обновить баланс второго пользователя.
Между таблицами transaction
и person
есть связь, а пользователя с идентификатором 3 не существует, поэтому мы не сможем выполнить INSERT
. И дальнейшее выполнение кода будет остановлено, то есть у первого пользователя баланс уменьшается, а у второго не увеличивается.
Исправим эту ситуацию с помощью транзакции:
BEGIN;
UPDATE person SET balance = (balance - 10) WHERE id = 1;
INSERT INTO transaction(person_from, person_to, amount) values (1, 3, 10);
UPDATE person SET balance = (balance + 10) WHERE id = 2;
COMMIT;
Мы обернули код в команды BEGIN
и COMMIT
. Но теперь, когда произойдёт ошибка в операторе INSERT
, изменения, внесенные первым оператором UPDATE
, не будут сохранены в базу данных. Таким образом, все три изменения будут записаны либо вместе, либо не будут записаны вовсе. А теперь переходим к родному для нас JDBC.
ROLLBACK
. Для упрощения в примере этот момент опущен.Управление транзакциями в JDBC
Первое, что вам стоит понять и запомнить: не имеет значения, используете ли вы аннотацию @Transactional
от Spring, Hibernate, jOOQ или любую другую библиотеку для работы с базой данных. В конечном счёте все они делают одно и то же - открывают и закрывают транзакции базы данных.
Обычный код управления транзакциями JDBC выглядит следующим образом:
import java.sql.Connection;
Connection connection = dataSource.getConnection(); // (1)
try (connection) {
connection.setAutoCommit(false); // (2)
// execute some SQL statements...
connection.commit(); // (3)
} catch (SQLException e) {
connection.rollback(); // (4)
}
- Для запуска транзакций вам необходимо подключение к базе данных.
DriverManager.getConnection(url, user, password)
тоже подойдёт, хотя в большинстве корпоративных приложений вы будете иметь настроенный источник данных и получать соединения из этого источника. - Это единственный способ начать транзакцию базы данных в Java, возможно, звучит немного странно. Вызов
setAutoCommit(true)
гарантирует, что каждый SQL-оператор будет автоматически завёрнут в собственную транзакцию, аsetAutoCommit(false)
- наоборот. Теперь вы должны управлять жизнью транзакции. Обратите внимание, что флагautoCommit
действует в течение всего времени, пока соединение открыто. - Фиксируем транзакцию
- Или откатываем изменения, если возникло исключение.
Управление транзакциями в Spring
Поскольку теперь у вас есть понимание транзакций JDBC, посмотрим, как Spring управляет транзакциями. Это также применимо и к Spring Boot и Spring MVC.
В обычном JDBC у вас есть один способ (setAutocommit(false)
) управлять транзакциями, Spring предлагает вам множество различных, более удобных способов добиться того же самого.
Программное управление транзакциями Spring
Первый, но довольно редко используемый способ внедрения транзакций – программный. Либо через TransactionTemplate
, либо через PlatformTransactionManager
. Это выглядит следующим образом:
@Service
public class UserService {
@Autowired
private TransactionTemplate template;
public Long registerUser(User user) {
Long id = template.execute(status -> {
// execute some SQL that e.g.
// inserts the user into the db and returns the autogenerated id
return id;
});
}
}
По сравнению с обычным примером JDBC:
- Вам не нужно открывать и закрывать соединений с базой данных самостоятельно. Вместо этого, вы используете Transaction Callbacks.
- Также не нужно ловить
SQLExceptions
, так как Spring преобразует их в исключения времени выполнения. - Кроме того, вы лучше интегрируетесь в экосистему Spring.
TransactionTemplate
будет использоватьTransactionManager
внутри, который будет использовать источник данных. Всё это бины, которые выукажете в конфигурации, но о которых вам больше не придется беспокоиться в дальнейшем.
Хотя это, и выглядит небольшим улучшением по сравнению с JDBC, программное управление транзакциями — это не самый лучший способ. Вместо этого, используйте декларативное управление транзакциями.
Декларативное управление транзанкциями
Посмотрим, как обычно выглядит управление транзакциями в Spring:
public class UserService {
@Transactional
public Long registerUser(User user) {
// execute some SQL that e.g.
// inserts the user into the db and retrieves the autogenerated id
// userDao.save(user);
return id;
}
}
Никакого лишнего "технического" кода. Вместо этого, нужно сделать две вещи:
- Убедиться, что одна из Spring конфигурации аннотирована
@EnableTransactionManagement
. В SpringBoot даже этого делать не нужно. - Убедиться, что вы указали менеджер транзакций в конфигурации. Это делается в любом случае.
И тогда Spring будет обрабатывать транзакции за вас. Любой публичный метод бина, который вы аннотируете @Transactional
, будет выполняться внутри транзакции базы данных.
Итак, чтобы аннотация @Transactional
заработала, делаем следующее:
@Configuration
@EnableTransactionManagement
public class MySpringConfig {
@Bean
public PlatformTransactionManager txManager() {
return yourTxManager; // more on that later
}
}
Вооружившись знаниями из примера про транзакций JDBC, приведенный выше код можно упрощенно представить следующим образом:
- Всё это просто стандартное открытие и закрытие соединения JDBC. Это то, что транзакционная аннотация Spring делает для вас автоматически, без необходимости писать её явно.
- Ваш собственный код, сохраняющий пользователя через DAO или что-то подобное.
Этот пример выглядит немного магическим. Разберёмся, как Spring делает эту магию.
CGlib и JDK Proxies
Spring не может изменить существующий класс. Метод registerUser()
действительно просто вызывает userDao.save(user)
, и нет способа изменить это во время выполнения программы.
Но у Spring есть преимущество. По сути это IoC-контейнер. Spring создает UserService
и обеспечивает внедрение UserService
в любой другой бин, которому нужен UserService
.
Когда вы ставите @Transactional
над методом, Spring использует маленькую хитрость. Он создает прокси класс, который содержит userService
, и внедряет вместо вашего объекта эту проксю. Этот прокси-класс содержит такие же методы, что и ваш класс, поэтому он подходит для внедрения в бины, которые ожидают UserService
.
Spring делает это с помощью метода под названием proxy-through-subclassing, используя библиотеку Cglib. Существуют и другие способы построения прокси (например, Dynamic JDK proxies).
Изобразим схематично
Как видно из этой схемы, у прокси следующие задачи:
- открытие и закрытие соединений/транзакций с базой данных;
- А затем делегирование выполнения настоящему
UserService
, тому, который вы написали; - А другие бины, такие как ваш
UserRestController
, никогда не узнают, что они работают с прокси, а не с настоящим сервисом;
Для чего нужен менеджер транзакций?
Теперь не хватает только одной важной части информации, хотя мы уже несколько раз упоминали о ней.
Ваш UserService
проксируется во время выполнения, и прокси управляет транзакциями. Но не сам прокси управляет всем транзакционным состоянием, прокси делегирует работу менеджеру транзакций.
Spring предлагает вам интерфейс PlatformTransactionManager
/ TransactionManager
, который по умолчанию поставляется с парой удобных реализаций. Одна из них – DataSourceTransactionManager
.
Он делает то же самое, что вы делали до сих пор для управления транзакциями, но сначала рассмотрим необходимую конфигурацию Spring:
@Bean
public DataSource dataSource() {
return new MysqlDataSource(); // (1)
}
@Bean
public PlatformTransactionManager txManager() {
return new DataSourceTransactionManager(dataSource()); // (2)
}
- Создаем источник базы данных. В этом примере используется MySQL.
- Создаем менеджер транзакций, которому нужен источник данных, чтобы иметь возможность управлять транзакциями.
Все менеджеры транзакций имеют такие методы, как doBegin()
или doCommit()
, которые выглядят следующим образом:
public class DataSourceTransactionManager implements PlatformTransactionManager {
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
Connection newCon = obtainDataSource().getConnection();
// ...
con.setAutoCommit(false);
// yes, that's it!
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
// ...
Connection connection = status.getTransaction().getConnectionHolder().getConnection();
try {
con.commit();
} catch (SQLException ex) {
throw new TransactionSystemException("Could not commit JDBC transaction", ex);
}
}
}
Таким образом, менеджер транзакций источника данных использует при управлении транзакциями такой же код, который вы видели в разделе про JDBC.
Учитывая это, улучшим схему:
Подведём итоги:
- Если Spring обнаруживает аннотацию
@Transactional
на бине, он создаёт динамический прокси этого бина. - Прокси имеет доступ к менеджеру транзакций и будет просить его открывать и закрывать транзакции/соединения.
- Сам менеджер транзакций будет просто управлять старым добрым соединением JDBC.
Резюмирую
К этому моменту вы должны иметь довольно хорошее представление о том, что такое транзакция. Как она выглядит в SQL. И как управление транзакциями работает с фреймворком Spring.
Главным выводом должно быть то, что в итоге не имеет значения, какой фреймворк вы используете, всё дело в основах JDBC.
Транзакции не всегда изолированы друг от друга, поэтому две параллельные транзакции, которые работают с одними и теми же данными, могут оказывать влияние друг на друга. Подробнее об этом читайте в следующей статье 👇