Skip scan в PostgreSQL 18: как читать Index Searches и не принять полный проход за оптимизацию
Вы обновились до PostgreSQL 18, увидели skip scan в примечаниях к выпуску, открыли EXPLAIN — а слова «skip» в плане нет. Это нормально: в PostgreSQL 18 оптимизация выполняется внутри обычного индексного узла и отдельным названием не помечается. Я покажу, как сопоставить определение индекса, Index Cond, фактический счётчик Index Searches и Buffers, почему число поисков не обязано совпадать с числом значений ведущей колонки и какие выводы об индексах из такого плана действительно можно делать. Заодно исправлю несколько опасных упрощений: высокий idx_scan может быть следствием внутренних повторных поисков, REINDEX не меняет порядок колонок, а одна строка Buffers сама по себе не доказывает использование skip scan.
Отдельного узла Skip Scan в PostgreSQL 18 нет
Ко мне регулярно приходят с одним и тем же вопросом: «Обновились до PostgreSQL 18, но в плане нет Skip Scan — функция не работает?». Обычно к этому моменту разработчик уже успел предложить ещё один индекс по хвостовой колонке. Я начинаю разбор не с создания индекса, а с определения существующего индекса и фактического плана: искать отдельный узел в PostgreSQL 18 бесполезно.
Skip scan — внутренняя оптимизация сканирования многоколоночного B-tree. Если, например, есть индекс по (region_id, created_at), а запрос ограничивает только created_at, исполнитель может многократно перепозиционироваться в индексе, подставляя встречающиеся значения пропущенной ведущей колонки. Документация описывает это как динамически формируемое ограничение равенства. Планировщик выбирает такой путь только тогда, когда считает повторные поиски дешевле доступных альтернатив.
Снаружи узел по-прежнему называется Index Scan, Index Only Scan или Bitmap Index Scan. В текстовом, JSON-, XML- и YAML-форматах PostgreSQL 18 нет отдельного типа узла Skip Scan и нет булевого поля, которое прямо подтверждало бы эту оптимизацию. Поэтому вывод всегда является результатом сопоставления нескольких признаков, а не чтения одной волшебной строки.
В примечаниях к выпуску PostgreSQL 18 перечислены три связанные перемены. Первая — поддержка skip scan для B-tree. Вторая — строка Index Searches в фактических планах индексных узлов. Третья — автоматическое включение статистики BUFFERS для EXPLAIN ANALYZE. Именно две последние перемены сделали внутреннюю работу индекса заметнее, хотя отдельного имени для skip scan не появилось.
Отдельного параметра enable_indexskipscan в PostgreSQL 18 нет. В документированном наборе настроек планировщика присутствуют enable_indexscan, enable_indexonlyscan и enable_bitmapscan, но ни одна из них не отключает только skip scan, сохраняя остальные варианты работы того же индексного узла. Поэтому честного эксперимента «тот же план, но без skip scan» штатными настройками провести нельзя.
- Skip scan применяется только к многоколоночным B-tree-индексам.
- Отдельного типа узла Skip Scan в PostgreSQL 18 нет.
- Основные признаки — определение индекса, `Index Cond`, `Index Searches` и объём работы по `Buffers`.
- Параметр `enable_indexskipscan` не существует.
Что именно показывает Index Searches
Index Searches — фактическое суммарное число отдельных поисков, выполненных индексным узлом во всех его запусках. Документация PostgreSQL 18 выводит эту строку для Index Scan, Index Only Scan и Bitmap Index Scan. Для получения счётчика требуется ANALYZE, потому что обычный EXPLAIN не выполняет запрос и не располагает фактическими данными исполнительного узла.
Полезно говорить не просто о «спусках от корня», а об отдельных поисках и перепозиционированиях. В примере с разнесёнными значениями массива каждый поиск действительно начинается от корневой страницы. При skip scan новый поиск выполняется, когда сканирование переставляется к следующей области листовых страниц, где ещё могут находиться подходящие записи. Это точнее, чем считать строку универсальным счётчиком прочитанных ветвей дерева.
Эталонный пример из официальной главы Using EXPLAIN выглядит так:
EXPLAIN ANALYZE
SELECT four, unique1
FROM tenk1
WHERE four BETWEEN 1 AND 3
AND unique1 = 42;Index Only Scan using tenk1_four_unique1_idx on tenk1
(cost=0.29..6.90 rows=1 width=8)
(actual time=0.006..0.007 rows=1.00 loops=1)
Index Cond: ((four >= 1) AND (four <= 3) AND (unique1 = 42))
Heap Fetches: 0
Index Searches: 3
Buffers: shared hit=7Индекс в документационном примере построен по (four, unique1). Исполнитель выполнил три поиска, соответствующие допустимым значениям four от 1 до 3, и во время каждого использовал равенство по unique1. Семь обращений к общим буферам при трёх поисках показывают небольшой объём работы, но сами по себе не являются универсальным нормативом: высота дерева, кэш и распределение строк у другой базы будут иными.
Главная ловушка состоит в том, что несколько поисков возникают не только при skip scan. Официальный пример WHERE thousand IN (1, 500, 700, 999) показывает Index Searches: 4, потому что значения разнесены по индексу. Вариант WHERE thousand IN (1, 2, 3, 4) показывает один поиск: подходящие записи находятся рядом на одной листовой странице. Оба плана демонстрируют обработку массива, а не пропущенную ведущую колонку.
Поэтому условие «Index Searches больше единицы» недостаточно. Я сначала смотрю точное определение индекса, затем проверяю, какие его ведущие колонки отсутствуют в Index Cond, и отдельно исключаю IN, ANY и преобразованные планировщиком цепочки OR. Если хвостовая колонка ограничена, ведущая не ограничена, а узел выполняет несколько поисков по небольшим участкам B-tree, вывод о skip scan становится обоснованным. Но отдельного стопроцентного флага в плане всё равно нет.
- Строка появляется в фактическом плане, полученном с `ANALYZE`.
- Значение суммируется по всем выполнениям индексного узла.
- Несколько значений `IN` или `ANY` тоже могут увеличить число поисков.
- Один поиск для соседних значений массива не означает полный проход индекса.
- Skip scan распознаётся по совокупности признаков, а не по одному счётчику.
Практика: торговая компания «Северный склад», 40 РМ и два склада
Условный клиент в этом разборе — торговая компания «Северный склад», 40 РМ и два склада. Учётная система работает с PostgreSQL на виртуальной машине в ЦОД МТС. Внутри двух физических складов используются девять учётных зон, поэтому в warehouse_id фактически встречается девять значений. Это важное уточнение: число физических объектов и кардинальность ведущей колонки индекса не обязаны совпадать.
Базу обновили с PostgreSQL 17 до PostgreSQL 18.6. Версия 18.6 действительно выпущена 13 августа 2026 года и на дату подготовки материала является текущим минорным выпуском ветки 18. Таблица orders содержит 42 млн строк и занимает около 190 ГБ вместе с индексами. Нужный составной индекс покрывает выводимый номер заказа с помощью INCLUDE:
CREATE INDEX orders_wh_created_idx
ON public.orders (warehouse_id, created_at)
INCLUDE (order_no);Проблемным был отчёт по всем заказам за период без ограничения конкретной складской зоны. На PostgreSQL 17 один из наблюдавшихся планов выполнял последовательное чтение примерно за 2,4 секунды. После перехода на PostgreSQL 18 план изменился без переработки запроса и схемы. Для столбца created_at типа timestamptz запрос фиксировали с явными типизированными границами, чтобы результат не зависел от неявного разбора строковых литералов и часового пояса сессии:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT warehouse_id, created_at, order_no
FROM public.orders
WHERE created_at >= TIMESTAMPTZ '2026-08-01 00:00:00+03'
AND created_at < TIMESTAMPTZ '2026-08-08 00:00:00+03';Index Only Scan using orders_wh_created_idx on public.orders
(actual time=0.041..17.900 rows=31842.00 loops=1)
Output: warehouse_id, created_at, order_no
Index Cond: ((orders.created_at >= '2026-08-01 00:00:00+03'::timestamp with time zone)
AND (orders.created_at < '2026-08-08 00:00:00+03'::timestamp with time zone))
Heap Fetches: 0
Index Searches: 11
Buffers: shared hit=402 read=87План показывает важную комбинацию: ведущая колонка warehouse_id отсутствует в условии, ограничение по второй колонке находится в Index Cond, а индексный узел выполнил 11 поисков и затронул 489 общих буферов. При этом Heap Fetches: 0 означает, что проверять видимость строк в основной таблице не пришлось. Для этого конкретного выполнения совокупность признаков согласуется с полезным skip scan.
Сравнение 2,4 секунды и примерно 18 миллисекунд нельзя выдавать за лабораторное доказательство ускорения ровно в 133 раза: состояние кэша, параллельность, параметры сервера и фоновые операции влияют на время. В кейсе это были наблюдавшиеся значения до и после перехода. Для воспроизводимого теста я несколько раз прогоняю оба варианта на одинаковом наборе данных, сохраняю полный план и отдельно сравниваю буферы, а не только Execution Time.
Перед интерпретацией плана я проверил статистику ведущих колонок:
SELECT attname, n_distinct, null_frac
FROM pg_stats
WHERE schemaname = 'public'
AND tablename = 'orders'
AND attname IN ('warehouse_id', 'client_id');Для warehouse_id оценка n_distinct была равна 9, для client_id расчётная кардинальность составляла около 380 тыс. Здесь важно помнить формат pg_stats: положительное n_distinct означает оценённое количество различных значений, а отрицательное хранит отрицательную долю от числа строк. Поэтому отрицательное значение нельзя читать как буквальное количество — его нужно умножить по модулю на оценку числа строк.
Второй индекс, orders_client_status_idx (client_id, status), на запросе только по status планировщик не выбрал. При примерно 380 тыс. вариантов ведущей колонки повторные поиски оказались слишком дорогими, и последовательное чтение было разумной альтернативой. Из этого разработчик сделал неверный вывод, что индекс больше не нужен, и удалил его. Через сутки запросы карточки клиента с условиями по client_id и status начали читать 42 млн строк; восстановление через CREATE INDEX CONCURRENTLY заняло около 40 минут.
Этот эпизод для меня важнее красивого ускорения отчёта. Один план отвечает только на вопрос о конкретном запросе при конкретных параметрах и статистике. Он ничего не говорит обо всех остальных запросах, ограничениях уникальности и зависимостях приложения. Отсутствие skip scan не делает индекс бесполезным, а появление skip scan не превращает всякий отдельный индекс по хвостовой колонке в дубликат.
- Два физических склада представлены девятью значениями учётных зон в `warehouse_id`.
- Покрывающий индекс обязан содержать `order_no` через ключевую колонку или `INCLUDE`, иначе показанный `Index Only Scan` невозможен.
- Значение `pg_stats.n_distinct` может быть отрицательной долей, а не готовым количеством.
- Кейс сохранил измерения: 42 млн строк, 190 ГБ, 31 842 результата, 11 поисков и 489 буферов.
- Индекс нельзя удалять по одному запросу, который его не использует.
Почему число поисков может быть больше числа значений
В кейсе девять значений warehouse_id, но Index Searches: 11. Такое соотношение возможно, однако превращать его в формулу «число различных значений плюс два» нельзя. Количество поисков зависит от границ запроса, расположения значений на листовых страницах, порядка NULLS FIRST или NULLS LAST, направления прохода и того, удалось ли продолжить работу на уже открытой странице.
Хорошо задокументированный частный случай обсуждался в официальной рассылке pgsql-performance в ноябре 2025 года. Для индекса по (boolean_field, integer_field) и запроса только по integer_field ведущая булева колонка имела два реальных значения, а план показал четыре поиска. При пяти реальных значениях автор теста получил семь. Ответ дал Питер Гейган, автор реализации skip scan.
Во внутреннем массиве пропуска присутствовал сентинел SK_BT_MINVAL, обозначающий нижнюю границу возможных значений. Для булева типа эта граница практически совпадает с false, поэтому дополнительный доступ к самой левой листовой странице особенно заметен. Другим элементом был SK_ISNULL: исполнитель не полагался только на декларативное NOT NULL и учитывал область значений NULL, если сам предикат не исключал её.
Даже в этом примере ответ автора сформулирован осторожно: поиск для SK_ISNULL может не стать отдельным дополнительным доступом, если нужная крайняя страница всё равно была прочитана. Поэтому совпадение с «кардинальность плюс два» — полезное объяснение конкретного плана, но не контракт PostgreSQL и не диагностический порог. Если число другое, это ещё не означает устаревшую статистику или ошибку.
В тесте из рассылки явное перечисление допустимых булевых значений сократило число поисков до двух:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT boolean_field
FROM example
WHERE integer_field = 5432
AND boolean_field IN (true, false);После такого изменения ведущая колонка уже явно ограничена массивом, поэтому механизм плана нельзя описывать так же, как исходный skip scan с пропущенной колонкой. Переписывать прикладной запрос только ради красивого счётчика я не советую: два лишних поиска по небольшому B-tree обычно дешевле усложнения кода и риска изменить семантику. Условие IS NOT NULL имеет смысл добавлять лишь тогда, когда оно логически верно и подтверждено ограничениями предметной области.
Если Index Searches неожиданно велик, я проверяю не одну предполагаемую формулу, а четыре вещи: число loops, массивы и цепочки OR в условии, актуальность статистики после ANALYZE и фактическую кардинальность данных. Затем сопоставляю число затронутых буферов с размером индекса. Такой порядок быстрее приводит к причине, чем попытка вывести точное количество поисков из одного n_distinct.
- Соотношение «различные значения плюс два» описывает известный частный случай, а не общее правило.
- `SK_BT_MINVAL` обозначает внутреннюю нижнюю границу массива пропуска.
- `SK_ISNULL` связан с областью значений `NULL`, но не всегда создаёт отдельное дополнительное чтение.
- Явный `IN (true, false)` в тесте дал два поиска, но изменил способ задания ведущей колонки.
- Не переписывайте рабочий запрос только ради уменьшения `Index Searches`.
Как сопоставлять loops, Buffers и размер индекса
Первая ловушка — забыть, что Index Searches суммируется по всем выполнениям узла. Если индексный узел находится внутри Nested Loop и показывает loops=1200, значение 13 200 означает в среднем 11 поисков на один запуск. Деление даёт именно среднее: параметры внешнего узла могут различаться, поэтому отдельные запуски необязательно выполняли одинаковое количество поисков.
Вторая ловушка — считать Buffers точным счётчиком страниц самого индекса. Статистика у узла показывает обращения к буферам, но не раскладывает их по объектам. У обычного Index Scan туда попадает и работа с таблицей, у Index Only Scan возможны обращения к карте видимости и к таблице при ненулевом Heap Fetches. Кроме того, shared hit означает попадание в буферный кэш PostgreSQL, а shared read — загрузку в него; это не прямое измерение физического дискового ввода с учётом кэша операционной системы.
Тем не менее сравнение с размером индекса остаётся хорошей проверкой порядка величин. Размер блока нельзя жёстко считать равным 8192 байтам: это типичное значение, но PostgreSQL может быть собран с другим BLCKSZ. Я запрашиваю активный block_size у сервера:
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
pg_relation_size(indexrelid)
/ current_setting('block_size')::bigint AS main_fork_blocks
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
AND relname = 'orders'
AND indexrelname = 'orders_wh_created_idx';В проверяемом кейсе основной fork индекса занимал около 405 тыс. блоков, а узел с Heap Fetches: 0 затронул 489 общих буферов. Это не математическое доказательство конкретного внутреннего алгоритма, но разница почти на три порядка согласуется с чтением небольших участков индекса. Сочетание отсутствующей ведущей колонки, 11 поисков и такого объёма буферов делает вывод о полезном skip scan убедительным.
Обратная картина — один поиск и число буферов, близкое к размеру индекса, — указывает на широкий проход, но тоже требует осторожности. Буферы могут включать таблицу, повторно учитываться по loops, а часть страниц может уже находиться в кэше. Я формулирую вывод так: узел выполнил объём работы порядка размера индекса, поэтому оптимизация не дала ожидаемого пропуска большей части листьев. Называть это доказанным «полным чтением каждого блока индекса» по одному EXPLAIN нельзя.
Третья ловушка — высокое Heap Fetches у Index Only Scan. Такой узел имеет право обращаться к таблице, если карта видимости не подтверждает, что нужная heap-страница целиком видима текущему снимку. Причиной могут быть свежие изменения данных или недостаточная работа VACUUM; высокий счётчик не доказывает неисправность autovacuum. Я проверяю интенсивность записи, состояние vacuum и долю all-visible страниц, а не назначаю VACUUM наугад.
Для сравнения с планом без обычного индексного доступа можно временно отключить три семейства индексных путей только в тестовой транзакции:
BEGIN;
SET LOCAL enable_indexscan = off;
SET LOCAL enable_indexonlyscan = off;
SET LOCAL enable_bitmapscan = off;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT warehouse_id, created_at, order_no
FROM public.orders
WHERE created_at >= TIMESTAMPTZ '2026-08-01 00:00:00+03'
AND created_at < TIMESTAMPTZ '2026-08-08 00:00:00+03';
ROLLBACK;Такой тест показывает лучшую найденную альтернативу при отключённых индексных типах сканирования, но не изолирует только skip scan. Он также выполняет запрос из-за ANALYZE, поэтому на изменяющих данные командах нужен особенно осторожный транзакционный тест, а на тяжёлом SELECT — оценка допустимой нагрузки. На рабочем сервере я сначала снимаю обычный EXPLAIN без ANALYZE либо воспроизвожу запрос на реплике.
- Делите `Index Searches` на `loops` только как среднее значение.
- `Buffers` у узла не разделяет обращения к индексу, таблице и карте видимости по объектам.
- Размер блока получайте через `current_setting('block_size')`, а не фиксируйте как 8192.
- Мало буферов относительно размера индекса — сильный косвенный признак эффективного пропуска.
- Для альтернативы отключайте также `enable_bitmapscan`; двух параметров недостаточно.
Какие решения по индексам после диагностики безопасны
Первое правило у меня простое: существующий индекс не удаляется после просмотра одного плана. Я собираю статистику хотя бы за две репрезентативные недели, включая закрытие периода, обмены, регламентные задания и редкие отчёты. Это мой эксплуатационный минимум, а не срок из документации PostgreSQL. Перед выводами обязательно проверяю, не сбрасывалась ли статистика и не происходило ли переключение на другой сервер.
В PostgreSQL 18 значение pg_stat_all_indexes.idx_scan нельзя трактовать как количество SQL-запросов или запусков узла. Официальная документация прямо уточняет: каждый отдельный индексный поиск увеличивает idx_scan. Skip scan, массивы и некоторые преобразованные цепочки OR способны дать несколько приращений за одно выполнение запроса. Это исправляет опасное заблуждение, будто внутренние повторные поиски в статистике индексов вообще не видны.
Для первичной проверки я использую пользовательское представление и одновременно смотрю время последнего обращения:
SELECT schemaname,
relname,
indexrelname,
idx_scan,
last_idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
AND relname = 'orders'
ORDER BY idx_scan DESC;Даже нулевой idx_scan за наблюдаемый период не является разрешением на немедленное удаление. Индекс может обеспечивать PRIMARY KEY, UNIQUE или другой механизм целостности, использоваться редкой процедурой либо понадобиться при аварийном сценарии. Я дополнительно проверяю определение через pg_indexes, ограничения, запросы приложения и планы на наборе характерных параметров.
Второе правило — у skip scan нет универсального порога кардинальности. Документация объясняет, что малое число значений ведущей колонки повышает вероятность выгоды, а в главе о комбинировании индексов приводит ориентир вплоть до нескольких сотен значений для некоторых поисков. Это не обещание и не настройка: решение зависит от размера таблицы, селективности хвостового условия, корреляции, стоимости случайного доступа и ожидаемого числа возвращаемых строк.
Третье правило касается порядка колонок. Советы вида «самую селективную колонку всегда ставить первой» слишком грубы для многоколоночного B-tree. Важнее реальные шаблоны равенств, диапазонов, сортировки и возможности покрыть запрос. Skip scan снижает ущерб от отсутствующего равенства по ранней колонке, но не отменяет физический порядок ключей и не гарантирует, что один индекс одинаково хорошо обслужит все запросы.
Отдельный индекс по created_at при наличии (warehouse_id, created_at) становится кандидатом на проверку, но не автоматически лишним. Он может быть меньше, дешевле для широкого диапазона, поддерживать нужный ORDER BY без повторных групп и выигрывать при большой кардинальности warehouse_id. Я сравниваю планы, размер, стоимость записи и статистику использования обоих индексов на полном наборе запросов.
Наконец, REINDEX CONCURRENTLY не меняет порядок колонок и не добавляет INCLUDE: он перестраивает индекс с прежним определением. Для изменения структуры создают новый индекс, проверяют его под нагрузкой и лишь затем удаляют старый. На большой таблице даже CREATE INDEX CONCURRENTLY расходует ввод-вывод и CPU, выполняет дополнительные проходы и может долго ждать старые снимки, поэтому такую операцию всё равно планируют и наблюдают.
В кейсе торговой компании «Северный склад», 40 РМ и два склада правильным решением было оставить orders_client_status_idx, потому что он обслуживал карточку клиента, и отдельно наблюдать за индексом по дате. Skip scan расширил применимость существующего orders_wh_created_idx, но не дал автоматического права сократить набор индексов.
- `idx_scan` в PostgreSQL 18 увеличивается на каждый отдельный индексный поиск.
- Период наблюдения должен включать редкие и регламентные нагрузки.
- Несколько сотен значений могут быть приемлемы в одном запросе и невыгодны в другом.
- Отдельный индекс по хвостовой колонке — кандидат на тестирование, а не автоматический дубликат.
- `REINDEX CONCURRENTLY` перестраивает прежнее определение и не меняет порядок ключей.
Как наблюдать Index Searches на рабочей системе
Ручной EXPLAIN ANALYZE выполняет запрос. На рабочей базе это может быть слишком дорого, а план подготовленного запроса ещё и зависит от параметров, статистики и выбора между generic- и custom-планом. Для выборочного сбора фактических планов я использую поставляемый вместе с PostgreSQL модуль auto_explain, предварительно оценивая накладные расходы на стенде.
Сначала выясняю реальный путь к активному конфигурационному файлу. Универсального пути вроде /etc/postgresql/postgresql.conf для всех пакетов и операционных систем не существует:
SHOW config_file;
SHOW session_preload_libraries;
SHOW shared_preload_libraries;Для временной диагностики документация рекомендует session_preload_libraries: параметр можно изменить без полного рестарта, а новое значение применяется к новым сессиям. Для одной административной сессии суперпользователь может выполнить LOAD 'auto_explain'. Если модуль добавляют именно в shared_preload_libraries, изменение, напротив, требует перезапуска сервера. При редактировании любого списка preload-библиотек важно сохранить уже подключённые элементы, а не затереть их.
Ниже мой консервативный стартовый профиль для postgresql.conf. Значения 500 мс и 0,02 — эксплуатационный выбор для первичного наблюдения, а не вендорские нормы; после оценки объёма логов и нагрузки их корректируют:
session_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_verbose = on
auto_explain.log_settings = on
auto_explain.log_nested_statements = on
auto_explain.sample_rate = 0.02
auto_explain.log_timing = off
auto_explain.log_format = 'text'
auto_explain.log_parameter_max_length = 0После изменения конфигурации её можно перечитать, но старые соединения не начнут загружать библиотеку задним числом:
SELECT pg_reload_conf();auto_explain.log_analyze = on нужен для фактических счётчиков, включая Index Searches, однако документация предупреждает о потенциально очень высокой цене инструментирования. При включённом анализе измерение времени узлов может выполняться даже для запросов, которые в итоге не достигнут порога логирования. auto_explain.log_timing = off уменьшает эту цену, но не делает сбор бесплатным; долю выборки и влияние на задержки всё равно нужно измерять.
auto_explain.log_buffers = on указан явно, потому что у модуля это отдельный параметр и по умолчанию он выключен. Автоматическое включение BUFFERS в обычном EXPLAIN ANALYZE PostgreSQL 18 не означает, что настройку модуля можно опустить. Параметр log_parameter_max_length = 0 отключает запись значений параметров в планах и уменьшает риск попадания чувствительных данных в журнал.
pg_stat_statements полезен для поиска тяжёлых запросов по времени и блокам, но не хранит дерево плана и строку Index Searches. Зато pg_stat_all_indexes.idx_scan, вопреки распространённому утверждению, учитывает каждый внутренний индексный поиск. Он помогает наблюдать общую активность индекса, но агрегирует разные запросы и не объясняет, какой именно из них породил приращение.
Если фактический план слишком тяжело снимать на основном сервере, физическая реплика подходит для читающего запроса: данные и системные каталоги передаются физической репликацией. Но реплика не является идеальной копией условий исполнения — у неё другой кэш, конкуренция за ресурсы и возможны отмены запросов из-за конфликта с восстановлением. В отчёте я поэтому отделяю форму плана и число поисков от времени, измеренного на другом узле.
- Активный путь к конфигурации узнавайте через `SHOW config_file`.
- Для временной диагностики подходит `session_preload_libraries`; новое значение действует в новых сессиях.
- Изменение `shared_preload_libraries` требует перезапуска сервера.
- Для фактических счётчиков нужны `log_analyze` и `log_buffers`.
- `log_timing = off` уменьшает, но не устраняет накладные расходы.
- `idx_scan` учитывает отдельные поиски, а `pg_stat_statements` не хранит `Index Searches`.
Частые вопросы
Почему в EXPLAIN нет узла Skip Scan?
Потому что PostgreSQL 18 реализует skip scan внутри обычного `Index Scan`, `Index Only Scan` или `Bitmap Index Scan`. Отдельного типа узла и прямого поля Skip Scan нет ни в текстовом, ни в JSON-формате. Оптимизацию распознают по определению многоколоночного B-tree, отсутствующему ограничению ранней колонки, `Index Cond`, числу поисков и объёму буферов.
Index Searches больше единицы доказывает использование skip scan?
Нет. Несколько поисков возникают также при `IN`, `ANY` и некоторых цепочках `OR`. Официальный пример с четырьмя разнесёнными значениями `IN` показывает четыре поиска, а с четырьмя соседними значениями — один. Нужна совокупность признаков, а не один счётчик.
Почему при девяти значениях ведущей колонки получилось 11 поисков?
Это возможно из-за внутренних границ массива пропуска, проверки области `NULL` и перепозиционирований между листовыми страницами. Известный пример с булевой колонкой включает `SK_BT_MINVAL`, два реальных значения и `SK_ISNULL`. Однако формула `n_distinct + 2` не является гарантией: фактическое число зависит от данных, границ и расположения записей.
Как отличить полезный skip scan от широкого прохода индекса?
Сначала проверьте определение индекса и `Index Cond`, затем учтите `loops` и сравните `Buffers` с размером индекса в блоках. Небольшая доля затронутых буферов при нескольких поисках согласуется с эффективным пропуском. Сопоставимый с размером индекса объём работы вызывает подозрение на широкий проход, но `Buffers` не разделяет индекс, таблицу и карту видимости, поэтому это не самостоятельное доказательство.
Можно ли отключить только skip scan?
Нет. Параметра `enable_indexskipscan` в PostgreSQL 18 нет. Для грубого сравнения можно в тестовой транзакции временно выключить `enable_indexscan`, `enable_indexonlyscan` и `enable_bitmapscan`, но это покажет альтернативу без соответствующих индексных путей, а не тот же индексный план без skip scan.
Считает ли pg_stat_all_indexes.idx_scan внутренние поиски skip scan?
Да. Документация PostgreSQL 18 прямо говорит, что каждый отдельный индексный поиск увеличивает `idx_scan`. Поэтому значение может быть значительно больше числа выполнений SQL-запроса или индексного узла. Этот счётчик нельзя интерпретировать как количество пользовательских запросов.
Можно ли удалить отдельный индекс по второй колонке после обновления?
Только после проверки всего профиля нагрузки. Отдельный индекс может быть меньше, лучше поддерживать сортировку или широкий диапазон и выигрывать при высокой кардинальности ведущей колонки составного индекса. Нужны планы характерных запросов, статистика использования за репрезентативный период и проверка ограничений.
Поможет ли REINDEX CONCURRENTLY поменять порядок колонок?
Нет. `REINDEX CONCURRENTLY` перестраивает индекс с прежним определением. Для другого порядка ключей или нового списка `INCLUDE` создают новый индекс, проверяют его и лишь затем рассматривают удаление старого.
Источники
- PostgreSQL 18 Documentation — Multicolumn Indexes — Раздел 11.3 «Multicolumn Indexes»: механизм динамического ограничения, условия применения skip scan и предупреждение о большой кардинальности ведущей колонки. https://www.postgresql.org/docs/18/indexes-multicolumn.html
- PostgreSQL 18 Documentation — Using EXPLAIN — Раздел 14.1 «Using EXPLAIN»: точные примеры Index Searches для skip scan и условий IN, а также пояснение о суммировании по loops. https://www.postgresql.org/docs/18/using-explain.html
- PostgreSQL 18 Release Notes — Разделы E.6.3.1.2 и E.6.3.2.3: добавление skip scan для B-tree, Index Searches в EXPLAIN ANALYZE и автоматическое включение BUFFERS. https://www.postgresql.org/docs/18/release-18.html
- PostgreSQL 18 Documentation — Cumulative Statistics — Раздел 27.2.20 «pg_stat_all_indexes»: каждый отдельный индексный поиск увеличивает idx_scan; отдельно названы массивы, преобразованные OR и skip scan. https://www.postgresql.org/docs/18/monitoring-stats.html#MONITORING-PG-STAT-ALL-INDEXES-VIEW
- PostgreSQL 18 Documentation — auto_explain — Раздел F.3: загрузка модуля, параметры log_analyze, log_buffers, log_timing, log_nested_statements, sample_rate и предупреждение о накладных расходах. https://www.postgresql.org/docs/18/auto-explain.html
- PostgreSQL 18 Documentation — Query Planning — Раздел 19.7.1: документированный список настроек методов планировщика, включая enable_indexscan, enable_indexonlyscan и enable_bitmapscan; отдельного параметра skip scan нет. https://www.postgresql.org/docs/18/runtime-config-query.html#RUNTIME-CONFIG-QUERY-ENABLE
- PostgreSQL 18 Documentation — Shared Library Preloading — Раздел 19.11.3: различия session_preload_libraries и shared_preload_libraries, применение к новым сессиям и требование перезапуска для shared preload. https://www.postgresql.org/docs/18/runtime-config-client.html#RUNTIME-CONFIG-CLIENT-PRELOAD
- pgsql-performance — Index Searches higher than expected for skip scan — Первичное сообщение Майкла Кристофидеса от 6 ноября 2025 года с воспроизводимым примером двух булевых значений и Index Searches: 4. https://www.postgresql.org/message-id/CAFwT4nD8r2XGGw4yVONoLmnCc93te-UgjCcLbuvSsEPtagdSqg%40mail.gmail.com
- pgsql-performance — ответ Peter Geoghegan о сентинелах — Ответ автора реализации от 6 ноября 2025 года: SK_BT_MINVAL, false, true, SK_ISNULL, оговорки о крайних листовых страницах и явном IS NOT NULL. https://www.postgresql.org/message-id/CAH2-Wz%3DBrVUghRp61nwgL2DZwMQ8EJfFphzC43QPhjm07U-61A%40mail.gmail.com
- PostgreSQL 18 Documentation — pg_stats — Раздел 53.29: точная семантика положительных и отрицательных значений n_distinct и определение null_frac. https://www.postgresql.org/docs/18/view-pg-stats.html
- PostgreSQL Versioning Policy — Таблица поддерживаемых версий: текущий минор ветки 18 — 18.6, первый выпуск 25 сентября 2025 года, окончание поддержки 14 ноября 2030 года. https://www.postgresql.org/support/versioning/
- PostgreSQL 18.6 Release Notes — Примечания к текущему минорному выпуску PostgreSQL 18.6; дата выпуска — 13 августа 2026 года. https://www.postgresql.org/docs/18/release-18-6.html
