Что такое explain plan?
4. Hash Left Join → соединил 5. HashAggregate → сгруппировал
🟢 Junior Level
EXPLAIN — команда PostgreSQL, которая показывает, как база данных планирует выполнить запрос, не выполняя его.
Простая аналогия: Перед поездкой GPS показывает маршрут: какие дороги, сколько поворотов, примерное время. EXPLAIN — это GPS для запроса: показывает, какие индексы, какие таблицы, сколько времени займёт.
Пример:
-- Показать план запроса
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- Результат:
-- Index Scan using idx_users_email on users (cost=0.42..8.44 rows=1 width=100)
-- Index Cond: (email = 'test@example.com'::text)
Что означают числа:
cost=0.42..8.44— стоимость: начало..получение всех строкrows=1— сколько строк ожидает базаwidth=100— средний размер строки в байтах
Когда использовать:
- Запрос работает медленно
- Хотите проверить, используется ли индекс
- Перед созданием индекса — поможет ли он
🟡 Middle Level
EXPLAIN vs EXPLAIN ANALYZE
-- EXPLAIN: только план, НЕ выполняет запрос
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com';
-- EXPLAIN ANALYZE: выполняет запрос + показывает реальное время
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com';
-- Результат:
-- Index Scan using idx_users_email on users (cost=0.42..8.44 rows=1 width=100)
-- (actual time=0.035..0.037 rows=1 loops=1)
-- ↑ реальное время в ms
-- Planning Time: 0.123 ms
-- Execution Time: 0.058 ms
Ключевые флаги
-- Полный набор для анализа
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT ...;
-- Флаги:
-- ANALYZE → выполнить запрос + реальное время
-- BUFFERS → показать чтение из кэша/диска
-- COSTS → показать стоимость (по умолчанию on)
-- ROWS → показать ожидаемое кол-во строк
-- WIDTH → показать размер строки
-- FORMAT → TEXT (по умолчанию), JSON, YAML, XML
Чтение плана
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, COUNT(o.id) as orders
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.city = 'Moscow'
GROUP BY u.name;
-- План:
-- HashAggregate (cost=... rows=... actual time=15.2..15.3 rows=500 loops=1)
-- Buffers: shared hit=250 read=50
-- → Hash Left Join (cost=... actual time=10.1..12.5 rows=2000 loops=1)
-- Buffers: shared hit=200 read=40
-- Hash Cond: (o.user_id = u.id)
-- → Seq Scan on orders o (actual time=0.01..5.2 rows=10000 loops=1)
-- Buffers: shared hit=150
-- → Hash (cost=... actual time=3.1..3.1 rows=500 loops=1)
-- Buffers: shared hit=50 read=40
-- → Seq Scan on users u (actual time=0.02..2.8 rows=500 loops=1)
-- Filter: (city = 'Moscow')
-- Rows Removed by Filter: 999500
-- Planning Time: 0.5 ms
-- Execution Time: 16.0 ms
Читаем снизу вверх:
- Seq Scan on users → отфильтровал 500 из 1 млн
- Hash → построил хэш для JOIN
- Seq Scan on orders → прочитал 10,000 заказов
- Hash Left Join → соединил
- HashAggregate → сгруппировал
Типы сканирования
| Тип | Когда | Скорость |
|---|---|---|
| Seq Scan | Чтение всей таблицы | Медленно для больших, быстро для маленьких |
| Index Scan | Поиск по индексу → чтение строки | Быстро для малых выборок |
| Index Only Scan | Все данные в индексе | Очень быстро |
| Bitmap Index Scan | Индекс → битовая карта → строки | Средние выборки (100-10,000 строк) |
Bitmap Index Scan: собирает ВСЕ matching TID из индекса в битовую карту, потом читает строки. Выгоднее Index Scan для 100-10,000 строк из разных мест — меньше случайных I/O.
Типичные проблемы
- Seq Scan на большой таблице
EXPLAIN SELECT * FROM users WHERE email = 'test@example.com'; -- Seq Scan on users ← плохо! -- Filter: (email = ...) -- Rows Removed by Filter: 999999 -- Решение: создать индекс CREATE INDEX idx_users_email ON users(email); - Оценка не совпадает с реальностью
-- rows=1000 actual rows=1 -- Планировщик ошибся в 1000 раз! -- Причина: устаревшая статистика -- Решение: ANALYZE users; - Spill на диск
EXPLAIN (ANALYZE) SELECT ... GROUP BY ...; -- HashAggregate (actual time=... rows=...) -- Disk: 5GB ← плохо! work_mem не хватило -- Решение: SET work_mem = '256MB';
Практические примеры
-- Проверка использования индекса
EXPLAIN SELECT * FROM orders WHERE user_id = 123;
-- Index Scan using idx_orders_user_id → отлично!
-- Проверка JOIN
EXPLAIN SELECT * FROM users u JOIN orders o ON u.id = o.user_id;
-- Hash Join → нормально для больших таблиц
-- Nested Loop → отлично, если есть индекс
-- Проверка сортировки
EXPLAIN SELECT * FROM orders ORDER BY created_at DESC LIMIT 10;
-- Limit → Index Scan Backward → отлично!
-- Limit → Sort → можно оптимизировать индексом
🔴 Senior Level
Анатомия узла Costs
Sort (cost=150.10..155.10 rows=2000 width=40)
↑ ↑
Startup Total
Cost Cost
Startup Cost: Стоимость получения первой строки
- Критично для
LIMIT 1,WHERE EXISTS - Планировщик может выбрать план с огромным Total Cost, но низким Startup Cost
Total Cost: Стоимость получения всех строк
- Критично для полной выборки
Арбитражные единицы:
- 1 unit ≈ чтение одной 8KB страницы с диска
- cost=100 ≈ чтение 100 страниц
BUFFERS: анализ I/O
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
-- Buffers: shared hit=250 read=50 written=10
-- ↑ ↑ ↑
-- из кэша с диска записано
-- shared hit: 250 / (250+50) = 83% Cache Hit Rate
-- read=50: 50 × 8KB = 400KB прочитано с диска
-- written=10: 10 × 8KB = 80KB записано (temp files?)
Интерпретация:
shared hit»shared read→ отлично (всё в кэше)shared read»shared hit→ проблема (много Disk I/O)written> 0 → spill на диск или запись temp файлов
Generic vs Custom Plans
Проблема Prepared Statements:
-- Custom Plan (знает конкретное значение)
EXPLAIN SELECT * FROM users WHERE id = 5;
-- Plan: Index Scan (идеально для id=5)
-- Generic Plan (универсальный для любого $1)
PREPARE stmt(int) AS SELECT * FROM users WHERE id = $1;
EXPLAIN EXECUTE stmt(5);
-- Plan: Seq Scan (универсальный, "средний" для всех id)
Почему Generic Plan может быть хуже:
Данные skewed:
id=5 → 1 строка (Index Scan идеален)
id=100 → 500,000 строк (Seq Scan идеален)
Generic Plan: "средний" → Seq Scan
→ Для id=5 катастрофически медленный!
Планировщик «усредняет» стоимость: для id=5 нужен Index Scan (1 строка),
для id=100 — Seq Scan (500,000 строк). Seq Scan «безопаснее» в среднем,
но для конкретных значений с малой выборкой — катастрофа.
Решение:
-- 1. plan_cache_mode
SET plan_cache_mode = 'force_custom_plan'; -- Всегда Custom
SET plan_cache_mode = 'force_generic_plan'; -- Всегда Generic
SET plan_cache_mode = 'auto'; -- По умолчанию (5 раз Custom, потом Generic)
-- 2. В JDBC/Hibernate:
-- Проверьте: prepareThreshold=0 (отключить Prepared Statements)
JIT-компиляция
JIT (Just-In-Time) — PostgreSQL компилирует части запроса в машинный код через LLVM. Как если бы база «переписала» часть запроса на C.
Ускоряет:
- Тяжёлые выражения
(a * b + c / d)на миллионах строк - Агрегаты (SUM, AVG) с большим количеством группировок
- Оценки кортежей при Hash Join
Замедляет: простые запросы (SELECT * FROM t WHERE id = 1) — компиляция > выполнения!
-- В EXPLAIN ANALYZE может появиться блок JIT
-- JIT:
-- Functions: 5
-- Options: Inlining, Optimization
-- Timing: Min 2.1 ms, Max 2.5 ms
-- Плюсы: ускоряет сложные выражения и агрегаты
-- Минусы: на коротких запросах компиляция > выполнения!
-- Отключение для OLTP
SET jit = off;
-- Проверка
SHOW jit;
Parallel Query в планах
EXPLAIN SELECT COUNT(*) FROM large_table;
-- Gather
-- Workers Planned: 4
-- → Parallel Seq Scan on large_table
-- Workers Launched: 4
-- Важные метрики:
-- Workers Planned: сколько запланировал оптимизатор
-- Workers Launched: сколько реально запустилось
-- Если Launched < Planned → проверьте:
-- max_parallel_workers_per_gather
-- max_parallel_workers
-- Загрузки сервера
Incremental Sort (PG 13+)
EXPLAIN SELECT * FROM orders ORDER BY user_id, created_at;
-- Incremental Sort ← PG 13+!
-- Sort Key: user_id, created_at
-- Presorted Key: user_id ← данные уже отсортированы по user_id
-- → Sort только по created_at внутри каждого user_id
-- → В 5-10 раз быстрее полной сортировки!
На PG 12 и ниже — полная сортировка всех колонок, даже если часть уже отсортирована.
Edge Cases
- EXPLAIN ANALYZE выполняет запрос!
-- ⚠️ DML запрос будет выполнен! EXPLAIN ANALYZE DELETE FROM users WHERE status = 'INACTIVE'; -- → Все неактивные пользователи УДАЛЕНЫ! -- ✅ Безопасный способ: BEGIN; EXPLAIN ANALYZE DELETE FROM users WHERE status = 'INACTIVE'; ROLLBACK; -- Отменяем изменения - Первый запуск EXPLAIN ANALYZE может врать
Данные могут быть не в кэше → первый запуск медленнее. Запустите 2-3 раза и смотрите на второй/третий результат. Или: DISCARD ALL; — очистка кэша. - WAL генерация (PG 13+)
EXPLAIN (ANALYZE, WAL) INSERT INTO orders SELECT ...; -- WAL: -- Records: 50000 -- Bytes: 10MB -- FPI: 100 (Full Page Images — полные копии 8KB страниц в WAL при -- первом изменении страницы после checkpoint. Большое число FPI = -- высокая нагрузка на WAL и репликацию) -- Критично для оценки нагрузки на репликацию
На PG 12 и ниже флаг WAL в EXPLAIN недоступен. Оценивайте нагрузку на репликацию через
pg_stat_walили логи.
- Timing overhead
-- EXPLAIN ANALYZE добавляет overhead на замеры времени -- Для микро-оптимизаций отключите TIMING EXPLAIN (ANALYZE, TIMING off) SELECT ...; -- Быстрее, но без детальных замеров каждого узла
Production Experience
Реальный сценарий #1: Generic Plan убивает API
- Приложение: JDBC Prepared Statement для поиска по email
- В консоли: 5ms (Custom Plan, Index Scan)
- В приложении: 500ms (Generic Plan, Seq Scan)
- Причина: email skewed (у некоторых пользователей 1000 записей)
- Решение:
plan_cache_mode = 'force_custom_plan' - Результат: 5ms стабильно
Реальный сценарий #2: JIT замедляет OLTP
- REST API: простые запросы < 1ms
- EXPLAIN ANALYZE: JIT Timing: 8ms
- Причина: JIT включён по умолчанию, компиляция > выполнения
- Решение:
SET jit = offдля сессии - Результат: 0.5ms вместо 8.5ms (ускорение в 17 раз)
Monitoring
-- 1. Поиск медленных запросов
SELECT query, mean_exec_time, calls, shared_blks_read
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- 2. Проверка конкретного запроса
EXPLAIN (ANALYZE, BUFFERS, WAL)
SELECT ...;
-- 3. Поиск запросов с Generic Plan проблемами
-- (в логах приложения: медленно, в консоли: быстро)
-- 4. Проверка JIT
EXPLAIN (ANALYZE) SELECT ...;
-- Ищите блок "JIT" и Timing
-- 5. Parallel Query эффективность
EXPLAIN (ANALYZE) SELECT ...;
-- Workers Launched vs Planned
Best Practices
- Всегда используйте
EXPLAIN (ANALYZE, BUFFERS) - Сравнивайте Estimated vs Actual rows → расхождение = проблема статистики
- Проверяйте Cache Hit Rate через Buffers
- Generic vs Custom — тестируйте Prepared Statements
- JIT отключайте для OLTP, включайте для OLAP
- DML + EXPLAIN ANALYZE → только в транзакции с ROLLBACK
- WAL (PG 13+) для оценки нагрузки на репликацию
- SETTINGS (PG 12+) —
EXPLAIN (ANALYZE, SETTINGS)показывает изменённые параметры сессии. На PG 11 и ниже — проверяйте параметры вручную черезSHOW.
Резюме для Senior
- Startup Cost критичен для
LIMIT, Total Cost для полной выборки - BUFFERS = главный метрик I/O (hit vs read)
- Generic vs Custom Plans — частая причина расхождений prod/dev
- plan_cache_mode управляет поведением Prepared Statements
- JIT ускоряет OLAP, замедляет OLTP → отключайте где не нужно
- WAL (PG 13+) показывает нагрузку на репликацию
- Estimated ≠ Actual rows →
ANALYZEилиSET STATISTICS - DML + EXPLAIN ANALYZE → всегда в
BEGIN...ROLLBACK
🎯 Шпаргалка для интервью
Обязательно знать:
- EXPLAIN: показывает план БЕЗ выполнения; EXPLAIN ANALYZE: выполняет + реальное время
- Cost:
startup..totalв условных единицах (1 unit ≈ чтение 8KB страницы) - BUFFERS:
shared hit(из кэша),shared read(с диска),written(запись) - Generic Plan (Prepared Statements): универсальный, может быть хуже для skewed данных
- Custom Plan: знает конкретное значение, лучше для skewed данных
- plan_cache_mode:
auto(5 раз Custom → Generic),force_custom_plan,force_generic_plan - JIT: ускоряет сложные выражения/агрегаты, замедляет OLTP (< 1ms запросы)
- Parallel Query: Workers Planned vs Launched → если Launched < Planned, проверьте настройки
- Incremental Sort (PG 13+): данные частично отсортированы → сортируем остаток
- WAL (PG 13+): Records, Bytes, FPI → оценка нагрузки на репликацию
- DML + EXPLAIN ANALYZE → ВЫПОЛНЯЕТ запрос! Только в BEGIN…ROLLBACK
Частые уточняющие вопросы:
- «Почему в приложении медленнее, чем в консоли?» → Generic Plan в Prepared Statements
- «Как проверить Cache Hit Rate?» → BUFFERS: hit / (hit + read)
- «Почему JIT замедляет?» → Компиляция 8ms > выполнения 1ms → отключите для OLTP
- «Что значит Estimated rows ≠ Actual?» → Устаревшая статистика → ANALYZE
Красные флаги (НЕ говорить):
- ❌ «EXPLAIN ANALYZE безопасен для DML» (ВЫПОЛНЯЕТ запрос!)
- ❌ «Generic Plan всегда хорош» (для skewed данных — катастрофа)
- ❌ «JIT всегда ускоряет» (замедляет OLTP!)
- ❌ «Cost — это миллисекунды» (нет, условные единицы!)
Связанные темы:
- [[Как оптимизировать медленные запросы]] → практическое применение EXPLAIN
- [[Зачем нужен ANALYZE]] → Estimated ≠ Actual → ANALYZE
- [[Что такое коррелированный подзапрос]] → поиск SubPlan в плане