Каждый, кто работал с базами данных, слышал про ACID и транзакции. Если кто не работал и не знает что это такое, то всё просто: транзакция это набор операций, которые могут быть либо целиком и успешно выполнены, либо полностью не выполнены. В IBM DB2 транзакции называют более логично Unit of Work (UoW) — единица работы.
Транзакции в базах данных соответствуют свойствам ACID:
- Атомарность — транзакция может быть либо целиком выполнена, либо целиком отменена.
- Согласованность — состояние данных должно быть логически согласованным после выполнения (или отмены) транзации
- Изолированность — в процессе работы транзакции другие выполняющиеся в это время транзакции не влияют на неё.
- наДёжность — что-бы не произошло, транзакция останется атомарной.
Если вдруг транзакции в вашей базе не соответствуют этим принципам, то это плохие, негодные транзакции и они не заслуживают чести носить это имя.
Использование транзакций в дикой природе проще показать на примере: положим у нас есть система, в которой клиенты размещают какие-то заказы. За каждый заказ со счёта клиента снимается какая-то сумма. Таким образом, одна логическая единица работы разбивается на три операции в базе данных:
- Добавить заказ.
- Получить текущее значение счёта клиента.
- Уменьшить счёт клиента и обновить значение в базе.
Оборачивание этих трёх операций в одну транзакцию гарантирует нам, что либо мы добавим заказ и спишем деньги, либо ничего не произойдёт вообще. Третьего не дано.
Commit и Rollback
Пора переходить к коду. По умолчанию все соединения в JDBC устанавливаются в режиме auto commit, когда каждый отдельный запрос выполняется в своей собственной транзакции, которая автоматически подтверждается после завершения запроса. Если мы хотим управлять транзакциями вручную, а мы хотим, то в первую очередь надо выключить этот режим:
1
|
db.setAutoCommit(false);
|
Первый же запрос, отправленный к базе после этой команды автоматически начнёт новую транзакцию. Чтобы её подтвердить, необходимо позвать метод commit() объекта соединения:
1
2
|
addOrder(db, FIFTH_ROW);
db.commit();
|
метод addOrder() делает ровно то, что я описал в примере выше: добавляет что-то к заказу и списывает деньги:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
protected static void addOrder(Connection db, int item_id) throws SQLException {
try (PreparedStatement itemStatement = db.prepareStatement(ADD_ITEM)) {
itemStatement.setInt(1, 1);
itemStatement.setInt(2, 1);
itemStatement.setInt(3, item_id);
itemStatement.execute();
}
try (PreparedStatement accountStatement = db.prepareStatement(GET_ACCOUNT, ResultSet.TYPE_SCROLL_SENSITIVE,
ResultSet.CONCUR_UPDATABLE)) {
accountStatement.setInt(1, 1);
try (ResultSet rs = accountStatement.executeQuery()) {
rs.next();
int value = rs.getInt("ACCOUNT");
value -=50;
rs.updateInt("ACCOUNT", value);
rs.updateRow();
}
}
}
|
1
2
3
4
5
|
Initial client data:
Client login: test, client account: 100
Client data after one item:
Client login: test, client account: 50
Order id: 1, item id: 5
|
Откатывается транзакция методом rollback()
1
2
3
|
addOrder(db,FIFTH_ROW);
addOrder(db,FIFTH_ROW);
db.rollback();
|
Изменения, внёсённые всеми запросами с начала транзакции и до вызова rollback(), откатываются на состояние до начала транзакции. Начинает транзакцию первый запрос, выполненный после commit(), rollback() или setAutoCommit(false)
1
2
3
|
Client data after three items and rollback:
Client login: test, client account: 50
Order id: 1, item id: 5
|
Savepoints
Некоторые базы данных поддерживают Savepoints — транзакции внутри транзакций. Savepoint позволяет сохранить какое-либо состояние внутри транзакции и, при необходимости, откатиться к нему, не откатывая всю транзакцию. Подтвердить savepoint нельзя, они все автоматически подтверждаются с подтверждением транзакции.
Savepoint можно использовать, если у пользователя есть какое-то сложное взаимодействие с данными в пределах одной логической единицы работы. Например трёхшаговое подтверждение заказа: пользователь формирует заказ и в этот момент создаётся транзакция. Потом пользователь добавляет, например, адрес доставки и в этот момент создаётся Savepoint и со счёта пользователя сразу списываются деньги за доставку. Потом выбираются какие-нибудь дополнительные услуги, опять создаётся Savepoint и списываются деньги за эти услуги. Потом заказ подтверждается пользователем или отменяется пользователем, а мы делаем соответственно commit() или rollback(). Но, если пользователь решает отступить на шаг назад и изменить доставку, мы не отменяя всей транзакции отменяем только этот шаг.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
addOrder(db, FIFTH_ROW);
Savepoint firstItem = db.setSavepoint("first");
addOrder(db, FIFTH_ROW);
Savepoint secondItem = db.setSavepoint("second");
addOrder(db, ADDITIONAL_ITEM);
Savepoint thirdItem = db.setSavepoint("third");
System.out.println("Client data after three savepoints:");
printClientData(db);
db.rollback(secondItem);
System.out.println("Client data after second savepoint:");
printClientData(db);
db.releaseSavepoint(firstItem);
db.releaseSavepoint(thirdItem);
//db.rollback(firstItem); //This line will throw an SQLException
db.commit();
System.out.println("Client data after another one item and commit:");
printClientData(db);
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
Client data after three savepoints:
Client login: test, client account: -100
Order id: 1, item id: 5
Order id: 1, item id: 5
Order id: 1, item id: 5
Order id: 1, item id: 10
Client data after first savepoint:
Client login: test, client account: -50
Order id: 1, item id: 5
Order id: 1, item id: 5
Order id: 1, item id: 5
Client data after another one item and commit:
Client login: test, client account: -50
Order id: 1, item id: 5
Order id: 1, item id: 5
Order id: 1, item id: 5
|
Savepoint создаётся вызовом метода setSavepoint(), которому можно опционально передать какую-нибудь метку. Откатывается Savepoint методом rollback(). У откатывания Savepoint есть несколько стратегий, зависящих от конкретной реализации. Обычно все Savepoint внутри транзакции представляют собой стек, и если мы откатывает Savepoint из середины этого стека, то есть не верхний, то в зависимости от реализации в базе данных может:
- Откатиться всё равно только верхний Savepoint, как в примере выше.
- Откатиться все Savepoint’ы предшествующие заданному
- Прилететь SQLException.
Старый, ненужный Savepoint можно закрыть методом releaseSavepoint(). Закрытый Savepoint нельзя откатывать и, в зависимости от реализации в базе данных, предшевствующие Savepoints тоже могут закрыться.
Код примера доступен на github.