Learning Platform
Урок 12.02 · 26 мин
Продвинутый
snapshot isolationMVCCvisibilityxmin horizonwrite-write conflictlost update

В предыдущем уроке мы говорили об уровнях изоляции абстрактно — что разрешено, что запрещено. Теперь спускаемся в механику: как именно Postgres реализует REPEATABLE READ. Здесь нет никаких блокировок чтений, нет ожидания на SELECT, а вся изоляция держится на одной структуре — snapshot.

Что такое snapshot физически

Когда транзакция в режиме REPEATABLE READ выполняет первый запрос, Postgres строит snapshot — три числа, описывающие «состояние мира на этот момент»:

Структура snapshot в PostgreSQL

Три ключевых компонента: xmin — нижняя граница активных, xmax — верхняя, xip[] — список явно открытых на момент snapshot транзакций.

Snapshotвзят в момент первого statement транзакции (REPEATABLE READ)
snapshot.xminmin(active txids) — всё ниже считается завершённым
snapshot.xmaxследующий txid, который ещё не выдан — всё выше невидимо
snapshot.xip[]массив открытых транзакций между xmin и xmax — невидимых для нас
свойствоsnapshot держится до COMMIT/ROLLBACK; все запросы видят одну и ту же картину мира

Различие с READ COMMITTED — фундаментальное:

  • В READ COMMITTED snapshot пересчитывается перед каждым statement. Между двумя SELECT в одной транзакции состояние мира может измениться.
  • В REPEATABLE READ snapshot берётся один раз и держится до конца транзакции. Все statement’ы видят одно и то же.

В Postgres’е первый из этих режимов называется RC, второй — snapshot isolation (SI). ANSI термин «REPEATABLE READ» здесь — историческая этикетка, которая описывает результат, но не механизм.

Visibility test: видна ли мне эта версия

Для каждого heap tuple, который сканер встречает, Postgres делает

visibility test
. Алгоритм упрощённо:

Visibility test: можно ли видеть tuple версии v

Tuple виден, если xmin зафиксирован в нашем snapshot AS COMMITTED, и xmax либо 0, либо НЕ виден нашему snapshot.

шаг 1: xmin?смотрим транзакцию-создателя
если xmin >= snapshot.xmaxневидим (создан после snapshot)
если xmin в xip[]невидим (была активна в момент snapshot)
если xmin < snapshot.xminвидим, если COMMITTED
иначевидим, если COMMITTED
шаг 2: xmax?смотрим транзакцию-удалителя
xmax = 0видим, версия живая
xmax >= snapshot.xmaxвидим (удалена после snapshot)
xmax в xip[]видим (удалитель был активен на момент snapshot)
xmax COMMITTED и < snapshot.xminневидим (удалена до snapshot)
итогxmin committed AND visible AND xmax (либо 0, либо invisible)

Эта функция называется HeapTupleSatisfiesMVCC и вызывается для каждой версии каждой строки, которую видит сканер. Её результат кэшируется через hint bits (модуль 4 урок 1), чтобы повторные сканы не лезли в pg_xact.

Демо: snapshot держит «прошлое»

Покажем, что snapshot в REPEATABLE READ видит исходное состояние, даже когда строка явно обновлена:

Создаём строку, открываем RR-транзакцию, делаем SELECT (snapshot фиксируется). Внутри той же транзакции UPDATE — он создаст новую версию, но первый SELECT всё равно видит свою. Однако SELECT после UPDATE увидит свой собственный UPDATE — за это отвечают cmin/cmax.

PostgreSQL

Заметь две важные детали:

  1. Внутри RR-транзакции SELECT после UPDATE видит новое значение, потому что snapshot включает изменения этой же транзакции (cmin/cmax-механизм).
  2. После COMMIT снаружи мы видим окончательное состояние с новым xmin.

Write-write conflict: lost update prevention

В Postgres REPEATABLE READ имеет встроенную защиту от lost update — аномалии, когда два конкурирующих UPDATE одной строки заканчиваются тем, что один из них «теряется».

Сценарий, который защищается:

Lost update prevention в REPEATABLE READ

T1 и T2 начинают одновременно. Обе видят balance=100. T1 пишет 110 и коммитит. T2 пытается записать 120 — но Postgres видит, что строка с xmin, видимым моему snapshot, уже имеет новый xmax от закоммиченной T1. Ошибка 40001.

T1 (RR)BEGIN ISOLATION LEVEL REPEATABLE READ
T1SELECT balance FROM acc WHERE id=1 → 100
T1UPDATE acc SET balance = 110 WHERE id=1
T1COMMIT
T2 (RR)BEGIN ISOLATION LEVEL REPEATABLE READ
T2SELECT balance FROM acc WHERE id=1 → 100 (свой snapshot)
T2UPDATE acc SET balance = 120 WHERE id=1
T2ERROR: could not serialize access due to concurrent update
T2ROLLBACK — приложение должно retry'ить

Механизм: когда T2 пытается обновить строку, Postgres находит последнюю версию и проверяет её xmax. Если xmax принадлежит закоммиченной транзакции, которой нет в snapshot T2 — это означает, что между snapshot T2 и попыткой записи кто-то уже обновил эту строку. Поведение зависит от уровня:

  • READ COMMITTED: T2 ждёт T1 на row-level lock, потом перечитывает версию и применяет свой UPDATE к новой версии. Lost update может случиться, если приложение читает значение в одной statement, считает в коде и пишет в другой statement.
  • REPEATABLE READ / SERIALIZABLE: T2 получает ошибку 40001 serialization_failure. Приложение обязано retry’ить.

SQLSTATE 40001 и retry-логика

Код ошибки 40001 (или text serialization_failure) — критически важный для приложений в RR/SERIALIZABLE. Любая транзакция в этих режимах потенциально может его получить, и retry — единственный правильный путь:

BEGIN ISOLATION LEVEL REPEATABLE READ;
  -- ... запросы и UPDATE'ы
COMMIT;
-- если упало с 40001 — закрыть транзакцию, начать новую целиком,
-- повторить все запросы. НЕ retry'ить отдельный statement.

Retry’ить отдельный запрос бессмысленно: snapshot всей транзакции уже скомпрометирован. Откатить, начать с нуля.

Симуляция lost update prevention. В pglite parallel connection'ов нет, поэтому мы напрямую покажем UPDATE-on-UPDATE — Postgres всё равно отслеживает xmax. Для полного эффекта в проде запусти две psql-сессии.

PostgreSQL

Полноценный демо требует двух connection’ов. Принцип запомни: в RR/SERIALIZABLE write-write конфликт = 40001.

SI vs ANSI Repeatable Read: что Postgres называет «REPEATABLE READ»

Подчеркнём ещё раз: то, что в Postgres называется REPEATABLE READ, сильнее того, что описано в ANSI SQL-92. Сравнение:

ANSI RR vs Postgres RR (snapshot isolation)

Постгрес запрещает phantom (snapshot не показывает изменений другой транзакции). Но допускает write skew, которого SQL-92 не описывает.

dirty readANSI: запрещён | PG: запрещён
non-repeatableANSI: запрещён | PG: запрещён
phantomANSI: разрешён | PG: запрещён
write skewANSI: ? | PG: разрешён

Phantom read запрещён, потому что snapshot фиксируется на первом statement: даже если другая транзакция вставляет новые строки, удовлетворяющие нашему WHERE, snapshot их не покажет (их xmin либо в xip[], либо >= snapshot.xmax).

Write skew — это ситуация, когда T1 и T2 читают одно и то же множество строк, каждая считает «я выполнила условие», и обе пишут в разные строки. Snapshot этого не видит — про это весь урок 4.

Cost: что стоит snapshot isolation

Цена SI в Postgres:

  • bloat: пока RR-транзакция держит snapshot, VACUUM не может убрать dead tuples, чьи xmax < OldestXmin = min(active snapshot.xmin). Длинные RR-транзакции = растущий bloat. Это объясняет, почему мониторинг pg_stat_activity ищет «longest running transaction».
  • 40001-ошибки: write conflict — это исключение в приложении, требующее retry-логики. На больших нагрузках частота 40001 может стать заметной.
  • per-snapshot overhead: построить snapshot = взять ProcArrayLock и пройтись по PGPROC-массиву всех активных бэкендов. На системах с тысячами соединений это становится bottleneck’ом (исправляется connection pooler’ом).

Зато profit: читатели не блокируют писателей, и долгие аналитические транзакции работают без штрафа для OLTP-трафика, который их обходит.

В следующем уроке начинаем разбираться с Serializable Snapshot Isolation — что добавил Cahill et al. поверх SI.

Проверка знанийKnowledge check
Транзакция T1 в REPEATABLE READ открылась, прочитала строку (balance=100), сделала паузу на 3 секунды. За это время T2 в любом уровне обновила ту же строку (balance=200) и закоммитила. Что произойдёт, когда T1 попытается `UPDATE balance = 105`? Опиши и поведение, и причину.
ОтветAnswer
T1 получит ошибку SQLSTATE 40001 'could not serialize access due to concurrent update' и должна сделать ROLLBACK + retry. Причина: при попытке UPDATE Postgres находит самую свежую версию строки (от T2), смотрит её xmax, видит что предыдущая версия имела xmax от закоммиченной T2, которой нет в snapshot T1. Это write-write conflict: snapshot T1 видит balance=100, но физическая строка уже balance=200. В REPEATABLE READ это не разрешено — иначе бы T1 записала 105 поверх 200 и потеряла бы UPDATE T2 (классический lost update). В READ COMMITTED поведение было бы другим: T1 бы дождалась T2, перечитала бы новую версию и применила бы UPDATE к ней.

Чек-лист

  • Snapshot — это xmin / xmax / xip[], взятый в момент первого statement REPEATABLE READ-транзакции и держащийся до COMMIT.
  • HeapTupleSatisfiesMVCC проверяет каждый кортеж против snapshot — это горячий путь, кэшируется через hint bits.
  • В REPEATABLE READ snapshot фиксирован; в READ COMMITTED — пересчитывается на каждом statement.
  • Lost update prevention: RR/SERIALIZABLE detect’ят write-write conflict через xmax и поднимают ошибку 40001 serialization_failure.
  • Приложение обязано retry’ить транзакцию целиком при 40001 — retry отдельного statement бессмысленен.
  • Postgres REPEATABLE READ = snapshot isolation: phantom запрещён (сильнее ANSI), но write skew разрешён (этого ANSI не описывает).
ACID на пальцах: что именно обещает СУБД ACID-транзакции и разрешение конфликтов в Delta Lake

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что входит в структуру snapshot в PostgreSQL?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5