Уровни изоляций Transactional

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

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

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

Свойство Isolation в ACID гласит: Во время выполнения транзакции другие транзакции не должны оказывать влияние на результат. Но изолированность транзакций обходится дорого, поэтому в базах данных существуют режимы, которые не полностью изолируют транзакцию от других. Однако, это приводит к неприятным последствиям, таким как:

  • Потерянное обновление. Две параллельные транзакции меняют одни и те же данные. Итоговый результат обновления предсказать невозможно.
  • «Грязное» чтение. В результатах запроса появляются промежуточные результаты параллельной транзакции, которая ещё не завершилась.
  • Неповторяющееся чтение. Запрос с одними и теми же условиями даёт неодинаковые результаты в рамках транзакции.
  • Фантомное чтение. В результатах повторяющегося запроса появляются и исчезают строки, которые модифицирует параллельная транзакция.

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

  • READ_UNCOMMITTED. Могут происходить грязные чтения, неповторяемые чтения, фантомные чтения и потерянное обновление.
  • READ_COMMITTED. Грязные чтения предотвращены, но могут возникать неповторяющиеся чтения, фантомные чтения и потерянное обновление.
  • REPEATABLE_READ. Грязные чтения, неповторяющиеся чтения и потерянное обновление предотвращены, но могут возникать фантомные чтения
  • SERIALIZABLE Транзакции полностью изолированы. Исключено влияние одной транзакции на другую в момент выполнения.

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

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

Для примеров будем использовать PostgreSQL. Для наглядности все примеры будут на JDBC, однако всё то же самое будет справедливо и для Spring. Декларативный подход спринга с аннотацией @Transactional мы разберём в следующей статье.

Спонсор поста

База данных для примеров

DEMO
😺

Проект на GitHub: github.com/Example-uPagge/transactional
Модуль: jdbc-transaction

Создадим таблицу person, которая содержит два поля: id и balance. В этой таблице создадим две записи.

CREATE SEQUENCE seq_person;

CREATE TABLE person
(
    id      BIGINT NOT NULL PRIMARY KEY DEFAULT nextval('seq_person'),
    balance BIGINT NOT NULL
);

INSERT INTO person VALUES (1, 1000);
INSERT INTO person VALUES (2, 1000);

В каждом дальнейшем примере изначальный баланс пользователей равен 1000.

Грязное чтение

😺
Класс с примером: DirtyReadExample

«Грязное» чтение (dirty reads) — в результатах запроса появляются промежуточные результаты параллельной транзакции, которая ещё не завершилась.

Эта проблема наблюдается при уровне изоляции READ_UNCOMMITTED.

Рассмотрим на примере. У нас будет два параллельных потока. В первом мы открываем транзакцию и устанавливаем новый баланс первому пользователю равным 100_000. Но транзакцию не коммитим. Вместо этого, запускаем вторую параллельную транзакцию в отдельном потоке, а текущий поток засыпает на 2 секунды.

Во второй транзакции считывается баланс пользователя из БД и выводится в консоль. Значение баланса будет 100_000, несмотря на то, что первая транзакция ещё не закоммитила свои изменения.

Но дальше самое интересное, мы выполняем rollback первой транзакции, и баланс пользователя снова становится 1000. То есть вторая транзакция работала с не валидными данными.

public class DirtyReadExample {

    private static final int ISOLATION_LEVEL = Connection.TRANSACTION_READ_UNCOMMITTED;

    public static void main(String[] args) throws SQLException, InterruptedException {
        try (
                final Connection connection = Repository.getConnectionH2();
                final Statement statement = connection.createStatement()
        ) {
            connection.setAutoCommit(false);
            connection.setTransactionIsolation(ISOLATION_LEVEL);

            statement.executeUpdate("UPDATE person SET balance = 100000 WHERE id = 1");

            new OtherTransaction().start();
            Thread.sleep(2000);
            connection.rollback();
        }

    }

    static class OtherTransaction extends Thread {
        @Override
        public void run() {
            try (
                    final Connection connection = Repository.getConnectionH2();
                    final Statement statement = connection.createStatement()
            ) {
                connection.setAutoCommit(false);
                connection.setTransactionIsolation(ISOLATION_LEVEL);

                final ResultSet resultSet = statement.executeQuery("SELECT * FROM person WHERE id = 1");
                while (resultSet.next()) {
                    System.out.println("Balance: " + resultSet.getString("balance"));
                }
            } catch (SQLException e) {
                System.out.println(e.getMessage());
            }
        }
    }

}

Результат работы примера:

Balance: 100000

Для устранения этого эффекта устанавливаем уровень изоляции в READ_COMMITTED. Теперь при выполнении второй транзакции получаем баланс 1000, вместо 100_000.

Результат работы примера с READ_COMMITTED:

Balance: 1000

Уровень изоляции по умолчанию для СУБД

У большинства СУБД по умолчанию установлен уровень изоляции READ_COMMITTED, а READ_UNCOMMITTED может не поддерживаться. Более того, некоторые уровни изоляции могут и не иметь описанных далее проблем. Здесь всё индивидуально, изучайте документацию СУБД.

В этом примере проблема не воспроизведётся с PostgreSQL, но возникнет при использовании H2.

Потерянное обновление

😺
Класс с примером: LostUpdateExample

Потерянное обновление (lost update) — две параллельные транзакции меняют одни и те же данные, при этом итоговый результат обновления предсказать невозможно.

Рассмотрим эту проблему на примере:

public class LostUpdateExample {

    public static final String READ = "SELECT person.balance FROM person WHERE id = ?";
    public static final String UPDATE = "UPDATE person SET balance = ? WHERE id = ?";

    @SneakyThrows
    public static void main(String[] args) {

        // Начинаем две транзакции.
        final Connection connectionOne = getNewConnection();
        final Connection connectionTwo = getNewConnection();

        // Первая и вторая транзакция запрашивают баланс пользователя.
        // balance = 1000
        final long balanceOne = getBalance(connectionOne);
        final long balanceTwo = getBalance(connectionTwo);

        // Первая транзакция готовится обновить баланс пользователю.
        final PreparedStatement updateOne = connectionOne.prepareStatement(UPDATE);
        updateOne.setLong(1, balanceOne + 10);
        updateOne.setLong(2, 1);
        updateOne.execute();

        // Первая транзакция фиксирует изменения и завершается.
        // Значение balance в базе в этот момент = 1010.
        connectionOne.commit();
        connectionOne.close();

        // Но вторая транзакция ничего не знает про изменения в БД.
        // Значение balanceTwo все еще равно 1000, к этому значению мы добавляем 5.
        final PreparedStatement updateTwo = connectionTwo.prepareStatement(UPDATE);
        updateTwo.setLong(1, balanceTwo + 5);
        updateTwo.setLong(2, 1);
        updateTwo.execute();

        // Вторая транзакция фиксирует свои изменения и завершается.
        // В итоге в БД остается значение 1005, а не 1015, как хотелось бы нам.
        connectionTwo.commit();
        connectionTwo.close();
    }

    private static Connection getNewConnection() throws SQLException {
        final Connection connection = Repository.getConnection();
        connection.setAutoCommit(false);
        connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
        return connection;
    }

    private static long getBalance(Connection connectionOne) throws SQLException {
        final PreparedStatement preparedStatement = connectionOne.prepareStatement(READ);
        preparedStatement.setLong(1, 1);
        final ResultSet resultSet = preparedStatement.executeQuery();
        resultSet.next();
        final long balanceOne = resultSet.getLong(1);
        return balanceOne;
    }

}

На строках 10, 11 мы получаем 2 независимых соединения с БД, в которых отключён auto-commit и установлен уровень изоляции READ_COMMITTED.

На строках 15 и 16 обе транзакции получают баланс первого пользователя. Напомню, что баланс равен 1000.

На строках 19-22 выполняется обновление баланса пользователя первой транзакцией. Для этого к полученному ранее балансу прибавляется 10. Но сейчас эти изменения не были отправлены в БД. Это произойдёт только при закрытии транзакции.

Закрываем первую транзакцию (26, 27). Баланс пользователя в БД равен 1010. Но вторая транзакция идёт параллельно, и ничего не знает об изменении баланса пользователя. Ведь баланс мы уже считали из БД, и там было 1000.

Вторая транзакция прибавляет к балансу пользователя 5 (31-34). После чего вторая транзакция также закрывается (38, 39). Баланс пользователя в БД равен 1005. Мы потеряли обновления, которые выполнила первая транзакция.

Такое поведение называют состоянием гонки. Схематично его можно представить вот так:

Изменим уровень транзакций на более изолированный. В примере у нас используется READ_COMMITTED, установим REPETABLE_READ в строке 45.

В таком случае при выполнении нашего кода получаем исключение PSQLException.

Схематично можно представить это вот так:

Первая транзакция началась. Считала баланс первого пользователя. Обновила его, но не зафиксировала изменения. Началась вторая транзакция. Она также считала баланс первого пользователя, обновила его, но тоже не зафиксировала свои изменения.

А теперь правильной окажется та транзакция, которая зафиксирует свои изменения первой. Первая транзакция выполнила коммит первой, поэтому коммит второй транзакции завершился ошибкой.

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

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

Неповторяющееся чтение

😺
Класс с примером: NonRepeatableRead

Неповторяющееся чтение (non-repeatable reads) — запрос с одними и теми же условиями даёт неодинаковые результаты в рамках транзакции.

Эта проблема присутствует на уровне изоляции READ_COMMITTED и ниже.

Рассмотрим пример. Начинаем первую транзакцию. Считываем баланс пользователя, получаем значение 1000. Далее стартует вторая транзакция в отдельном потоке, а текущий поток засыпает.

Во второй транзакции устанавливаем пользователю баланс равный 100_000. После чего закрываем вторую транзакцию. Баланс успешно обновился в БД.

Первая транзакция продолжает выполнение. Снова запрашивает баланс пользователя из БД, на этот раз получает значение 100_000. Таким образом, вторая транзакция повлияла на выполнение первой.

public class NonRepeatableRead {

    private static final int ISOLATION_LEVEL = Connection.TRANSACTION_READ_COMMITTED;

    public static void main(String[] args) {
        try (
                final Connection connection = Repository.getConnection();
                final Statement statement = connection.createStatement()
        ) {
            connection.setAutoCommit(false);
            connection.setTransactionIsolation(ISOLATION_LEVEL);

            final ResultSet resultSetOne = statement.executeQuery("SELECT * FROM person WHERE id = 1");
            while (resultSetOne.next()) {
                final String balance = resultSetOne.getString("balance");
                System.out.println("[one] Balance: " + balance);
            }

            new OtherTransaction().start();
            Thread.sleep(2000);

            final ResultSet resultSetTwo = statement.executeQuery("SELECT * FROM person WHERE id = 1");
            while (resultSetTwo.next()) {
                final String balance = resultSetTwo.getString("balance");
                System.out.println("[one] Balance: " + balance);
            }

        } catch (SQLException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    static class OtherTransaction extends Thread {
        @Override
        public void run() {
            try (
                    final Connection connection = Repository.getConnection();
                    final Statement statement = connection.createStatement()
            ) {
                connection.setAutoCommit(false);
                connection.setTransactionIsolation(ISOLATION_LEVEL);

                statement.executeUpdate("UPDATE person SET balance = 100000 WHERE id = 1");
                connection.commit();

                final ResultSet resultSetTwo = statement.executeQuery("SELECT * FROM person WHERE id = 1");
                while (resultSetTwo.next()) {
                    final String balance = resultSetTwo.getString("balance");
                    System.out.println("[two] Balance: " + balance);
                }

                connection.commit();
            } catch (SQLException e) {
                System.out.println(e.getMessage());
            }
        }
    }

}

Результат работы примера:

[one] Balance: 1000
[two] Balance: 100000
[one] Balance: 100000

Для устранения этой проблемы воспользуемся известным нам уровнем изоляции REPEATABLE_READ.

Тогда при выполнении примера первая транзакция дважды получит значение баланса равным 1000, несмотря на то, что в БД будет уже 100_000.

Результат работы примера с REPEATABLE_READ:

[one] Balance: 1000
[two] Balance: 100000
[one] Balance: 1000

Фантомное чтение

😺
Класс с примером: PhantomRead.java

Фантомное чтение (phantom reads) — в результатах повторяющегося запроса появляются и исчезают строки, которые в данный момент модифицирует параллельная транзакция.

Эта проблема присутствует на уровне изоляции REPEATABLE_READ и ниже.

Рассмотрим пример. Открываем первую транзакцию. Запрашиваем количество строк в таблице пользователей, получаем ответ 2. Далее стартуем вторую транзакцию, а поток с первой транзакцией засыпает. Вторая транзакция добавляет новую запись в таблицу пользователей и коммитит изменения. После этого первая транзакция продолжается. Снова запрашиваем количество строк, получаем на этот раз ответ 3.

public class PhantomRead {

    private static final int ISOLATION_LEVEL = Connection.TRANSACTION_READ_COMMITTED;

    public static void main(String[] args) {
        try(
                final Connection connection = Repository.getConnection();
                final Statement statement = connection.createStatement()
        ) {
            connection.setAutoCommit(false);
            connection.setTransactionIsolation(ISOLATION_LEVEL);

            final ResultSet resultSet = statement.executeQuery("SELECT count(*) FROM person");
            while (resultSet.next()) {
                final int count = resultSet.getInt(1);
                System.out.println("Count: " + count);
            }

            new OtherTransaction().start();
            Thread.sleep(2000);

            final ResultSet resultSetTwo = statement.executeQuery("SELECT count(*) FROM person");
            while (resultSetTwo.next()) {
                final int count = resultSetTwo.getInt(1);
                System.out.println("Count: " + count);
            }

        } catch (SQLException | InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    static class OtherTransaction extends Thread {
        @Override
        public void run() {
            try (
                    final Connection connection = Repository.getConnection();
                    final Statement statement = connection.createStatement()
            ) {
                connection.setAutoCommit(false);
                connection.setTransactionIsolation(ISOLATION_LEVEL);

                statement.executeUpdate("INSERT INTO person(id, balance) values (3, 1000)");
                connection.commit();
            } catch (SQLException e) {
                System.out.println(e.getMessage());
            }
        }
    }

}

Чтобы устранить эту проблему, используем самый изолированный уровень SERIALIZABLE. Однако, для PostgreSQL будет достаточно и REPEATABLE_READ.

Но мы всё равно установим SERIALIZABLE. Теперь в первой транзакции нам дважды возвращается ответ 2, несмотря на то, что в таблице появились новые записи.

Резюмирую

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

  • READ_UNCOMMITTED. Могут происходить грязные чтения, неповторяемые чтения, фантомные чтения и потерянное обновление.
  • READ_COMMITTED. Грязные чтения предотвращены, но могут возникать неповторяющиеся чтения, фантомные чтения и потерянное обновление.
  • REPEATABLE_READ. Грязные чтения, неповторяющиеся чтения и потерянное обновление предотвращены, но могут возникать фантомные чтения
  • SERIALIZABLE Транзакции полностью изолированы. Исключено влияние одной транзакции на другую в момент выполнения.

Выбирайте уровень изолированности в зависимости от вашей задачи. Также помните, что СУБД могут по-разному реализовывать эти уровни. Например, в PostgreSQL на уровне REPEATABLE_READ также предотвращены фантомные чтения.

В следующей статье мы рассмотрим, как Spring позволяет, декларативно выполнять транзакции с использованием аннотации @Transactional.

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