digital-garden/_inbox/Потерянное обновление.md

5.8 KiB
Raw Blame History

aliases tags date zero-link parents linked link
lost update
зрелость/🌱
2024-06-19
00 Базы Данных
Проблемы при параллельном выполнении нескольких транзакций
https://struchkov.dev/blog/ru/transactional-isolation-levels/#%D0%BF%D0%BE%D1%82%D0%B5%D1%80%D1%8F%D0%BD%D0%BD%D0%BE%D0%B5-%D0%BE%D0%B1%D0%BD%D0%BE%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5

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

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

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. Мы потеряли обновления, которые выполнила первая транзакция. Такое поведение называют Race condition.

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

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

Дополнительные материалы