Learning Platform
Глоссарий Troubleshooting
Урок 13.05 · 23 мин
Средний
filter-pushdownprojection-pushdownconstant-foldingcse

Filter pushdown, projection pushdown, constant folding и CSE

В уроке про оптимизатор мы видели проходы целиком, сверху. Теперь — четыре конкретных rule-based преобразования крупным планом: filter pushdown, projection pushdown, constant folding и common subexpression elimination. Все четыре объединяет один принцип: уменьшить объём данных и количество вычислений до того, как начнётся дорогая часть работы. Это не «микрооптимизации» — на колоночном движке вроде DuckDB они дают разницу в разы и определяют, какие запросы вообще выполнимы на больших данных.

Эти преобразования rule-based: они улучшают план всегда, поэтому применяются безусловно. Понимание их механики — то, что позволяет читать EXPLAIN осмысленно и писать запросы, которые оптимизатору легко ускорить.


Filter pushdown: фильтруй как можно раньше

Filter pushdown опускает условия WHERE вниз по дереву плана — как можно ближе к чтению таблиц. Логика: если строка не пройдёт фильтр, нет смысла прогонять её через join, агрегацию или сортировку. Чем раньше она отброшена, тем меньше работы у всех операторов выше.

Рассмотрим наивный план запроса с join и фильтром.

Filter pushdown: было и стало
Наивный планФильтр стоит после join — через join проходят ВСЕ строки обеих таблиц
оптимизатор
После pushdownФильтр опущен в скан — join получает только прошедшие фильтр строки

Самое мощное проявление filter pushdown — связка с zonemap-статистикой. DuckDB хранит для каждого колоночного сегмента (и для каждой row group) min/max значений. Когда фильтр опущен в скан, движок до чтения данных сравнивает условие с этими min/max: если фильтр — amount > 1000, а у row group max равен 800, всю группу можно пропустить, не читая ни байта её данных. Это превращает фильтр из «прочитать и отбросить» в «не читать вовсе».

EXPLAIN ANALYZE
SELECT * FROM sales WHERE sale_date >= DATE '2026-01-01';
┌─────────────────────────────────────┐
│              SEQ_SCAN               │
│               sales                 │
│   Filters: sale_date >= 2026-01-01  │
│         rows scanned: 1200000       │   <- не вся таблица
│           result: 1200000          │
└─────────────────────────────────────┘

Если таблица отсортирована или партиционирована по sale_date, число прочитанных строк (rows scanned) будет заметно меньше размера всей таблицы — row group, целиком не попадающие в диапазон, пропущены по zonemap. Тот же механизм работает и при чтении Parquet: фильтр опускается в чтение файла и пропускает row group по статистике, записанной в метаданных Parquet.

TIP

Filter pushdown работает тем лучше, чем удачнее физически упорядочены данные. Если строки, удовлетворяющие частым фильтрам, лежат рядом (таблица отсортирована или партиционирована по этой колонке), zonemap отсекает большие куски. Если они разбросаны равномерно — каждая row group содержит и подходящие, и нет, и пропустить нечего. Порядок данных на диске — рычаг производительности.


Projection pushdown: читай только нужные колонки

Projection pushdown — про колонки, а не про строки. Он гарантирует, что из источника читаются только те колонки, которые реально используются в запросе. Для колоночного хранилища это фундаментально: данные каждой колонки лежат отдельно, поэтому ненужную колонку можно просто не загружать с диска.

Запрос SELECT name, amount FROM orders WHERE region = 'EU' обращается к трём колонкам: name, amount, region. Если в таблице orders сорок колонок, projection pushdown сделает так, что движок прочитает только эти три, а остальные тридцать семь даже не коснутся диска и памяти.

Projection pushdown: читаем только 3 из 40 колонок
Таблица orders40 колонок на диске, каждая хранится отдельным набором сегментов
projection pushdown
Читается со сканаТолько name, amount, region — три колонки, нужные запросу; остальные не загружаются

Именно поэтому в аналитике SELECT * — антипаттерн. На строковом хранилище * и три колонки читают примерно одинаковый объём (строка всё равно лежит целиком). На колоночном SELECT * заставляет прочитать все сорок колонок вместо трёх — то есть может быть на порядок дороже без всякой пользы. Запрашивайте ровно те колонки, что нужны, и projection pushdown превратит это в реальную экономию I/O.

-- В плане видно, какие колонки реально читаются:
EXPLAIN SELECT name, amount FROM orders WHERE region = 'EU';

В выводе у SEQ_SCAN есть строка Projections со списком читаемых колонок — там будут только name, amount, region, а не все сорок.


Constant folding: вычисли постоянное один раз

Constant folding — часть прохода Expression Rewriter. Он вычисляет подвыражения, не зависящие от данных строки, один раз при компиляции запроса, а не на каждой из миллионов строк при исполнении.

Примеры свёртки:

Исходное выражениеПосле constant folding
2 + 3 * 414
WHERE x > 100 / 4WHERE x > 25
WHERE TRUE AND status = 'A'WHERE status = 'A'
WHERE 1 = 0(план «пустой результат», таблица не читается вовсе)
upper('eu')'EU'

Последняя строка — самый сильный случай: если фильтр сворачивается в заведомо ложное условие (1 = 0, WHERE FALSE), оптимизатор понимает, что результат пуст, и заменяет всё поддерево на оператор пустого результата — таблица не сканируется ни на строку.

EXPLAIN SELECT * FROM huge_table WHERE 1 = 2;
┌───────────────────────────┐
│      EMPTY_RESULT         │
│   (constant folding:      │
│    1 = 2 свёрнуто в FALSE)│
└───────────────────────────┘

Стоит подчеркнуть: constant folding сворачивает только то, что действительно константно. Выражение now() или random() не свернётся — их значение не постоянно. А вот current_date в пределах одного запроса фиксирован, и сравнение с ним участвует в folding и pushdown.


Common subexpression elimination: не считай одно дважды

CSE (common subexpression elimination, иногда «extraction») находит выражения, встречающиеся в запросе несколько раз, и устраивает так, чтобы они вычислялись однократно, а результат переиспользовался.

Классический случай:

SELECT
  upper(trim(name)) AS clean_name,
  length(upper(trim(name))) AS name_len
FROM customers
WHERE upper(trim(name)) <> '';

Выражение upper(trim(name)) написано трижды: в двух элементах SELECT и в WHERE. Без CSE движок выполнил бы trim и upper три раза для каждой строки. CSE распознаёт идентичность подвыражений и вычисляет upper(trim(name)) один раз на строку, переиспользуя результат во всех трёх местах.

CSE: общее подвыражение вычисляется один раз
upper(trim(name))Подвыражение встречается трижды в запросе; CSE вычисляет его однократно на строку
результат переиспользуется
clean_nameПервое использование результата общего подвыражения
name_lenlength() применяется к уже вычисленному результату
WHEREУсловие фильтра использует тот же вычисленный результат

Экономия CSE тем заметнее, чем дороже подвыражение и чем чаще оно повторяется. Для дешёвого a + 1 выигрыш минимален; для regex-операции или сложной строковой обработки, повторённой в большом запросе, — существенный. Практический вывод: не нужно вручную выносить повторы в CTE ради «оптимизации» — оптимизатор сам устранит дублирование. CTE стоит использовать ради читаемости, а не как костыль производительности.


Четыре преобразования вместе

Все четыре работают на одном запросе и усиливают друг друга. Constant folding упрощает условия — после него filter pushdown опускает уже упрощённые фильтры в скан, где zonemap отсекает row group. Projection pushdown параллельно урезает набор колонок. CSE убирает повторные вычисления в том, что осталось.

ПреобразованиеЧто уменьшаетГлавный механизм
Filter pushdownчисло читаемых/обрабатываемых строкопускание WHERE в скан + zonemap-скип
Projection pushdownчисло читаемых колонокчтение только нужных колонок (колоночное хранилище)
Constant foldingвычисления на строкусвёртка постоянных подвыражений при компиляции
CSEповторные вычисленияоднократный расчёт общего подвыражения

Вместе они реализуют принцип «не читай и не вычисляй ничего лишнего». Запрос, написанный так, чтобы оптимизатору было легко их применить — конкретный список колонок вместо *, фильтры по колонкам с удачным физическим порядком данных — исполняется в разы быстрее логически эквивалентного «небрежного» запроса.


Pushdown работает и сквозь файлы

Важная деталь: эти преобразования не ограничены нативными таблицами DuckDB. Они срабатывают и при запросах к внешним файлам — прежде всего к Parquet, и это одна из причин, почему DuckDB так быстра на Parquet-датасетах.

Parquet — колоночный формат со встроенной структурой: данные разбиты на row group, у каждой колонки в каждой row group есть статистика (min/max), и колонки физически разделены. Это идеально совпадает с тем, что нужно pushdown:

  • Projection pushdown в Parquet. Когда запрос обращается к трём колонкам файла на сорок, DuckDB читает из Parquet-файла только байтовые диапазоны этих трёх колонок. Остальные тридцать семь не загружаются — колоночная раскладка Parquet это позволяет напрямую.
  • Filter pushdown в Parquet. Фильтр опускается в чтение файла, и DuckDB сравнивает его с min/max каждой row group из метаданных Parquet. Row group, заведомо не попадающие под фильтр, пропускаются — их данные не читаются с диска вообще.
-- читается только колонка amount и только подходящие row group
EXPLAIN
SELECT sum(amount) FROM 'big_dataset.parquet' WHERE region = 'EU';

В плане у PARQUET_SCAN будут видны и опущенный фильтр, и список читаемых колонок. То есть запрос к Parquet-файлу в DuckDB — это не «прочитать весь файл и отфильтровать»: благодаря pushdown читается лишь нужное подмножество. Поэтому при работе с внешними Parquet-данными те же правила, что и для таблиц: конкретные колонки вместо *, фильтры по колонкам, по которым файл удачно упорядочен.


Попробуй сам

  1. Создай широкую таблицу: CREATE TABLE wide AS SELECT range AS id, range % 100 AS bucket, range::VARCHAR AS s1, (range*2)::VARCHAR AS s2 FROM range(2000000);
  2. Сравни EXPLAIN для SELECT * FROM wide WHERE bucket = 7 и для SELECT id FROM wide WHERE bucket = 7. Найди строку Projections у SEQ_SCAN — сколько колонок читается в каждом случае? Это projection pushdown.
  3. Выполни EXPLAIN SELECT * FROM wide WHERE 5 > 7; — убедись, что план свёлся к пустому результату и таблица не сканируется. Это constant folding.
  4. Выполни EXPLAIN ANALYZE SELECT id FROM wide WHERE id >= 1999000; и посмотри rows scanned у скана. Сравни с 2000000 — отсекаются ли row group по zonemap (таблица создана range, то есть id отсортирован)? Это filter pushdown + zonemap.
  5. Напиши запрос с трижды повторённым выражением (s1 || '-' || s2) в SELECT и WHERE. Подумай, почему CSE делает ненужным ручной вынос этого выражения в отдельный CTE.
Predicate pushdown в PostgreSQL: как оптимизатор опускает фильтры
Проверка знанийKnowledge check
Почему в аналитике на DuckDB SELECT * считается антипаттерном, и какое преобразование оптимизатора превращает выбор конкретных колонок в реальную экономию?
ОтветAnswer
SELECT * — антипаттерн из-за колоночного хранилища. В DuckDB данные каждой колонки лежат отдельным набором сегментов на диске. Преобразование projection pushdown гарантирует, что из источника читаются только колонки, реально используемые в запросе, — остальные просто не загружаются с диска и в память. Когда вы пишете SELECT с тремя конкретными колонками из таблицы на сорок колонок, projection pushdown заставит прочитать только эти три. SELECT * отменяет эту экономию: он требует прочитать все сорок колонок, что на колоночном движке может быть на порядок дороже по I/O и памяти без всякой пользы. На строковом хранилище разницы почти нет — строка лежит целиком, — но на колоночном выбор конкретных колонок и есть рычаг производительности. Поэтому в аналитических запросах всегда перечисляют ровно нужные колонки, а projection pushdown превращает это в пропорциональную экономию чтения.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Как filter pushdown в связке с zonemap-статистикой позволяет не читать данные вовсе?

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

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

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

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