Средний ~50 мин чтения

EXPLAIN ANALYZE: читаем план запроса

Урок 4 из 10 в курсе PostgreSQL для backend-разработчика

EXPLAIN ANALYZE: читаем план запроса

EXPLAIN ANALYZE выполняет запрос и показывает реальный план с фактическим временем, количеством строк и использованием буферов. Это главный инструмент для понимания почему запрос медленный и что именно нужно исправить. Без EXPLAIN ANALYZE оптимизация запросов — угадывание; с ним — точная диагностика: видно где теряется время, насколько точна оценка планировщика и сколько данных читается с диска.

Почему это важно: Большинство проблем производительности PostgreSQL решаются через цикл: pg_stat_statements (найти медленный запрос) → EXPLAIN ANALYZE (понять почему) → создать индекс или обновить статистику (исправить). Умение читать план запроса — это навык, отличающий разработчика, который тратит часы на догадки, от того, кто находит причину за 5 минут. Неправильная интерпретация EXPLAIN приводит к обратным результатам: добавлению индекса там, где проблема в устаревшей статистике, или увеличению памяти там, где нужен другой план соединения.

Главная идея

EXPLAIN строит дерево операций (query plan tree) снизу вверх: каждый узел — одна операция. Нижние узлы — источники данных (Seq Scan, Index Scan). Средние узлы — операции над данными (Filter, Sort, Hash). Верхние узлы — финальные операции (Aggregate, Limit). Данные текут снизу вверх: каждый узел получает строки от дочерних и передаёт родительскому. Ключевые числа в каждом узле: (cost=startup..total rows=N width=W) (actual time=start..end rows=R loops=L). cost — оценочная стоимость в условных единицах (не секунды). actual time — реальное время в миллисекундах. Расхождение rows (оценка) vs actual rows (реальность) > 10x — признак устаревшей статистики или плохой оценки, что может привести к неоптимальному плану. BUFFERS опция добавляет данные об I/O: shared hit (из кеша), shared read (с диска), shared written. Запрос с большим shared read при горячем кеше означает, что данные не помещаются в shared buffers — либо нужно увеличить кеш, либо запрос читает слишком много данных (например, из-за отсутствия индекса).

Как это выглядит на практике

  1. Всегда используй расширенный формат: EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20;. ANALYZE выполняет запрос реально. BUFFERS добавляет статистику I/O. FORMAT TEXT — удобочитаемый формат.
  2. Читай план снизу вверх: нижние узлы выполняются первыми. Если в самом низу Seq Scan на таблице с миллионами строк — это первый кандидат на оптимизацию. Если Index Scan — смотри на rows vs actual rows.
  3. Анализируй расхождение rows vs actual rows: 'rows=1000 actual rows=150000' означает, что планировщик недооценил число строк в 150 раз. Следствие — он выбрал Hash Join вместо Nested Loop, неправильный порядок соединений. Запусти ANALYZE orders; и повтори EXPLAIN ANALYZE.
  4. Диагностика I/O через BUFFERS: найди узел с наибольшим shared read. Если read >> hit — данные этого узла не в кеше. Либо увеличь shared_buffers, либо запрос читает слишком много лишних данных.
  5. Hash Join Batches > 1 означает spill на диск: 'Hash Batches: 8 (originally 1)'. Это говорит, что для Hash Join не хватило work_mem. Решение: SET work_mem = '256MB'; перед запросом и проверь снова. Если Batches стал 1 — это был спил.
  6. Sort Method: external merge Disk означает что сортировка не поместилась в work_mem и записывалась на диск. Увеличь work_mem для сессии: SET work_mem = '128MB';. После сортировка должна стать Sort Method: quicksort Memory.
  7. Для EXPLAIN ANALYZE с DML (DELETE, UPDATE, INSERT) обязательно оборачивай в транзакцию: BEGIN; EXPLAIN ANALYZE DELETE FROM old_events WHERE created_at < '2023-01-01'; ROLLBACK;. Без ROLLBACK данные реально удалятся.
  8. Используй EXPLAIN (ANALYZE, BUFFERS, VERBOSE) для дополнительной информации: VERBOSE добавляет список выходных столбцов каждого узла и схему в имена таблиц/функций — полезно при сложных запросах с несколькими таблицами.
  9. Проверь loops — сколько раз выполнялся узел: '(actual time=0.1..0.5 rows=10 loops=5000)'. Если loops=5000 и каждый занимает 0.5ms, суммарно узел занял 2500ms. Это типично для Nested Loop Join по неиндексированному столбцу.
  10. Используй онлайн-инструмент explain.dalibo.com: вставь вывод EXPLAIN ANALYZE и получи графическую визуализацию с подсветкой медленных узлов. Особенно полезно для сложных планов с 20+ узлами.
  11. Проверь JIT компиляцию в плане: 'JIT: Functions: 3 Timing: 8.2 ms'. Если JIT timing > 10% от total time на коротком запросе — отключи JIT для него: SET jit = off; EXPLAIN ANALYZE ...;. JIT помогает для длинных аналитических запросов, вредит для коротких OLTP.
  12. Сравни план до и после добавления индекса: сохрани вывод EXPLAIN ANALYZE в файл, добавь индекс, запусти снова и сравни. Ищи: изменение типа scan (Seq Scan → Index Scan), изменение join strategy, изменение actual time.

Примеры кода

Базовый EXPLAIN ANALYZE с интерпретацией

-- Полный синтаксис с всеми полезными опциями
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT
  o.id,
  o.total,
  o.status,
  u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.user_id = 42
  AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 20;

-- Типичный хороший вывод (Index Scan, данные в кеше):
-- Limit  (cost=0.56..10.18 rows=20 width=52)
--        (actual time=0.089..0.312 rows=20 loops=1)
--   Buffers: shared hit=25
--   ->  Nested Loop  (cost=0.56..48.20 rows=100 width=52)
--         Buffers: shared hit=25
--         ->  Index Scan using idx_orders_user_status on orders o
--               Index Cond: (user_id = 42 AND status = 'paid')
--               Rows Removed by Index Recheck: 0
--         ->  Index Scan using users_pkey on users u
--               Index Cond: (id = o.user_id)
-- Planning Time: 0.3 ms
-- Execution Time: 0.4 ms

Хороший план: Index Scan (не Seq Scan), все данные из кеша (shared hit, нет read), Nested Loop для небольшого числа строк, Execution Time < 1ms. Порядок чтения плана — снизу вверх: сначала Index Scan на orders, затем для каждой строки Index Scan на users, затем Limit.

Диагностика: плохая оценка строк и устаревшая статистика

-- Запрос с плохой оценкой
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE event_type = 'purchase'
  AND created_at >= now() - interval '7 days';

-- Плохой вывод (rows сильно занижены):
-- Seq Scan on events
--   Filter: (event_type = 'purchase' AND created_at >= ...)
--   Rows Removed by Filter: 4500000
--   rows=100  actual rows=450000  <-- расхождение в 4500x!
--   actual time=2100..8400

-- Причина: статистика устарела после большого импорта данных
-- Решение: обновить статистику
ANALYZE events;

-- Или для более детальной статистики:
ALTER TABLE events ALTER COLUMN event_type
  SET STATISTICS 500;  -- дефолт 100
ANALYZE events;

-- Теперь повторить EXPLAIN ANALYZE — планировщик выберет индекс

Расхождение rows (оценка планировщика) vs actual rows > 10x — критический сигнал. Планировщик принимает неверные решения о типе join, порядке таблиц, использовании индексов. ANALYZE обновляет статистику. Для столбцов с неравномерным распределением значений увеличивай statistics до 500-1000.

Hash Join с spill на диск

-- Медленный запрос с большим join
EXPLAIN (ANALYZE, BUFFERS)
SELECT
  p.name,
  sum(oi.quantity * oi.price) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.created_at >= '2024-01-01'
GROUP BY p.id, p.name
ORDER BY revenue DESC;

-- Плохой вывод:
-- Hash  (cost=...)  actual time=1200..1200
--   Buckets: 1024  Batches: 16  Memory Usage: 4096kB
--                              ^^^^^^^^^^ spill на диск!
--   Buffers: shared hit=450, shared read=12000, temp written=8000

-- Решение: увеличить work_mem для этого запроса
SET work_mem = '256MB';

EXPLAIN (ANALYZE, BUFFERS)
SELECT ... (тот же запрос);
-- Ожидаем: Batches: 1  (всё в памяти)

Hash Batches > 1 означает что PostgreSQL не мог построить hash table в work_mem и разбил её на batches — каждый batch читается с диска. temp written в BUFFERS подтверждает запись временных файлов. Увеличение work_mem для конкретного тяжёлого запроса через SET work_mem = '...' — правильное решение. Не меняй глобально — work_mem умножается на число операций × соединений.

Сравнение планов: до и после индекса

-- ДО: нет индекса на payment_method
EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, amount, payment_method
FROM payments
WHERE payment_method = 'card'
  AND status = 'failed'
  AND created_at >= now() - interval '30 days';

-- Plan: Seq Scan on payments
--   Filter: (payment_method = 'card' AND status = 'failed' AND ...)
--   Rows Removed by Filter: 2847392
--   actual time=0.5..3200  rows=1847  loops=1
--   Buffers: shared hit=15, read=18920  <-- почти всё с диска

-- Добавляем индекс
CREATE INDEX CONCURRENTLY idx_payments_method_status_created
  ON payments(payment_method, status, created_at DESC);

-- ПОСЛЕ:
EXPLAIN (ANALYZE, BUFFERS)
SELECT ... (тот же запрос);
-- Plan: Index Scan using idx_payments_method_status_created
--   Index Cond: (payment_method = 'card' AND status = 'failed' AND ...)
--   actual time=0.1..2.3  rows=1847  loops=1
--   Buffers: shared hit=890, read=0  <-- всё из кеша

Сравнение до/после показывает эффект индекса: Seq Scan → Index Scan, 3200ms → 2.3ms, 18920 блоков с диска → 0 блоков с диска (всё закешировалось в shared buffers после первого прохода через индекс). read=0 означает 100% cache hit ratio для этого запроса.

Анализ Nested Loop с большим numbers of loops

EXPLAIN (ANALYZE, BUFFERS)
SELECT
  u.name,
  count(o.id) AS order_count,
  sum(o.total) AS total_spent
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at >= '2024-01-01'
GROUP BY u.id, u.name;

-- Плохой вывод (нет индекса на orders.user_id):
-- Nested Loop Left Join
--   ->  Index Scan on users (50000 rows)
--   ->  Seq Scan on orders
--         Filter: (user_id = u.id)
--         actual time=0.1..15.0 rows=3 loops=50000  <-- 50000 seq scan!
--         Buffers: shared hit=250000, read=500000

-- total time: 50000 * 15ms = 750 секунд

-- Решение: CREATE INDEX на orders.user_id
CREATE INDEX idx_orders_user_id ON orders(user_id);

-- После: Nested Loop с Index Scan, loops=50000 но каждый 0.05ms
-- total time: 50000 * 0.05ms = 2.5 секунды

loops в EXPLAIN ANALYZE показывает сколько раз выполнялся узел. actual time умножается на loops — итоговое время узла. Nested Loop с большим loops и дорогим inner node — типичная проблема missing index на join column. После добавления индекса на orders.user_id каждый lookup стоит 0.05ms вместо 15ms.

Что происходит под капотом

  • Планировщик PostgreSQL использует статистику из pg_statistic (обновляется через ANALYZE) для оценки числа строк. Для каждого столбца хранит: количество уникальных значений (n_distinct), наиболее частые значения (most_common_vals) с частотами, гистограмму распределения (histogram_bounds). Неточная статистика = неверные оценки rows = плохой план.
  • cost единицы: seq_page_cost = 1.0, random_page_cost = 4.0 (или 1.1 для SSD), cpu_tuple_cost = 0.01, cpu_operator_cost = 0.0025. Планировщик вычисляет взвешенную сумму операций для каждого возможного плана и выбирает наименьший. Это объясняет почему при random_page_cost = 4.0 (дефолт для HDD) планировщик избегает Index Scan даже когда он был бы быстрее — он 'думает' что random I/O дорогой.
  • Join strategies: Nested Loop — хорош для небольших наборов данных с индексом на inner table. Hash Join — хорош для больших наборов без индекса, строит hash table из меньшей таблицы. Merge Join — хорош когда обе таблицы уже отсортированы. Планировщик выбирает стратегию на основе оценки строк — неверная оценка = неверная стратегия.
  • startup cost vs total cost: startup = время до первой строки, total = время до последней строки. LIMIT сильно влияет: планировщик может выбрать Nested Loop с low startup cost вместо Hash Join с high startup cost, зная что нужно только 10 строк. Без LIMIT предпочтёт Hash Join с лучшим total cost.
  • BUFFERS детализация: shared hit — страница была в shared buffers. shared read — страница читалась с диска (или OS page cache). local hit/read — для temporary tables. temp read/written — временные файлы на диске (Sort spill, Hash spill). dirtied — страница была модифицирована. Соотношение hit/(hit+read) = cache hit ratio для этого конкретного запроса.
  • actual time в узле — это время только этого узла без дочерних. Чтобы найти суммарное время включая дочерние, смотри на самый верхний узел, который включает всё. actual time=start..end — start это время до первой строки, end это время до последней. В VERBOSE режиме виден вклад каждого узла в общее время.
  • never executed в узле (видно как actual rows=0 loops=0) — этот узел не выполнялся. Типично для правой ветки Anti Join или для плана с несколькими путями где выбрался только один. Не ошибка — просто планировщик избежал этой ветки.
  • JIT (Just-In-Time compilation): PostgreSQL 11+ может компилировать части плана в нативный код через LLVM. Полезен для сложных аналитических запросов (много arithmetic, expression evaluation). Вреден для коротких OLTP-запросов — overhead компиляции 10-50ms выше самого запроса. Управление: SET jit = off/on. В плане видно: 'JIT: Functions: N Timing: Xms'.
  • Parallel query: планировщик может разбить Seq Scan на несколько параллельных воркеров. В плане: 'Gather (workers: 4)', 'Parallel Seq Scan'. Эффективно для аналитики (100M строк full scan). Параметры: max_parallel_workers_per_gather, min_parallel_table_scan_size. Для OLTP с индексами — параллелизм обычно не нужен и не включается.
  • Проверка через explain.dalibo.com или pgMustard: онлайн-инструменты парсят вывод EXPLAIN ANALYZE и строят графическое дерево с подсветкой медленных узлов, автоматически указывают на проблемы (bad estimates, seq scans, disk spills). Используй при анализе сложных планов с 15+ узлами — читать текстовый вывод сложно.

Типичные ошибки и заблуждения

  • Ошибка: EXPLAIN (без ANALYZE) показывает реальное время выполнения. EXPLAIN без ANALYZE — только оценочный план без выполнения запроса. Не видно фактического времени, не видно actual rows, не видно BUFFERS информации. Для диагностики производительности всегда нужен EXPLAIN ANALYZE.
  • Ошибка: высокий cost узла = медленный запрос. cost — относительные единицы планировщика, не секунды. cost=1000000 может выполниться за 5ms если данные в кеше. Смотри на actual time, не на cost. Cost используй только для сравнения двух планов одного запроса.
  • Ошибка: Seq Scan — всегда ошибка, нужно добавить индекс. Seq Scan — правильный выбор при выборке > 10-20% строк, для маленьких таблиц (< 1000 строк), при работе с горячими данными в shared buffers. Планировщик выбирает Seq Scan осознанно. Если plan правильный — Seq Scan это не проблема.
  • Ошибка: EXPLAIN ANALYZE безопасен в продакшене для любых запросов. EXPLAIN ANALYZE реально выполняет запрос. DELETE, UPDATE, INSERT выполнятся по-настоящему. Всегда оборачивай DML в BEGIN ... ROLLBACK. SELECT-запросы безопасны но потребляют реальные ресурсы — не запускай EXPLAIN ANALYZE тяжёлых аналитических запросов в часы пик.
  • Ошибка: rows в EXPLAIN ANALYZE — это число строк которые вернул запрос. rows в каждом узле — число строк, которые этот узел обработал и передал родительскому. В Filter узле Rows Removed by Filter: 450000 означает что 450000 строк было прочитано и отфильтровано — они не видны в итоговых rows.
  • Ошибка: добавление индекса всегда устраняет Seq Scan. Даже с индексом PostgreSQL может выбрать Seq Scan если: (1) selectivity низкая (индекс вернёт > 10-20% строк), (2) таблица маленькая, (3) random_page_cost не настроен под SSD (дефолт 4.0 делает Index Scan 'дорогим'). Правильная настройка random_page_cost = 1.1 для SSD критична.

Ключевые выводы

  • Читай план снизу вверх — нижние узлы выполняются первыми; найди самый дорогой нижний узел и начинай оптимизацию оттуда.
  • Расхождение rows vs actual rows > 10x — устаревшая статистика; запусти ANALYZE table_name или увеличь statistics target для проблемного столбца.
  • BUFFERS: shared read >> shared hit означает что данные читаются с диска; либо нужен индекс, либо увеличить shared_buffers.
  • Hash Batches > 1 и Sort Method: external merge Disk — spill на диск; SET work_mem = '256MB' для тяжёлых аналитических запросов.
  • actual time × loops = реальное время узла; большой loops с дорогим inner node — missing index на join column.
  • Для DML (DELETE, UPDATE, INSERT) оборачивай в BEGIN ... ROLLBACK; без этого EXPLAIN ANALYZE реально изменяет данные.

Термины урока

EXPLAIN: вывод оценочного плана выполнения запроса без его реального выполнения.
EXPLAIN ANALYZE: реальное выполнение запроса с выводом плана, фактического времени и числа строк.
cost: оценочная стоимость операции в условных единицах планировщика; не миллисекунды; используется только для сравнения планов одного запроса.
actual rows: реальное количество строк, обработанных узлом при выполнении; сравнивается с rows для проверки точности оценки.
Seq Scan: последовательное чтение всех страниц таблицы; эффективен при выборке > 10-20% строк или для маленьких таблиц.
Hash Join Batches: количество проходов Hash Join; Batches > 1 означает spill на диск из-за нехватки work_mem.
shared hit / shared read: количество страниц, прочитанных из shared buffers (кеш) и с диска соответственно; из BUFFERS опции EXPLAIN.
loops: сколько раз выполнялся узел плана; actual time умножается на loops для получения суммарного времени узла.
Sort Method: external merge Disk: сортировка не поместилась в work_mem и использует временные файлы на диске.
startup cost vs total cost: startup — время до первой строки; total — до последней; LIMIT заставляет планировщик оптимизировать под startup cost.

Связь с работой backend-разработчика

Стандартный workflow оптимизации: (1) Найди медленные запросы через pg_stat_statements ORDER BY total_exec_time DESC. (2) Возьми запрос с наибольшим total_exec_time и запусти EXPLAIN (ANALYZE, BUFFERS). (3) Найди самый дорогой узел (большой actual time × loops). (4) Проверь rows vs actual rows — при расхождении > 10x запусти ANALYZE. (5) Проверь BUFFERS — много shared read = нет нужного индекса. (6) Создай или улучши индекс. (7) Повтори EXPLAIN ANALYZE и сравни. Для сложных аналитических запросов со spill на диск: SET work_mem = '256MB' перед запросом (не глобально!), проверь Hash Batches = 1 и Sort in memory. Глобальное увеличение work_mem = потенциальный OOM при пиковой нагрузке. Инструменты: explain.dalibo.com для визуализации сложных планов, pgMustard для автоматических рекомендаций, auto_explain для логирования планов медленных запросов (log_min_duration = 1000 + shared_preload_libraries = 'auto_explain').

Мини-разбор реальной ситуации

Медленный отчёт в SaaS-приложении: SELECT user_id, count(*), sum(amount) FROM payments WHERE created_at >= '2024-01-01' AND status = 'completed' GROUP BY user_id ORDER BY sum DESC LIMIT 100. Время: 28 секунд. pg_stat_statements показал этот запрос в топ-1 по total_exec_time. EXPLAIN (ANALYZE, BUFFERS) выявил: Seq Scan на таблице 80M строк (8 секунд), Hash Aggregate (Batches: 4 — spill на диск, temp written: 12000 blocks), Sort (Sort Method: external merge Disk). Три проблемы одновременно. Решения: (1) CREATE INDEX CONCURRENTLY idx_payments_status_created ON payments(status, created_at) INCLUDE (user_id, amount). (2) SET work_mem = '128MB' для аналитических запросов через connection parameter в Rails. После оптимизации: Index Scan (0.8 сек), Hash Batches: 1, Sort in memory. Итоговое время: 1.2 секунды вместо 28. В pg_stat_statements total_exec_time для этого запроса упала на 96%.

Что запомнить

  • EXPLAIN (ANALYZE, BUFFERS) — всегда с BUFFERS; без него нет информации об I/O.
  • Читай снизу вверх; ищи узел с максимальным actual time × loops — это узкое место.
  • rows vs actual rows > 10x → ANALYZE table; это может сменить весь план.
  • Hash Batches > 1 или external merge Disk → SET work_mem = '128MB-256MB' для сессии.
  • DML в EXPLAIN ANALYZE → BEGIN ... ROLLBACK; иначе данные реально изменятся.
  • random_page_cost = 1.1 для SSD; без этого планировщик избегает Index Scan даже когда он быстрее.
  • auto_explain с log_min_duration логирует планы медленных запросов автоматически — включай в продакшене.

Итог

EXPLAIN ANALYZE — самый важный инструмент PostgreSQL-разработчика. Умение читать план за 2-3 минуты, находить проблемный узел и знать что с ним делать — это навык, который экономит часы работы при каждом инциденте производительности.