Learning Platform
Глоссарий Troubleshooting
Урок 16.02 · 24 мин
Средний
explain-analyzequery-planbottlenecktuning

EXPLAIN ANALYZE VERBOSE: чтение статистики операторов

Web UI говорит, какая стадия горячая. EXPLAIN ANALYZE говорит почему. Это самый точный инструмент диагностики Trino: он реально исполняет запрос и возвращает распределённый план, где у каждого оператора проставлены фактические числа — сколько строк прошло, сколько CPU потрачено, сколько wall-времени, сколько памяти. Вы держите в руках не оценку оптимизатора, а измеренную реальность исполнения.

Этот урок учит читать вывод EXPLAIN ANALYZE сверху вниз, отделять estimate от actual, находить bottleneck-оператор и распознавать характерные промахи оптимизатора. Без этого навыка тюнинг Trino — гадание; с ним — инженерная работа.


Семейство EXPLAIN

В Trino несколько форм EXPLAIN, и они отвечают на разные вопросы.

КомандаИсполняет запросЧто показывает
EXPLAINНетРаспределённый план с estimates оптимизатора
EXPLAIN (TYPE LOGICAL)НетЛогический план до фрагментации на стадии
EXPLAIN (TYPE DISTRIBUTED)НетПлан, разбитый на фрагменты-стадии
EXPLAIN (TYPE IO)НетКакие таблицы и колонки будут прочитаны и записаны
EXPLAIN ANALYZEДаФактическая статистика операторов после исполнения
EXPLAIN ANALYZE VERBOSEДаТо же плюс низкоуровневая статистика и распределения

EXPLAIN без исполнения хорош, чтобы проверить решения оптимизатора заранее — порядок join, тип распределения, сработал ли pushdown. EXPLAIN ANALYZE — чтобы понять, что произошло на самом деле. VERBOSE добавляет распределения метрик по задачам (min/max/avg) и метрики отдельных операторов, например статистику reader’а или dynamic filtering.

WARNING

EXPLAIN ANALYZE исполняет запрос целиком, со всеми его побочными эффектами. Для SELECT это просто время и ресурсы кластера. Но EXPLAIN ANALYZE INSERT ..., EXPLAIN ANALYZE CREATE TABLE AS ... или EXPLAIN ANALYZE DELETE ... реально вставят, создадут или удалят данные. Чтобы проанализировать DML, не меняя данные, оборачивайте логику в SELECT или работайте на копии таблицы.


Как устроен вывод

EXPLAIN ANALYZE печатает план как дерево фрагментов. Каждый Fragment — это стадия распределённого плана; внутри фрагмента — дерево операторов. Читать дерево нужно сверху вниз по тексту, но понимать снизу вверх по данным: листовые операторы (TableScan) читают данные, корневой оператор фрагмента отдаёт результат выше.

Уровни вложенности вывода EXPLAIN ANALYZE
FragmentСтадия распределённого плана. У фрагмента есть тип ([SOURCE], [HASH], [SINGLE]) и сводные строки CPU, Scheduled, Blocked по всем его задачам.
внутри фрагмента
OperatorУзел дерева операторов внутри фрагмента: ScanFilterProject, InnerJoin, Aggregate. У каждого свой процент CPU и число строк.
у каждого оператора
МетрикиФактические числа: CPU с процентом, Output rows, Input и Filtered у сканов, Input std.dev. в VERBOSE.

Рассмотрим реальный сокращённый вывод для join fact- и dimension-таблиц:

Trino version: 481
Fragment 1 [HASH]
    CPU: 4.21m, Scheduled: 9.84m, Blocked 12.30m (Input: 6.10m, Output: 0.00ns), Input: 59986052 rows (1.61GB)
    Output layout: [returnflag, count, sum]
    Output partitioning: SINGLE []
    Aggregate[type = FINAL, keys = [returnflag]]
    │   CPU: 1.18m (28.04%), Output: 3 rows (54B)
    │   Input avg.: 1875376.62 rows, Input std.dev.: 4.21%
    └─ LocalExchange[partitioning = HASH, arguments = [returnflag]]
       │   CPU: 0.92m (21.85%), Output: 96 rows
       └─ RemoteSource[sourceFragmentIds = [2]]
              CPU: 2.11m (50.11%), Output: 59986052 rows (1.61GB)
              Input avg.: 1874564.12 rows, Input std.dev.: 3.98%

Fragment 2 [SOURCE]
    CPU: 47.81m, Scheduled: 71.20m, Blocked 0.00ns, Input: 59986052 rows (1.61GB)
    Output layout: [returnflag, extendedprice]
    Output partitioning: HASH [returnflag]
    Aggregate[type = PARTIAL, keys = [returnflag]]
    │   CPU: 8.04m (16.81%), Output: 96 rows
    └─ InnerJoin[criteria = ("orderkey" = "orderkey_0"), distribution = REPLICATED]
       │   CPU: 21.93m (45.86%), Output: 59986052 rows (1.61GB)
       │   Distribution: REPLICATED
       │   dynamicFilterAssignments = {orderkey_0 -> #df_440}
       ├─ ScanFilterProject[table = tpch:lineitem:sf10, filterPredicate = true, dynamicFilters = {"orderkey" = #df_440}]
       │      CPU: 16.32m (34.13%), Output: 59986052 rows (1.61GB)
       │      Input: 59986052 rows (0B), Filtered: 0.00%
       │      orderkey := tpch:orderkey
       │      extendedprice := tpch:extendedprice
       └─ LocalExchange[partitioning = SINGLE]
          └─ RemoteSource[sourceFragmentIds = [3]]
                 CPU: 1.52m (3.18%), Output: 2278602 rows (39.1MB)

Fragment 3 [SOURCE]
    CPU: 6.40m, Scheduled: 9.90m, Input: 15000000 rows (404MB)
    Output layout: [orderkey_0]
    Output partitioning: BROADCAST []
    ScanFilterProject[table = tpch:orders:sf10, filterPredicate = ("orderdate" >= DATE '1995-01-01')]
           CPU: 6.40m (13.38%), Output: 2278602 rows (39.1MB)
           Input: 15000000 rows (0B), Filtered: 84.81%
           orderkey_0 := tpch:orderkey
           orderdate := tpch:orderdate

Разберём ключевые поля.

На уровне фрагмента строка CPU / Scheduled / Blocked — суммы по всем задачам фрагмента. CPU — чистое процессорное время. Scheduled (wall) — сколько реального времени задачи были на исполнении. Blocked — сколько они ждали (данных на вход, места в выходных буферах). [HASH], [SOURCE], [SINGLE], [BROADCAST] — тип распределения фрагмента.

На уровне оператора проценты CPU: 21.93m (45.86%) показывают долю оператора в CPU своего фрагмента — это и есть карта горячих точек. Output: N rows — фактический выход. У ScanFilterProject есть Input и Filtered: NN% — сколько строк прочитано и какая доля отсеяна предикатом.

Поля cardinality Input avg. и Input std.dev. (видны полнее в VERBOSE) — распределение нагрузки по задачам или драйверам. Низкий std.dev. (единицы процентов) — нагрузка равномерная. Высокий (десятки процентов) — data skew.


Поиск bottleneck: алгоритм

Чтение EXPLAIN ANALYZE ради тюнинга — это процедура из четырёх шагов.

Алгоритм поиска узкого места по EXPLAIN ANALYZE
Шаг 1. Горячий фрагментСравните строки CPU и Scheduled у всех фрагментов. Фрагмент с наибольшим CPU — главный кандидат на оптимизацию.
Шаг 2. Горячий операторВнутри фрагмента найдите оператор с наибольшим процентом CPU. Это конкретное место, которое тормозит.
Шаг 3. Estimate против actualСравните оценку оптимизатора со строкой actual. Расхождение в разы означает плохую статистику и, как следствие, плохой план.
Шаг 4. Гипотеза и правкаПо типу горячего оператора выберите рычаг: ANALYZE, смена join-стратегии, партиционирование, pushdown.

В нашем примере горячий фрагмент — Fragment 2 (CPU 47.81m против 6.40m и 4.21m). Внутри него горячий оператор — InnerJoin (45.86% CPU). Это ожидаемо: join 60 млн строк lineitem с отфильтрованными orders. Важный позитивный сигнал — dynamicFilters = {"orderkey" = #df_440} в ScanFilterProject: dynamic filtering работает, build-сторона (orders) собрала рантайм-предикат по orderkey. Distribution REPLICATED (то же, что BROADCAST) означает: orders отфильтровались до 2.3 млн строк (39 MB), это влезло под порог broadcast, и Trino разослал их на все воркеры вместо дорогого repartition. Здесь оптимизатор отработал правильно.

Сравнение estimate и actual — самый ценный шаг. Trino печатает рядом с операторами оценку (Estimates: {rows: ...}); если оценка говорит «10 тысяч строк», а actual показывает 60 миллионов, оптимизатор строил план на ложных данных и почти наверняка ошибся с порядком join и типом распределения.

SQL: EXPLAIN ANALYZE в PostgreSQL — фактические числа vs оценки Spark: Spark UI и диагностика узких мест

Почему фрагменты пронумерованы именно так

Стоит отдельно понять нумерацию фрагментов, иначе вывод читается неверно. EXPLAIN ANALYZE печатает фрагменты сверху вниз, и Fragment 0 — это всегда корень: финальный шаг, отдающий результат клиенту. Фрагменты с большими номерами — ближе к источникам данных. То есть по тексту вы читаете сверху вниз, а данные текут снизу вверх: листовые фрагменты ([SOURCE]) читают из коннекторов, корневой ([SINGLE]) собирает итог.

Связь фрагментов — через операторы RemoteSource и Output partitioning. Когда во фрагменте встречается RemoteSource[sourceFragmentIds = [2]], это значит: данные сюда приходят из Fragment 2 по сети. А строка Output partitioning у фрагмента говорит, как он раздаёт свой результат наверх: SINGLE — всё в одну задачу, HASH [key] — репартиционирование по хэшу ключа, BROADCAST — копирование на все ноды. Прослеживая RemoteSource и Output partitioning, вы реконструируете, как стадии соединены в дерево и где между ними происходят дорогие сетевые обмены.

Это объясняет и тип фрагмента в квадратных скобках. [SOURCE] исполняется там, где доступны входные сплиты, — это data locality. [HASH] исполняется на фиксированном числе нод, и данные распределены по хэшу. [SINGLE] — на одной ноде. Тип фрагмента — не косметика: он определяет, сколько данных и как именно гоняется по сети, а значит прямо влияет на то, какие фрагменты окажутся горячими.

Элемент выводаЧто означает
Fragment 0Корень: финальный результат клиенту
Fragment с большим номеромБлиже к источнику данных
RemoteSource[sourceFragmentIds = [N]]Данные приходят из Fragment N по сети
Output partitioning: HASH [key]Фрагмент репартиционирует результат по хэшу ключа
[SOURCE] / [HASH] / [SINGLE]Тип распределения фрагмента

Характерные промахи оптимизатора

Несколько паттернов, которые EXPLAIN ANALYZE выдаёт сразу.

Перекос (skew). В VERBOSE-выводе у горячего оператора Input std.dev. в десятки процентов. Значит одна задача обрабатывает кратно больше строк, чем соседи: стадия не закончится, пока не доедет самая нагруженная задача. Причина — неравномерное распределение значений ключа партиционирования (например, NULL или одно доминирующее значение в ключе join).

Не сработавший pushdown. ScanFilterProject показывает Input: 50000000 rows, Filtered: 0.00%, а отдельным узлом выше стоит FilterProject, который режет 95% строк. Значит фильтр не протолкнулся в коннектор: 50 млн строк прочитаны с диска зря, фильтрация идёт в движке. Сравните с EXPLAIN (TYPE IO), чтобы увидеть, что планировалось прочитать.

Промах cardinality и плохой join order. Estimate под join сильно расходится с actual. Из-за этого Trino выбрал PARTITIONED, где хватило бы BROADCAST, или поставил большую таблицу на build-сторону. Лечится через ANALYZE — сбор статистики.

Дорогой обмен. У фрагмента строка Blocked (особенно Input) сопоставима с Scheduled или больше — стадия половину времени простаивает в ожидании данных по сети. Сигнал, что между стадиями гоняется слишком много данных: репартиционируется большая таблица, которую можно было бы транслировать, или фильтр применяется слишком поздно.

Симптом в EXPLAIN ANALYZEДиагнозРычаг
Input std.dev. десятки процентовData skew по ключуПересмотреть ключ, фильтровать NULL
Filtered: 0% на scan, фильтр вышеPredicate pushdown не сработалПроверить коннектор, переписать предикат
Estimate расходится с actual в разыНет/устарела статистикаANALYZE table
Blocked сопоставим с ScheduledДорогой сетевой обменJoin-стратегия, dynamic filtering, ранний фильтр
Большая таблица на build-стороне joinПромах оптимизатора с порядком joinANALYZE, при необходимости join_distribution_type
TIP

Снимайте EXPLAIN ANALYZE дважды: до правки и после. Сравнение двух выводов — единственный честный способ убедиться, что фикс сработал. Главные числа для сравнения — CPU горячего фрагмента и его Scheduled-время. Если после ANALYZE join сменил распределение и CPU упало вдвое — гипотеза подтвердилась цифрами, а не ощущением.


Попробуй сам

На встроенном коннекторе tpch воспроизведите цикл диагностики.

  1. Снимите план до сбора статистики:
EXPLAIN ANALYZE
SELECT n.name, sum(l.extendedprice) AS revenue
FROM tpch.sf10.lineitem l
JOIN tpch.sf10.orders o ON l.orderkey = o.orderkey
JOIN tpch.sf10.customer c ON o.custkey = c.custkey
JOIN tpch.sf10.nation n ON c.nationkey = n.nationkey
GROUP BY n.name
ORDER BY revenue DESC;
  1. Найдите горячий фрагмент по строке CPU, внутри него — горячий оператор по проценту CPU.
  2. Найдите в выводе строки dynamicFilters на сканах. По каким ключам Trino собрал рантайм-фильтры?
  3. Перезапустите с EXPLAIN ANALYZE VERBOSE и найдите оператор с наибольшим Input std.dev. — есть ли перекос?
  4. Сравните EXPLAIN (TYPE DISTRIBUTED) того же запроса (без ANALYZE) с фактическим выводом: совпали ли estimates оптимизатора с реальными числами строк?

Цель — пройти все четыре шага алгоритма на живом запросе и научиться отличать здоровый план от плана с промахом.


Проверка знанийKnowledge check
В выводе EXPLAIN ANALYZE оператор ScanFilterProject показывает Input: 80000000 rows и Filtered: 0.00%, а на узел выше стоит отдельный FilterProject, после которого остаётся 600000 строк. О какой проблеме это говорит и почему она дорогая?
ОтветAnswer
Это классическая картина не сработавшего predicate pushdown. Filtered: 0.00% на скане означает, что коннектор отдал в движок все 80 миллионов строк без фильтрации, а реальный отбор (до 600 тысяч строк, то есть отсев более 99%) произошёл уже отдельным оператором FilterProject внутри Trino. Проблема дорогая по двум причинам. Во-первых, дисковый и сетевой I/O: все 80 миллионов строк физически прочитаны из источника и переданы в движок, хотя 99% из них будут немедленно отброшены — это зря потраченная полоса I/O, самый дефицитный ресурс при работе с lakehouse. Во-вторых, CPU: движок Trino тратит процессорное время на материализацию и фильтрацию строк, которые источник мог бы отсеять сам, не доставая их с диска. Если бы предикат протолкнулся в коннектор, скан вернул бы сразу около 600 тысяч строк, и стоимость упала бы примерно в сто раз. Причины, которые стоит проверять: коннектор не поддерживает pushdown для этого типа предиката или функции, предикат написан в форме, которую коннектор не распознаёт (например, обёрнут в функцию над колонкой), либо отсутствует статистика. Сверить ожидаемое поведение помогает EXPLAIN (TYPE IO), который показывает, что планировалось прочитать из источника.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Чем EXPLAIN ANALYZE принципиально отличается от обычного EXPLAIN?

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

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

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

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