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.
Index Only Scan: бит all-visible решает, ходить ли в таблицу
Опыт: 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.
Чек-лист
- Для запроса с покрывающим индексом смотри
Heap FetchesвEXPLAIN (ANALYZE, BUFFERS): цифра должна быть близка к нулю, иначе узел «только индекс» не помогает. - Долю страниц с битом измеряй:
SELECT * FROM pg_visibility_map_summary('имя_таблицы')с расширениемpg_visibility, илиrelallvisibleиrelpagesизpg_class. - После массовых
UPDATEиDELETEпроверь, чтоVACUUMотработал, прежде чем судить о покрывающем индексе. - Если запросу не хватает одного-двух столбцов, добавь их в индекс через
INCLUDE, но измерь размер индекса до и после.
Контейнер удаляется командой docker rm -f -v dba-index-only-scan-visibility-map.