Перейти к содержимому
Skein
← Все проекты

backend · intermediate · 6d

Визуализатор планов запросов

Вставь EXPLAIN (ANALYZE, FORMAT JSON) и отрисуй дерево плана с таймингом по узлам и ошибкой оценки строк, чтобы плохой join был виден сразу.

Читать сырой EXPLAIN JSON — это аналог чтения минифицированного бандла: информация вся тут, но структура враждебна. Этот проект заставляет корректно учесть число циклов (за цикл против суммарно), вычислить self-time из итогов потомков, взвесить тяжесть worst-node по self-time, чтобы избежать ложных тревог на дешёвых узлах, детектировать спиллы work_mem по Batches > 1 и сравнить два плана, чтобы доказать фикс. Практический выигрыш очевиден: каждый сеньор, оптимизирующий запросы, рано или поздно строит или тянется именно к такому инструменту — а его самостоятельная сборка делает семантику плана незабываемой, а финальный этап превращает его в цикл тюнинга с отработанным инцидентом от seq scan к index scan.

Результат

Веб-приложение, превращающее EXPLAIN (ANALYZE, FORMAT JSON) в аннотированное сворачиваемое дерево с таймингом по узлам (self-time), скорректированными оценками строк (Actual Rows × Loops), бейджами worst-node и спилла и диффингом план-к-плану — без ложных тревог на дешёвых узлах.

Этапы

0/5 · 0%
  1. 01Распарси EXPLAIN JSON в дерево

    Распарси EXPLAIN (ANALYZE, FORMAT JSON) в точное дерево узлов — сохраняя структуру родитель/потомок, типы узлов и поля, от которых зависят следующие этапы. Верхний уровень JSON — массив с одним объектом плана; у каждого узла Node Type, Plans (потомки), Startup Cost, Total Cost, Plan Rows, Actual Startup/Total Time, Actual Rows и Loops. Loops — множитель, ломающий или спасающий корректность: внутри nested loop на 1000 итераций Actual Rows указан за цикл, так что суммарные фактические строки = Actual Rows × Loops — смешение даёт ошибку оценки в 1000 раз. Корректно обработай много-потомковые узлы (например, стороны build и probe Hash Join — отдельные массивы потомков, не сливать) и все типы scan/join без схлопывания. Некорректный или не-JSON ввод должен падать с понятным сообщением и подсказкой строки, а не крашем или пустым деревом.

    Критерии готовности
    • EXPLAIN (ANALYZE, FORMAT JSON) с nested loop, hash join (build/probe) и смешанными типами сканов парсится в дерево узлов с сохранением родитель/потомок и полей на узел — проверено минимум на 3 фикстурах (простой скан, nested-loop join, hash join с batches).
    • Некорректный / не-JSON ввод показывает понятную ошибку с позицией, а не краш; суммарные Actual Rows × Loops вычислены и доступны на узел для следующих этапов.
    Самопроверка

    Вставь распарсенное дерево для плана с nested loop и покажи суммарные Actual Rows × Loops на узел. Senior-ревьюер проверяет применение Loops (не сырых Actual Rows), раздельность build/probe потомков и понятную ошибку на некорректном вводе.

  2. 02Тайминг и оценки строк по узлам

    Отрисуй дерево с фактическими против плановых строк и self-time против накопленного времени по каждому узлу, со сворачиванием. Для каждого узла покажи: Plan Rows против суммарных фактических строк (Actual Rows × Loops) и коэффициент ошибки оценки (факт/план), Actual Total Time против self-time. Self-time — собственный вклад узла: `self = Total Time − Σ Total Time потомков` — для листовых узлов (seq scan, index scan) self равен Total Time; у корня Total Time всегда наибольший, но self может быть крошечным, так что показ только Total Time вводит в заблуждение, делая корень всегда похожим на узкое место. Скорректируй строки по циклам, вычисли self-time и сделай дерево сворачиваемым, чтобы большие планы оставались читаемыми. Покажи сигналы латентности и строк рядом, чтобы пользователь видел и 'куда ушло время', и 'где планировщик ошибся сильнее', не смешивая cost (предсказание планировщика) со временем (реальность выполнения).

    Критерии готовности
    • Каждый узел показывает Plan Rows против суммарных фактических строк (× Loops) с коэффициентом ошибки и Total Time против self-time (self = Total − Σ потомков); дерево сворачивается, у листьев self равен Total.
    • Фикстура nested-loop показывает скорректированные суммарные строки (не за цикл), hash-join — self-time корректно меньше Total Time корня — оба проверены численно.
    Самопроверка

    Покажи фикстуру nested loop с суммарными Actual Rows × Loops и узел где self-time ≠ Total Time. Senior-ревьюер проверяет формулу self = Total − Σ потомков и коррекцию строк по циклам, а не сырые значения.

  3. 03Подсвети худший узел без ложных тревог

    Подсветь худший узел двумя независимыми сигналами — наибольшей ошибкой оценки (факт/план, с коррекцией по циклам) и наибольшим self-time — показав каждый отдельно, чтобы пользователь видел оба измерения. Сеньорная тонкость — ложные тревоги: ошибка оценки в 100 раз на узле 0,01 мс — шум, а не узкое место. Взвесь тяжесть по self-time (например, `severity = log(error) × self-time` или `error × self-time`), чтобы дешёвые узлы никогда не доминировали. Корректный план без большой ошибки не должен показывать флагов — явно протестируй это. Объясни, почему одна Total Cost — неверный сигнал (это предсказание планировщика, не время выполнения) и почему один self-time пропускает ошибки планировщика (быстрый узел с огромным разрывом оценки сигнализирует об устаревшей статистике или отсутствующем индексе, даже если он не самый медленный).

    Критерии готовности
    • Узлы с наибольшей ошибкой оценки (с коррекцией по циклам) и наибольшим self-time помечены независимо; тяжесть взвешивает ошибку по self-time, так что ошибка 100× на узле 0,01 мс не флагуется.
    • Корректный план без большой ошибки не даёт ложной тревоги — проверено фикстурой с равномерно малыми ошибками, для которой ноль флагов утверждается.
    Самопроверка

    Покажи план где наибольшая ошибка на дешёвом узле и докажи отсутствие флага из-за взвешивания тяжести, плюс корректный план без флагов. Senior-ревьюер проверяет severity = f(error, self-time) и что одна Total Cost не используется.

  4. 04Детекция спилла и рассуждение о work_mem

    Определи проливающиеся hash/sort узлы и предложи целевой work_mem — самый полезный инсайт этого инструмента. Hash join и сортировки используют хеш-таблицы в памяти, ограниченные work_mem (по умолчанию 4 МБ на операцию); когда данные превышают его, Postgres сбрасывает на диск партиями (Batches > 1), что типично даёт замедление в 10–100 раз. Покажи: какие узлы пролились (Batches > 1), их Peak Memory Usage если есть (или оценку по числу партий hash при отсутствии) и предложенную нижнюю границу work_mem. Затем порассуждай о компромиссе: слишком высокий work_mem глобально опасен — каждый параллельный запрос может его использовать, так что 100 соединений × 256 МБ = 25 ГБ только на сортировки, риск OOM. Правильный совет — per-query `SET work_mem` для проливающего запроса, а не глобальное повышение. Докажи: фикстура с 10-партийным hash-спиллом флажена, предложенный work_mem показан, не проливающий план без бейджа спилла.

    Критерии готовности
    • Узлы с Batches > 1 помечены как спиллы с Peak Memory Usage (или оценкой по партиям) и предложенной нижней границей work_mem; не проливающая фикстура без флага спилла.
    • Совет по work_mem предупреждает о глобальном против per-query с математикой 100×256 МБ = 25 ГБ и рекомендует per-query SET для проливающего запроса.
    Самопроверка

    Покажи 10-партийный спилл с предложенным work_mem и не-спилл без флага. Senior-ревьюер проверяет использование Peak Memory Usage (или оценку по партиям) и совет per-query SET с математикой 25 ГБ, а не 'увеличь work_mem'.

  5. 05Сравни два плана и отработай инцидент

    Добавь диффинг план-к-плану и сделай инструмент наблюдаемым в цикле тюнинга. Сравни два EXPLAIN JSON (до/после индекса или фикса статистики) рядом: подсветь какие узлы появились/исчезли, где ошибка оценки и self-time улучшились или ухудшились и где исчез спилл. Затем отработай инцидент: возьми план медленного запроса с seq scan + большим разрывом оценки (устаревшая статистика или отсутствующий индекс), добавь индекс или запусти ANALYZE, повторно EXPLAIN и покажи дифф — seq scan становится index scan, ошибка схлопывается, спилл (если был) исчезает. Снимай лёгкие RED-метрики самого визуализатора (время парсинга, время рендера, число флагов) и задокументируй цикл тюнинга: EXPLAIN → визуализация → фикс (индекс/ANALYZE/work_mem) → повторный EXPLAIN → дифф → проверка отсутствия нового спилла или регрессии. Пост-мортем: каким был сигнал плохого плана, какой фикс применён и что доказывает дифф.

    Критерии готовности
    • Два плана (до/после) сравниваются рядом с подсветкой изменений узлов, дельт ошибки/self-time и появления/исчезновения спилла — проверено парой фикстур до/после (seq scan → index scan).
    • Отработанный инцидент (медленный запрос → индекс/ANALYZE → повторный EXPLAIN → дифф) задокументирован с сигналом, фиксом и доказательством диффа; в плане после нет нового спилла или ложного флага.
    Самопроверка

    Покажи дифф до/после (seq scan → index scan, схлопывание ошибки, исчезновение спилла если был) и пост-мортем инцидента. Senior-ревьюер проверяет подсветку дельт узлов/ошибки/спилла и что фикс — индекс/ANALYZE/work_mem, а не 'добавь железо'.

Стартер

fallowlone/skein-projects

projects/query-plan-visualizer

Открыть на GitHub ↗
  • README.md
  • src/plan.ts
  • test/plan.test.ts
Забрать только этот проект npx degit fallowlone/skein-projects/projects/query-plan-visualizer query-plan-visualizer

Реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test

Форкни репозиторий и запушь свою работу — workflow grade прогонит тесты и статические проверки на твоих раннерах.

Рубрика

Джуниор Миддл Сеньор
Корректность парсинга плана Парсит узел верхнего уровня и отображает тип узла, оценочную стоимость и плановые строки для простых однорузловых планов. Рекурсивно обходит Plans/children, сохраняет отношения родитель-потомок для всех типов узлов скана и join, и обрабатывает планы с несколькими массивами потомков (например, стороны build и probe hash join) без их смешения. Корректно учитывает число циклов при вычислении фактических итогов по узлу: потомок внутри nested loop с 1000 итерациями сообщает строки за цикл, а не суммарные — смешение даёт ошибку оценки ровно в 1000 раз. Умеет объяснить, какие поля JSON используются и почему Loops необходимо учитывать.
Атрибуция стоимости и тайминга Отображает Total Cost планировщика и Actual Total Time из JSON как есть для каждого узла. Вычисляет self-time каждого узла как разность Total Time и суммы Total Time потомков, отображает его рядом с накопленным временем и показывает плановые против фактических строк, чтобы ошибка оценки была сразу видна. Помечает спиллующие узлы: hash или sort с Batches > 1 означают спилл work_mem на диск, что типично даёт замедление в 10–100 раз. Предлагает нижнюю границу work_mem из поля Peak Memory Usage или оценивает её по числу партий hash-таблицы при отсутствии поля.
Обнаружение худшего узла Подсвечивает узел с наибольшей Total Cost как узкое место. Вычисляет два отдельных сигнала — наибольшую ошибку оценки (фактические/плановые строки с учётом циклов) и наибольший self-time — и показывает каждый независимо, чтобы пользователь видел одновременно «куда ушло время» и «где планировщик ошибся сильнее всего». Избегает ложных тревог на тривиально дешёвых узлах: ошибка оценки в 100 раз на узле длительностью 0,01 мс — это шум; оценка тяжести взвешивает величину ошибки по self-time. Формулирует, почему корректный план без большого разрыва в оценке не должен давать флаги, и явно тестирует этот случай.
Эталонный разбор (спойлер)

Структура EXPLAIN JSON: массив верхнего уровня содержит один объект плана; каждый узел имеет Node Type, массив потомков Plans, Startup Cost, Total Cost, Plan Rows, Actual Startup Time, Actual Total Time, Actual Rows и Loops. Loops — это множитель: Actual Rows в JSON указан за цикл, поэтому суммарные фактические строки = Actual Rows × Loops. Пропустить это значит получить катастрофически неверные вычисления ошибки оценки для любого плана с nested loop.

Self-time против накопленного времени: Total Time в узле включает время всех потомков. Стоимость самого узла — это Total Time минус сумма Total Time его потомков. Для листовых узлов (seq scan, index scan) self-time равен Total Time. Отображать только Total Time вводит в заблуждение — корень всегда выглядит самым дорогим узлом.

Seq scan как диагностический сигнал: seq scan по большой таблице не всегда ошибочен — если запрос возвращает значительную долю строк, seq scan выигрывает у index scan, потому что случайные обращения к куче дороже последовательного чтения. Флаг стоит поднимать при сочетании seq scan с большим разрывом в оценке — обычно это означает устаревшую статистику или отсутствующий индекс.

Спиллы work_mem: hash join и сортировки используют хеш-таблицы в памяти, ограниченные work_mem (по умолчанию 4 МБ на операцию). Когда данные превышают лимит, Postgres сбрасывает их на диск партиями (Batches > 1). Спилл на 10 партий означает примерно в 10 раз больше I/O. Глобально устанавливать высокий work_mem опасно — каждый одновременный запрос может его использовать: 100 соединений × 256 МБ = 25 ГБ только на сортировки.

Сделай по-сеньорски

  • Определи сигналы устаревшей статистики: seq scan по большой таблице с большим разрывом оценки при наличии индекса — предложи запустить ANALYZE и покажи схлопывание ошибки до/после.
  • Добавь scatter cost-против-времени по узлам, чтобы показать где модель стоимости планировщика сильнее расходится со временем выполнения.
  • Экспортируй дифф как шарибельный URL (сжатая пара планов во фрагменте), чтобы коллега мог открыть до/после без вставки JSON.

Навыки

EXPLAIN JSON parsing (Loops, Plans, Batches)self-time vs cumulative time attributionestimate-vs-actual error with loop correctionspill detection (Batches > 1) & work_mem reasoningplan diffing & worst-node severity

Рекомендуемый стек

typescriptpreact

Материалы