EXPLAIN ANALYZE в Postgres: как читать план запроса
EXPLAIN ANALYZE для меня — главный инструмент, чтобы превратить «запрос медленный» в «вот тут планнер не знает про индекс, добавим». Расскажу свой подход: что я смотрю в плане в первую очередь, какие нюансы бывают, и какие команды обычно стоят рядом.
Чем EXPLAIN отличается от EXPLAIN ANALYZE
Ключевая разница простая. EXPLAIN показывает, что планнер собирался делать. EXPLAIN ANALYZE запускает запрос на самом деле и показывает, что было сделано на самом деле. С ANALYZE ты сравниваешь оценку планнера и реальность; ради этого сравнения мы и работаем.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT id, title FROM posts WHERE author_id = 42 ORDER BY created_at DESC LIMIT 20;Я почти всегда добавляю BUFFERS: показывает, сколько страниц прочитано из shared buffers и сколько с диска. Без этого половина диагностики невозможна. VERBOSE добавляет имена колонок и схему — удобно, когда планы длинные.
Что мы видим в выводе
Пример короткий:
Limit (cost=0.42..16.84 rows=20 width=66) (actual time=0.034..0.158 rows=20 loops=1)
Buffers: shared hit=24
-> Index Scan Backward using posts_author_created_idx on posts (cost=0.42..820.55 rows=1000 width=66) (actual time=0.032..0.155 rows=20 loops=1)
Index Cond: (author_id = 42)
Buffers: shared hit=24
Planning Time: 0.214 ms
Execution Time: 0.181 msЧитаем сверху вниз и слева направо, начиная с самого вложенного узла:
- cost=... — оценка планнера в условных единицах. Сами цифры не говорят почти ничего, важно сравнивать узлы.
- actual time — реальное время на узел. Первое число — момент возврата первой строки, второе — момент завершения.
- rows — оценка планнера и фактическое количество строк. Сильное расхождение — главный сигнал, что статистика устарела.
- loops — сколько раз узел запустился. Для nested loop это бывает много, и тогда время умножай на loops.
- Buffers — что почитано из shared buffers (
hit) и что пришлось читать с диска (read).
Главное правило: сравнивай оценку и факт
Когда планнер ошибается на порядки — это и есть источник медленных запросов. Видишь rows=10 actual=5000 — почти наверняка статистика по таблице старая или нет нужного индекса. Видишь обратное — иногда планнер выбирает дорогой план там, где данные на самом деле тонкие.
ANALYZE posts;Самое простое лекарство. После большого insert/update/delete свежий ANALYZE приносит планнеру актуальные гистограммы.
Типы узлов, которые видим чаще всего
Seq Scan
Полное сканирование таблицы. На маленькой таблице — норма. На большой — почти всегда плохо. Если ты видишь Seq Scan на таблице в миллионы строк, скорее всего нужен индекс на условие из WHERE.
Index Scan и Index Only Scan
Index Scan — идём по индексу к строкам. Index Only Scan — все нужные колонки лежат в индексе, в таблицу можно не ходить. Самый быстрый сценарий. Чтобы он сработал, нужно, чтобы visibility map считала страницы all-visible — иначе всё равно будет heap fetch.
Bitmap Heap Scan + Bitmap Index Scan
Сначала строится битовая карта позиций по индексу, потом по ней дёргаются страницы. Хорошо, когда строк по условию много — сотни и тысячи. На малом числе строк дешевле обычный Index Scan.
Nested Loop, Hash Join, Merge Join
Способы соединить две таблицы.
- Nested Loop — для каждой строки внешней таблицы пробегаем по внутренней. Работает, когда внешняя короткая, а на внутреннюю есть индекс. Если ловишь его на двух больших таблицах без индекса — катастрофа.
- Hash Join — строим хеш по одной таблице, проходим по второй. Быстрый, но требует памяти на хеш.
- Merge Join — обе таблицы отсортированы и идём параллельно. Хорош, когда уже есть подходящие индексы.
На что смотрю в первую очередь
- Самый дорогой узел. Где actual time больше всего? Туда и смотри. На вложенных планах суммируем по веткам.
- Расхождение rows. Если планнер думает «10», а возвращается 100000 — план мог быть выбран совсем не тот.
- Buffers read большие — значит много I/O. На частом запросе это мешает другим, страницы вытесняют друг друга.
- Loops у вложенных циклов. Чтение «один Index Scan = 0.05 мс» на 50000 повторов — это уже 2.5 секунды.
- Filter rows removed. Видишь
Rows Removed by Filter: 90000— индекс или составной индекс мог бы это срезать.
Пример: типичная ошибка с типами
Очень частая засада — индекс не используется, потому что в WHERE сравнение с другим типом.
CREATE INDEX posts_author_id_idx ON posts(author_id); -- bigint
EXPLAIN ANALYZE SELECT * FROM posts WHERE author_id = '42';Postgres приведёт строку к bigint, индекс сработает. А вот если индекс на varchar(20), а в WHERE = 42 (число), индекс не используется: преобразование идёт в обратную сторону. План показывает Seq Scan, и непонятно, что не так. Лечится либо явным cast, либо приведением типа в коде приложения.
Пример: ORDER BY и LIMIT
Запрос «лента постов автора, последние 20». Без индекса по (author_id, created_at desc) ты получишь либо Seq Scan + Sort (плохо на больших таблицах), либо Index Scan по author_id и Sort сверху (плохо, если у автора 100k постов). С нужным составным индексом план становится Index Scan Backward + Limit, как в моём первом примере, и время уходит в десятки микросекунд.
CREATE INDEX posts_author_created_idx ON posts(author_id, created_at DESC);Что добавить к EXPLAIN
FORMAT JSON для машинной обработки
Если план длинный и хочется визуализатор — отдавай его в JSON и грузи в инструменты типа explain.dalibo.com. На длинных планах с десятками узлов глазами читать тяжело, визуализатор сэкономит много времени.
auto_explain
Расширение, которое логирует планы запросов, превышающих заданное время. Незаменимо в проде, когда конкретный медленный запрос ловится не каждый раз.
LOAD 'auto_explain';
SET auto_explain.log_min_duration = '500ms';
SET auto_explain.log_analyze = on;
SET auto_explain.log_buffers = on;Любой запрос дольше 500 мс попадает в лог Postgres вместе с планом. Я почти всегда включаю это на стейджинге и довольно консервативно (1–2 секунды) — на проде, чтобы не переполнить лог.
Подводные камни
- EXPLAIN ANALYZE действительно выполняет запрос. На
UPDATE/DELETEэто означает, что данные изменятся. Заверни в транзакцию сROLLBACKлибо используйEXPLAIN (ANALYZE, COMMIT FALSE ...)в свежих версиях. - Параллельные планы. Если включён parallel query, узлы вида
Parallel Seq Scanведут себя иначе по времени. Смотри наWorkers Launchedи помни, что суммарная нагрузка по CPU кратно выше, чем actual time одного воркера. - Кэш. Первый прогон холодный, со страницами с диска. Второй и третий уже из shared buffers и pagecache. Сравнивай не один прогон, а сразу серию из 3–5.
Минимальный набор привычек
Что я делаю на любом запросе, который вызывает подозрение:
- Запускаю
EXPLAIN (ANALYZE, BUFFERS)два-три раза подряд, смотрю стабильное время. - Сравниваю rows planner и rows actual. Если расхождение —
ANALYZEпо таблице, и снова EXPLAIN. - Ищу самый тяжёлый узел. Если это Seq Scan по большой таблице — план на индекс. Если nested loop по 100к строк — отдельный составной индекс или
SET enable_nestloop = offради эксперимента. - Если всё ещё медленно — заглядываю в
pg_stat_statements, не ходит ли запрос значимо чаще, чем кажется.
EXPLAIN ANALYZE — навык, который нарабатывается недели за две. Один раз привыкнешь читать «cost / actual / rows / buffers / loops» автоматически — и почти любой медленный запрос разбирается за минуту. Без него я бы не вытащила несколько проектов из их тёмных моментов.