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
Читаем так:
VectorSeqScanчитает таблицуpublic.ux_stats_v2.VectorFilterприменяет условиеWHERE.Projectоставляет нужные колонки в результате.- Блок
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.00 | startup_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 выполняются через векторизованный путь:
VectorSeqScanVectorIndexScanVectorFilterVectorProjectVectorWindowFunctionVectorSetOperation
Векторный путь обрабатывает данные пачками, снижая overhead на строку. Для оператора это обычно хороший признак, особенно на scan/filter/aggregate workload.
Если ожидаемый Vector* исчез:
- Проверьте query shape: не добавили ли выражение, которое пока не поддерживается vector executor-ом.
- Сравните
EXPLAIN (VERBOSE, DIAGNOSTIC)до/после изменения запроса. - Смотрите
reason_codesи плановые node types. - Для 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. |
NLIndexJoin | Nested-loop probe по индексу. | Малый outer input + селективный index lookup. | Большой outer input: может превратиться в много index probes. |
Aggregate | COUNT, 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. |
SetOperation | UNION, INTERSECT, EXCEPT. | Set queries. | Если unexpectedly дорогой из-за dedup/sort. |
LateralJoin | LATERAL/derived-table dependent path. | Коррелированные derived inputs. | На больших outer inputs может быть дорогим. |
LateMaterialize | Отложенная материализация колонок. Читает только необходимые для фильтрации колонки, а остальные дочитывает позже для строк, прошедших фильтр. | Высокая селективность фильтра (selectivity < 0.3). | Если селективность низкая, двойное чтение может быть дороже обычного. |
DmlInsert / DmlUpdate / DmlDelete | Sentinel для DML. | EXPLAIN DML показывает intent. | Для runtime counters используйте EXPLAIN ANALYZE осторожно. |
Ddl | Sentinel для 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 там, где ожидали индекс:
- Проверьте актуальность статистики (
ANALYZE). - Проверьте значение
distinct_estimateвsys.column_stats. - Подстройте пороги в
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-а:
selectwriteddl- другие классы, если конкретный путь их маркирует.
Оператору это помогает отделить OLTP read path от write/DDL событий.
replan_reason
Почему план был перестроен или почему явной причины нет.
| Значение | Смысл | Что делать |
|---|---|---|
none | Явной причины перепланирования нет. Обычно это нормальный путь. | Если cache_status=hit, кэш работает. |
stats_drift | Статистика изменилась достаточно сильно, старый план мог устареть. | Проверить частоту ANALYZE, churn таблицы, стабильность latency. |
schema_changed | Схема изменилась: DDL, индекс, колонка или другой schema signal. | Нормально после миграций; подозрительно при частом DDL в production. |
aqp_feedback | Runtime feedback повлиял на оценку/планирование. | Проверить AQP metrics и skew workload. |
forced_fallback | Planner/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. |
unknown | Runtime не передал статус. | Не делайте выводов о кэше только по этому полю. |
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_mem | Hash 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 TypeStartup CostTotal CostPlan RowsPlansworkers_plannedworkers_launchednuma_affinityquery_fingerprintplan_fingerprintreplan_reasonreason_codescache_status
Для release evidence сравнивайте не весь JSON byte-for-byte, а устойчивые свойства: node class, join type, fingerprints, reason codes и ключевые оценки.
Типовые сценарии чтения
1. Медленный точечный lookup
Симптом:
VectorSeqScan table=public.orders ... rows=1000000
Что проверить:
- Есть ли индекс по колонке фильтра.
- Видит ли планировщик статистику (
sys.column_stats). - Не показывает ли diagnostics
stats_default_fallback. - Не слишком ли низкая селективность фильтра.
Желаемый план для точечного 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=...
Что проверить:
- Можно ли отфильтровать строки до aggregate.
- Есть ли лишние projected columns.
- Подходит ли group key под fast path (например, single integer key).
- Не слишком ли много групп.
- Не требует ли запрос сортировки после aggregate.
5. Parallel planned, но latency высокая
Симптом:
workers_planned=2 workers_launched=0
или workers_launched меньше workers_planned.
Что проверить:
- Глобальные лимиты parallel runtime.
- CPU saturation.
- Очереди pgwire/runtime.
- Memory pressure.
- 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
Когда пользователь говорит «запрос стал медленным», действуйте так:
-
Снимите план:
EXPLAIN (VERBOSE, DIAGNOSTIC) <query>; -
Если безопасно, снимите runtime:
EXPLAIN ANALYZE <query>; -
Прочитайте дерево снизу вверх.
-
Найдите самый широкий input (
rowsрезко больше ожидаемого). -
Проверьте, используется ли ожидаемый operator class:
IndexScan,IndexOnlyScan,HashSemiJoin,Aggregate,Vector*. -
Проверьте
reason_codes. -
Если есть
stats_default_fallback, выполнитеANALYZEи сравните план. -
Сравните
query_fingerprintиplan_fingerprintдо/после. -
Если проблема в parallel path, переходите к Parallel runtime observability runbook.
-
Если проблема в storage/IO, переходите к Performance tuning guide.
Частые ошибки интерпретации
| Ошибка | Почему неверно | Правильно |
|---|---|---|
cost=1000 значит 1000 ms | Cost — условная модель 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, если есть.
Дальше
- Performance tuning guide — как действовать после чтения плана, если проблема в latency/throughput.
- Parallel runtime observability runbook — как
расследовать
workers_planned/workers_launchedи runtime pressure. - Observability metrics checklist — какие метрики
сопоставлять с
query_fingerprintиplan_fingerprint. - Diagnostics bundle runbook — как собрать evidence для поддержки.