В предыдущем уроке мы говорили об уровнях изоляции абстрактно — что разрешено, что запрещено. Теперь спускаемся в механику: как именно Postgres реализует REPEATABLE READ. Здесь нет никаких блокировок чтений, нет ожидания на SELECT, а вся изоляция держится на одной структуре — snapshot.
Что такое snapshot физически
Когда транзакция в режиме REPEATABLE READ выполняет первый запрос, Postgres строит snapshot — три числа, описывающие «состояние мира на этот момент»:
Три ключевых компонента: xmin — нижняя граница активных, xmax — верхняя, xip[] — список явно открытых на момент snapshot транзакций.
Различие с READ COMMITTED — фундаментальное:
- В READ COMMITTED snapshot пересчитывается перед каждым statement. Между двумя SELECT в одной транзакции состояние мира может измениться.
- В REPEATABLE READ snapshot берётся один раз и держится до конца транзакции. Все statement’ы видят одно и то же.
В Postgres’е первый из этих режимов называется RC, второй — snapshot isolation (SI). ANSI термин «REPEATABLE READ» здесь — историческая этикетка, которая описывает результат, но не механизм.
Visibility test: видна ли мне эта версия
Для каждого heap tuple, который сканер встречает, Postgres делает
Tuple виден, если xmin зафиксирован в нашем snapshot AS COMMITTED, и xmax либо 0, либо НЕ виден нашему snapshot.
Эта функция называется HeapTupleSatisfiesMVCC и вызывается для каждой версии каждой строки, которую видит сканер. Её результат кэшируется через hint bits (модуль 4 урок 1), чтобы повторные сканы не лезли в pg_xact.
Демо: snapshot держит «прошлое»
Покажем, что snapshot в REPEATABLE READ видит исходное состояние, даже когда строка явно обновлена:
Создаём строку, открываем RR-транзакцию, делаем SELECT (snapshot фиксируется). Внутри той же транзакции UPDATE — он создаст новую версию, но первый SELECT всё равно видит свою. Однако SELECT после UPDATE увидит свой собственный UPDATE — за это отвечают cmin/cmax.
Заметь две важные детали:
- Внутри RR-транзакции SELECT после UPDATE видит новое значение, потому что snapshot включает изменения этой же транзакции (cmin/cmax-механизм).
- После COMMIT снаружи мы видим окончательное состояние с новым xmin.
Write-write conflict: lost update prevention
В Postgres REPEATABLE READ имеет встроенную защиту от lost update — аномалии, когда два конкурирующих UPDATE одной строки заканчиваются тем, что один из них «теряется».
Сценарий, который защищается:
T1 и T2 начинают одновременно. Обе видят balance=100. T1 пишет 110 и коммитит. T2 пытается записать 120 — но Postgres видит, что строка с xmin, видимым моему snapshot, уже имеет новый xmax от закоммиченной T1. Ошибка 40001.
Механизм: когда 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-сессии.
Полноценный демо требует двух connection’ов. Принцип запомни: в RR/SERIALIZABLE write-write конфликт = 40001.
SI vs ANSI Repeatable Read: что Postgres называет «REPEATABLE READ»
Подчеркнём ещё раз: то, что в Postgres называется REPEATABLE READ, сильнее того, что описано в ANSI SQL-92. Сравнение:
Постгрес запрещает phantom (snapshot не показывает изменений другой транзакции). Но допускает write skew, которого SQL-92 не описывает.
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.
Чек-лист
- 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 не описывает).