«Индекс есть, но запрос всё равно медленный» — почти всегда история про селективность. PostgreSQL не использует индекс, если считает, что seq-scan дешевле. Эта статья — как считать селективность, читать статистику и понимать решения планировщика.
1. Что такое селективность
PG-I-030 Селективность колонки = доля разных значений в таблице. Чем выше, тем эффективнее индекс по этой колонке.
Формально: selectivity = distinct_values / total_rows.
Примеры:
| Колонка | distinct | total | selectivity |
|---|---|---|---|
id (PK) | 1 000 000 | 1 000 000 | 1.0 — идеально |
email | 998 000 | 1 000 000 | 0.998 — почти идеально |
customer_id | 50 000 | 1 000 000 | 0.05 — средне |
status (5 значений) | 5 | 1 000 000 | 0.000005 — низкая |
is_deleted | 2 | 1 000 000 | 0.000002 — почти нулевая |
PG-I-031 Индекс на низкоселективной колонке (is_deleted, status, country в одной стране) бесполезен. Планировщик увидит, что условие WHERE is_deleted = false отдаст 98% таблицы — выгоднее прочитать всю таблицу одним проходом, чем 98% случайных IO через индекс.
2. Как посмотреть селективность
SELECT
attname,
n_distinct,
most_common_vals,
most_common_freqs,
null_frac
FROM pg_stats
WHERE schemaname = 'public' AND tablename = 'orders';
Что важно:
n_distinct— оценка количества уникальных значений. Если положительное — это абсолютное число. Если отрицательное (например,-0.05) — это доля от строк (-0.05= 5% уникальных, т.е. 50К на 1M).most_common_vals— массив самых частых значений.most_common_freqs— частоты этих значений (доли от 0 до 1).null_frac— доляNULL.
Пример:
attname | status
n_distinct | 5
most_common_vals | {DELIVERED,SHIPPED,NEW,CANCELLED,PAID}
most_common_freqs | {0.62, 0.18, 0.10, 0.06, 0.04}
null_frac | 0
Видно: 62% строк — DELIVERED. Запрос WHERE status = 'DELIVERED' — индекс не поможет, планировщик пойдёт seq-scan. А вот WHERE status = 'PAID' (4% строк) — индекс может зайти.
3. Когда индекс точно поможет
PG-I-032 Запрос с равенством, отдающий < 5–10% таблицы, обычно идёт через индекс.
| Доля строк по условию | Индекс? |
|---|---|
< 1% | Да, точно |
1–5% | Скорее всего да |
5–20% | Зависит от размера строки и кеша |
> 20% | Скорее всего нет (seq-scan дешевле) |
> 50% | Точно нет |
Точные пороги определяются:
- Размером строки (узкие строки → больше fits в один heap-блок → seq-scan дешевле).
effective_cache_size(косвенно — сколько данных PG считает в кеше).- Стоимостью random vs sequential IO (
random_page_costvsseq_page_cost).
PG-I-033 На SSD имеет смысл снизить random_page_cost с дефолтных 4.0 до 1.1. Дефолт рассчитан под HDD и часто заставляет планировщик предпочесть seq-scan, когда индекс действительно быстрее.
ALTER SYSTEM SET random_page_cost = 1.1;
SELECT pg_reload_conf();
4. Композитный индекс и селективность
Для индекса (a, b, c) селективность считается по левому префиксу:
WHERE a = ?— селективность колонкиa.WHERE a = ? AND b = ?— комбинированная селективностьa×b(приближённо).
Пример: (status, created_at) на таблице 1M заказов.
WHERE status = 'DELIVERED'— 62% строк → индекс не пойдёт.WHERE status = 'DELIVERED' AND created_at > now() - interval '1 day'— 62% × 0.5% = 0.3% → индекс пойдёт идеально.
PG-I-034 Композитный индекс может ожить там, где single-column бесполезен. Низкоселективное status сужает пространство только до 62% таблицы, range по created_at уже внутри этой выборки сужает до 0.3%. Однако нужно, чтобы оба условия были в WHERE.
5. Когда статистика врёт
Планировщик опирается на статистику, собранную через ANALYZE. Если статистика устарела или нерепрезентативна — план кривой.
PG-I-035 После массовой загрузки / INSERT-ов / UPDATE-ов — ANALYZE руками.
ANALYZE orders; -- одна таблица
ANALYZE orders (status, created_at); -- конкретные колонки
ANALYZE; -- весь schema
Autovacuum запускает ANALYZE сам, когда изменилось ~10% таблицы. На быстрых нагрузках этого может не хватить — autovacuum не успевает, статистика отстаёт.
PG-I-036 Если в данных есть редкие пиковые значения (бизнес-выбросы), увеличьте default_statistics_target для конкретной колонки.
-- по умолчанию PG сэмплит 100 значений для most_common_vals
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
Пример: колонка country_code в международном маркетплейсе. 99% — RU, остальные 1% — 50 разных стран. Без увеличения статистики PG не увидит мелкие страны в most_common_vals, селективность для них будет оценена неверно.
6. Планировщик: проверка через EXPLAIN
PG-E-020 EXPLAIN ANALYZE показывает реальное и оценочное число строк. Расхождение в 10x+ = криво посчитано.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE status = 'PAID';
Seq Scan on orders (cost=0.00..18334.00 rows=400000 width=...) (actual time=0.012..89.123 rows=42137 loops=1)
Filter: (status = 'PAID')
Rows Removed by Filter: 957863
Здесь:
rows=400000— оценка планировщика (оценил, что вернёт 400К).actual rows=42137— реальность (вернулось 42К).- Разница в 10x → планировщик ошибся → выбрал seq-scan, хотя был возможен Index Scan.
Решение: ANALYZE orders; (обновить статистику) → план должен пересчитаться.
7. Multi-column statistics (расширенная)
PG-I-037 Для коррелирующих колонок — CREATE STATISTICS с зависимостями.
Пример: в orders колонки country_code и currency коррелированы (страна → валюта). Селективность WHERE country_code = 'RU' AND currency = 'RUB' равна селективности country_code = 'RU', а не произведению. PostgreSQL по умолчанию считает их независимыми и завышает селективность в 10–50 раз.
CREATE STATISTICS stats_orders_geo (dependencies, ndistinct)
ON country_code, currency FROM orders;
ANALYZE orders;
После этого планировщик понимает зависимость и оценивает корректнее.
8. Чек-лист «индекс не работает»
Когда видишь медленный запрос с подходящим (на бумаге) индексом — пройти по списку:
EXPLAIN ANALYZEпоказываетSeq ScanвместоIndex Scan?- Сравнить
rows=оценку иactual rows=. Расхождение 10x+ →ANALYZE. - Посмотреть
pg_statsдля фильтрующей колонки —n_distinct,most_common_freqs. Низкая селективность → индекс не поможет, нужен другой подход. - Проверить
random_page_cost— на SSD должен быть 1.1, не 4.0. - Проверить, что колонка в индексе действительно стоит первой в композитном (или хотя бы первой в левом префиксе по условию).
- Для условий типа
WHERE LOWER(email) = ...— нужен functional index. - Для частичных предикатов (
WHERE deleted = false) — partial index. - Проверить корреляцию колонок — может нужны
CREATE STATISTICS.
Связанные
- Composite-индексы — порядок полей.
- Типы индексов — выбор B-tree vs GIN vs GiST.
- Чтение EXPLAIN — узлы плана и метрики.