Все углублённые блоки
Углублённые блоки
Блок 05 / Углублённый разбор

Изоляция: снимки, потерянные изменения и write skew

Выполняем двухсессионные истории PostgreSQL, отличаем row conflict от нарушения общего правила и выбираем блокировку или retry Serializable.

Два администратора открыли описание встречи «Луна». Один исправляет название, другой — вместимость. Оба прочитали прежнюю строку. Если каждый сохраняет весь объект, последний UPDATE может незаметно вернуть старое название. Ни один запрос не упал; пользовательское изменение потерялось. Нужна модель конкуренции, а не только слово ACID.

Мы рассматриваем одну PostgreSQL 18, устойчивую фиксацию по её выбранным настройкам и две сессии. Для эксперимента нужна отдельная локальная учебная база и клиент psql; платный сервис не требуется. Команды ниже не запускаются автоматически. Таблицы создаются в новой схеме без IF NOT EXISTS: совпадение имени останавливает setup вместо использования чужих данных. Не выполняйте их в рабочей базе.

Снимок отвечает на вопрос видимости

MVCC сохраняет версии. Обычное чтение выбирает видимые версии, а не обязательно ждёт незавершённую запись. Read Committed берёт снимок для команды; Repeatable Read — стабильный снимок транзакции после начала работы со снимком. Версии не освобождают UPDATE от блокировок и конфликтов. Долгая транзакция также может мешать очистке старых версий.

Dirty read видит неподтверждённое изменение. Nonrepeatable read получает другое значение одной строки при повторном чтении. Read skew получает несовместимые наблюдения нескольких фактов. Lost update теряет изменение из-за записи на основе прежнего чтения. Write skew возникает, когда общий проверенный предикат и разные записи вместе дают запрещённое состояние. Эти слова описывают истории; название уровня само по себе не показывает ошибку.

PostgreSQL Repeatable Read запрещает phantom read сильнее минимальной таблицы SQL, но не запрещает все serialization anomalies. Read Uncommitted работает как Read Committed. Сверяйте конкретный движок с PostgreSQL 18: Isolation; не переносите эти детали на любую БД.

Подготовка изолированного опыта

Создайте отдельную локальную учебную базу. Скачайте комплект лабораторий и выполните setup один раз из распакованного каталога; LAB_DATABASE_URL должен указывать именно на эту учебную базу.

psql "$LAB_DATABASE_URL" -f transactions.sql

Файл включает ON_ERROR_STOP и выполняет создание схемы, таблиц и начальных строк в одной транзакции. Если схема sd_isolation_lab уже существует, ошибка прекращает выполнение; соединение закрывается, незавершённая транзакция откатывается. Существующие таблицы не используются. Продолжайте сценарий только после успешного setup своей новой базы. Для повторного setup используйте новую учебную базу, а не удаляйте схему неизвестного происхождения.

После успешного setup откройте два окна psql к той же базе. Условия остановки между командами указаны в тексте и в transactions.sql; ожидание по секундам вместо барьера не гарантирует нужный interleaving. Комментированные сценарии файла копируют по меткам SESSION A/B, а не выполняют целиком в одном окне. При итоговой проверке на PostgreSQL 18.6 выполнены setup, отказ повторного setup и эти двухсессионные истории, включая SQLSTATE 40001. Результат не отменяет необходимости повторить опыт в своей среде.

Lost update: база не знает намерение

Сессии A и B начинают BEGIN ISOLATION LEVEL READ COMMITTED и обе выполняют SELECT value по id=1. Каждая получает 0 и решает записать 1. A выполняет UPDATE value=1 и COMMIT. После этого B выполняет тот же UPDATE и COMMIT. Значение равно 1, хотя было два намерения увеличить счётчик.

SELECT value FROM sd_isolation_lab.counters WHERE id = 1;
UPDATE sd_isolation_lab.counters SET value = 1 WHERE id = 1;

Правильный атомарный путь для этого конкретного намерения:

UPDATE sd_isolation_lab.counters
SET value = value + 1
WHERE id = 1
RETURNING value;

Если логика меняет целый объект, можно читать под FOR UPDATE, либо использовать compare-and-set с сохранённой версией и проверять число изменённых строк. Ноль обновлённых строк означает конфликт, который надо разобрать или повторить; это не успешное сохранение. Автоматический retry не должен перетирать новую редакторскую правку без решения пользователя.

Write skew: конфликт живёт между строками

Инвариант: не меньше двух активных дежурных. Каждая транзакция корректна при последовательной работе: уйти разрешено только если активных больше двух. A и B начинают Repeatable Read и обе выполняют COUNT до первого commit.

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM sd_isolation_lab.duty WHERE active;

Обе получили 3. После этого A выключает anna и фиксирует. B выключает boris и фиксирует. UPDATE обращаются к разным строкам; обычного write-write conflict между ними нет.

-- A:
UPDATE sd_isolation_lab.duty SET active=false WHERE name='anna';
COMMIT;
-- B после того, как уже прочитала COUNT=3:
UPDATE sd_isolation_lab.duty SET active=false WHERE name='boris';
COMMIT;

После обоих commit новый SELECT COUNT даёт 1. Если сначала выключилась Анна, последовательный Борис должен был увидеть двоих и остаться. Если первым Борис — остаться должна Анна. Ни один последовательный порядок не даёт одну Веру при указанной логике. Это проверяемый контрпример сериализуемости, а не lost update одного поля.

Исправление общей точкой сериализации

Reset выполняется только после завершения обеих транзакций:

UPDATE sd_isolation_lab.duty SET active=true;

Каждый путь изменения состава берёт одну guard-row. В Read Committed COUNT выполняется отдельной командой после получения блокировки, чтобы наблюдать актуальный подтверждённый состав. Не переносите это доказательство на уже взятый Repeatable Read snapshot.

BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT id FROM sd_isolation_lab.guard WHERE id=1 FOR UPDATE;
SELECT COUNT(*) FROM sd_isolation_lab.duty WHERE active;
-- UPDATE разрешён приложением только если COUNT > 2.
-- Сессия A выключает anna; затем COMMIT.
COMMIT;

B ждёт guard до commit A, затем COUNT возвращает 2 и B оставляет свой active=true. Все удаления, замены состава и импорт должны соблюдать тот же протокол. Цена: изменения одной встречи идут последовательно. При 10 ms удержания guard идеальная верхняя оценка этого пути — около 100 изменений/с без накладных расходов; длинная проверка уменьшает её.

Serializable и отказ — часть нормальной работы

Второй вариант выполняет проверку и изменение в Serializable. PostgreSQL SSI отслеживает опасные зависимости и может отменить транзакцию с SQLSTATE 40001. Приложение повторяет весь блок, включая COUNT, с новым снимком. После первого успешного ухода повтор второй попытки увидит двоих и откажет по бизнес-правилу.

Точное место отказа в конкурентном исполнении не должно становиться контрактом приложения: ошибку обрабатывают в любом предусмотренном шаге. Retry получает предел, backoff и тот же ID намерения. Deadlock также требует корректного разбора, но это другая причина и код ошибки. Внешнее письмо, отправленное до commit, не отменяется вместе с SQL; используйте outbox.

Two-phase locking удерживает нужные блокировки по протоколу; optimistic concurrency проверяет предположения перед фиксацией; SSI обнаруживает опасные зависимости снимков. Эти механизмы имеют разные цены ожидания и повторов. FOR UPDATE над одной найденной строкой не является блокировкой произвольного предиката или отсутствующего объекта.

Самостоятельная практика и проверка

Составьте полный transcript двух окон для write skew. Укажите барьер «обе COUNT завершились» и ожидаемое состояние после каждого commit. Затем замените Repeatable Read на Serializable и повторите всё после reset. Какой код подтверждает отказ? Почему retry только UPDATE неверен?

Подсказка: сохранённый COUNT относится к прежнему снимку. Разбор: в первом опыте оба commit могут дать active=1; в Serializable успешная группа не должна дать это состояние при правильной логике. Отказавший блок повторяется с COUNT, а не с прежним разрешением. Итог обязан сохранять минимум двоих. Команды являются инструкцией опыта; их исполнение на вашем PostgreSQL проверяется transcript, а не заявлениями о тысяче успешных запросов.

Пересматривайте протокол при добавлении массовой замены дежурных, нескольких БД или нового пути импорта. Локальная Serializable не объединяет независимые регионы в одну транзакцию. Для следующей ступени изучите согласованность и распределённые транзакции.

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

PostgreSQL 18: MVCC, Transaction Isolation, CMU concurrency control и MVCC. Приведённые схемы и расписания — оригинальные учебные задания.

Проверьте себя

Две транзакции Repeatable Read увидели троих дежурных и выключили разные строки. Остался один. Почему блокировки разных UPDATE не защитили правило?

Запишите ход рассуждений, расчёты и вопросы. Сохраните текст перед уходом со страницы. После входа в аккаунт ответ участвует в общей синхронизации прогресса. Автоматической оценки архитектуры здесь нет.