Как читать EXPLAIN ANALYZE
EXPLAIN ANALYZE печатает дерево плана с пометками оценка против факта. Зазор между оценочными и фактическими строками — это сигнал, что планировщику соврали.
Отчётный запрос упирается в таймаут 30 с на проде. Джуниор трижды переписывает SQL — не помогает. Ты один раз запускаешь EXPLAIN ANALYZE, и ответ лежит на третьей строке: узел говорит rows=1 оценка, rows=2 300 000 факт. Планировщик думал, что тронет одну строку, и построил весь join вокруг этой лжи. План никто не прочитал. Прочитать его — навык на пять минут, который предотвращает пятичасовые инциденты.
Что на самом деле в выводе
EXPLAIN ANALYZE <query> выполняет твой запрос и печатает дерево плана, которое его породило, по одному узлу на строку, с отступами по вложенности. Глава об execution-plans трека databases выводит, почему планировщик строит дерево сканов и join’ов; здесь ты учишься только читать его как runbook (пошаговый рабочий справочник, к которому тянешься при инциденте). Каждая строка-узел несёт две группы чисел:
- Оценки (с планирования):
cost=0.00..431.00(startup..total в условных единицах стоимости) иrows=1000— что планировщик угадал до запуска. - Факты (с выполнения):
actual time=0.012..4.318(мс) иrows=998 loops=1— что реально произошло.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total
FROM orders o
JOIN line_items li ON li.order_id = o.id
WHERE o.status = 'shipped';Реальный (урезанный) результат:
Hash Join (cost=33.50..812.40 rows=1200 width=20)
(actual time=1.2..58.7 rows=1180 loops=1)
Hash Cond: (li.order_id = o.id)
Buffers: shared hit=512 read=140
-> Seq Scan on line_items li
(cost=0..512 rows=20000 width=12)
(actual time=0.01..21.3 rows=20000 loops=1)
-> Hash (cost=20..20 rows=300 width=12)
(actual time=1.1..1.1 rows=305 loops=1)
-> Index Scan on orders o
(cost=0.29..20 rows=300 width=12)
(actual time=0.03..0.8 rows=305 loops=1)
Index Cond: (status = 'shipped')
Planning Time: 0.4 ms
Execution Time: 59.4 msЧитай изнутри наружу, следи за зазором
Дерево выполняется от самых глубоких, внутренних узлов наружу — листья первыми, корень последним. Читай так же. Для каждого узла задай один вопрос: совпадает ли rows= оценка с rows= факт?
- Оценка 300, факт 305 → планировщик откалиброван хорошо. Этому поддереву можно доверять.
- Оценка 1, факт 2 300 000 → планировщик промахнулся в оценке. Каждое решение выше этого узла (какой алгоритм join, какой порядок join’ов) принималось на неверном числе строк, поэтому весь план над ним под подозрением. Это самый ценный сигнал во всём выводе, и весь урок 03 — про то, как это чинить.
Зазор в 10× — жёлтый флаг; зазор в 1000× — это баг. Фикс почти никогда не «переписать запрос» — это обновить статистику, добавить индекс, которому планировщик поверит, или поправить оценки коррелированных колонок.
Три числа, которые читают неверно
Loops — actual time это на одну итерацию, не итого. На внутренней стороне nested loop ты увидишь actual time=0.05..0.06 rows=1 loops=50000. Этот узел отработал 50 000 раз. Итоговое время на нём — примерно время на итерацию × loops ≈ 0.06 мс × 50000 = 3 секунды, а не 0.06 мс. Самая частая ошибка новичка — «этот узел быстрый», когда он тайком и есть всё время выполнения. Так же rows на узле в цикле — среднее на итерацию; умножай на loops для реального итого.
Cost против time. cost безразмерен и имеет смысл только для сравнения кандидатных планов внутри планировщика (урок 02). Это не миллисекунды и не сравнимо между машинами. Для диагностики реального запроса игнорируй cost и читай actual time и rows.
BUFFERS — куда ушёл I/O. BUFFERS добавляет shared hit=N (страницы по 8 КБ из буферного кеша) и read=N (страницы с диска/ОС). Узел с shared read=180000 упёрт в I/O: он вытащил 180k страниц (~1.4 ГБ) с диска. Это, а не CPU, и есть твоя медлительность — фикс это более селективный индекс или меньше сканируемых данных. BUFFERS — разница между догадкой «может, это I/O» и доказательством этого.
▸Почему это работает
Почему EXPLAIN ANALYZE реально выполняет запрос (и потому опасен на записях)? Потому что половина «ANALYZE» значит «инструментируй и выполни», а не «только спланируй». Запуск EXPLAIN ANALYZE на UPDATE или DELETE выполняет запись. Чтобы безопасно осмотреть изменяющий запрос, оберни его: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;. Сама инструментация тоже добавляет накладные расходы (чтение часов на каждом узле), поэтому Execution Time здесь слегка завышен относительно голого запроса — используй его для сравнения, а не как SLA-число. FORMAT TEXT (по умолчанию) — для чтения глазами; FORMAT JSON — когда вывод будет парсить инструмент.
Расставь, как сеньор читает план EXPLAIN ANALYZE, чтобы найти проблему:
- 1 Запусти EXPLAIN (ANALYZE, BUFFERS) на медленном запросе
- 2 Найди самые внутренние (глубже отступ) узлы — они выполняются первыми
- 3 На каждом узле сравни оценочные строки с фактическими
- 4 Найди узел с самым большим зазором оценка-против-факта
- 5 Умножь время на итерацию на loops, чтобы получить реальную цену узла
- 6 Проверь BUFFERS: цена это чтения с диска или попадания в кеш
- 7 Выбери фикс: обновить статистику, индекс или переписать — по тому, что показали числа
Внутренний узел nested loop показывает: actual time=0.04..0.05 rows=1 loops=80000. Сколько реального времени стоит этот узел?
Узел Seq Scan читает rows=1 оценка, но rows=2 300 000 факт. О чём это говорит в первую очередь?
- 01Какое самое полезное число читать в выводе EXPLAIN ANALYZE и почему?
- 02Почему 'actual time=0.05 rows=1' на узле в цикле обманчиво и как получить реальную цену?
- 03Что добавляет BUFFERS и что говорят shared hit против read?
EXPLAIN ANALYZE выполняет запрос и печатает выбранное дерево плана, по одному узлу с отступом на строку, и каждый несёт оценку планировщика (cost, rows) рядом с фактом исполнителя (time, rows, loops). Читай изнутри наружу — глубочайшие узлы выполняются первыми — и на каждом узле сравнивай оценочные строки с фактическими: самый большой зазор — где планировщика обманули и где живёт реальный фикс (статистика, индекс, оценки коррелированных колонок — уроки 02 и 03), а не переписывание запроса. Следи за двумя ловушками: на узлах в цикле actual time и rows — на итерацию, поэтому умножай на loops для реальной цены; а cost — безразмерная мерка планирования, не миллисекунды. Добавь BUFFERS, чтобы превратить «похоже на I/O» в доказательство — shared hit это кеш, read это диск. Теперь, когда видишь медленный запрос, твой первый ход — EXPLAIN (ANALYZE, BUFFERS), а первое, что ищешь — зазор оценка-против-факта по строкам на каждом узле, а не новый индекс и не переписывание.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.