Справочник DBA: базы данных изнутри читается по порядку

Статья 4.2 · раздел 4 «Индексы изнутри» · 25 из 109

Index Only Scan и карта видимости: почему индекс ходит в таблицу и как VACUUM это чинит

опубликовано
читать
≈ 8 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18, расширение pg_visibility; опыт выполнен 27 октября 2026
содержание решать задачи SQL на собеседовании

EXPLAIN показывает Index Only Scan, а запрос читает половину таблицы: на 500 000 строк Heap Fetches: 500000, 9 062 буфера вместо 1 370. Индекс хранит ключ и адрес строки, но не xmin и xmax, поэтому сам не может сказать, видна ли строка твоему снимку (устройство индекса — в статье про B-tree, виды индексов и CREATE INDEX — в основе про индексы). Видимость берётся из строки в таблице, если только карта видимости не обещает, что вся страница видна всем. Бит в карту ставит VACUUM (основа про vacuum), а стирает любое изменение страницы; откуда берутся версии строк, разобрано в статье про MVCC.

Карта видимости: два бита на страницу

У каждой таблицы есть отдельный файл-форк _vm. По комментарию в visibilitymap.c (строки 25–36) это битовая карта с двумя битами на страницу таблицы: all-visible («все строки страницы видны всем транзакциям») и all-frozen. Карта консервативна: если бит стоит, условие точно выполнено, если не стоит, может быть и выполнено, просто это неизвестно.

Index Only Scan спрашивает карту для каждого найденного в индексе адреса. Вот nodeIndexonlyscan.c, строки 162–170 дословно:

		if (!VM_ALL_VISIBLE(scandesc->heapRelation,
							ItemPointerGetBlockNumber(tid),
							&node->ioss_VMBuffer))
		{
			/*
			 * Rats, we have to visit the heap to check visibility.
			 */
			InstrCountTuples2(node, 1);
			if (!index_fetch_heap(scandesc, node->ioss_TableSlot))

Бита нет — счётчик ntuples2 растёт на единицу и идёт index_fetch_heap. Этот же счётчик EXPLAIN печатает как Heap Fetches (explain.c, строка 1983, ветка T_IndexOnlyScan). Весь смысл узла «только индекс» держится на доле страниц с битом.

Опыт: до и после VACUUM

Таблица в миллион строк, индекс по k, автовакуум выключен, чтобы он не поставил биты раньше тебя:

docker run -d --name dba-index-only-scan-visibility-map -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-index-only-scan-visibility-map psql -U postgres
CREATE EXTENSION pg_visibility;
CREATE TABLE t (id int, k int, pad text) WITH (autovacuum_enabled = false);
INSERT INTO t SELECT g, g, repeat('x', 80) FROM generate_series(1, 1000000) g;
CREATE INDEX t_k ON t (k);
ANALYZE t;
CHECKPOINT;
\set Q 'EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT k FROM t WHERE k BETWEEN 1 AND 500000'

SELECT pg_relation_size('t') / 8192 AS heap_pages, pg_relation_size('t_k') / 8192 AS index_pages;
SELECT * FROM pg_visibility_map_summary('t');
:Q;
VACUUM t;
SELECT * FROM pg_visibility_map_summary('t');
:Q;

Вывод (сокращён: строки Planning и заголовки таблиц опущены; version() вернул PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2)):

 heap_pages | index_pages 
------------+-------------
      15385 |        2745

 all_visible | all_frozen 
-------------+------------
           0 |          0

 Index Only Scan using t_k on t (actual rows=500000.00 loops=1)
   Index Cond: ((k >= 1) AND (k <= 500000))
   Heap Fetches: 500000
   Index Searches: 1
   Buffers: shared hit=7696 read=1366

 all_visible | all_frozen 
-------------+------------
       15385 |          0

 Index Only Scan using t_k on t (actual rows=500000.00 loops=1)
   Index Cond: ((k >= 1) AND (k <= 500000))
   Heap Fetches: 0
   Index Searches: 1
   Buffers: shared hit=316 read=1054 written=322

Свежезагруженная таблица не имеет ни одного бита: all_visible = 0 из 15 385 страниц, и все 500 000 найденных в индексе строк проверены в таблице. Повторный запуск того же EXPLAIN без VACUUM снова дал Heap Fetches: 500000: чтение карту не заполняет. После VACUUM биты стоят на всех 15 385 страницах, Heap Fetches: 0, а буферов 316 + 1 054 = 1 370 против 7 696 + 1 366 = 9 062. Разница 7 692 буфера — ровно половина страниц таблицы (15 385 / 2), на которых лежат первые 500 000 строк: по 65 строк на страницу, подряд идущие строки делят страницу, поэтому буферов на 500 000 проверок видимости не 500 000, а 7 692. Остальные 1 370 — это чтение половины индекса из 2 745 страниц.

Планировщик видит те же биты. VACUUM записывает число страниц с битом в pg_class.relallvisible, а cost_index в costsize.c (строки 746–747) умножает число посещаемых страниц таблицы на 1.0 - allvisfrac. Проверка на втором экземпляре таблицы:

CREATE TABLE t2 (id int, k int, pad text) WITH (autovacuum_enabled = false);
INSERT INTO t2 SELECT g, g, repeat('x', 80) FROM generate_series(1, 1000000) g;
CREATE INDEX t2_k ON t2 (k);
ANALYZE t2;
SELECT relpages, relallvisible FROM pg_class WHERE relname = 't2';
EXPLAIN SELECT k FROM t2 WHERE k BETWEEN 1 AND 500000;
VACUUM t2;
SELECT relpages, relallvisible FROM pg_class WHERE relname = 't2';
EXPLAIN SELECT k FROM t2 WHERE k BETWEEN 1 AND 500000;
 relpages | relallvisible 
----------+---------------
    15385 |             0

 Index Only Scan using t2_k on t2  (cost=0.42..23025.88 rows=496523 width=4)

 relpages | relallvisible 
----------+---------------
    15385 |         15385

 Index Only Scan using t2_k on t2  (cost=0.42..15382.88 rows=496523 width=4)

План не поменялся, поменялась цена: 23 025.88 → 15 382.88. Планировщик узнаёт о битах только из relallvisible, поэтому Index Only Scan в плане сам по себе ничего не обещает: до VACUUM он стоил дороже, потому что relallvisible = 0.

DBA / 01

Index Only Scan: бит all-visible решает, ходить ли в таблицу

Как Index Only Scan решает, заходить ли в таблицуДля каждого адреса из индекса t_k узел спрашивает карту видимости, стоит ли бит all-visible у страницы таблицы. Если бит стоит, ключ возвращается из индекса. Если нет, выполняется заход в таблицу и растёт Heap Fetches. VACUUM ставит бит, UPDATE его стирает. До VACUUM бит не стоял ни у одной из 15 385 страниц: 500 000 заходов и 9 062 буфера. После VACUUM бит у всех 15 385 страниц: 0 заходов и 1 370 буферов. После обновления 1 % строк вразброс бит остался у 5 385 страниц: 334 289 заходов.индекс t_k: k BETWEEN 1 AND 500000VM_ALL_VISIBLE(страница)?UPDATE: бит → 0VACUUM: бит → 1ключ из индекса, таблицу не читаемindex_fetch_heap: Heap Fetches +1бит 1бит 0после VACUUM: бит у 15 385 страницHeap Fetches: 0 · 1 370 буферовдо VACUUM: бит у 0 страниц, Heap Fetches 500 0001 % UPDATE вразброс: бит у 5 385 страницHeap Fetches: 334 289
Для каждого адреса из индекса узел спрашивает бит страницы: 1 — строка отдаётся из индекса, 0 — заход в таблицу. Бит ставит VACUUM, стирает любой UPDATE страницы; доля страниц с битом, а не имя узла в плане, определяет цену запроса.

Опыт: UPDATE стирает биты

Любое изменение страницы стирает её бит. В heap_update (heapam.c, строки 4268–4276):

	/*
	 * Clear PD_ALL_VISIBLE flags and reset all visibilitymap bits. In all
	 * cases, it's possible that PD_ALL_VISIBLE was set but the corresponding
	 * visibility map bits were already clear.
	 */
	if (clear_all_visible)
	{
		if (visibilitymap_clear_locked(relation, block,
									   vmbuffer, VISIBILITYMAP_VALID_BITS))

Сначала обновим 6 500 подряд идущих строк (около 100 страниц), потом VACUUM, потом 10 000 строк вразброс (каждую сотую) и снова VACUUM:

UPDATE t SET pad = pad WHERE id <= 6500;
SELECT * FROM pg_visibility_map_summary('t');
:Q;
VACUUM t;
UPDATE t SET pad = pad WHERE id % 100 = 0;
SELECT * FROM pg_visibility_map_summary('t');
:Q;
VACUUM t;
SELECT * FROM pg_visibility_map_summary('t');
:Q;
SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 't';
 all_visible | all_frozen 
-------------+------------
       15284 |          0
   Heap Fetches: 13000
   Buffers: shared hit=14388

 all_visible | all_frozen 
-------------+------------
        5385 |          0
   Heap Fetches: 334289
   Buffers: shared hit=19594 read=2386 dirtied=2386 written=2386

 all_visible | all_frozen 
-------------+------------
       15639 |        100
   Heap Fetches: 0
   Buffers: shared hit=1388

 n_tup_upd | n_tup_hot_upd 
-----------+---------------
     16500 |             0

(Остальные строки плана те же, что выше: Index Only Scan, rows=500000.00.) 6 500 строк подряд стёрли 101 бит из 15 385 (15 385 − 15 284) и дали 13 000 заходов в таблицу — по два на строку, потому что n_tup_hot_upd = 0: страницы заполнены под завязку, HOT невозможен, и у каждой обновлённой строки в индексе есть старая запись и новая, обе на страницах без бита. Те же 1 % строк (10 000 из миллиона), но вразброс, убрали бит у 10 000 страниц, около 65 % таблицы, потому что на странице 65 строк и среди них не больше одной с id, кратным 100. Запрос по половине таблицы дал 334 289 заходов, и запрос ещё и пометил 2 386 страниц грязными. Второй VACUUM вернул Heap Fetches: 0 и 1 388 буферов (против 21 980 на предыдущем шаге: 19 594 + 2 386).

INCLUDE: чтобы индекса хватало

Бит решает, ходить ли в таблицу, а запрос с SELECT k, id вообще не годится для индекса по k: id в нём нет. INCLUDE кладёт столбец в листья индекса, не делая его ключом:

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT k, id FROM t WHERE k BETWEEN 1 AND 50000;
CREATE INDEX t_k_incl ON t (k) INCLUDE (id);
DROP INDEX t_k;
ANALYZE t;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT k, id FROM t WHERE k BETWEEN 1 AND 50000;
SELECT relname, pg_relation_size(oid) / 8192 AS pages FROM pg_class WHERE relname = 't_k_incl';
 Index Scan using t_k on t (actual rows=50000.00 loops=1)
   Index Searches: 1
   Buffers: shared hit=1653 read=235

 Index Only Scan using t_k_incl on t (actual rows=50000.00 loops=1)
   Heap Fetches: 0
   Index Searches: 1
   Buffers: shared hit=4 read=136

 relname  | pages 
----------+-------
 t_k_incl |  2745

Без INCLUDE — обычный Index Scan, 1 888 буферов. С ним — Index Only Scan, Heap Fetches: 0 и 140 буферов. Индекс занял те же 2 745 страниц, что и t_k в начале опыта: лишний int места не добавил: запись индекса выравнивается до 8 байт, и 8 байт заголовка + 4 байта ключа занимают те же 16 байт, что 8 + 4 + 4. Для столбцов другого размера это не проверялось, размер индекса мерь сам.

InnoDB: видимость по странице индекса, а не по карте

В InnoDB карты видимости нет. По комментарию к lock_sec_rec_cons_read_sees, страница вторичного индекса хранит о своих изменениях так мало, что о версии записи судят по одному числу на страницу: максимальному номеру менявшей её транзакции (PAGE_MAX_TRX_ID). Вот конец lock_sec_rec_cons_read_sees (lock0lock.cc, строки 296–300):

  trx_id_t max_trx_id = page_get_max_trx_id(page_align(rec));

  ut_ad(max_trx_id > 0);

  return (view->sees(max_trx_id));

Функция отвечает true, если снимок видит все изменения страницы; комментарий над ней говорит прямо: при false нужная версия «может понадобиться из кластерного индекса». В row0sel.cc (строки 5352–5375) при false запрос идёт в кластерный индекс за версией через undo, если только условие по самому индексу не отсеяло запись. Решение принимается, как и в PostgreSQL, на страницу, но на разную: PostgreSQL проверяет страницу таблицы по карте, которую заполняет VACUUM, а InnoDB — страницу самого индекса по номеру транзакции и снимку читателя (undo устроен в статье про MVCC в InnoDB). Поэтому «покрывающий индекс в InnoDB работает всегда» неточно: он работает, пока снимок видит все изменения листа. В PostgreSQL доля таких страниц зависит от того, успел ли VACUUM.

Что из этого следует

Heap Fetches — главный показатель Index Only Scan. Узел с Heap Fetches порядка числа строк — обычный Index Scan с лишней проверкой. Читай EXPLAIN (ANALYZE, BUFFERS), а не имя узла.

Доля страниц с битом — свойство таблицы, не запроса. Один UPDATE 1 % строк вразброс убрал бит у 65 % страниц. Следи за pg_visibility_map_summary или за relallvisible / relpages из pg_class на таблицах, ради которых создавались покрывающие индексы. Биты возвращает VACUUM: в опыте после него доля вернулась к 15 639 из 15 639 страниц; как часто он запускается автоматически, разобрано в основе про vacuum.

INCLUDE нужен, когда запросу не хватает столбца. Он превращает Index Scan в Index Only Scan (1 888 → 140 буферов), но решает вопрос только состава столбцов, не видимости: без бита заходы в таблицу остаются.

Запрос сразу после загрузки читает всю таблицу. После массовой загрузки или обновления запусти VACUUM, прежде чем мерить покрывающий индекс: в опыте без него 500 000 заходов в таблицу. Что ещё читающий запрос пишет в страницы, разобрано в статье про pg_xact.

Чек-лист

  1. Для запроса с покрывающим индексом смотри Heap Fetches в EXPLAIN (ANALYZE, BUFFERS): цифра должна быть близка к нулю, иначе узел «только индекс» не помогает.
  2. Долю страниц с битом измеряй: SELECT * FROM pg_visibility_map_summary('имя_таблицы') с расширением pg_visibility, или relallvisible и relpages из pg_class.
  3. После массовых UPDATE и DELETE проверь, что VACUUM отработал, прежде чем судить о покрывающем индексе.
  4. Если запросу не хватает одного-двух столбцов, добавь их в индекс через INCLUDE, но измерь размер индекса до и после.

Контейнер удаляется командой docker rm -f -v dba-index-only-scan-visibility-map.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru