Learning Platform
Урок 05.02 · 23 мин
Продвинутый
MVCCsnapshotsnapshot isolationvisibilityxip_listisolation level

В прошлом уроке мы выяснили, что физически на странице может лежать несколько версий одной логической строки — с разными 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

snapshot.xmin = граница «всё закоммитилось», snapshot.xmax = граница «ещё не существует», между ними — диапазон, в котором есть активные транзакции (xip_list).

txid 1..149закоммитились или аборт
snapshot.xmin150
диапазон [150, 200)есть и committed, и активные
xip_list[152, 178, 195]
snapshot.xmax200
txid >= 200ещё не существуют
на момент snapshot активны три транзакции152, 178, 195. Все остальные между 150 и 200 закоммитились.

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»:

  1. Если tuple.xmin == snapshot.xmin OR tuple.xmin in xip_list → транзакция всё ещё активна (для меня) → invisible.
  2. Если tuple.xmin < snapshot.xmin → транзакция закоммитилась до моего snapshot → visible.
  3. Если tuple.xmin >= snapshot.xmax → транзакция запустилась после моего snapshot → invisible.
  4. Иначе (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 — это

snapshot isolation
. Ключевая особенность: читатели не блокируют писателей, писатели не блокируют читателей.

Сравним с классической lock-based базой:

Lock-based vs Snapshot Isolation

Слева: lock-based (2PL) — T1 ждёт T2 или наоборот, читатели и писатели мешают друг другу. Справа: MVCC + snapshot isolation — оба работают параллельно, каждый видит свою «фотографию».

Lock-based (2PL)
T1: SELECT id=42взяла shared lock
T2: UPDATE id=42ждёт T1 (хочет exclusive)
T1: COMMITотпускает lock
T2: получает lockи пишет
MVCC (Postgres)
T1: snapshot — версия v1видит amount=500
T2: UPDATE — пишет v2параллельно, без ожидания
T1: продолжает читать v1видит amount=500 (старое)
T2: COMMIT, T1: COMMITоба завершились без блокировок

Когда 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.

PostgreSQL

В 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() в начале и в конце.

PostgreSQL

Главная мысль: между 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 не справляется».

PostgreSQL

Если у тебя xid_age > 50000000 для какого-то backend’а — значит, есть транзакция, открытая дольше, чем была хоть какая-то активность за 50 миллионов записей xid. С большой вероятностью её надо найти и убить.

В следующем уроке посмотрим на структуру, которая помогает Postgres быстро находить «полностью видимые» страницы — visibility map.

Проверка знанийKnowledge check
Транзакция T200 (REPEATABLE READ) сделала snapshot в момент, когда активны были T180 и T195 (xip_list = [180, 195]), а xmin = 180, xmax = 200. После старта T200 закоммитилась T300, которая удалила строку (xmax = 300). Увидит ли T200 эту строку?
ОтветAnswer
Да, увидит. xmax строки = 300 >= snapshot.xmax = 200, что означает «эта транзакция запустилась после моего snapshot» → её удаление невидимо для T200. С точки зрения T200, строка всё ещё жива (если её xmin был меньше 180 или попадал в видимый диапазон). T200 застрахована от любых изменений, сделанных транзакциями с id >= 200, до самого своего COMMIT. Это и есть гарантия repeatable read.

Чек-лист

  • 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 не «видел сам себя».
Четыре аномалии: dirty read, non-repeatable, phantom, write skew Уровни изоляции: что реально даёт PostgreSQL

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

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

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

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

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

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