PostgreSQL — одна из лучших СУБД на рынке с точки зрения оптимизатора запросов. Но даже лучший оптимизатор не поможет, если не понимать, как он работает. Разберём путь запроса от текста до результата и научимся читать планы выполнения.
Этапы выполнения запроса
Каждый SQL-запрос проходит три этапа: разбор, планирование, выполнение.
1. Разбор (Parsing)
- Синтаксический анализ: проверка синтаксиса, построение дерева разбора
- Семантический анализ: проверка существования таблиц, колонок, типов данных
- Переписывание: применение правил (например, раскрытие VIEW)
2. Планирование (Query Planning)
Оптимизатор строит множество возможных планов и оценивает их стоимость:
- Какие индексы использовать (или Seq Scan)
- Какой алгоритм соединения выбрать (Nested Loop, Hash Join, Merge Join)
- В каком порядке соединять таблицы
Выбирается план с минимальной оценочной стоимостью. PostgreSQL не имеет хинтов для оптимизатора — он сам принимает решения на основе статистики.
3. Выполнение (Execution)
План представляет собой дерево операций. Узлы выполняются снизу вверх, данные передаются от детей к родителям.
Как считается стоимость
Стоимость (cost) — абстрактная единица, отражающая ресурсы (диск, память, CPU) для выполнения операции.
Total Cost = Startup Cost + Run Cost
- Startup Cost: затраты на подготовку (сортировка, построение хеш-таблицы)
- Run Cost: затраты на выполнение (чтение данных, фильтрация)
Ключевые параметры конфигурации
| Параметр | По умолчанию | Описание |
|---|---|---|
seq_page_cost |
1.0 | Стоимость последовательного чтения страницы |
random_page_cost |
4.0 | Стоимость случайного доступа (обычно дороже из-за seek) |
cpu_tuple_cost |
0.01 | Стоимость обработки одной строки |
cpu_index_tuple_cost |
0.005 | Стоимость обработки строки индекса |
work_mem |
4MB | Память для сортировок и хеш-таблиц |
Типы сканирования
- Seq Scan: последовательное чтение всей таблицы. Cost = seq_page_cost × количество страниц
- Index Scan: поиск через индекс + чтение строк из таблицы. Cost = random_page_cost × страницы индекса
- Index Only Scan: чтение только из индекса, если все нужные колонки там есть
- Bitmap Heap Scan: строится битовая карта по индексу, потом читаются страницы. Эффективно для 2-20% таблицы
Селективность и статистика
Селективность — доля строк, которую выбирает условие:
Селективность = Количество строк, удовлетворяющих условию / Общее количество строк
- Высокая селективность (близка к 0): условие выбирает мало строк → выгоден Index Scan
- Низкая селективность (близка к 1): условие выбирает почти все → выгоден Seq Scan
Правило: индекс обычно эффективен для выборок не более 2-5% таблицы.
Откуда берётся статистика
PostgreSQL собирает статистику командой ANALYZE и хранит её в pg_statistic:
- Most Common Values (MCV): самые частые значения и их частоты
- Histogram Bounds: границы гистограммы распределения
- Null Fraction: доля NULL-значений
- n_distinct: количество уникальных значений
- Correlation: упорядоченность данных относительно физического порядка
ANALYZE на всех таблицах.
Индексы: когда использовать
B-tree индексы
Основной тип индекса в PostgreSQL. Только листья хранят ссылки на строки. Узлы хранят наименьший ключ следующего уровня. Сложность поиска — O(log n).
Виды индексов по применению
Составные индексы
CREATE INDEX idx_name ON account (last_name, first_name, middle_name);
Будет использоваться для запросов по last_name, last_name + first_name, last_name + first_name + middle_name.
Но не будет использоваться для запросов только по first_name.
Индексы по выражениям
-- Обычный индекс не поможет для lower()
SELECT * FROM account WHERE lower(last_name) = 'smith';
-- Нужен индекс по выражению
CREATE INDEX idx_lower_name ON account (lower(last_name));
Покрывающие индексы
CREATE INDEX idx_name ON account (last_name) INCLUDE (phone, email);
Дополнительные колонки в INCLUDE позволяют выполнить Index Only Scan без обращения к таблице.
Частичные индексы
CREATE INDEX idx_active ON orders (created_at) WHERE status = 'pending';
Индексирует только часть таблицы — меньше размер, выше селективность.
Когда индексы невыгодны
- Выборка более 5-10% таблицы — двойное чтение (индекс + таблица) дороже Seq Scan
- Очень маленькие таблицы — Seq Scan быстрее из-за накладных расходов
- Частые UPDATE/INSERT — индексы замедляют запись
Читаем EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE order_date >= '2024-01-01';
Ключевые метрики
| Метрика | Описание |
|---|---|
cost=0.00..1000.00 |
Startup cost..Total cost (оценка) |
rows=50000 |
Ожидаемое количество строк (оценка) |
actual time=0.012..15.034 |
Реальное время выполнения в мс |
actual rows=48000 |
Реальное количество строк |
loops=1 |
Сколько раз выполнялся узел |
Buffers: shared hit=100 read=50 |
Страницы из кеша / с диска |
На что обращать внимание
- Seq Scan на больших таблицах: проверьте, есть ли индекс
- rows vs actual rows сильно отличаются: устаревшая статистика, запустите ANALYZE
- Nested Loop с большими таблицами: возможно, лучше Hash Join
- Sort с большим cost: не хватает work_mem или нужен индекс
Оптимизация коротких запросов
Короткие запросы (OLTP) — используют небольшое количество данных. Цель: минимизировать количество чтений через индексы.
Стратегия
- Найти самый ограничивающий критерий в запросе
- Проверить, есть ли индекс для этого критерия
- Рассмотреть составной индекс для нескольких условий
- Добавить покрывающие колонки для Index Only Scan
Мониторинг использования индексов
SELECT relname, idx_scan, seq_scan, idx_tup_read
FROM pg_stat_all_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan DESC;
Следите за неиспользуемыми индексами — они замедляют запись без пользы.
Оптимизация длинных запросов
Длинные запросы (OLAP) — высокая селективность, читают значительную часть таблиц.
Стратегии
- Избегать многократного сканирования одних и тех же таблиц
- Уменьшать размер результата на ранних стадиях
- Hash Join вместо Nested Loop для больших наборов
- Контролировать порядок соединений — начинать с меньших результатов
CTE: материализация vs инлайн
С PostgreSQL 12 CTE по умолчанию могут инлайниться. Для контроля используйте:
-- Принудительная материализация
WITH cte AS MATERIALIZED (
SELECT * FROM large_table WHERE condition
)
SELECT * FROM cte WHERE other_condition;
-- Принудительный инлайн
WITH cte AS NOT MATERIALIZED (
SELECT * FROM large_table WHERE condition
)
SELECT * FROM cte WHERE other_condition;
Материализованные представления
Для тяжёлых аналитических запросов рассмотрите Materialized Views:
CREATE MATERIALIZED VIEW monthly_sales AS
SELECT product_id, SUM(amount) AS total
FROM sales
WHERE sale_date >= DATE_TRUNC('month', CURRENT_DATE)
GROUP BY product_id;
-- Обновление (можно по расписанию)
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales;
Чек-лист оптимизации
Перед оптимизацией
- Убедитесь, что запрос вообще нужен — возможно, задача решается по-другому
- Запустите
ANALYZEна таблицах - Получите план с
EXPLAIN (ANALYZE, BUFFERS)
Анализ плана
- Сравните
rowsиactual rows - Найдите узлы с максимальным
actual time - Проверьте Seq Scan на больших таблицах
Типичные решения
| Проблема | Решение |
|---|---|
| Seq Scan вместо Index Scan | Создать индекс, проверить селективность |
| Ошибки в оценке rows | ANALYZE, увеличить default_statistics_target |
| Медленная сортировка | Увеличить work_mem, создать индекс |
| Nested Loop на больших данных | Добавить индекс на ключ соединения |
| Индекс не используется для функции | Создать индекс по выражению |
- Масштабирование PostgreSQL — эксперименты с репликацией и шардированием