Learning Platform
Урок 07.01 · 22 мин
Продвинутый
PlannerExecutorParserRewriterPipeline

В предыдущих модулях мы спускались всё глубже к диску: страницы, B-tree, MVCC, VACUUM. Теперь меняем ось координат и смотрим, что Postgres делает с твоим SQL-текстом, прежде чем хоть один байт будет прочитан с диска.

Запрос проходит ровно четыре стадии: parser, rewriter, planner, executor. Большинство «странных» поведений базы — почему план не меняется, почему EXPLAIN показывает не ту таблицу, почему prepare иногда хуже обычного запроса — объясняются тем, на какой из этих стадий что-то закрепляется.

Четыре стадии

Pipeline запроса в PostgreSQL

SQL-текст проходит четыре стадии. После каждой стадии в памяти лежит другая структура: text → parse tree → query tree → plan tree → результат.

1. ParserSQL-текст → parse tree
2. Rewriterparse tree → query tree
3. Plannerquery tree → plan tree
4. Executorplan tree → tuples
что переживает между стадиямистатистика (pg_statistic) читается только на стадии 3

Разберём каждую.

1. Parser

Получает на вход сырой текст SQL и превращает его в parse tree — синтаксическое дерево. На этой стадии Postgres только проверяет грамматику и базовые имена (есть ли такая таблица, есть ли такая колонка). Никаких оптимизаций. Если ты написал SELECT * FROM customres с опечаткой, ошибка вылетит именно отсюда.

Parse tree
— это структура 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 берёт оценки числа строк? Из

pg_statistic
— статистики, которую обновляет команда ANALYZE. Поэтому правило: ANALYZE влияет только на стадию planner, ничего больше. На вычисления и чтение диска она не влияет напрямую — она меняет план, а уже план диктует чтения.

4. Executor

Получает plan tree и выполняет его. Plan tree — это дерево узлов; executor запрашивает строки сверху вниз: «дай мне следующую строку» (

volcano-iterator model
).

Executor — это «глупый» компонент в том смысле, что он не оптимизирует. Он строго следует плану: если planner сказал «Seq Scan + Filter», executor честно прочитает все страницы и применит фильтр к каждой строке, даже если индекс был бы быстрее.

Где «закрепляется» план

Это критически важно для понимания PREPARE / prepared statements.

Когда переплан

Обычный запрос плана→exec каждый раз. PREPARE — один раз parser+rewriter, planner может закэшировать generic plan.

ad-hoc SELECTparse → rewrite → plan → exec при каждом вызове
PREPARE + EXECUTEparse + rewrite один раз; plan: 5 раз custom, потом generic
generic planне знает параметры; иногда хуже custom (см. plan_cache_mode)

Когда ты делаешь обычный 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.

PostgreSQL

В выводе видно: узлы плана (Seq Scan / Limit), оценки (cost=0.00..193.00 rows=2000), реальные числа (actual time=...), и количество прочитанных буферов (Buffers: shared hit=...). Заметь: ни одна стадия parser/rewriter в EXPLAIN не отражается — её просто нет в выводе, она прозрачна.

Тот же запрос через view: смотрим, что view раскрывается на стадии rewriter, и план идентичен предыдущему.

PostgreSQL

Внутри плана нет ни упоминания view — только базовая таблица customers. Это и есть работа rewriter: вью растворилась в подзапрос ещё до того, как planner увидел запрос.

ANALYZE — это только про planner

Пример различия: ANALYZE ничего не оптимизирует и ничего не пересчитывает в данных. Она просто обновляет pg_statistic — гистограммы, n_distinct, most-common values. После этого planner на следующем запросе может выбрать другой план.

Стираем статистику и смотрим, как меняется оценка. (Постгресс не позволяет ALL clean; делаем через UPDATE pg_class.reltuples — наглядно, что планировщику плохо без статистики.)

PostgreSQL

Без статистики 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.

Проверка знанийKnowledge check
Ты сделал PREPARE и EXECUTE одного и того же запроса 100 раз. На 6-м вызове его время удвоилось и так и осталось. Что могло произойти и как проверить?
ОтветAnswer
Скорее всего, Postgres переключился с custom plan (планируется под конкретные параметры) на generic plan (один раз построен без знания значений). Generic plan игнорирует selectivity конкретного $1 и может выбрать худший scan. Проверка: SET plan_cache_mode = 'force_custom_plan' и сравни — если стало быстро, виноват именно generic plan. Лечение: 'force_custom_plan' для проблемной prepared (с пониманием цены планирования) или 'force_generic_plan' если все параметры близки по selectivity. Это feature, не bug — Postgres экономит CPU на планирование.

Чек-лист

  • 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.
Декларативность SQL: «что», а не «как» EXPLAIN PIPELINE: читаем DAG процессоров ClickHouse

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Какая стадия pipeline запроса в PostgreSQL единственная читает pg_statistic, и что является её результатом?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5