PostgreSQL CookbookТранзакцииУровни изоляции на практике
0 / 63 (0%)

Уровни изоляции на практике

В смене 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 readWrite-skewЦена
READ COMMITTED (дефолт)на каждую командупропускаетпропускаетдёшево
REPEATABLE READна всю транзакциюловитпропускаетдёшево
SERIALIZABLEна транзакцию + SSIловитловит (40001)ретраи под нагрузкой

Почему write-skew коварен

Каждая из двух транзакций по отдельности корректна: прочитала «на полу двое», сняла один флаг, оставила одного — инвариант соблюдён. Беда в том, что обе читали устаревшее к моменту коммита состояние: к тому времени, как Алиса коммитит, Борис уже ушёл, но снимок Алисы этого не видит. REPEATABLE READ честно даёт каждой стабильный снимок — и именно поэтому не замечает проблему. SERIALIZABLE замечает: он видит, что Алиса прочитала строку, которую Борис изменил, и наоборот, — это «опасная структура», совместимого последовательного порядка для неё нет, и одну транзакцию приходится отклонить.

Что показывает наш код

demo.sql (цель run) детерминированно показывает доступные уровни (SHOW transaction_isolationread committed; BEGIN ISOLATION LEVEL ... → нужный) и логику write-skew на одной сессии: оба бариста «видят 2», оба снимают флаг, на полу остаётся 0.

session-a.sql / session-b.sql показывают живой конфликт под SERIALIZABLE в двух терминалах:

sql
-- обе сессии:
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:

sh
docker compose up -d
make lecture L=05-transactions-and-mvcc/05-04-isolation-levels-for-devs T=db-reset

Детерминированное демо (уровни + логика write-skew):

sh
make lecture L=05-transactions-and-mvcc/05-04-isolation-levels-for-devs
plaintext
── Уровень изоляции по умолчанию (дефолт 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 Алисы упадёт:

plaintext
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, и увидим, как повторный прогон на свежем снимке принимает уже верное решение.

·Модуль 06

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Транзакции / Уровни изоляции на практике