Learning Platform
Урок 05.03 · 22 мин
Продвинутый
Visibility MapIndex Only Scanall-visibleall-frozenVACUUMFSM

Мы выяснили, что для определения «виден ли мне tuple» нужно: прочитать сам tuple, посмотреть xmin/xmax, заглянуть в pg_xact (или hint bit в infomask). Это операции на уровне одного кортежа.

Но что, если страница гарантированно содержит только видимые всем кортежи? Тогда для Index Only Scan можно вообще не читать heap — достаточно того, что попало в индекс. Эту оптимизацию реализует visibility map (VM).

Файл .vm

У каждой heap-таблицы рядом с основным файлом лежит fork-файл с расширением _vmpg_class.relfilenode он адресуется как VISIBILITYMAP_FORKNUM). Это карта 2 бит на каждую страницу heap’а:

  • all-visible (bit 0) — все кортежи на странице видимы любой текущей и будущей транзакции. То есть никаких dead tuples, никаких xmax!=0, никаких неподтверждённых xmin.
  • all-frozen (bit 1) — все xmin на странице «заморожены», т.е. транзакции уже старше vacuum_freeze_min_age и помечены как «всегда committed». Это страховка от
    transaction wraparound
    .
Структура .vm файла

Один файл на таблицу, по 2 бита на страницу heap. На 1 GiB heap = 131072 страниц = 256 KiB visibility map. Это очень компактно.

heap page 0all-visible=1, all-frozen=0
heap page 1all-visible=1, all-frozen=1
heap page 2all-visible=0, all-frozen=0
heap page 3all-visible=1, all-frozen=0
......
итого2 бита на каждую 8 KiB страницу heap → отношение 1 : 32768 (очень компактно)

Index Only Scan: главный потребитель VM

Представь индексный запрос:

SELECT COUNT(*) FROM customers WHERE country = 'RU';

С обычным Index Scan:

  1. Спуск в B-tree до leaf-страницы с country = 'RU'.
  2. Для каждой записи в leaf’е — взять ctid, прочитать соответствующий heap tuple, проверить видимость по MVCC.
  3. Посчитать.

Шаг 2 — это random I/O по heap для каждой строки. Если строк миллионы — это очень дорого.

С Index Only Scan:

  1. Спуск в B-tree.
  2. Для каждой записи в leaf’е — проверить в visibility map, не помечена ли соответствующая heap-страница как all-visible.
  3. Если да → данные в индексе считаются актуальными, heap не читаем.
  4. Если нет → fallback на обычный Index Scan: читаем heap-страницу, проверяем видимость.

Когда таблица «зрелая» (VACUUM отработал, все страницы all-visible) — Index Only Scan читает только индекс. Огромная экономия, особенно для count(*) и для covering-индексов.

EXPLAIN Index Only Scan по country. До VACUUM возможен fallback (Heap Fetches > 0). После VACUUM Heap Fetches должен упасть до нуля.

PostgreSQL

В выводе обращай внимание на строку Heap Fetches: N. Это число случаев, когда Index Only Scan не смог обойтись без heap’а и пришлось вернуться к чтению tuple’а. Идеальный план = Heap Fetches: 0.

Сделаем VACUUM (он обновит visibility map) и повторим. Heap Fetches должен снизиться до 0 для статичной таблицы.

PostgreSQL

Как VM обновляется

VM не обновляется при обычных INSERT/UPDATE/DELETE. Эти операции, наоборот, сбрасывают биты:

  • Любой INSERT/UPDATE/DELETE на странице → all-visible и all-frozen биты сбрасываются в 0 (если они были 1).
  • VACUUM сканирует страницы, и если на странице нет dead tuples и все xmin закоммитились → ставит all-visible = 1.
  • VACUUM FREEZE дополнительно проставляет all-frozen = 1, если все xmin старше vacuum_freeze_min_age.

Это означает: на горячей таблице (много write) большая часть страниц не будет all-visible. Index Only Scan там почти бесполезен. Чтобы он работал — нужен либо часто запускаемый autovacuum, либо ручной VACUUM в окнах низкой нагрузки.

Жизненный цикл бита all-visible

Сценарий: страница сначала all-visible (свежий VACUUM), потом UPDATE сбрасывает бит, потом VACUUM возвращает его обратно. Между этими событиями Index Only Scan вынужден читать heap.

t0: VACUUM пройденall-visible = 1
Index Only Scanне читает heap
t1: UPDATE одной строкиall-visible = 0
Index Only Scanfallback на heap
t2: VACUUM повторностраница чиста → all-visible = 1
Index Only Scanснова не читает heap

all-frozen и transaction wraparound

txid в Postgres — 32-битное число. Когда счётчик доходит до 2^31, начинается «обратный отсчёт» (xid arithmetic в 2’s complement). Если кортеж имеет xmin, отличающийся от текущего счётчика более чем на 2 миллиарда — Postgres перестаёт правильно определять его «прошлое vs будущее».

Чтобы это не сломалось, существует VACUUM FREEZE. Он заменяет в xmin старых кортежей специальный маркер FrozenTransactionId (или ставит бит HEAP_XMIN_FROZEN в infomask), который означает: «эта строка committed, всегда видна, забудь о её исходном xmin».

all-frozen бит говорит: вся страница уже frozen — её можно вообще пропустить при VACUUM ... TO PREVENT WRAPAROUND. Это критически важная оптимизация на больших, преимущественно read-only таблицах: на таблице в 100 GiB агрессивный auto-freeze превращает ежедневный VACUUM из «прочитать 100 GiB» в «прочитать только новые блоки».

FSM: рядом с VM

Помимо _vm, у каждой таблицы есть ещё один fork — _fsm (Free Space Map). Он не относится к видимости, но я упомяну его для полноты:

  • FSM хранит для каждой страницы её свободное место (с точностью ~256 байт).
  • INSERT’ам нужно знать, на какую страницу можно положить новый tuple — FSM даёт быстрый ответ «вот страница, где есть N свободных байт».
  • Когда VACUUM удаляет dead tuples, он обновляет FSM (свободного места стало больше).

Если у тебя возникает ощущение, что INSERT-операции стали медленнее на таблице с большим количеством write/delete-нагрузки — возможно, FSM устарел и Postgres плохо находит свободные страницы. Это лечится VACUUM или (в крайнем случае) pg_freespace extension для диагностики.

pg_visibility: посмотреть VM напрямую

В extension pg_visibility есть функции, дающие посмотреть содержимое VM:

CREATE EXTENSION pg_visibility;

SELECT
  count(*) FILTER (WHERE all_visible) AS visible_pages,
  count(*) FILTER (WHERE all_frozen)  AS frozen_pages,
  count(*) AS total_pages
FROM pg_visibility_map('customers');

В pglite этого extension нет, но в production-Postgres — must-have для отладки Index Only Scan и для понимания, когда autovacuum успешно отрабатывает на ваших таблицах.

Если бы pg_visibility был доступен — мы бы посчитали процент all-visible страниц. В pglite сделаем имитацию через pg_stat_user_tables: смотрим, сколько dead tuples в таблице. Если 0 — все страницы потенциально all-visible.

PostgreSQL

Если n_dead_tup высокий и last_vacuum давно — Index Only Scan на этой таблице вероятно не работает оптимально.

Когда Index Only Scan всё равно читает heap

Даже при all-visible = 1, есть случаи, когда heap fetch неизбежен:

  1. Запрос требует данных, которых нет в индексе. Например, индекс на (country), но SELECT email. Index Only Scan невозможен — нужен heap.
  2. TOAST’ed колонки: даже если они в индексе, у TOAST-pointer’а в heap’е может быть нюанс с видимостью.
  3. Свежеинзертированные строки на странице, ещё не помеченные all-visible: до следующего VACUUM Index Only Scan туда упадёт.

В следующем уроке посмотрим оптимизацию, которая позволяет UPDATE’ам не делать ничего с индексами — HOT updates.

Проверка знанийKnowledge check
Таблица size = 1 GiB (131072 страницы). После большого batch UPDATE'а 10% строк по distributed pattern. Какой ожидаемый эффект на Index Only Scan SELECT COUNT(*) и почему?
ОтветAnswer
Сильное замедление. UPDATE на странице сбрасывает all-visible = 0. Если 10% строк распределены равномерно, то это, скорее всего, ~10-50% страниц затронуто (зависит от плотности). Index Only Scan для каждой записи в индексе будет лезть в VM, видеть all-visible = 0 и делать Heap Fetch. Heap Fetches вырастут с 0 до десятков тысяч. После VACUUM (когда все xmax committed транзакций будут признаны и dead tuples удалены) VM восстановится, Heap Fetches вернутся к нулю. Урок: для read-heavy запросов важен регулярный autovacuum, иначе Index Only Scan деградирует.

Чек-лист

  • Visibility Map (_vm) — отдельный fork-файл с 2 битами на каждую heap-страницу: all-visible и all-frozen.
  • Index Only Scan использует VM, чтобы не лезть в heap, когда страница all-visible. Это даёт огромное ускорение count(*) и covering-запросов.
  • VM не обновляется обычными write’ами — только VACUUM ставит биты. Любой INSERT/UPDATE/DELETE сбрасывает биты.
  • all-frozen защищает от transaction id wraparound: позволяет пропускать страницы, чьи xmin уже отмечены как FrozenTransactionId.
  • Если ваш Index Only Scan в EXPLAIN показывает Heap Fetches > 0 — autovacuum не успевает.
  • Рядом с VM есть FSM (_fsm) — карта свободного места, нужная INSERT’ам.
Чтение EXPLAIN: план запроса как дерево операций Metadata и Statistics в Parquet

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Сколько бит на каждую страницу heap хранится в visibility map?

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

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

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

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