Лаба 1. Изоляция и аномалии
Цель — увидеть каждую аномалию своими глазами и понять, какой механизм её закрывает. Две сессии: A и B. Выполняйте шаги строго по порядку и переключайтесь между окнами. Перед каждым экспериментом сбрасывайте данные:
UPDATE accounts SET balance = 100;
UPDATE doctors SET on_call = true;
После каждого эксперимента запишите в «Заметки» модуля 2.2 одну фразу: что произошло и почему.
Эксперимент 1. Non-repeatable read в Read Committed
| Шаг | A | B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT balance FROM accounts WHERE id = 1; → 100 |
|
| 3 | UPDATE accounts SET balance = 50 WHERE id = 1; (автокоммит) |
|
| 4 | SELECT balance FROM accounts WHERE id = 1; → 50 |
|
| 5 | COMMIT; |
Внутри одной транзакции A одно и то же чтение вернуло разные значения: в Read Committed снимок берётся на каждый оператор.
Повторите с BEGIN ISOLATION LEVEL REPEATABLE READ; на шаге 1. На шаге 4 будет 100: снимок один на всю транзакцию.
Эксперимент 2. Lost update в Read Committed
Приложение читает баланс, считает новое значение у себя и записывает результат.
| Шаг | A (списание 10) | B (пополнение 50) |
|---|---|---|
| 1 | BEGIN; |
BEGIN; |
| 2 | SELECT balance FROM accounts WHERE id = 1; → 100 |
|
| 3 | SELECT balance FROM accounts WHERE id = 1; → 100 |
|
| 4 | UPDATE accounts SET balance = 90 WHERE id = 1; |
|
| 5 | UPDATE accounts SET balance = 150 WHERE id = 1; — ждёт |
|
| 6 | COMMIT; |
UPDATE выполнился |
| 7 | COMMIT; |
|
| 8 | SELECT balance FROM accounts WHERE id = 1; → 150 |
Списание потеряно, должно было получиться 140. Ни одной ошибки, всё «успешно».
Повторите с BEGIN ISOLATION LEVEL REPEATABLE READ; в обеих сессиях. На шаге 6 сессия B получит ERROR: could not serialize access due to concurrent update. Потери нет, но B должна повторить транзакцию целиком.
Три способа закрыть lost update в Read Committed — проверьте каждый:
- Атомарное изменение:
UPDATE accounts SET balance = balance - 10 WHERE id = 1;— вычисление внутри базы, по актуальной версии строки. - Пессимистичная блокировка: на шаге 2 и 3 —
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;. Сессия B будет ждать уже на чтении и прочитает 90. - Оптимистичная блокировка: добавьте столбец версии и обновляйте с условием.
ALTER TABLE accounts ADD COLUMN version int NOT NULL DEFAULT 0; -- в каждой сессии: прочитать balance и version, затем UPDATE accounts SET balance = :new, version = version + 1 WHERE id = 1 AND version = :read_version; -- "UPDATE 0" означает, что кто-то успел раньше: перечитать и повторить
Эксперимент 3. Write skew в Repeatable Read
Инвариант: на смене должен оставаться хотя бы один дежурный. Каждый врач проверяет, что дежурных двое, и снимает дежурство с себя.
| Шаг | A (Иванов) | B (Петров) |
|---|---|---|
| 1 | BEGIN ISOLATION LEVEL REPEATABLE READ; |
BEGIN ISOLATION LEVEL REPEATABLE READ; |
| 2 | SELECT count(*) FROM doctors WHERE shift_id = 1 AND on_call; → 2 |
|
| 3 | SELECT count(*) FROM doctors WHERE shift_id = 1 AND on_call; → 2 |
|
| 4 | UPDATE doctors SET on_call = false WHERE id = 1; |
|
| 5 | UPDATE doctors SET on_call = false WHERE id = 2; |
|
| 6 | COMMIT; |
COMMIT; |
| 7 | SELECT * FROM doctors; → оба не дежурят |
Обе транзакции обновили разные строки, поэтому конфликта записи нет, и Snapshot Isolation его не видит. Инвариант нарушен.
Повторите с BEGIN ISOLATION LEVEL SERIALIZABLE;. Одна из транзакций получит ERROR: could not serialize access due to read/write dependencies among transactions (SQLSTATE 40001).
Альтернатива без Serializable — материализовать конфликт: на шаге 2 и 3 заблокировать все строки смены.
SELECT * FROM doctors WHERE shift_id = 1 FOR UPDATE;
Вторая сессия будет ждать и после ожидания увидит актуальное состояние. Если бы строк смены не существовало (например, при бронировании свободного слота), блокировать было бы нечего. Тогда нужна отдельная строка-«замок» (сама смена или слот) либо ограничение EXCLUDE.
Эксперимент 4 (дополнительно). Ограничение исключения для бронирований
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE bookings (
room int,
during tstzrange,
EXCLUDE USING gist (room WITH =, during WITH &&)
);
INSERT INTO bookings VALUES (1, '[2026-10-01 10:00, 2026-10-01 11:00)');
INSERT INTO bookings VALUES (1, '[2026-10-01 10:30, 2026-10-01 11:30)'); -- ошибка
Попробуйте вставить пересекающиеся интервалы из двух параллельных транзакций. Вторая будет ждать первую и упадёт после её фиксации. Инвариант держит сама база при любом уровне изоляции.
Выводы, которые нужно уметь сформулировать
- Какой уровень изоляции какие аномалии допускает — именно в PostgreSQL.
- Почему Repeatable Read и Serializable требуют повтора транзакции в приложении, и что это требование к разработке.
- Какие механизмы закрывают аномалию без повышения уровня: атомарный UPDATE,
FOR UPDATE, версия строки, ограничения целостности.