EXPLAIN, EXPLAIN ANALYZE и чтение планов запросов
SQL — декларативный язык: вы пишете, что хотите получить, а как это вычислить — решает движок. Это удобно, но создаёт разрыв: текст запроса не показывает, что DuckDB на самом деле делает. Один и тот же SELECT может исполниться эффективно или катастрофически медленно — а по тексту это незаметно.
Мост через этот разрыв — EXPLAIN и EXPLAIN ANALYZE. Они показывают план запроса: дерево операций, которым движок исполняет ваш SQL. Умение читать планы — базовый навык инженера: без него оптимизация запросов превращается в гадание. Этот урок — введение в чтение планов; глубоко оптимизатор курс разбирает в модуле 12, но фундамент закладывается здесь. Версия — DuckDB 1.5.2.
Почему этот навык настолько важен именно для аналитической СУБД. На OLTP-запросе, который трогает несколько строк по ключу, разница между хорошим и плохим планом измеряется долями миллисекунды — её можно не замечать. На OLAP-запросе, который сканирует и соединяет миллионы строк, та же разница в плане превращается в разницу между секундами и минутами. Аналитический запрос — это место, где решения оптимизатора видны невооружённым глазом, и где умение прочитать план напрямую конвертируется в скорость работы. Поэтому для инженера, работающего с DuckDB, EXPLAIN — не экзотика «на крайний случай», а повседневный инструмент.
Что такое план запроса
Когда вы отправляете SQL, DuckDB не исполняет текст напрямую. Он проводит запрос через конвейер: парсит, связывает с каталогом, строит логический план, оптимизирует его и строит физический план — дерево конкретных операторов (scan, filter, join, aggregate, projection), которые и будут исполнены. (Полностью этот конвейер курс разбирает в модуле про векторизованный движок.)
Физический план — это дерево. Листья дерева — операторы чтения данных (SEQ_SCAN — сканирование таблицы). Внутренние узлы — операторы обработки (фильтр, соединение, агрегация). Корень — то, что отдаёт финальный результат. Данные текут снизу вверх: от листьев-сканов через операторы обработки к корню.
EXPLAIN показывает это дерево, не исполняя запрос. EXPLAIN ANALYZE исполняет запрос и показывает дерево с реальными измерениями.
EXPLAIN: посмотреть план без исполнения
EXPLAIN ставится перед запросом и показывает физический план, не выполняя запрос:
EXPLAIN
SELECT region, count(*) AS cnt
FROM orders
WHERE amount > 100
GROUP BY region;
Упрощённый вид вывода:
┌───────────────────────────┐
│ PROJECTION │
│ region, cnt │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HASH_GROUP_BY │
│ groups: region │
│ aggregate: count() │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ FILTER │
│ amount > 100 │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ SEQ_SCAN │
│ orders │
│ projection: region, │
│ amount │
└───────────────────────────┘
Читается дерево снизу вверх — в порядке течения данных:
SEQ_SCAN orders— лист: сканирование таблицыorders. Важная деталь — строкаprojection: region, amount: DuckDB читает только две колонки, нужные запросу, а не всю таблицу. Это projection pushdown.FILTER amount > 100— отбрасывает строки, не прошедшие условие.HASH_GROUP_BY— группирует поregionи считаетcount(). Реализован через хеш-таблицу.PROJECTION— корень: формирует финальный набор колонок результата.
Уже из этого EXPLAIN видно полезное: движок не читает лишние колонки (projection pushdown), а группировка идёт через хеш-агрегацию. И всё это — без исполнения запроса: EXPLAIN показывает намерение движка.
Стоит отдельно объяснить, почему EXPLAIN показывает именно физический план, а не текст вашего запроса. Между ними DuckDB проводит несколько преобразований. Сначала логический план — абстрактное «что нужно сделать»: какие отношения, какие проекции, какие фильтры. Затем оптимизатор применяет к логическому плану набор проходов: проталкивает фильтры ближе к источнику, убирает лишние колонки, переупорядочивает соединения. И только после этого строится физический план — конкретные операторы с конкретными алгоритмами (HASH_GROUP_BY, а не абстрактная «группировка»). EXPLAIN показывает результат всей этой работы, поэтому он отвечает не на вопрос «что я написал», а на вопрос «что движок решил сделать». Часто эти два ответа заметно расходятся — и именно расхождение полезно: оно показывает, что оптимизатор сделал с вашим запросом.
Есть и практический приём чтения. Когда план большой, не пытайтесь осмыслить его целиком сразу. Найдите листья — все SEQ_SCAN — и для каждого проверьте, какие колонки он читает и какие фильтры к нему опущены. Это говорит, сколько данных вообще входит в запрос. Затем поднимайтесь вверх по одному оператору, отслеживая, что происходит с потоком: где он сужается (фильтр), где расширяется (соединение), где сворачивается (агрегация). Такое послойное чтение превращает дерево из произвольного набора прямоугольников в понятную историю обработки данных.
EXPLAIN ANALYZE: профиль реального исполнения
EXPLAIN показывает план; EXPLAIN ANALYZE идёт дальше — он исполняет запрос и показывает дерево с реальными измерениями: сколько строк прошло через каждый оператор и сколько времени он занял.
EXPLAIN ANALYZE
SELECT region, count(*) AS cnt
FROM orders
WHERE amount > 100
GROUP BY region;
В выводе у каждого оператора появляются фактические данные. Концептуально это выглядит так:
HASH_GROUP_BY исполнено за ... , на выходе ... строк
FILTER исполнено за ... , на выходе 240118 строк
SEQ_SCAN исполнено за ... , прочитано 600572 строк
Здесь становится видно главное, чего нет в обычном EXPLAIN — сколько строк реально протекло через каждый оператор. В примере: SEQ_SCAN прочитал 600572 строки, FILTER пропустил дальше 240118 — то есть условие amount > 100 отсеяло больше половины. Плюс у каждого оператора — реальное время.
Эти числа отвечают на главный вопрос диагностики: где запрос тратит время и какой оператор обрабатывает больше всего строк. Оператор с наибольшим временем — узкое место; именно с него начинают оптимизацию.
EXPLAIN ANALYZE действительно исполняет запрос. Для SELECT это безопасно — лишь тратится время на исполнение. Но EXPLAIN ANALYZE перед INSERT, UPDATE, DELETE или CREATE TABLE реально выполнит изменение данных. Если нужно лишь посмотреть план изменяющего запроса, не меняя данные, используйте обычный EXPLAIN — он ничего не исполняет.
Есть тонкость, которую важно держать в голове при чтении чисел EXPLAIN ANALYZE. Время отдельного оператора в параллельном плане — это не просто «сколько он работал по часам». DuckDB исполняет запрос многопоточно, и один логический оператор может работать сразу в нескольких потоках. Поэтому, сравнивая операторы между собой, ориентируйтесь в первую очередь на их относительный вклад: какой оператор доминирует в общей картине, а не на абсолютную секунду в отрыве от остального. Для совсем чистого сравнения, без эффектов параллелизма, иногда полезно выполнить тот же запрос с SET threads = 1 — тогда числа становятся проще для интерпретации, хотя и не отражают реальную параллельную скорость. Это связка с уроком про конфигурацию: threads и чтение планов работают вместе.
И ещё одно про числа строк. EXPLAIN ANALYZE показывает, сколько строк вышло из каждого оператора. Сравнивая выход оператора с выходом того, что ниже, вы видите, что оператор сделал с потоком. Фильтр обычно поток сужает — на выходе строк меньше, чем на входе. Соединение может и сузить, и резко расширить — если ключ соединения не уникален, одной входной строке может соответствовать много строк с другой стороны, и поток размножается. Агрегация сворачивает поток до числа групп. Когда вы видите оператор, после которого строк стало на порядок больше, это почти всегда соединение, и почти всегда — кандидат на то, чтобы разобраться, почему оно размножает данные.
Типичные находки и что с ними делать
Чтение планов имеет смысл, только если за находкой следует действие. Соберём несколько типичных наблюдений и их трактовку — это практический мостик к модулю про оптимизатор.
SEQ_SCAN читает все колонки таблицы, хотя запросу нужны не все. Если в SEQ_SCAN нет строки projection: с коротким списком или перечислены явно лишние колонки — проверьте, не написан ли в запросе SELECT * там, где нужно несколько колонок. Лишние колонки — это лишний ввод-вывод и лишняя работа.
Фильтр стоит высоко в дереве, далеко от SEQ_SCAN. Чем раньше отсеиваются ненужные строки, тем меньше данных проходит через дорогие операторы выше. Обычно DuckDB сам опускает фильтры вниз (filter pushdown), но если в плане фильтр оказался высоко — стоит понять, почему оптимизатор не смог его протолкнуть (часто причина в том, что условие зависит от результата соединения или агрегации).
После соединения число строк резко выросло. Это размножение строк из-за неуникального ключа соединения. Иногда это ожидаемо (соединение «один ко многим»), иногда — ошибка в условии соединения, ведущая к декартову взрыву. EXPLAIN ANALYZE делает такой взрыв видимым сразу.
Один оператор доминирует по времени. Это узкое место, и оптимизировать нужно именно его, а не запрос целиком наугад. Что именно делать с узким оператором — зависит от его типа, и это содержание модуля про оптимизатор; но первый шаг — точно знать, какой оператор дорогой, — даёт именно EXPLAIN ANALYZE.
На что смотреть в плане
Читая план, инженер ищет несколько вещей. Вот базовый чек-лист — он же мостик к модулю про оптимизатор.
Pushdown. В строках SEQ_SCAN ищите projection: (читаются ли только нужные колонки) и признаки filter pushdown (фильтр опущен ближе к сканированию). Pushdown — это хорошо: он сокращает объём читаемых и обрабатываемых данных.
Тип соединения. Для join смотрите, какой оператор выбран. HASH_JOIN — основной способ соединения в DuckDB. Видеть, как соединяются таблицы, важно для понимания стоимости запроса.
Селективность фильтров (в ANALYZE). Сравнивайте число строк на входе и выходе FILTER. Если фильтр отсеивает большую часть строк — хорошо бы, чтобы он применялся как можно раньше, до тяжёлых операций.
Узкое место (в ANALYZE). Оператор с наибольшим временем — то, что нужно ускорять в первую очередь. Оптимизировать стоит самый дорогой узел, а не наугад выбранный.
Объёмы строк (в ANALYZE). Резкий рост числа строк вверх по дереву (например, после join) — сигнал: соединение размножает строки, и это потенциальный источник медлительности.
| Что искать | В каком операторе | О чём говорит |
|---|---|---|
projection: ... | SEQ_SCAN | Projection pushdown — читаются только нужные колонки |
| Filter рядом со scan | FILTER / SEQ_SCAN | Filter pushdown — отсев близко к источнику |
HASH_JOIN | Узлы соединения | Способ соединения таблиц |
| Строки на входе и выходе | FILTER (в ANALYZE) | Селективность: насколько фильтр сокращает данные |
| Время оператора | Любой (в ANALYZE) | Самый дорогой узел — узкое место |
Чтение плана как навык, а не разовое действие
Полезно зафиксировать правильную привычку. Многие открывают EXPLAIN только тогда, когда запрос уже работает мучительно медленно. Это рабочий, но запоздалый подход. Чтение плана стоит сделать частью обычного цикла: написал нетривиальный запрос — посмотрел его план — убедился, что движок делает то, что ты ожидаешь. Так проблема обнаруживается на стадии написания, а не в продакшене.
Есть и обратная польза. Регулярно читая планы простых, заведомо хороших запросов, вы строите в голове эталон — как выглядит «нормальный» план. Тогда план проблемного запроса бросается в глаза сразу: вы видите лишний оператор, неожиданно высокий фильтр, размножение строк — потому что есть с чем сравнивать. Без этого эталона любой план выглядит одинаково непонятно.
Наконец, EXPLAIN и EXPLAIN ANALYZE дополняют друг друга, и порядок их применения важен. Сначала — EXPLAIN: он быстрый, ничего не исполняет, и сразу показывает структуру. Если структура уже выглядит подозрительно (читаются лишние колонки, странный порядок соединений) — проблему видно без исполнения. Если структура выглядит разумно, но запрос всё равно медленный — тогда нужен EXPLAIN ANALYZE, чтобы получить реальные числа и найти дорогой оператор. Такой порядок экономит время: дешёвый инструмент применяется первым, дорогой — только когда дешёвый не дал ответа.
Глубокую механику того, почему оптимизатор принимает те или иные решения — проходы оптимизатора, статистику, алгоритм выбора порядка соединений — курс разбирает в модуле про транзакции и оптимизатор. Этот урок дал базу: вы умеете получить план, прочитать дерево снизу вверх, отличить намерение от профиля и найти узкое место. С этим навыком оптимизация перестаёт быть гаданием.
Попробуй сам
Научитесь читать планы на данных TPC-H.
- Запустите CLI, сгенерируйте данные:
INSTALL tpch; LOAD tpch; CALL dbgen(sf = 0.5);. - Возьмите запрос с фильтром и группировкой:
SELECT l_returnflag, count(*), sum(l_quantity) FROM lineitem WHERE l_shipdate < DATE '1995-01-01' GROUP BY l_returnflag;. Сначала выполните его сEXPLAIN. Прочитайте дерево снизу вверх; найдите вSEQ_SCANстрокуprojection:и убедитесь, что читаются только колонки запроса. - Тот же запрос выполните с
EXPLAIN ANALYZE. Найдите фактическое число строк, прочитанныхSEQ_SCAN, и число строк на выходеFILTER. Посчитайте, какую долю строк отсеял фильтр по дате. - Возьмите запрос с соединением:
SELECT count(*) FROM lineitem JOIN orders ON l_orderkey = o_orderkey WHERE o_orderstatus = 'F';. Выполните сEXPLAIN ANALYZE. Найдите операторHASH_JOINи определите, какой узел дерева занял больше всего времени. - Сформулируйте письменно, в чём разница между тем, что вам показал
EXPLAIN, и тем, что добавилEXPLAIN ANALYZE.
Шаг 5 закрепляет ключевое различие двух инструментов — план против профиля.
Trino: логическое и физическое планирование запроса EXPLAIN ANALYZE в PostgreSQL: аналогичный инструмент