Планировщик запросов (Query Planner)

Предпосылки: страницы и кортежи, B-tree (типы сканирования: Index Scan, Bitmap Index Scan, Index Only Scan), BRIN (корреляция физического и логического порядка), хеш-таблица (для понимания Hash Join).

SP-GiST | Порядок соединения

Таблица orders — 10 млн строк, индекс по status. Запрос WHERE status = 'pending' может выполняться Index Scan (быстро обойти 5000 строк через индекс) или Seq Scan (прочитать все 10 млн строк). Разница — 2 мс vs 3 секунды. Выбор делает планировщик, и он основывает решение на статистике: если ‘pending’ — это 0.05% строк, Index Scan; если 80% — Seq Scan дешевле.

Масштаб задачи растёт с количеством таблиц: запрос с тремя JOIN’ами даёт 6 вариантов порядка соединения, для каждого — три алгоритма (nested loop, hash, merge), плюс выбор метода доступа к каждой таблице. Даже такой простой запрос может иметь сотни возможных планов.

Статистика — входные данные планировщика

Планировщик не может выбирать план вслепую. Ему нужна информация о данных: размеры таблиц, распределение значений, корреляции. На основе этой информации планировщик оценивает selectivity — долю строк, которые пройдут каждое условие фильтрации (от 0.0 до 1.0). Чем точнее selectivity, тем ближе оценка числа строк к реальности, и тем вероятнее выбор оптимального плана.

Статистику собирает команда ANALYZE и хранит в системных каталогах — служебных таблицах PostgreSQL, описывающих структуру и содержимое базы данных. Вызвать её можно для конкретной таблицы — ANALYZE table_name — или для всей базы, просто ANALYZE. В штатном режиме запускать её вручную не нужно: фоновый процесс autovacuum отслеживает количество изменённых строк и автоматически запускает ANALYZE, когда число изменений превышает порог (см. VACUUM). Но после массовых операций — bulk load, крупный DELETE — стоит запустить ANALYZE явно, не дожидаясь autovacuum: пока статистика устаревшая, планировщик работает с неверной картиной данных и выбирает неоптимальные планы.

Статистика уровня таблицы

Системная таблица pg_class хранит метаданные о каждой таблице и индексе в базе. Для планировщика важны два поля: reltuples — приблизительное количество строк, и relpages — количество занятых страниц. Префикс «rel» — от «relation»: так в терминологии PostgreSQL называются таблицы и индексы.

SELECT relname, reltuples, relpages
FROM pg_class
WHERE relname = 'users';

Отношение reltuples / relpages показывает среднее количество строк на страницу — плотность данных. Планировщик использует эту плотность при оценке index scan: зная количество строк после фильтрации, он делит на плотность и получает число страниц heap, за которыми нужно обратиться.

Статистика уровня колонки

Представление pg_stats — удобная обёртка над системной таблицей pg_statistic, которая хранит детальную статистику по каждой колонке. В отличие от pg_class, которая знает только общие размеры таблицы, pg_stats описывает распределение значений внутри колонки.

null_frac — доля NULL-значений в колонке (от 0.0 до 1.0).

n_distinct — количество уникальных значений. Положительное число (например, 42) означает точное количество. Отрицательное (например, -0.5) — долю от общего числа строк: при 100 000 строк и n_distinct = -0.5 уникальных значений примерно 50 000.

most_common_vals (MCV) и most_common_freqs — массив самых частых значений и их точных частот:

SELECT attname, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';
 
-- Результат:
-- most_common_vals:  {pending, completed, shipped, cancelled}
-- most_common_freqs: {0.45, 0.30, 0.20, 0.05}

Для запроса WHERE status = 'pending' планировщик берёт частоту напрямую из MCV: selectivity = 0.45, то есть 45% строк пройдут фильтр.

histogram_bounds — границы корзин гистограммы для значений, не попавших в MCV. Гистограмма компактно описывает распределение: весь диапазон делится на корзины примерно равной высоты, то есть каждая содержит одинаковую долю строк:

histogram_bounds: {18, 25, 32, 41, 55, 80}

Это 5 корзин, в каждой примерно 20% строк (не попавших в MCV): [18, 25), [25, 32), [32, 41), [41, 55), [55, 80]. Для диапазонных запросов (например, WHERE age > 50) планировщик определяет, какую долю корзин покрывает условие, и суммирует их доли. Гистограмма не используется для условий равенства (=) — для них планировщик обращается к n_distinct или MCV.

correlation — степень соответствия физического порядка строк в heap и логического порядка значений в колонке. Значение около 1.0 или -1.0 означает предсказуемый порядок (строки лежат компактно), а значение около 0 — хаотичное расположение. Correlation напрямую влияет на стоимость index scan: при высокой корреляции последовательные по индексу строки лежат на соседних страницах heap, и чтение обходится почти как sequential I/O. При низкой корреляции каждая строка может оказаться на другой странице — это random I/O, и на HDD запрос, который при correlation ≈ 1.0 занимал бы 10 мс, может растянуться до сотен миллисекунд. Например, таблица events с колонкой created_at, куда только добавляются записи (append-only), будет иметь correlation ≈ 1.0: timestamp монотонно растёт, строки ложатся в конец heap в том же порядке.

Почему MCV и гистограмма разделены

Если распределение сильно перекошено (skewed), объединённая гистограмма теряет информацию о популярных значениях. Допустим, колонка country содержит USA = 40%, India = 25%, а остальные 50 стран — по <1%. Если всё засунуть в гистограмму, ‘USA’ окажется в одной корзине с ‘Uganda’, и планировщик не узнает, что ‘USA’ — это 40% таблицы. Вместо этого PostgreSQL выделяет популярные значения в MCV с точными частотами, а гистограмму строит только по оставшимся (редким) значениям.

Вычисление selectivity

Статистика по колонкам описывает распределение данных, но планировщику нужна конкретная цифра: какая доля строк пройдёт каждое условие WHERE. Этот расчёт превращает статистику в оценку числа строк.

Условие равенства (=)

Если значение есть в MCV, selectivity берётся напрямую из most_common_freqs. Если нет — оставшаяся доля строк делится поровну между оставшимися уникальными значениями:

selectivity = (1 - sum(mcv_freqs) - null_frac) / (n_distinct - mcv_count)

Диапазонные условия (>, <, BETWEEN)

Используется гистограмма. Планировщик определяет, какие корзины покрывает условие, и суммирует их доли. Внутри корзины предполагается равномерное распределение: если граница условия попадает в середину корзины, берётся пропорциональная часть.

Когда статистика недоступна

Не для всех условий планировщик может вычислить selectivity из собранной статистики. Если условие содержит функцию или выражение от колонки (WHERE age * 2 > 60), сравнение двух колонок (WHERE start_date < end_date) или пользовательские типы и операторы — планировщик подставляет захардкоженную константу:

СитуацияКонстанта
Неизвестное условие равенства0.005 (0.5%)
Неизвестное условие неравенства0.3333 (33%)
LIKE 'prefix%' без статистики0.005

Эти константы — «лучшая догадка», и планировщик может ошибиться в оценке строк на порядки, что приведёт к выбору заведомо медленного плана. Решение — переписать запрос так, чтобы условие было на «голую» колонку (WHERE age > 30), или создать expression index.

Cost Model — как сравниваются планы

Зная selectivity для каждого условия, планировщик может оценить число строк на каждом этапе плана. Но чтобы сравнить планы между собой, нужна единая метрика. Эту роль выполняет cost — безразмерное число, отражающее примерную стоимость выполнения. Cost нужен не для предсказания реального времени, а для ранжирования: план с cost = 100 предпочтительнее плана с cost = 500.

Базовые константы

ПараметрDefaultОписание
seq_page_cost1.0Стоимость последовательного чтения одной страницы
random_page_cost4.0Стоимость случайного чтения одной страницы
cpu_tuple_cost0.01Стоимость обработки одной строки
cpu_index_tuple_cost0.005Стоимость обработки одной записи индекса
cpu_operator_cost0.0025Стоимость одной операции сравнения

PostgreSQL не определяет оборудование автоматически — это статические параметры, которые администратор настраивает вручную. Соотношение random_page_cost к seq_page_cost по умолчанию 4:1, что отражает реальность HDD: случайное чтение требует перемещения головки диска (seek), которое на порядок дороже последовательного чтения (HDD vs SSD). На SSD механического seek нет, и random I/O обходится почти так же дёшево, как sequential, поэтому типичная рекомендация — random_page_cost = 1.1 при seq_page_cost = 1.0.

Формула cost для Seq Scan

cost = (relpages × seq_page_cost) + (reltuples × cpu_tuple_cost)
         ↑                            ↑
      I/O cost                     CPU cost

С условием WHERE добавляется стоимость проверки:

filter_cost = reltuples × cpu_operator_cost

Формула cost для Index Scan

cost = (index_pages × random_page_cost × cache_adjustment) +
       (index_tuples × cpu_index_tuple_cost) +
       (heap_pages × random_page_cost × correlation_adjustment) +
       (rows × cpu_tuple_cost)

Два ключевых фактора делают эту формулу менее предсказуемой, чем seq scan. Correlation определяет, насколько кучно лежат нужные строки в heap: при correlation ≈ 1.0 число страниц heap близко к числу страниц при sequential чтении, при correlation ≈ 0 каждая строка может потребовать отдельного random I/O. Параметр effective_cache_size подсказывает планировщику, сколько данных вероятно уже в памяти (shared_buffers + OS page cache). Чем больше значение, тем меньше планировщик «штрафует» random I/O, считая что страницы уже закешированы, и тем охотнее выбирает index scan — для больших таблиц с горячими данными это может ускорить запросы в разы, но при неверной настройке приведёт к выбору index scan там, где seq scan был бы быстрее.

Методы доступа к таблице

Cost model оценивает стоимость плана, но конкретный план строится из методов доступа — способов, которыми PostgreSQL читает данные из таблицы. Каждый метод имеет свой профиль затрат, и планировщик выбирает тот, чей cost минимален при данной selectivity, correlation и размере таблицы.

Seq Scan

Последовательное чтение всех страниц таблицы. Seq scan выгоден, когда нужна большая доля строк (высокая selectivity), нет подходящего индекса или таблица маленькая. I/O дешёвый (sequential), но читаются все страницы без исключения.

Index Scan

Поиск через индекс, затем переход в heap за каждой строкой. Index scan выигрывает при низкой selectivity (мало строк после фильтрации), наличии подходящего индекса и высокой correlation, когда строки лежат компактно в heap. I/O дорогой (random обращения к heap), но объём данных мал.

Bitmap Index Scan

Сначала сканируется индекс и собирается битовая карта страниц heap, содержащих подходящие строки. Затем эти страницы читаются в порядке их физического расположения, что превращает random I/O в quasi-sequential. Bitmap scan занимает промежуточную нишу: при средней selectivity (примерно 5–20% строк) index scan уже генерирует слишком много случайных обращений к heap и запрос ощутимо замедляется, а seq scan по-прежнему читает лишние данные. Bitmap scan решает эту проблему, объединяя точность индекса с упорядоченным чтением.

Index Only Scan

Все нужные данные берутся прямо из индекса, без обращения к heap. Это возможно, когда все запрашиваемые колонки входят в индекс и visibility map подтверждает, что соответствующие страницы heap полностью видимы (all-visible) — иначе PostgreSQL всё равно обратится к heap для проверки видимости строки.

Алгоритмы соединения (JOIN)

Методы доступа определяют, как PostgreSQL читает отдельные таблицы. Когда запрос объединяет несколько таблиц, планировщик дополнительно выбирает алгоритм соединения — способ сопоставить строки из разных источников. Три основных алгоритма различаются по структуре данных и профилю затрат.

Nested Loop Join

Два вложенных цикла: внешний перебирает строки одной таблицы, внутренний ищет соответствия в другой.

outer.each do |outer_row|
  inner.each do |inner_row|
    if outer_row.key == inner_row.key
      result << combine(outer_row, inner_row)
    end
  end
end

Без индекса стоимость O(N × M), с индексом на inner-таблице — O(N × log M). Nested loop выгоден, когда внешняя таблица маленькая (после фильтрации) и на внутренней есть индекс по ключу соединения.

Hash Join

Построить хеш-таблицу из одной таблицы (build), пройти по второй (probe) и искать соответствия за O(1).

Фаза Build:

hash_table = {}
build_input.each do |row|
  key = row.join_key
  hash_table[key] ||= []
  hash_table[key] << row
end

Фаза Probe:

probe_input.each do |row|
  key = row.join_key
  matches = hash_table[key]
  # ...
end

Стоимость O(N + M). Hash join требует условие соединения на равенство (хеш не работает для диапазонов) и достаточно памяти для хеш-таблицы. Память ограничена параметром work_mem — лимит на объём оперативной памяти, который одна операция сортировки или хеширования может использовать (по умолчанию 4 MB). Если хеш-таблица не помещается в work_mem, PostgreSQL разбивает данные на пакеты (batches) и сбрасывает часть на диск. Каждый batch потом обрабатывается отдельно — это добавляет I/O и может замедлить запрос в разы по сравнению с полностью in-memory hash join. Hash join выгоден, когда обе таблицы большие, нет подходящего индекса и условие — равенство.

Merge Join

Если обе таблицы отсортированы по ключу соединения, можно пройти их параллельно за один проход.

while left_idx < left.size && right_idx < right.size
  if left[left_idx].key < right[right_idx].key
    left_idx += 1
  elsif left[left_idx].key > right[right_idx].key
    right_idx += 1
  else
    # Совпадение: нужно обработать все дубликаты с обеих сторон.
    # Merge join запоминает позицию начала группы дубликатов в правой таблице
    # и для каждой совпадающей строки слева перематывает правую обратно
    # к началу группы, генерируя все комбинации.
    emit_all_matches(left, right, left_idx, right_idx)
  end
end

Если данные уже отсортированы (например, читаются из индекса), стоимость O(N + M). Если нужна предварительная сортировка — O(N log N + M log M). Merge join выгоден, когда данные уже отсортированы по ключу соединения или когда сортировка и так нужна для ORDER BY (тогда она не «лишняя»). В отличие от hash join, merge join работает и для диапазонных условий соединения.

Сводка по алгоритмам

АлгоритмСтоимостьТребованияЛучший сценарий
Nested LoopO(N × M) или O(N × log M)Индекс для эффективностиМаленькая внешняя таблица + индекс
Hash JoinO(N + M)Равенство, память для хеш-таблицыБольшие таблицы без индекса
Merge JoinO(N + M)Отсортированные данныеДанные из индексов или нужен ORDER BY

Диагностика: EXPLAIN и EXPLAIN ANALYZE

Планировщик выбирает план на основе оценок — и эти оценки могут расходиться с реальностью. Чтобы проверить, какой план выбран и насколько точны его оценки, PostgreSQL предоставляет команды EXPLAIN и EXPLAIN ANALYZE.

EXPLAIN

Показывает план выполнения без выполнения запроса.

EXPLAIN SELECT * FROM users WHERE age > 30;
Seq Scan on users  (cost=0.00..1750.00 rows=33000 width=64)
  Filter: (age > 30)

В выводе Seq Scan on users — выбранный метод доступа, cost=0.00..1750.00 — startup cost и total cost, rows=33000 — оценка количества строк, width=64 — средний размер строки в байтах.

EXPLAIN ANALYZE

Реально выполняет запрос и показывает фактические числа рядом с оценками.

EXPLAIN ANALYZE SELECT * FROM users WHERE age > 30;
Seq Scan on users  (cost=0.00..1750.00 rows=33000 width=64)
                   (actual time=0.015..12.340 rows=32847 loops=1)
  Filter: (age > 30)
  Rows Removed by Filter: 67153
Planning Time: 0.082 ms
Execution Time: 13.521 ms

К оценкам добавляются реальные числа: actual time — фактическое время в миллисекундах, actual rows — реальное количество строк, loops — сколько раз выполнялся узел (для nested loop inner будет > 1).

Диагностика плохих планов

Самый информативный сигнал — расхождение между rows (оценка) и actual rows (реальность). Разница в разы говорит о том, что планировщик принял решение на основе неверных данных. Типичные причины: устаревшая статистика (давно не было ANALYZE), невычислимая selectivity (функции от колонок, сложные выражения) и неучтённая корреляция между колонками — планировщик по умолчанию предполагает, что условия на разные колонки независимы.

Недооценка числа строк приводит к тому, что планировщик выбирает index scan, рассчитывая на 1 000 строк, а реально приходит 10 000 — объём random I/O оказывается в 10 раз больше ожидаемого, и запрос, который должен был выполниться за 50 мс, работает секунду. Переоценка даёт обратный эффект: планировщик выбирает seq scan или hash join для «большого» результата, хотя на деле строк мало и nested loop с индексом был бы в разы быстрее.

Параметры enable_*

Когда EXPLAIN ANALYZE показывает расхождение, но причина неочевидна, полезно проверить, что произойдёт при запрете конкретной стратегии. Параметры enable_* не отключают метод полностью — они добавляют к его cost огромный штраф, и планировщик выберет метод только если альтернатив нет.

Методы доступа к таблице:

ПараметрЧто штрафуется
enable_seqscanSequential Scan
enable_indexscanIndex Scan
enable_indexonlyscanIndex Only Scan
enable_bitmapscanBitmap Index Scan

Алгоритмы соединения:

ПараметрЧто штрафуется
enable_nestloopNested Loop Join
enable_hashjoinHash Join
enable_mergejoinMerge Join

Прочие операции:

ПараметрЧто штрафуется
enable_sortЯвная сортировка
enable_hashaggHash Aggregation
enable_materialМатериализация промежуточного результата

Пример использования для диагностики:

SET enable_seqscan = off;
EXPLAIN SELECT * FROM users WHERE age > 30;

Если после отключения seq scan планировщик выбрал index scan — значит, индекс существует, но планировщик считал seq scan дешевле. Сравнив cost обоих планов, можно понять, насколько велика разница и не связано ли это с устаревшей статистикой или неверным random_page_cost. Эти параметры — инструмент диагностики на уровне сессии, а не настройка для production: отключение seq scan глобально заставит планировщик избегать его даже для маленьких таблиц, где seq scan оптимален.

Практические рекомендации

Понимание работы планировщика позволяет целенаправленно влиять на качество планов.

Актуальная статистика — основа. Если autovacuum по каким-то причинам не успевает (например, слишком консервативные пороги), планировщик работает с устаревшей картиной и выбирает неоптимальные планы.

Запросы стоит писать так, чтобы планировщик мог применить статистику: условия на «голые» колонки (WHERE age > 30 вместо WHERE age * 2 > 60), минимум функций в WHERE, а для неизбежных выражений — expression index.

Настройки под железо: на SSD уменьшить random_page_cost до 1.1–1.5, чтобы планировщик не штрафовал index scan за random I/O, которое на SSD почти бесплатно. effective_cache_size установить в соответствии с доступной памятью (shared_buffers — см. буферный кеш + ожидаемый OS page cache).

Для запросов с условиями на несколько коррелированных колонок — CREATE STATISTICS для связанных колонок, чтобы планировщик знал о зависимости между ними и не считал условия независимыми. Альтернатива — composite index, который косвенно даёт планировщику информацию о совместном распределении.

Все рассмотренные механизмы — статистика, cost model, методы доступа и алгоритмы соединения — работают в рамках одной таблицы или пары таблиц. Когда запрос объединяет много таблиц, возникает отдельная задача: в каком порядке выполнять соединения, и как планировщик обрабатывает подзапросы и CTE.

Sources


SP-GiST | Порядок соединения