В прошлом уроке мы выяснили, что физически на странице может лежать несколько версий одной логической строки — с разными xmin/xmax. Сегодня вопрос: какую из них видит моя транзакция?
Ответ — snapshot. Это структура, которая позволяет за O(1) ответить «жива ли эта версия с точки зрения меня».
Что такое snapshot
Snapshot — это «фотография» состояния БД в конкретный момент. У него три поля:
- xmin (snapshot.xmin) — минимальный transaction id среди ещё активных транзакций на момент создания snapshot. Все транзакции с id меньше этого числа уже либо закоммитились, либо аборт’нулись. Их статус определён.
- xmax (snapshot.xmax) —
latestCompletedXid + 1. Это первый txid, который ещё не выдан. Любая транзакция с id>= xmaxещё не существует на момент snapshot. - xip_list (transactions in progress) — массив id транзакций, которые активны прямо сейчас в момент создания snapshot. Это «дыры» в диапазоне
[xmin, xmax).
snapshot.xmin = граница «всё закоммитилось», snapshot.xmax = граница «ещё не существует», между ними — диапазон, в котором есть активные транзакции (xip_list).
Visibility rule
Имея snapshot и tuple с xmin/xmax, решение «виден ли мне tuple» строится по правилам (упрощённо, без HOT и subtransactions):
viewable(tuple, snapshot) :=
xmin_committed(tuple, snapshot)
AND NOT xmax_committed(tuple, snapshot)
Где xmin_committed(tuple, snapshot) означает «xmin tuple’а закоммитился с точки зрения snapshot»:
- Если
tuple.xmin == snapshot.xmin OR tuple.xmin in xip_list→ транзакция всё ещё активна (для меня) → invisible. - Если
tuple.xmin < snapshot.xmin→ транзакция закоммитилась до моего snapshot → visible. - Если
tuple.xmin >= snapshot.xmax→ транзакция запустилась после моего snapshot → invisible. - Иначе (
tuple.xmin in [snapshot.xmin, snapshot.xmax) AND NOT in xip_list) → транзакция закоммитилась между моим xmin и xmax → visible.
Аналогично проверяется xmax (если он не 0). Если xmax закоммитился до меня → строка удалена → invisible.
Это означает: за O(log K), где K — размер xip_list (обычно единицы), мы можем решить судьбу любой строки.
Snapshot isolation
То, как PostgreSQL применяет snapshot — это
Сравним с классической lock-based базой:
Слева: lock-based (2PL) — T1 ждёт T2 или наоборот, читатели и писатели мешают друг другу. Справа: MVCC + snapshot isolation — оба работают параллельно, каждый видит свою «фотографию».
Когда snapshot создаётся
PostgreSQL поддерживает несколько уровней изоляции, и от уровня зависит, когда делается snapshot:
- READ COMMITTED (default) — snapshot пересоздаётся в начале каждого statement. Внутри одной транзакции
SELECTможет видеть разные данные. - REPEATABLE READ — snapshot создаётся один раз на всю транзакцию (при первом обращении к данным). Все
SELECTвнутри увидят одну и ту же картину. - SERIALIZABLE — то же, что REPEATABLE READ + дополнительные проверки конфликтов через SSI (Serializable Snapshot Isolation, Cahill 2008).
В первом приближении 80% production-запросов идут с READ COMMITTED, и это означает: внутри одной транзакции последовательные SELECT могут вернуть разные ответы. Если вам нужен консистентный read-across-statements (например, считать сумму по таблице, а потом ещё раз сверить) — используйте REPEATABLE READ.
Видимость в боевых условиях
Посмотрим snapshot в действии. Запустим в одной сессии транзакцию, в другой — UPDATE, и увидим, что первая транзакция «застряла во времени».
Симулируем snapshot isolation в одной сессии: BEGIN, потом UPDATE снаружи (через подзапрос-эмуляцию). Внутри pglite-сессии одна сессия, но мы можем посмотреть значение функций snapshot.
В pglite функция pg_current_snapshot() может вернуть что-то вроде 100:100:. Здесь:
- первое число — snapshot.xmin (= 100, всё до 100 known)
- второе — snapshot.xmax (= 100, не существуют 100 и выше)
- после второго двоеточия — xip_list (в данном случае пуст)
Когда в системе одна активная транзакция (текущая) — snapshot тривиален.
Demo REPEATABLE READ: внутри одной транзакции делаем SELECT, потом UPDATE в виде вставки команды другой транзакции (эмуляция через дополнительный BEGIN не доступна, но логика та же). Здесь мы используем pg_current_snapshot() в начале и в конце.
Главная мысль: между BEGIN и COMMIT REPEATABLE READ ваш SELECT-цикл застрахован от изменений — вы видите БД ровно такой, какой она была в момент первого обращения.
Subtle: невидимая собственная транзакция
Snapshot исключает саму себя (точнее: транзакция знает свой own txid отдельно). Поэтому строки, вставленные этой же транзакцией, будут видны — но через специальный путь:
viewable(tuple, snapshot) :=
tuple.xmin == my_txid AND tuple.cmin < cur_cid
OR (committed XOR active XOR aborted check, как выше)
Это и есть зачем нужен cmin: внутри одного statement INSERT ... RETURNING видит только что вставленные строки, но UPDATE в том же statement их не должен видеть (иначе INSERT-UPDATE на одной таблице может бесконечно зацикливаться). Логика «statement n’s own writes» обслуживается через cmin/cmax.
Лонг-раннинг транзакция и replica feedback
Snapshot хранит ссылку на «свой собственный xmin» (backend.xmin). Эта величина транслируется в pg_stat_activity.backend_xmin и в глобальный xmin кластера. И вот тут начинается самое неприятное.
VACUUM обязан сохранить все версии строк, которые могут быть видны хотя бы одной транзакции. То есть он не может удалить никакой кортеж, чей xmin или xmax больше глобального xmin. А глобальный xmin — это минимум среди backend.xmin по всем сессиям.
Что это значит на практике: одна долго работающая транзакция в системе (например, открытый BEGIN; SELECT ... в psql у разработчика) блокирует VACUUM по всей БД. Через час такой ситуации dead tuples начинают накапливаться, и таблицы пухнут.
Смотрим backend_xmin активных сессий. В реальном production-кластере это первое, куда лезут при «VACUUM не справляется».
Если у тебя xid_age > 50000000 для какого-то backend’а — значит, есть транзакция, открытая дольше, чем была хоть какая-то активность за 50 миллионов записей xid. С большой вероятностью её надо найти и убить.
В следующем уроке посмотрим на структуру, которая помогает Postgres быстро находить «полностью видимые» страницы — visibility map.
Чек-лист
- Snapshot = (xmin, xmax, xip_list). Это «фотография» состояния БД в конкретный момент.
- Видимость кортежа определяется через сравнение его xmin/xmax с полями snapshot и проверку статуса в
pg_xact(закэшированного в infomask). - READ COMMITTED пересоздаёт snapshot для каждого statement, REPEATABLE READ — один раз на транзакцию.
- MVCC через snapshot isolation = читатели и писатели не блокируют друг друга.
- Долгая транзакция блокирует VACUUM: её backend_xmin держит глобальный xmin, и dead tuples нельзя освободить. Это основная причина bloat в реальных кластерах.
- Внутри своей транзакции видимость дополнительно проверяется через cmin/cmax — чтобы свой же statement не «видел сам себя».