Мы выяснили, что для определения «виден ли мне tuple» нужно: прочитать сам tuple, посмотреть xmin/xmax, заглянуть в pg_xact (или hint bit в infomask). Это операции на уровне одного кортежа.
Но что, если страница гарантированно содержит только видимые всем кортежи? Тогда для Index Only Scan можно вообще не читать heap — достаточно того, что попало в индекс. Эту оптимизацию реализует visibility map (VM).
Файл .vm
У каждой heap-таблицы рядом с основным файлом лежит fork-файл с расширением _vm (в pg_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
Один файл на таблицу, по 2 бита на страницу heap. На 1 GiB heap = 131072 страниц = 256 KiB visibility map. Это очень компактно.
Index Only Scan: главный потребитель VM
Представь индексный запрос:
SELECT COUNT(*) FROM customers WHERE country = 'RU';
С обычным Index Scan:
- Спуск в B-tree до leaf-страницы с
country = 'RU'. - Для каждой записи в leaf’е — взять
ctid, прочитать соответствующий heap tuple, проверить видимость по MVCC. - Посчитать.
Шаг 2 — это random I/O по heap для каждой строки. Если строк миллионы — это очень дорого.
С Index Only Scan:
- Спуск в B-tree.
- Для каждой записи в leaf’е — проверить в visibility map, не помечена ли соответствующая heap-страница как
all-visible. - Если да → данные в индексе считаются актуальными, heap не читаем.
- Если нет → 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 должен упасть до нуля.
В выводе обращай внимание на строку Heap Fetches: N. Это число случаев, когда Index Only Scan не смог обойтись без heap’а и пришлось вернуться к чтению tuple’а. Идеальный план = Heap Fetches: 0.
Сделаем VACUUM (он обновит visibility map) и повторим. Heap Fetches должен снизиться до 0 для статичной таблицы.
Как 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 (свежий VACUUM), потом UPDATE сбрасывает бит, потом VACUUM возвращает его обратно. Между этими событиями 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.
Если n_dead_tup высокий и last_vacuum давно — Index Only Scan на этой таблице вероятно не работает оптимально.
Когда Index Only Scan всё равно читает heap
Даже при all-visible = 1, есть случаи, когда heap fetch неизбежен:
- Запрос требует данных, которых нет в индексе. Например, индекс на
(country), ноSELECT email. Index Only Scan невозможен — нужен heap. - TOAST’ed колонки: даже если они в индексе, у TOAST-pointer’а в heap’е может быть нюанс с видимостью.
- Свежеинзертированные строки на странице, ещё не помеченные all-visible: до следующего VACUUM Index Only Scan туда упадёт.
В следующем уроке посмотрим оптимизацию, которая позволяет UPDATE’ам не делать ничего с индексами — HOT updates.
Чек-лист
- 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’ам.