Learning Platform
Глоссарий Troubleshooting
Урок 03.06 · 24 мин
Средний
explainquery-planprofilingoptimization

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 исполняет запрос и показывает дерево с реальными измерениями.

От SQL к плану запроса
SQL-текстДекларативный запрос: что нужно получить. Не показывает, как движок будет это вычислять
оптимизатор
Физический планДерево конкретных операторов: scan, filter, join, aggregate — то, что реально исполнится
EXPLAIN показывает
Понимание исполненияПлан делает видимым, что движок делает на самом деле — основа для оптимизации

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       │
└───────────────────────────┘

Читается дерево снизу вверх — в порядке течения данных:

  1. SEQ_SCAN orders — лист: сканирование таблицы orders. Важная деталь — строка projection: region, amount: DuckDB читает только две колонки, нужные запросу, а не всю таблицу. Это projection pushdown.
  2. FILTER amount > 100 — отбрасывает строки, не прошедшие условие.
  3. HASH_GROUP_BY — группирует по region и считает count(). Реализован через хеш-таблицу.
  4. 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 против EXPLAIN ANALYZE
EXPLAINПоказывает план запроса, не исполняя его: какие операторы и в каком порядке выбрал оптимизатор
отвечает
ЧТО собирается делать движокСтруктура плана: pushdown, тип join, порядок операторов. Оценка без затрат на исполнение
EXPLAIN ANALYZEИсполняет запрос и показывает дерево с реальными числами: строки и время по каждому оператору
отвечает
ГДЕ запрос реально тратит времяФактические строки и время по операторам — указывают на узкое место для оптимизации
WARNING

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_SCANProjection pushdown — читаются только нужные колонки
Filter рядом со scanFILTER / SEQ_SCANFilter pushdown — отсев близко к источнику
HASH_JOINУзлы соединенияСпособ соединения таблиц
Строки на входе и выходеFILTER (в ANALYZE)Селективность: насколько фильтр сокращает данные
Время оператораЛюбой (в ANALYZE)Самый дорогой узел — узкое место

Чтение плана как навык, а не разовое действие

Полезно зафиксировать правильную привычку. Многие открывают EXPLAIN только тогда, когда запрос уже работает мучительно медленно. Это рабочий, но запоздалый подход. Чтение плана стоит сделать частью обычного цикла: написал нетривиальный запрос — посмотрел его план — убедился, что движок делает то, что ты ожидаешь. Так проблема обнаруживается на стадии написания, а не в продакшене.

Есть и обратная польза. Регулярно читая планы простых, заведомо хороших запросов, вы строите в голове эталон — как выглядит «нормальный» план. Тогда план проблемного запроса бросается в глаза сразу: вы видите лишний оператор, неожиданно высокий фильтр, размножение строк — потому что есть с чем сравнивать. Без этого эталона любой план выглядит одинаково непонятно.

Наконец, EXPLAIN и EXPLAIN ANALYZE дополняют друг друга, и порядок их применения важен. Сначала — EXPLAIN: он быстрый, ничего не исполняет, и сразу показывает структуру. Если структура уже выглядит подозрительно (читаются лишние колонки, странный порядок соединений) — проблему видно без исполнения. Если структура выглядит разумно, но запрос всё равно медленный — тогда нужен EXPLAIN ANALYZE, чтобы получить реальные числа и найти дорогой оператор. Такой порядок экономит время: дешёвый инструмент применяется первым, дорогой — только когда дешёвый не дал ответа.

Глубокую механику того, почему оптимизатор принимает те или иные решения — проходы оптимизатора, статистику, алгоритм выбора порядка соединений — курс разбирает в модуле про транзакции и оптимизатор. Этот урок дал базу: вы умеете получить план, прочитать дерево снизу вверх, отличить намерение от профиля и найти узкое место. С этим навыком оптимизация перестаёт быть гаданием.


Попробуй сам

Научитесь читать планы на данных TPC-H.

  1. Запустите CLI, сгенерируйте данные: INSTALL tpch; LOAD tpch; CALL dbgen(sf = 0.5);.
  2. Возьмите запрос с фильтром и группировкой: SELECT l_returnflag, count(*), sum(l_quantity) FROM lineitem WHERE l_shipdate < DATE '1995-01-01' GROUP BY l_returnflag;. Сначала выполните его с EXPLAIN. Прочитайте дерево снизу вверх; найдите в SEQ_SCAN строку projection: и убедитесь, что читаются только колонки запроса.
  3. Тот же запрос выполните с EXPLAIN ANALYZE. Найдите фактическое число строк, прочитанных SEQ_SCAN, и число строк на выходе FILTER. Посчитайте, какую долю строк отсеял фильтр по дате.
  4. Возьмите запрос с соединением: SELECT count(*) FROM lineitem JOIN orders ON l_orderkey = o_orderkey WHERE o_orderstatus = 'F';. Выполните с EXPLAIN ANALYZE. Найдите оператор HASH_JOIN и определите, какой узел дерева занял больше всего времени.
  5. Сформулируйте письменно, в чём разница между тем, что вам показал EXPLAIN, и тем, что добавил EXPLAIN ANALYZE.

Шаг 5 закрепляет ключевое различие двух инструментов — план против профиля.

Trino: логическое и физическое планирование запроса EXPLAIN ANALYZE в PostgreSQL: аналогичный инструмент
Проверка знанийKnowledge check
В чём разница между EXPLAIN и EXPLAIN ANALYZE, и почему для диагностики медленного запроса обычно нужен именно EXPLAIN ANALYZE?
ОтветAnswer
EXPLAIN показывает физический план запроса — дерево операторов (scan, filter, join, aggregate, projection), которое оптимизатор DuckDB выбрал для исполнения SQL, — но сам запрос при этом не исполняет. Он отвечает на вопрос «что движок собирается делать»: какой порядок операторов, применяется ли projection и filter pushdown, какой тип соединения выбран. EXPLAIN ANALYZE идёт дальше: он реально исполняет запрос и показывает то же дерево, но дополненное фактическими измерениями — сколько строк прошло через каждый оператор и сколько времени каждый занял. Для диагностики медленного запроса нужен именно EXPLAIN ANALYZE, потому что обычный EXPLAIN показывает только структуру плана, но не где запрос тратит время. Узкое место — это конкретный оператор, и определить его можно лишь по реальным числам: оператор с наибольшим временем и есть то, что нужно оптимизировать в первую очередь, а резкий рост числа строк вверх по дереву (например, после join) указывает на размножение строк. EXPLAIN полезен, чтобы быстро оценить план без затрат на исполнение и проверить, что pushdown сработал; но чтобы понять причину медлительности, нужен профиль реального исполнения, который даёт EXPLAIN ANALYZE. Важная оговорка: EXPLAIN ANALYZE действительно исполняет запрос, поэтому для изменяющих запросов (INSERT, UPDATE, DELETE, CREATE TABLE) он реально выполнит изменение данных — чтобы лишь посмотреть план такого запроса, используют обычный EXPLAIN.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что показывает оператор `EXPLAIN`, поставленный перед запросом?

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

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

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

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