lenec ru

← все посты

EXPLAIN ANALYZE в Postgres: как читать план запроса

10K

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 — обе таблицы отсортированы и идём параллельно. Хорош, когда уже есть подходящие индексы.

На что смотрю в первую очередь

  1. Самый дорогой узел. Где actual time больше всего? Туда и смотри. На вложенных планах суммируем по веткам.
  2. Расхождение rows. Если планнер думает «10», а возвращается 100000 — план мог быть выбран совсем не тот.
  3. Buffers read большие — значит много I/O. На частом запросе это мешает другим, страницы вытесняют друг друга.
  4. Loops у вложенных циклов. Чтение «один Index Scan = 0.05 мс» на 50000 повторов — это уже 2.5 секунды.
  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.

Минимальный набор привычек

Что я делаю на любом запросе, который вызывает подозрение:

  1. Запускаю EXPLAIN (ANALYZE, BUFFERS) два-три раза подряд, смотрю стабильное время.
  2. Сравниваю rows planner и rows actual. Если расхождение — ANALYZE по таблице, и снова EXPLAIN.
  3. Ищу самый тяжёлый узел. Если это Seq Scan по большой таблице — план на индекс. Если nested loop по 100к строк — отдельный составной индекс или SET enable_nestloop = off ради эксперимента.
  4. Если всё ещё медленно — заглядываю в pg_stat_statements, не ходит ли запрос значимо чаще, чем кажется.

EXPLAIN ANALYZE — навык, который нарабатывается недели за две. Один раз привыкнешь читать «cost / actual / rows / buffers / loops» автоматически — и почти любой медленный запрос разбирается за минуту. Без него я бы не вытащила несколько проектов из их тёмных моментов.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.