В прошлом уроке мы видели pipeline. Сейчас увеличим масштаб на самой интересной части — planner — и разделим его работу на два уровня: логический и физический. Это не академическая блажь; это та граница, на которой Postgres решает, что план корректен (выдаст правильный результат), и отдельно — какой дешевле.
Реляционная алгебра как промежуточный язык
Любой SQL-запрос можно представить как дерево операций реляционной алгебры: проекции, селекции, joins, агрегации. Это и есть логический план.
Например, запрос:
SELECT c.full_name, sum(o.total_cents) AS total
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.country = 'RU'
GROUP BY c.full_name;
В логике это:
Сверху вниз: aggregate группирует, join соединяет, selection фильтрует, scan читает таблицы. Никакой информации о методе выполнения — только что делается.
В реляционной алгебре никакой информации о как выполнять нет. Нет понятий «индекс», «hash table», «sort». Есть только что считать.
Эквивалентные преобразования
Логический план можно переписать другим, дающим тот же результат. Это — эквивалентные преобразования, и planner активно их применяет:
- Предикат «спустить вниз» (predicate pushdown):
σ(country='RU')(c ⋈ o)эквивалентноσ(country='RU')(c) ⋈ o. Лучше: фильтруемcustomersдо join, объём данных в join меньше. - Перестановка join’ов (associativity, commutativity):
(A ⋈ B) ⋈ C≡A ⋈ (B ⋈ C). Это даёт planner свободу выбрать порядок. - Inline subquery → join:
SELECT ... WHERE id IN (SELECT ...)часто переписывается в semi-join. - Projection pushdown: ненужные колонки выкидываются как можно раньше.
После преобразований остаётся
Физический план: один логический — много физических
Для одного логического плана есть много физических. Различия:
- Какой scan: Seq Scan, Index Scan, Bitmap Scan, Index Only Scan.
- Какой join: Nested Loop, Hash Join, Merge Join (модуль 8 — целый посвящён этому).
- Какой aggregate: Hash Aggregate, Group Aggregate (требует sorted input), Plain Aggregate (без group by).
- Нужны ли Sort/Materialize узлы и где.
Тот же логический join customers ⋈ orders → три разных executable plan. У каждого свой cost — planner выберет минимальный.
Planner перебирает (часть) физических планов в equivalent class, оценивает cost каждого, выбирает минимальный. Это и есть «cost-based optimization».
Посмотрим на разные физические планы
Базовый запрос. По умолчанию planner выберет — что-то одно. Запиши план.
Теперь принудительно отключим Hash Join — посмотрим, что planner выберет вторым.
Запрет Hash Join. Planner вынужден выбрать другой физический алгоритм для того же логического плана.
Та же выборка, тот же результат — но другое физическое дерево. Это и есть демонстрация: один логический план реализуется несколькими физическими.
Можно идти дальше — отключать поочерёдно:
Запрет hashjoin и mergejoin → остаётся только nested loop. Cost вырастет — но план корректен.
Сравни итоговый cost= — он гораздо выше. Именно поэтому planner и не выбрал nested loop сам.
Equivalent classes и pg_eclass
Внутри Postgres логические преобразования живут в структуре
a.x = b.y AND b.y = c.z, planner знает, что a.x = c.z — без явного указания. Это даёт ему свободу выбирать join order. Пример:
SELECT * FROM a, b, c
WHERE a.x = b.y AND b.y = c.z;
Logically {a.x, b.y, c.z} — одна equivalence class. Planner может выполнить a ⋈ c напрямую через выведенное условие a.x = c.z, даже если ты не написал это в WHERE.
Почему планы меняются между запусками
Не должны меняться без причины — но могут. Триггеры:
ANALYZEобновил статистику; раньше думали 1000 строк, теперь 1M — план перестроился.- Изменён GUC:
random_page_cost,work_mem,enable_hashjoin. - Новый индекс создан или удалён.
- В prepared statement переключение custom → generic plan (см. урок 1).
- Версия Postgres обновлена с новыми эвристиками.
Если в продакшене план «вдруг» поменялся, в первую очередь проверь pg_stat_user_tables / pg_class.reltuples — есть ли свежий ANALYZE.
Зачем нам это разделение
В практике различение логический/физический даёт несколько вещей:
- EXPLAIN читать стало понятно: видишь Hash Join — знаешь, что это физический выбор. Хочешь другой — отключай флаг или меняй статистику.
- Hint’ы (через pg_hint_plan) работают именно на уровне физического плана: можно запретить определённый join algorithm для конкретного запроса.
- Понимание границ оптимизатора: если логически невозможно (
SELECT count(*) FROM tобязан прочитать каждую строку), никакой физический план не спасёт.
Чек-лист
- Логический план — реляционная алгебра, описывает «что считать».
- Физический план — конкретные узлы (Seq Scan / Hash Join / Sort), описывает «как».
- Один логический → много физических; planner перебирает их в equivalent class.
- Эквивалентные преобразования: predicate pushdown, join reordering, projection pushdown, semi-join из IN.
enable_*GUC выключают физические узлы — отладочный инструмент, не для прода.- EquivalenceClass позволяет planner выводить транзитивные равенства между relations.
- Когда план «вдруг» изменился — ищи свежий
ANALYZE, изменение GUC или новый индекс.