Уровни изоляции на практике
В смене Brew негласное правило: на полу всегда должен оставаться хотя бы один бариста — нельзя, чтобы зал опустел. Сейчас на полу двое — бариста Алиса и Борис (однофамильцы клиентов Алисы Ивановой и Бориса Петрова из других модулей: это разные люди, персонал смены). Оба решают сходить на склад — одновременно. Каждый бросает взгляд в зал: «нас двое, я отойду, один останется». Каждый рассуждает безупречно. Но они уходят вместе — и зал пустеет. Никакой UPDATE не «затёр» чужой (Алиса меняла свою строку, Борис — свою), блокировка строк из 05-03 тут бессильна: они трогают разные строки. И всё же инвариант сломан.
Это write-skew — аномалия, которую не ловит ни FOR UPDATE (строки-то разные), ни даже фиксированный снимок REPEATABLE READ. Её ловит только самый строгий уровень изоляции — SERIALIZABLE. Этот юнит — про три доступных уровня изоляции и про то, чем именно отличается верхний.
Это escape-hatch-юнит (как 05-02): аномалии изоляции конкурентны по природе, и ведём мы урок psql-скриптами.
Три уровня, которые стоит знать
Стандарт SQL описывает четыре уровня; Postgres реально различает три (его READ UNCOMMITTED совпадает с READ COMMITTED — «грязного чтения» в Postgres не бывает никогда).
- READ COMMITTED — дефолт. Каждая команда видит свежий снимок зафиксированных данных. Внутри одной транзакции два одинаковых
SELECTмогут вернуть разное, если между ними кто-то закоммитил (non-repeatable read). Этого уровня достаточно для большинства веб-приложений. - REPEATABLE READ — снимок фиксируется на всю транзакцию (механику видели в 05-02). Повторное чтение всегда одинаковое; «фантомов» в Postgres на этом уровне тоже нет. Но write-skew проходит.
- SERIALIZABLE — самый строгий: результат любой группы параллельных транзакций гарантированно совпадает с каким-то их последовательным выполнением. Реализован через SSI (Serializable Snapshot Isolation): база следит за зависимостями чтения/записи и, обнаружив опасную пару, завершает одну из транзакций ошибкой 40001 (
serialization_failure). Никаких лишних блокировок — расплата в том, что транзакцию нужно быть готовым повторить.
Уровень задаётся на транзакцию: BEGIN ISOLATION LEVEL SERIALIZABLE; (или SET TRANSACTION ... сразу после BEGIN).
Три уровня и что каждый ловит (грязного чтения в Postgres нет ни на одном):
| Уровень | Снимок | Non-repeatable read | Write-skew | Цена |
|---|---|---|---|---|
| READ COMMITTED (дефолт) | на каждую команду | пропускает | пропускает | дёшево |
| REPEATABLE READ | на всю транзакцию | ловит | пропускает | дёшево |
| SERIALIZABLE | на транзакцию + SSI | ловит | ловит (40001) | ретраи под нагрузкой |
Почему write-skew коварен
Каждая из двух транзакций по отдельности корректна: прочитала «на полу двое», сняла один флаг, оставила одного — инвариант соблюдён. Беда в том, что обе читали устаревшее к моменту коммита состояние: к тому времени, как Алиса коммитит, Борис уже ушёл, но снимок Алисы этого не видит. REPEATABLE READ честно даёт каждой стабильный снимок — и именно поэтому не замечает проблему. SERIALIZABLE замечает: он видит, что Алиса прочитала строку, которую Борис изменил, и наоборот, — это «опасная структура», совместимого последовательного порядка для неё нет, и одну транзакцию приходится отклонить.
Что показывает наш код
demo.sql (цель run) детерминированно показывает доступные уровни (SHOW transaction_isolation → read committed; BEGIN ISOLATION LEVEL ... → нужный) и логику write-skew на одной сессии: оба бариста «видят 2», оба снимают флаг, на полу остаётся 0.
session-a.sql / session-b.sql показывают живой конфликт под SERIALIZABLE в двух терминалах:
-- обе сессии:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM shift_lab WHERE on_floor; -- обе видят 2
-- A: UPDATE ... WHERE id = 1; B: UPDATE ... WHERE id = 2; -- разные строки!
COMMIT; -- кто коммитит вторым — ловит 40001Борис (B) коммитит первым — успешно. Алиса (A) коммитит второй — её COMMIT падает с 40001, транзакция отменяется целиком, и Алиса остаётся на полу. Инвариант спасён ценой одной отклонённой транзакции.
Запуск
Подними песочницу (из корня репозитория) и восстанови схему Brew:
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-04-isolation-levels-for-devs T=db-resetДетерминированное демо (уровни + логика write-skew):
make lecture L=05-transactions-and-mvcc/05-04-isolation-levels-for-devs── Уровень изоляции по умолчанию (дефолт Postgres) ──
transaction_isolation
-----------------------
read committed
(1 row)
── Уровень задаётся на транзакцию через BEGIN ISOLATION LEVEL ... ──
внутри BEGIN REPEATABLE READ
------------------------------
repeatable read
(1 row)
внутри BEGIN SERIALIZABLE
---------------------------
serializable
(1 row)
── Write-skew: правило «на полу всегда ≥1 бариста». На полу сейчас:
на полу
---------
2
(1 row)
Алиса смотрит «сколько на полу» (видит 2 ≥ 1 → решает уйти):
Алиса видит на полу
---------------------
2
(1 row)
Борис смотрит ОДНОВРЕМЕННО, по своему снимку (тоже видит 2 → тоже решает уйти):
Борис видит на полу
---------------------
2
(1 row)
Итог — на полу не осталось никого, хотя каждый «оставлял одного»:
на полу
---------
0
(1 row)
→ инвариант сломан. READ COMMITTED и REPEATABLE READ это пропускают;
ловит только SERIALIZABLE — он завершит одну из транзакций ошибкой 40001 (см. сессии).Теперь живой конфликт. В первом терминале запусти session-a: Алиса откроет транзакцию SERIALIZABLE, прочитает «на полу 2», снимет свой флаг и остановится у подсказки. В этот момент во втором терминале прогони session-b целиком — Борис прочитает «на полу 2», снимет свой флаг и успешно закоммитит. Вернись в первый терминал, нажми Enter — и COMMIT Алисы упадёт:
A3) Алиса коммитит ВТОРОЙ. SERIALIZABLE видит: A и B прочитали одно множество,
а сняли РАЗНЫЕ флаги — вместе они нарушили бы «≥1 на полу». COMMIT падает 40001:
ERROR: could not serialize access due to read/write dependencies among transactions
DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt.
HINT: The transaction might succeed if retried.
A4) Транзакция A отменена целиком — её UPDATE не применён. На полу всё ещё есть Алиса:
id | name | on_floor
----+-------+----------
1 | Алиса | t
2 | Борис | f
(2 rows)HINT: ... might succeed if retried — это и есть контракт SERIALIZABLE: лови 40001 и повторяй. На ретрае Алиса прочтёт уже свежее состояние (на полу один) и не уйдёт. (Точный текст DETAIL/reason code может отличаться от прогона к прогону — важен код 40001.)
Заборчик
Порядок коммитов в сессиях держат \prompt — в реальной гонке «вторым» может оказаться любой, и 40001 прилетит непредсказуемо какой транзакции. Поэтому SERIALIZABLE нельзя использовать без ретрай-петли: код под ним обязан уметь повторить транзакцию при 40001 (об этом — следующий юнит, 05-05). А вот о чём ещё помнить:
SERIALIZABLE— не бесплатный «режим правильности». Под нагрузкой он даёт больше serialization failures, а значит ретраев и потраченной работы. Включать его на всю базу обычно не нужно — берут точечно, для транзакций с настоящим риском write-skew: бронирование, лимиты, расписания дежурств.- Ту же аномалию закрывают и на
READ COMMITTED— вручную. Материализовать конфликт черезSELECT … FOR UPDATEна «контрольной» строке, добавитьCHECK/уникальный индекс или явную блокировку. Что выбрать — зависит от того, как часто конфликт реально случается.
Что забрать с собой
- Уровень изоляции задаётся на транзакцию (
BEGIN ISOLATION LEVEL ...); дефолт Postgres —READ COMMITTED. READ COMMITTED— снимок на команду;REPEATABLE READ— снимок на транзакцию;SERIALIZABLE— как будто транзакции шли по очереди.- Write-skew: две транзакции читают общее множество, пишут в разные строки, и каждая по отдельности корректна — а вместе ломают инвариант.
- Ни
FOR UPDATE(строки разные), ниREPEATABLE READ(стабильный снимок) write-skew не ловят — ловит толькоSERIALIZABLE, ценой ошибки 40001 у одной из транзакций. SERIALIZABLEтребует ретрай-петли на 40001 (serialization_failure) — без неё применять его нельзя.
Дальше — юнит 05-05 «ретрай на 40001»: напишем на Go ту самую петлю, что повторяет транзакцию при serialization failure, и увидим, как повторный прогон на свежем снимке принимает уже верное решение.