В предыдущих модулях мы спускались всё глубже к диску: страницы, B-tree, MVCC, VACUUM. Теперь меняем ось координат и смотрим, что Postgres делает с твоим SQL-текстом, прежде чем хоть один байт будет прочитан с диска.
Запрос проходит ровно четыре стадии: parser, rewriter, planner, executor. Большинство «странных» поведений базы — почему план не меняется, почему EXPLAIN показывает не ту таблицу, почему prepare иногда хуже обычного запроса — объясняются тем, на какой из этих стадий что-то закрепляется.
Четыре стадии
SQL-текст проходит четыре стадии. После каждой стадии в памяти лежит другая структура: text → parse tree → query tree → plan tree → результат.
Разберём каждую.
1. Parser
Получает на вход сырой текст SQL и превращает его в parse tree — синтаксическое дерево. На этой стадии Postgres только проверяет грамматику и базовые имена (есть ли такая таблица, есть ли такая колонка). Никаких оптимизаций. Если ты написал SELECT * FROM customres с опечаткой, ошибка вылетит именно отсюда.
RawStmt, дальше «analyzer» превращает её в Query, дополняя информацией о таблицах из системных каталогов.
2. Rewriter
Применяет правила перезаписи. Самое частое:
- Раскрытие views:
SELECT * FROM v_active_usersпревращается в подзапрос с определениемv_active_usersподставленным на месте. View — это не материализованная сущность; для планировщика она прозрачна. - Применение RULE’ов:
CREATE RULE(исторический механизм, сейчас редко используется) позволяет переписать запрос. Например,INSERTв view может быть переписан вINSERTв реальную таблицу. - Row-level security: если на таблице включён , кRLS
WHEREдобавляется политика.
После rewriter в памяти лежит query tree — логическое представление запроса, всё ещё без понятия «как именно его выполнять».
3. Planner (он же optimizer)
Самая интересная стадия. Planner смотрит на query tree и решает:
- Какой scan-метод для каждой таблицы —
Seq Scan,Index Scan,Bitmap Scan,Index Only Scan. - В каком порядке делать JOIN’ы —
(A ⋈ B) ⋈ CилиA ⋈ (B ⋈ C)? - Какой join-алгоритм — Nested Loop, Hash Join, Merge Join.
- Где нужны Sort, Aggregate, Hash, Materialize.
И всё это — на основе оценок стоимости (cost-based). Planner перебирает кандидатов и считает cost = seq_page_cost × pages + cpu_tuple_cost × rows + .... Выигрывает план с минимальной стоимостью.
Откуда planner берёт оценки числа строк? Из
ANALYZE. Поэтому правило: ANALYZE влияет только на стадию planner, ничего больше. На вычисления и чтение диска она не влияет напрямую — она меняет план, а уже план диктует чтения.
4. Executor
Получает plan tree и выполняет его. Plan tree — это дерево узлов; executor запрашивает строки сверху вниз: «дай мне следующую строку» (
Executor — это «глупый» компонент в том смысле, что он не оптимизирует. Он строго следует плану: если planner сказал «Seq Scan + Filter», executor честно прочитает все страницы и применит фильтр к каждой строке, даже если индекс был бы быстрее.
Где «закрепляется» план
Это критически важно для понимания PREPARE / prepared statements.
Обычный запрос плана→exec каждый раз. PREPARE — один раз parser+rewriter, planner может закэшировать generic plan.
Когда ты делаешь обычный SELECT, все четыре стадии происходят каждый раз. Когда делаешь PREPARE stmt(int) AS SELECT * FROM orders WHERE customer_id = $1 и потом EXECUTE stmt(42), parser и rewriter работают только один раз. А вот planner — первые 5 раз строит custom plan под конкретные параметры, и на 6-м может переключиться на generic plan (без знания значения параметра), если оценки расходятся не сильно. Это объясняет вечную головную боль: prepared statement «вдруг» начал работать медленнее, потому что Postgres сэкономил на пересчёте плана.
Посмотрим на pipeline своими глазами
Простой запрос: смотрим план. EXPLAIN показывает результат стадии 3 (planner), но не стадии 1-2.
В выводе видно: узлы плана (Seq Scan / Limit), оценки (cost=0.00..193.00 rows=2000), реальные числа (actual time=...), и количество прочитанных буферов (Buffers: shared hit=...). Заметь: ни одна стадия parser/rewriter в EXPLAIN не отражается — её просто нет в выводе, она прозрачна.
Тот же запрос через view: смотрим, что view раскрывается на стадии rewriter, и план идентичен предыдущему.
Внутри плана нет ни упоминания view — только базовая таблица customers. Это и есть работа rewriter: вью растворилась в подзапрос ещё до того, как planner увидел запрос.
ANALYZE — это только про planner
Пример различия: ANALYZE ничего не оптимизирует и ничего не пересчитывает в данных. Она просто обновляет pg_statistic — гистограммы, n_distinct, most-common values. После этого planner на следующем запросе может выбрать другой план.
Стираем статистику и смотрим, как меняется оценка. (Постгресс не позволяет ALL clean; делаем через UPDATE pg_class.reltuples — наглядно, что планировщику плохо без статистики.)
Без статистики planner мог бы выбрать совсем не тот план — но executor бы выполнил именно его, со всеми последствиями.
Где у каждой стадии узкое место
В реальной жизни время каждой стадии распределяется так:
- Parser/Rewriter — микросекунды. Узким местом не бывают почти никогда. Исключение: сложные view-stack’и (view внутри view внутри view) могут заметно замедлить parsing на каждом вызове, если query repository собирается заново.
- Planner — обычно миллисекунды. Узким местом становится при 10+ JOIN’ах (см. урок про GEQO) или при крайне сложных подзапросах. При prepared statement amortized to zero.
- Executor — основная стоимость. Здесь же тратится IO, CPU, память на hash tables, sorts.
Поэтому когда ты оптимизируешь запрос, ты в 99% случаев целишься в executor (через изменение плана) или в planner (через ANALYZE, CREATE STATISTICS, hints). И почти никогда — в parser/rewriter.
Чек-лист
- Pipeline: parser → rewriter → planner → executor. Каждая стадия преобразует структуру.
- Parser — только синтаксис и базовые имена. Ошибки опечаток здесь.
- Rewriter — раскрытие views, применение RULE, RLS. View’ы исчезают как сущность.
- Planner — единственная стадия, читающая
pg_statistic.ANALYZEвлияет только сюда. - Executor — «глупый» исполнитель плана; volcano-iterator pull-model.
PREPAREэкономит parse+rewrite, может закэшировать generic plan (с риском хуже custom).- 99% оптимизаций — это влияние на план, не на parser/rewriter.