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