Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

How to Read Query Plans

Этот guide помогает DBA/SRE читать EXPLAIN в AngaraBase без знания внутреннего кода планировщика. Цель не в том, чтобы вручную «переиграть» optimizer, а в том, чтобы быстро ответить на операторские вопросы:

  • какой путь выполнения выбрала база;
  • почему был выбран именно он;
  • использует ли запрос векторный/параллельный путь;
  • переиспользован ли план из кэша или был перестроен;
  • где искать причину высокой latency.

Quick Start

Для обычного плана:

EXPLAIN SELECT * FROM public.orders WHERE customer_id = 42;

Для операторской диагностики:

EXPLAIN (DIAGNOSTIC)
SELECT * FROM public.orders WHERE customer_id = 42;

Для подробного вывода:

EXPLAIN (VERBOSE, DIAGNOSTIC)
SELECT * FROM public.orders WHERE customer_id = 42;

Для machine-readable evidence:

EXPLAIN (VERBOSE, DIAGNOSTIC, FORMAT JSON)
SELECT * FROM public.orders WHERE customer_id = 42;

Если нужно увидеть runtime-счётчики, используйте EXPLAIN ANALYZE. Он выполняет запрос, поэтому для DML используйте его осторожно и только в безопасном окружении.

Runtime Facts

В режиме ANALYZE AngaraBase собирает дополнительные факты о выполнении запроса в блоке runtime_facts. Этот блок появляется, если запрос столкнулся с ожиданиями, сбросом данных на диск (spill) или отклонением из-за лимитов ресурсов.

Пример JSON вывода:

"runtime_facts": {
  "spill_bytes": 4096,
  "wal_sync_wait_ms": 12,
  "resource_reject_count": 1,
  "last_runtime_reason": "spilled_memory_budget"
}

Пример текстового вывода: runtime_facts: spill_bytes=4096 wal_sync_wait_ms=12 resource_reject_count=1 last_runtime_reason=spilled_memory_budget

Основные поля:

  • spill_bytes — объем данных, сброшенных на диск (например, при нехватке памяти для HashJoin или Sort).
  • wal_sync_wait_ms — время ожидания синхронизации WAL. (Может быть не выведено для SELECT, при durability=relaxed, или если транзакция успешно попала в групповой коммит без дополнительного ожидания I/O).
  • resource_reject_count — количество отклонений из-за лимитов ресурсов.
  • last_runtime_reason — код причины, например spilled_memory_budget.

Примечание: выводятся только поля с ненулевыми значениями.

Как читать дерево

План читается снизу вверх. Нижний оператор получает данные из таблицы или индекса. Каждый следующий оператор выше применяет фильтр, join, агрегацию, сортировку или projection.

Пример:

Project cost=0.00..1030.00 rows=100
  VectorFilter cost=0.00..1025.00 rows=100
    VectorSeqScan workers_planned=2 workers_launched=2 numa_affinity=disabled table=public.ux_stats_v2 cost=0.00..1000.00 rows=1000
--- Optimizer Diagnostics ---
query_fingerprint=1795416667712787713
plan_fingerprint=3192678580981205807
workload_class=select
replan_reason=none
cache_status=hit
reason_codes=stats_default_fallback

Читаем так:

  1. VectorSeqScan читает таблицу public.ux_stats_v2.
  2. VectorFilter применяет условие WHERE.
  3. Project оставляет нужные колонки в результате.
  4. Блок Optimizer Diagnostics объясняет идентификаторы запроса/плана, кэш, причину перепланирования и reason codes.

Формат строки оператора

Разберём строку:

VectorSeqScan workers_planned=2 workers_launched=2 numa_affinity=disabled table=public.ux_stats_v2 cost=0.00..1000.00 rows=1000
ПолеЧто означаетКак читать оператору
VectorSeqScanТип оператора. Vector = векторный executor, SeqScan = последовательное чтение таблицы.Читаем всю таблицу пачками. Хорошо для full scan / analytics, плохо для точечного lookup на большой таблице без индекса.
workers_planned=2Сколько worker-ов планировщик хотел использовать.План допускает параллелизм.
workers_launched=2Сколько worker-ов реально выделено.Если меньше planned, возможны runtime pressure или лимиты параллелизма.
numa_affinity=disabledВключена ли привязка к NUMA-node.Обычно disabled нормально для dev/cloud; на bare metal может быть отдельным tuning-вопросом.
table=public.ux_stats_v2Таблица-источник.Проверяем, что сканируется ожидаемая таблица/схема.
cost=0.00..1000.00startup_cost..total_cost в условных единицах планировщика.Это не миллисекунды. Сравнивайте с альтернативными планами, а не с wall-clock.
rows=1000Оценка количества строк на выходе оператора.Сильная ошибка оценки часто ведёт к плохому join order или лишнему full scan.

Cost и Rows

cost — это внутренняя оценка работы, а не время выполнения.

  • startup_cost — цена получить первую строку.
  • total_cost — цена получить все строки.
  • rows — ожидаемое количество строк после оператора.

Типичная ошибка: читать cost=1000 как 1000 ms. Так делать нельзя. Cost нужен optimizer-у для сравнения вариантов:

  • full scan vs index scan;
  • hash join vs nested/index join;
  • сортировать до или после фильтра;
  • выполнять aggregate над всеми строками или над уже отфильтрованным input.

Если rows явно не похож на реальность, сначала проверьте статистику:

ANALYZE public.orders;

SELECT *
FROM sys.table_stats
WHERE schema_name = 'public' AND table_name = 'orders';

SELECT *
FROM sys.column_stats
WHERE schema_name = 'public' AND table_name = 'orders';

Vector Prefix

Операторы с префиксом Vector выполняются через векторизованный путь:

  • VectorSeqScan
  • VectorIndexScan
  • VectorFilter
  • VectorProject
  • VectorWindowFunction
  • VectorSetOperation

Векторный путь обрабатывает данные пачками, снижая overhead на строку. Для оператора это обычно хороший признак, особенно на scan/filter/aggregate workload.

Если ожидаемый Vector* исчез:

  1. Проверьте query shape: не добавили ли выражение, которое пока не поддерживается vector executor-ом.
  2. Сравните EXPLAIN (VERBOSE, DIAGNOSTIC) до/после изменения запроса.
  3. Смотрите reason_codes и плановые node types.
  4. Для latency-регрессий используйте Performance tuning guide и Parallel runtime observability runbook.

Словарь операторов

ОператорЧто делаетКогда хорошоКогда подозрительно
Scan / VectorSeqScanЧитает таблицу целиком.Маленькая таблица, аналитика, низкая селективность фильтра.Точечный lookup на большой таблице, где должен быть индекс.
IndexScan / VectorIndexScanЧитает через индекс, затем при необходимости проверяет residual filter.Селективный predicate по индексной колонке.Если возвращает большую долю таблицы, full scan может быть дешевле.
IndexOnlyScanЧитает только индекс, без heap fetch, если visibility map позволяет.Coverage index + all-visible страницы.Если часто fallback на heap, проверьте visibility map / vacuum-like процессы.
Filter / VectorFilterПрименяет WHERE/predicate к входному потоку.После scan/index scan.Если фильтр стоит выше дорогого join, проверьте pushdown.
Project / VectorProjectВыбирает/вычисляет выходные колонки.Обычный верхний оператор для SELECT.Обычно не проблема, кроме очень дорогих выражений.
JoinОбщий join node с kind=inner/left/right/full/cross.Ожидаемый join type соответствует SQL.cross почти всегда требует внимания.
HashSemiJoinРеализация EXISTS/semi join через hash.Хороший знак для декоррелированного EXISTS.Если ожидали semi join, но видите nested/cross-like план.
HashAntiJoinРеализация NOT EXISTS/anti join через hash.Хороший знак для anti-semi workload.Если input большой и нет memory headroom.
NLIndexJoinNested-loop probe по индексу.Малый outer input + селективный index lookup.Большой outer input: может превратиться в много index probes.
AggregateCOUNT, SUM, GROUP BY и другие aggregate операции.После фильтра или join с уже уменьшенным input.Если aggregate вынужден материализовать огромный input.
SortСортирует поток.Для ORDER BY, merge-like paths.Большой sort без LIMIT или без индекса по order key.
DistinctУдаляет дубликаты.Нужен для DISTINCT.На большом input без предварительного сокращения строк.
Limit / OffsetОграничивает или пропускает строки.LIMIT может резко снизить total cost.Большой OFFSET всё равно заставляет читать/пропускать много строк.
WindowFunctionОконные функции.Аналитические запросы.Если требует большой sort/partition.
SetOperationUNION, INTERSECT, EXCEPT.Set queries.Если unexpectedly дорогой из-за dedup/sort.
LateralJoinLATERAL/derived-table dependent path.Коррелированные derived inputs.На больших outer inputs может быть дорогим.
LateMaterializeОтложенная материализация колонок. Читает только необходимые для фильтрации колонки, а остальные дочитывает позже для строк, прошедших фильтр.Высокая селективность фильтра (selectivity < 0.3).Если селективность низкая, двойное чтение может быть дороже обычного.
DmlInsert / DmlUpdate / DmlDeleteSentinel для DML.EXPLAIN DML показывает intent.Для runtime counters используйте EXPLAIN ANALYZE осторожно.
DdlSentinel для DDL.Показывает DDL path.Не является query performance hot path.

Scan Strategy Reason

Для узлов Scan (SeqScan) и IndexScan планировщик выводит причину выбора конкретной стратегии сканирования в поле scan_strategy_reason. Это помогает понять, почему optimizer предпочел последовательное сканирование индексному или наоборот.

Примеры вывода:

  • index scan: high selectivity (0.0005) — выбран индекс, так как условие очень селективно.
  • seq scan chosen: low cardinality (0.1328) — выбран SeqScan: селективность выше порога [execution].index_cardinality_threshold (планировщик считает колонку «слишком низкой кардинальности» для индекса на этом предикате).
  • seq scan chosen: low selectivity (0.1111) — выбран SeqScan: селективность не ниже порога [execution].index_scan_selectivity_threshold (отдельный гейт после кардинального).

Если вы видите seq scan chosen там, где ожидали индекс:

  1. Проверьте актуальность статистики (ANALYZE).
  2. Проверьте значение distinct_estimate в sys.column_stats.
  3. Подстройте пороги в angarabase.conf ([execution]) или через env до старта (ANGARABASE_INDEX_CARDINALITY_THRESHOLD, ANGARABASE_INDEX_SCAN_SELECTIVITY_THRESHOLD), затем перезапустите сервер. SET ... из psql в Simple Query protocol не меняет эти knob (см. Performance tuning).

Optimizer Diagnostics

EXPLAIN (DIAGNOSTIC) добавляет блок:

--- Optimizer Diagnostics ---
query_fingerprint=1795416667712787713
plan_fingerprint=3192678580981205807
workload_class=select
replan_reason=none
cache_status=hit
reason_codes=stats_default_fallback

query_fingerprint

Стабильный идентификатор логической формы запроса. Значения литералов обычно не должны создавать новый fingerprint для каждой константы.

Используйте его, чтобы связать:

  • slow query;
  • метрики;
  • логи;
  • повторный EXPLAIN;
  • regression evidence.

plan_fingerprint

Идентификатор формы плана. Если запрос тот же, но план изменился, query_fingerprint останется тем же, а plan_fingerprint поменяется.

Это полезно при расследовании:

  • «после ANALYZE запрос стал быстрее/медленнее»;
  • «после добавления индекса план поменялся»;
  • «вчера был IndexOnlyScan, сегодня снова SeqScan».

workload_class

Класс workload-а:

  • select
  • write
  • ddl
  • другие классы, если конкретный путь их маркирует.

Оператору это помогает отделить OLTP read path от write/DDL событий.

replan_reason

Почему план был перестроен или почему явной причины нет.

ЗначениеСмыслЧто делать
noneЯвной причины перепланирования нет. Обычно это нормальный путь.Если cache_status=hit, кэш работает.
stats_driftСтатистика изменилась достаточно сильно, старый план мог устареть.Проверить частоту ANALYZE, churn таблицы, стабильность latency.
schema_changedСхема изменилась: DDL, индекс, колонка или другой schema signal.Нормально после миграций; подозрительно при частом DDL в production.
aqp_feedbackRuntime feedback повлиял на оценку/планирование.Проверить AQP metrics и skew workload.
forced_fallbackPlanner/runtime выбрал безопасный fallback.Сравнить reason codes и unsupported expressions.

cache_status

Показывает отношение запроса к plan cache.

ЗначениеСмыслКак интерпретировать
hitПлан переиспользован.Хорошо для стабильного OLTP.
missПлан построен заново.Нормально для первого запуска или нового query shape.
bypassКэш сознательно не использован.Проверить DDL, volatile shape, diagnostics mode или safety path.
invalidatedСтарый план сброшен.Ищите replan_reason.
unknownRuntime не передал статус.Не делайте выводов о кэше только по этому полю.

reason_codes

Причины выбора или fallback-а в планировщике.

КодСмыслЧто проверить
stats_default_fallbackПланировщик не смог использовать детальную статистику и применил defaults.Выполнить ANALYZE, проверить sys.table_stats и sys.column_stats.
index_only_eligibleПлан может читать только индекс без heap fetch.Проверить visibility map и покрытие индекса.
bitmap_candidate_rejectedБыл альтернативный bitmap-like/index path, но выбран другой путь или residual filter.Сравнить селективность predicate и наличие подходящего индекса.
hash_join_fits_work_memHash join считается допустимым по памяти.При p99 росте проверять memory pressure и join cardinality.
used_multicol_statsИспользована multi-column статистика.Хороший знак для коррелированных predicate-ов.

Если reason_codes пустой, AngaraBase показывает stats_default_fallback, чтобы оператор не получил «молчаливый» diagnostic block.

JSON Format

Для CI, evidence pack и diff между релизами используйте JSON:

EXPLAIN (VERBOSE, DIAGNOSTIC, FORMAT JSON)
SELECT * FROM public.orders WHERE customer_id = 42;

В JSON те же сущности представлены как поля:

  • Node Type
  • Startup Cost
  • Total Cost
  • Plan Rows
  • Plans
  • workers_planned
  • workers_launched
  • numa_affinity
  • query_fingerprint
  • plan_fingerprint
  • replan_reason
  • reason_codes
  • cache_status

Для release evidence сравнивайте не весь JSON byte-for-byte, а устойчивые свойства: node class, join type, fingerprints, reason codes и ключевые оценки.

Типовые сценарии чтения

1. Медленный точечный lookup

Симптом:

VectorSeqScan table=public.orders ... rows=1000000

Что проверить:

  1. Есть ли индекс по колонке фильтра.
  2. Видит ли планировщик статистику (sys.column_stats).
  3. Не показывает ли diagnostics stats_default_fallback.
  4. Не слишком ли низкая селективность фильтра.

Желаемый план для точечного lookup обычно ближе к:

IndexScan index_name=... index_col=customer_id key_range=eq(...)

или:

IndexOnlyScan index_name=... index_col=customer_id index_only_reason="..."

2. Отложенная материализация (Late Materialization)

Если фильтр отсекает значительную часть строк, планировщик может выбрать узел LateMaterialize. Это позволяет избежать дорогого чтения всех колонок для строк, которые всё равно будут отфильтрованы.

Порог включения регулируется параметром [execution].late_materialization_selectivity_threshold (по умолчанию 0.3).

Пример плана:

Project cost=10.00..50.00 rows=100
  LateMaterialize cost=5.00..45.00 rows=100
    VectorFilter (x > 100) cost=0.00..40.00 rows=100
      VectorSeqScan table=large_table cost=0.00..30.00 rows=1000

3. EXISTS не должен быть nested-loop

Для запроса:

EXPLAIN (DIAGNOSTIC)
SELECT *
FROM public.orders o
WHERE EXISTS (
  SELECT 1
  FROM public.order_items i
  WHERE i.order_id = o.id
);

Хороший признак:

HashSemiJoin kind=semi on=...

Это значит, что optimizer декоррелировал EXISTS и выбрал hash semi join.

3. NOT EXISTS и anti join

Хороший признак:

HashAntiJoin kind=anti on=...

Если input большой, смотрите hash_join_fits_work_mem и memory metrics.

4. GROUP BY слишком дорогой

Симптом:

Aggregate cost=... rows=...
  VectorSeqScan table=...

Что проверить:

  1. Можно ли отфильтровать строки до aggregate.
  2. Есть ли лишние projected columns.
  3. Подходит ли group key под fast path (например, single integer key).
  4. Не слишком ли много групп.
  5. Не требует ли запрос сортировки после aggregate.

5. Parallel planned, но latency высокая

Симптом:

workers_planned=2 workers_launched=0

или workers_launched меньше workers_planned.

Что проверить:

  1. Глобальные лимиты parallel runtime.
  2. CPU saturation.
  3. Очереди pgwire/runtime.
  4. Memory pressure.
  5. Parallel runtime observability runbook.

6. План поменялся после ANALYZE

Сравните:

  • query_fingerprint — должен остаться стабильным для той же формы SQL;
  • plan_fingerprint — меняется, если поменялась форма плана;
  • replan_reason — должен объяснить перестроение;
  • reason_codes — покажут, какие новые факторы стали доступны.

Если после ANALYZE появился IndexScan или IndexOnlyScan, это обычно хороший знак. Если появился SeqScan на большом OLTP lookup, проверьте selectivity и статистику.

Связь с sys.* views

EXPLAIN показывает план, а sys.* помогает проверить, есть ли у optimizer-а данные для хорошего решения.

Минимальный набор:

SELECT *
FROM sys.table_stats
WHERE schema_name = 'public' AND table_name = 'orders';

SELECT *
FROM sys.column_stats
WHERE schema_name = 'public' AND table_name = 'orders';

SELECT *
FROM sys.multicolumn_stats
WHERE schema_name = 'public' AND table_name = 'orders';

SELECT *
FROM sys.workload_stats
WHERE schema_name = 'public' AND table_name = 'orders';

Как читать:

  • row_count_estimate помогает понять, знает ли optimizer размер таблицы.
  • distinct_estimate помогает оценивать селективность equality predicate.
  • min_i64 / max_i64 помогают range predicate-ам.
  • multicolumn_stats помогает коррелированным условиям.
  • workload_stats показывает, как таблица реально используется.

Triage Checklist

Когда пользователь говорит «запрос стал медленным», действуйте так:

  1. Снимите план:

    EXPLAIN (VERBOSE, DIAGNOSTIC)
    <query>;
    
  2. Если безопасно, снимите runtime:

    EXPLAIN ANALYZE
    <query>;
    
  3. Прочитайте дерево снизу вверх.

  4. Найдите самый широкий input (rows резко больше ожидаемого).

  5. Проверьте, используется ли ожидаемый operator class: IndexScan, IndexOnlyScan, HashSemiJoin, Aggregate, Vector*.

  6. Проверьте reason_codes.

  7. Если есть stats_default_fallback, выполните ANALYZE и сравните план.

  8. Сравните query_fingerprint и plan_fingerprint до/после.

  9. Если проблема в parallel path, переходите к Parallel runtime observability runbook.

  10. Если проблема в storage/IO, переходите к Performance tuning guide.

Частые ошибки интерпретации

ОшибкаПочему неверноПравильно
cost=1000 значит 1000 msCost — условная модель optimizer-а.Для времени используйте EXPLAIN ANALYZE и latency метрики.
SeqScan всегда плохоFull scan может быть оптимальным для маленьких таблиц или low-selectivity фильтров.Смотрите размер таблицы, селективность и наличие индекса.
IndexScan всегда лучшеIndex scan может быть хуже full scan, если возвращает большую долю таблицы.Сравнивайте rows/cost и фактический runtime.
workers_planned=2 гарантирует ускорение в 2 разаWorker-и имеют overhead и могут не запуститься.Смотрите workers_launched и runtime metrics.
replan_reason=none значит optimizer ничего не сделалЭто значит, что явной причины replan нет.Смотрите cache_status, fingerprints и reason codes.
stats_default_fallback можно игнорироватьЭто сигнал, что optimizer мог гадать без статистики.Запустите ANALYZE и проверьте sys.* views.

Когда эскалировать

Эскалируйте как bug/perf issue, если:

  • EXPLAIN (DIAGNOSTIC) не показывает diagnostic block (убедитесь, что не используете EXPLAIN (DIAGNOSTIC ON) — булевый суффикс ON/OFF не поддерживается AngaraBase и silently игнорирует опцию; используйте EXPLAIN (DIAGNOSTIC) без суффикса);
  • query_fingerprint нестабилен для одной и той же формы запроса;
  • plan_fingerprint меняется без schema/stats/AQP причины;
  • replan_reason=stats_drift появляется слишком часто на стабильной таблице;
  • IndexOnlyScan выбран, но runtime постоянно делает heap fetch;
  • HashSemiJoin/HashAntiJoin исчезают для простых EXISTS/NOT EXISTS;
  • workers_launched системно ниже workers_planned без понятного pressure signal;
  • JSON/text вывод противоречат друг другу.

Для bug report приложите:

  • SQL запроса;
  • EXPLAIN (VERBOSE, DIAGNOSTIC) text;
  • EXPLAIN (VERBOSE, DIAGNOSTIC, FORMAT JSON);
  • relevant rows из sys.table_stats, sys.column_stats, sys.multicolumn_stats, sys.workload_stats;
  • версию AngaraBase и capability/profile snapshot, если есть.

Дальше