Продолжаем разбираться в транзакциях. В предыдущем посте мы узнали, зачем они нужны, и какими свойствами обладают. Сегодня поговорим про параллельные транзакции, которые работают с одними и теми же записями в БД.
Свойство Isolation в ACID гласит: Во время выполнения транзакции другие транзакции не должны оказывать влияние на результат. Но изолированность транзакций обходится дорого, поэтому в базах данных существуют режимы, которые не полностью изолируют транзакцию от других. Однако, это приводит к неприятным последствиям, таким как:
- Потерянное обновление. Две параллельные транзакции меняют одни и те же данные. Итоговый результат обновления предсказать невозможно.
- «Грязное» чтение. В результатах запроса появляются промежуточные результаты параллельной транзакции, которая ещё не завершилась.
- Неповторяющееся чтение. Запрос с одними и теми же условиями даёт неодинаковые результаты в рамках транзакции.
- Фантомное чтение. В результатах повторяющегося запроса появляются и исчезают строки, которые модифицирует параллельная транзакция.
Режимы, которые позволяют устранять эти недостатки, называются уровни изоляции транзакции. Всего их пять. Перечислим их в порядке увеличения изоляции:
READ_UNCOMMITTED
. Могут происходить грязные чтения, неповторяемые чтения, фантомные чтения и потерянное обновление.READ_COMMITTED
. Грязные чтения предотвращены, но могут возникать неповторяющиеся чтения, фантомные чтения и потерянное обновление.REPEATABLE_READ
. Грязные чтения, неповторяющиеся чтения и потерянное обновление предотвращены, но могут возникать фантомные чтенияSERIALIZABLE
Транзакции полностью изолированы. Исключено влияние одной транзакции на другую в момент выполнения.
Почему бы всегда не использовать уровень SERIALIZABLE
, если он убирает все недостатки. Как я уже сказал, чем выше уровень изоляции, тем большей производительностью вы жертвуете. Для каких-то задач подходит один уровень изоляции, а для каких-то другой.
Чтобы уметь выбирать уровень изоляции под конкретный случай, мы рассмотрим наглядно все проблемы и как тот или иной уровень изоляции их решает.
Для примеров будем использовать PostgreSQL. Для наглядности все примеры будут на JDBC, однако всё то же самое будет справедливо и для Spring. Декларативный подход спринга с аннотацией @Transactional
мы разберём в следующей статье.
База данных для примеров
Проект на 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.
Грязное чтение
«Грязное» чтение (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.
Потерянное обновление
Потерянное обновление (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
.
Схематично можно представить это вот так:
Первая транзакция началась. Считала баланс первого пользователя. Обновила его, но не зафиксировала изменения. Началась вторая транзакция. Она также считала баланс первого пользователя, обновила его, но тоже не зафиксировала свои изменения.
А теперь правильной окажется та транзакция, которая зафиксирует свои изменения первой. Первая транзакция выполнила коммит первой, поэтому коммит второй транзакции завершился ошибкой.
Но если вторая транзакция не изменяла данные, а добавляла новые строчки, то исключения не было бы. Также проблем не будет, если мы обновим баланс второго пользователя.
Но возникает закономерный вопрос: что делать с ошибкой, ведь мы хотели выполнить транзакцию, которая свалилась с исключением. Самое простое, что можно сделать — это повторить выполнение второй транзакции с новыми данными. Если исключение возникнет опять, то повторить снова.
Неповторяющееся чтение
Неповторяющееся чтение (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
Фантомное чтение
Фантомное чтение (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
.