Когда ты пишешь WHERE city = 'Москва', планировщик задаёт себе вопрос: сколько строк это вернёт? От ответа зависит всё — выбор индекса, join algorithm, порядок таблиц. Эта величина называется
pg_stats превращается в число rows=12345 в EXPLAIN.
Equality: col = X
Самый частый предикат. Алгоритм:
- Посмотреть, есть ли
Xвmost_common_vals. Если да — взять соответствующее значение изmost_common_freqs. Конец. - Если нет — оценка
(1 − sum(most_common_freqs) − null_frac) / (n_distinct − mcv_count). Логика: значение, которого нет в MCV, делит «оставшуюся вероятность» поровну между всеми не-MCV уникальными значениями.
Пример. Колонка status имеет MCV [paid: 0.30, pending: 0.20, shipped: 0.15], sum(freqs) = 0.65, null_frac = 0, n_distinct = 6.
WHERE status = 'paid'→ 0.30 (из MCV напрямую).WHERE status = 'cancelled'(не в MCV, но реально есть) →(1 − 0.65) / (6 − 3) = 0.35 / 3 ≈ 0.117.WHERE status = 'unknown'(вообще нет такого значения) → тоже0.117, потому что планировщик не отличает «есть, но редкое» от «нет вообще». Это известная слабость.
Если X в MCV — точная частота. Если нет — равномерная оценка по оставшимся.
Range: col > X, col < X, col BETWEEN A AND B
Для неравенств планировщик использует histogram_bounds. Логика:
- Посчитать долю MCV, удовлетворяющую условию (просто фильтр по массиву).
- Посчитать долю гистограммы выше/ниже X. Если X попадает между двумя баскетами
bound[i]иbound[i+1], оценивается линейной интерполяцией: какая доля баскета выше X. - Сложить.
Пример: histogram_bounds для signup_date имеет границы [2022-01-01, 2022-04-15, 2022-07-30, ..., 2024-12-31], 100 равночастотных баскетов. Запрос WHERE signup_date > 2024-01-01. Находим, что 2024-01-01 попадает в баскет 75 (например). Тогда selectivity = (101 − 75) / 100 = 0.26 — около 26% строк попадут.
Посмотрим histogram для signup_date и оценим WHERE signup_date > '2024-01-01'. Датасет инициализируется ~5 секунд.
EXPLAIN: смотрим, какую оценку планировщик даёт для предиката по дате.
В выводе видишь rows=... — это selectivity × reltuples. Сравни с реальным числом строк (запусти простой SELECT count(*) WHERE ...) — оценка обычно в пределах 10-20%.
IN: сумма по элементам
WHERE col IN (X1, X2, X3) оценивается как сумма selectivity отдельных равенств:
sel(IN) = sel(col = X1) + sel(col = X2) + sel(col = X3)
(с обрезкой до 1.0, если сумма больше). Например, при тех же MCV для status:
WHERE status IN ('paid', 'pending')→ 0.30 + 0.20 = 0.50.
Это работает корректно, потому что значения в IN дизъюнктны (status не может быть одновременно 'paid' и 'pending'). Для больших IN-списков (тысячи значений) Postgres переключается на оценку через n_distinct — точное суммирование становится дорогим.
AND и OR
Здесь начинается фокус. Постгрес считает, что предикаты по разным колонкам независимы:
sel(p1 AND p2) = sel(p1) × sel(p2)
sel(p1 OR p2) = sel(p1) + sel(p2) − sel(p1) × sel(p2)
Это допущение часто очень ошибочно. Классика — WHERE city = 'Москва' AND country = 'RU'. Если в таблице:
- 0.1% строк с
country = 'RU', - 0.5% строк с
city = 'Москва', - но все Москва — это RU,
то реальная selectivity = 0.005 (= sel(city=‘Москва’) — все они уже RU). А планировщик скажет: 0.005 × 0.001 = 0.000005, в 1000 раз меньше. И выберет план под 1 ожидаемую строку вместо 5000.
Это та самая проблема, которую решают extended statistics (следующий урок).
Простой пример: оценка для AND и реальное число строк. Здесь корреляция небольшая, но идею демонстрирует.
В output ищем rows=A loops=B (estimated) и потом actual rows=C — если расхождение >> 2x, у тебя проблема с оценкой.
Selectivity vs cost: где это всё применяется
Selectivity не самоцель — это вход в формулу cost каждого узла плана:
Seq Scan: cost =n_pages × seq_page_cost + n_tuples × cpu_tuple_cost + filter_cost × n_tuples. Selectivity влияет на размер выхода, который пойдёт в следующий узел.Index Scan: cost =index_pages × random_page_cost + heap_pages_estimate × random_page_cost + n_matched × cpu_tuple_cost. Selectivity напрямую определяетheap_pages_estimate.- Join: для nested loop важно, сколько строк во внешней таблице (= selectivity outer-фильтра), потому что внутренний цикл выполнится столько раз.
Если selectivity ошиблась в 100x — все эти cost-числа становятся бессмысленными, и Postgres выберет неправильный план.
Когда оценки врут систематически
Помимо корреляций между колонками, типичные ловушки:
- Predicate на функцию:
WHERE lower(email) = '[email protected]'. Postgres не знает распределениеlower(email), fallback =0.005(магическая константа). Может промахнуться. Решение — функциональный индекс. - JOIN на колонке с разным распределением:
customers JOIN orders ON country. Постгрес считает, что country у customer и у orders распределены одинаково, и берёт min(n_distinct). Часто верно, но для skewed-данных — нет. - Свежие данные (см. прошлый урок): после массового INSERT статистика устаревает.
- LIKE / regex:
WHERE name LIKE 'A%'оценивается через histogram по prefix — работает. АWHERE name LIKE '%suffix'— fallback на магическую константу.
Пример проблемного предиката: функция в WHERE. Сравни estimated и actual.
Видно, что планировщик не может «развернуть» lower(country) без функционального индекса и берёт fallback. Реальная selectivity — 20% (если 5 стран и одна из них RU).
Чек-лист
- Equality: в MCV — точная частота, иначе равномерное деление остатка по
n_distinct. - Range (
>,<,BETWEEN): черезhistogram_boundsс линейной интерполяцией внутри баскета. - IN: сумма по элементам (с обрезкой до 1.0).
- AND/OR между колонками: предполагается независимость →
sel = sel1 × sel2. Часто врёт, если колонки коррелированы. - Функции в
WHERE, LIKE с%-префиксом — fallback на магические константы, селективность врёт. - Если ошибка >> 2x — это серьёзный сигнал на оптимизацию (extended stats, partial index, переписать запрос).