Все главы учебника
Содержание учебника
Глава 07 / Базы данных

Транзакции и изоляция в двух окнах

17 мин чтенияКонтент v0.10.0

Анна возвращает экземпляр A-100, а Вера сразу хочет взять его. Приложение должно сохранить возврат и новую выдачу согласованно. Отдельные успешные UPDATE и INSERT не означают один неделимый результат: процесс может остановиться между ними. Транзакция объединяет изменения, но её наличие ещё не выбирает правильное поведение двух одновременно работающих клиентов.

Граница изменений

BEGIN начинает транзакцию, COMMIT фиксирует её, ROLLBACK отменяет незавершённые изменения. Атомарность означает, что предусмотренный результат группы не остаётся наполовину подтверждённым. Сохранность зависит от настроек и модели отказов, о которых поговорим отдельно. Бизнес-правило всё равно должно быть правильным при последовательном исполнении: транзакция не угадывает разрешённый срок выдачи.

BEGIN;
UPDATE club.loans SET returned_on=DATE '2026-09-18'
WHERE id=1000 AND returned_on IS NULL;
INSERT INTO club.loans
VALUES(2000,3,100,DATE '2026-09-18',DATE '2026-10-01',NULL);
SELECT id,member_id FROM club.loans
WHERE copy_id=100 AND returned_on IS NULL;
ROLLBACK;

Внутри транзакции активна новая выдача 2000 участнику 3; после отката исходная 1000 снова активна. Пример использует известный seed. В приложении проверяют число изменённых строк и права на конкретную выдачу: UPDATE 0 не должен превращаться в успешный возврат другого объекта. Уже отправленное письмо ROLLBACK не отзывает; внешнее намерение сохраняют отдельно по надёжному протоколу.

После SQL-ошибки транзакция PostgreSQL обычно находится в aborted state и требует ROLLBACK. Продолжать отправлять изменения в ней бесполезно. SAVEPOINT позволяет откатить часть по заданному протоколу, но не является разрешением скрывать любой отказ. Учебные negative tests ловят конкретные ожидаемые ошибки в собственных блоках.

Снимок задаёт видимость

PostgreSQL использует MVCC: у строк есть версии, а запрос выбирает видимые ему изменения. В Read Committed обычный SELECT видит снимок начала команды. Два SELECT одной транзакции поэтому могут увидеть разные подтверждённые состояния. Repeatable Read даёт устойчивее снимок транзакции, но не запрещает все истории, нарушающие общий инвариант.

Уровень Serializable требует результата, эквивалентного некоторому последовательному выполнению транзакций. PostgreSQL может отменить конкурентную работу с SQLSTATE 40001. Повторяют всю транзакцию с новой проверкой, а не только последний UPDATE. Read Uncommitted в PostgreSQL работает как Read Committed; Repeatable Read не допускает phantom reads, хотя минимальные требования SQL слабее. Эти детали не следует переносить на любую базу.

Воспроизводим lost update

В setup есть отдельная учебная таблица lab_counters, не часть библиотечной модели. Откройте два окна psql к своей новой базе. Сценарии доступны в transactions.sql; файл содержит комментарии, поэтому один его запуск не создаёт конкуренцию. Обе сессии сначала читают ноль и только затем изменяют значение.

BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT value FROM club.lab_counters WHERE id=1;
-- Барьер: обе сессии получили 0 до любого UPDATE.

Сессия A выполняет UPDATE ... SET value=1 WHERE id=1 и COMMIT. Затем B выполняет такую же запись и COMMIT. Итог 1, хотя было два намерения увеличить значение. База сохранила указанные числа правильно: приложение записало прежнее вычисление. Для этого намерения атомарная операция выглядит иначе:

UPDATE club.lab_counters SET value=value+1
WHERE id=1 RETURNING value;

После двух таких увеличений от нуля итог 2. Reset допустим только после завершения обеих транзакций и только для учебной таблицы. Для редактирования произвольного документа вместо инкремента можно выбрать версию/CAS либо чтение под блокировкой; автоматическое перезаписывание чужой правки не является безопасным retry.

Общий предикат и write skew

В lab_duty активны anna,boris,vera. Правило: после ухода остаются минимум двое. A и B начинают Repeatable Read, обе получают COUNT=3. A выключает anna и фиксирует; B выключает boris и фиксирует, используя прежний COUNT. Они меняли разные строки, поэтому прямого write-write столкновения нет. Итог COUNT=1 запрещён бизнес-правилом.

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM club.lab_duty WHERE active;
-- После барьера A меняет anna, B меняет boris.

Если бы A шла первой последовательно, B увидела бы двоих и осталась. В обратном порядке осталась бы A. Ни один правильный последовательный порядок не даёт одну Веру. Это write skew, а не потерянное обновление одного поля. Стабильный снимок не равен сериализуемой группе решений.

Первое исправление — общая guard-row. В Read Committed каждый writer сначала получает SELECT ... FOR UPDATE из lab_guard, затем отдельной командой читает актуальный COUNT и меняет строку только при значении больше двух. B ждёт commit A, после чего видит 2 и отказывается уходить. Все пути изменения состава должны соблюдать этот порядок. Блокировка после старой проверки не исправляет решение.

Второе исправление — Serializable для проверки и изменения, с повтором всей работы при 40001. Не закрепляйте за API точное место отказа: он может возникнуть на команде или commit. Retry имеет предел и сохраняет идентификатор намерения. Сторонний эффект не должен повторяться случайно при каждом запуске.

Конкурентная выдача физической копии

Для копии 103 partial unique index уже защищает не более одной активной выдачи. Если A вставила активную запись и ещё не завершилась, B со второй вставкой может ждать результата A. После COMMIT A B получает конфликт; после ROLLBACK A её вставка может стать допустимой. Индекс не обещает мгновенный отказ и не определяет, кто получит книгу первым.

Самостоятельная лаборатория

Отведите 45–60 минут. Выполните сценарии lost update, write skew и guard из файла в двух окнах с явными барьерами. Сохраните команды, ответы и финальные числа. Затем повторите write skew с Serializable после reset. При отказе запишите SQLSTATE и повторите COUNT вместе с изменением.

Разбор

Критерии: исходный lost update даёт 1; атомарный инкремент даёт 2; write skew может дать 1 активного дежурного; guard сохраняет минимум 2; корректный Serializable путь не подтверждает запрещённый итог. Разбор обязан объяснить видимость и конфликт каждой строки. Локальная транзакция не объединяет две независимые базы, а таймаут ответа не доказывает rollback уже начатой работы.

Первичные источники

PostgreSQL: Transactions, Transaction Isolation, Berkeley: Transactions and Concurrency.