Основы транзакций в Spring и JDBC

В этой статье мы разберемся, что такое транзакции. Какими обладают транзакции - ACID. Как транзакции выполняются на уровне JDBС, а также на уровне Spring.

· 7 минуты на чтение

Мы начнём изучение транзакций с нуля, шаг за шагом погружаясь в тему. Быстро рассмотрим проблему, которую решают транзакции. Далее посмотрим, как написать транзакцию на 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)
}
  1. Для запуска транзакций вам необходимо подключение к базе данных. DriverManager.getConnection(url, user, password) тоже подойдёт, хотя в большинстве корпоративных приложений вы будете иметь настроенный источник данных и получать соединения из этого источника.
  2. Это единственный способ начать транзакцию базы данных в Java, возможно, звучит немного странно. Вызов setAutoCommit(true) гарантирует, что каждый SQL-оператор будет автоматически завёрнут в собственную транзакцию, а setAutoCommit(false) - наоборот. Теперь вы должны управлять жизнью транзакции. Обратите внимание, что флаг autoCommit действует в течение всего времени, пока соединение открыто.
  3. Фиксируем транзакцию
  4. Или откатываем изменения, если возникло исключение.
Спонсор поста 3

Управление транзакциями в 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, приведенный выше код можно упрощенно представить следующим образом:

public class UserService {

    public Long registerUser(User user) {
        Connection connection = dataSource.getConnection(); // (1)
        try (connection) {
            connection.setAutoCommit(false); // (1)

            // execute some SQL that e.g.
            // inserts the user into the db and retrieves the autogenerated id
            // userDao.save(user); <(2)

            connection.commit(); // (1)
        } catch (SQLException e) {
            connection.rollback(); // (1)
        }
    }
}

Взято прямо из исходного кода Spring и немного упрощено

  1. Всё это просто стандартное открытие и закрытие соединения JDBC. Это то, что транзакционная аннотация Spring делает для вас автоматически, без необходимости писать её явно.
  2. Ваш собственный код, сохраняющий пользователя через 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)
}
  1. Создаем источник базы данных. В этом примере используется MySQL.
  2. Создаем менеджер транзакций, которому нужен источник данных, чтобы иметь возможность управлять транзакциями.

Все менеджеры транзакций имеют такие методы, как 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.

Транзакции не всегда изолированы друг от друга, поэтому две параллельные транзакции, которые работают с одними и теми же данными, могут оказывать влияние друг на друга. Подробнее об этом читайте в следующей статье 👇

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

Struchkov Mark
Struchkov Mark
Задавайте вопросы, если что-то осталось не понятным👇