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 — связка с 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.
Filter pushdown работает тем лучше, чем удачнее физически упорядочены данные. Если строки, удовлетворяющие частым фильтрам, лежат рядом (таблица отсортирована или партиционирована по этой колонке), zonemap отсекает большие куски. Если они разбросаны равномерно — каждая row group содержит и подходящие, и нет, и пропустить нечего. Порядок данных на диске — рычаг производительности.
Projection pushdown: читай только нужные колонки
Projection pushdown — про колонки, а не про строки. Он гарантирует, что из источника читаются только те колонки, которые реально используются в запросе. Для колоночного хранилища это фундаментально: данные каждой колонки лежат отдельно, поэтому ненужную колонку можно просто не загружать с диска.
Запрос SELECT name, amount FROM orders WHERE region = 'EU' обращается к трём колонкам: name, amount, region. Если в таблице orders сорок колонок, projection pushdown сделает так, что движок прочитает только эти три, а остальные тридцать семь даже не коснутся диска и памяти.
Именно поэтому в аналитике 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 * 4 | 14 |
WHERE x > 100 / 4 | WHERE 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 тем заметнее, чем дороже подвыражение и чем чаще оно повторяется. Для дешёвого 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-данными те же правила, что и для таблиц: конкретные колонки вместо *, фильтры по колонкам, по которым файл удачно упорядочен.
Попробуй сам
- Создай широкую таблицу:
CREATE TABLE wide AS SELECT range AS id, range % 100 AS bucket, range::VARCHAR AS s1, (range*2)::VARCHAR AS s2 FROM range(2000000); - Сравни
EXPLAINдляSELECT * FROM wide WHERE bucket = 7и дляSELECT id FROM wide WHERE bucket = 7. Найди строкуProjectionsуSEQ_SCAN— сколько колонок читается в каждом случае? Это projection pushdown. - Выполни
EXPLAIN SELECT * FROM wide WHERE 5 > 7;— убедись, что план свёлся к пустому результату и таблица не сканируется. Это constant folding. - Выполни
EXPLAIN ANALYZE SELECT id FROM wide WHERE id >= 1999000;и посмотриrows scannedу скана. Сравни с 2000000 — отсекаются ли row group по zonemap (таблица созданаrange, то естьidотсортирован)? Это filter pushdown + zonemap. - Напиши запрос с трижды повторённым выражением
(s1 || '-' || s2)в SELECT и WHERE. Подумай, почему CSE делает ненужным ручной вынос этого выражения в отдельный CTE.