← назад к разделу

Запрос работает медленно, но непонятно почему. Добавить индекс? Переписать JOIN? Увеличить память? Ответ на все эти вопросы даёт одна команда — EXPLAIN ANALYZE. Она показывает, что именно PostgreSQL делал во время выполнения запроса и сколько времени потратил на каждый шаг.

наверху — итог запроса, внизу — листья: они отрабатывают первыми снизу вверх Hash Join actual time=12 400 ms Seq Scan on orders actual rows=850 000 Hash хеш-таблица в памяти Seq Scan on customer actual rows=9 Seq Scan on customeractual rows=9 Hashхеш-таблица в памяти Seq Scan on ordersactual rows=850 000 Hash Joinactual time=12 400 ms 1 2 3 4 rows=1 200 — так думал планировщикactual rows=850 000 — в 700 раз большестатистика устарела: ANALYZE orders

План — это дерево, и номера показывают, в каком порядке оно выполняется: листья первыми, корень последним. Читают его в ту же сторону — снизу вверх. На третьем шаге оценка rows= разошлась с фактом в сотни раз: дальше планировщик выбирал способ соединения по неверным числам, и искать причину надо здесь, а не в самом Hash Join.

Обязательно

Как запустить

Минимальный вариант — просто добавить EXPLAIN ANALYZE перед запросом:

живой пример

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'PAID';
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Полезно видеть ещё и то, сколько данных нашлось в кеше базы, а сколько пришлось искать за его пределами. За это отвечает BUFFERS:

-- скобочная форма: в скобках перечисляют настройки вывода
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'PAID';

Начиная с PostgreSQL 18 просить BUFFERS отдельно не надо — вместе с ANALYZE он включается сам. На версиях постарше без него строк про буферы в выводе просто не будет, так что привычка писать EXPLAIN (ANALYZE, BUFFERS) ничего не портит и работает везде.

Важный момент: EXPLAIN ANALYZE выполняет запрос по-настоящему. Это нужно для точного замера. Если вы хотите проверить план для UPDATE или DELETE, не применяя изменения, оберните в транзакцию:

-- план для UPDATE, не оставляя следов в данных
BEGIN;
EXPLAIN ANALYZE UPDATE orders SET status = 'CANCELLED' WHERE id = 'ord-01';
ROLLBACK;

Идентификатор здесь строковый — так устроены данные учебной песочницы, на которых примеры из статьи можно запускать как есть. В своей базе на этом месте обычно стоит число или uuid; на чтение плана это никак не влияет.

Как читать вывод

Вот пример вывода:

Hash Join  (cost=12.34..567.89 rows=1000 width=64) (actual time=1.234..56.789 rows=987 loops=1)
  Hash Cond: (a.id = b.a_id)
  Buffers: shared hit=1234 read=56
  ->  Seq Scan on a
  ->  Hash
        ->  Seq Scan on b
Planning Time: 0.412 ms
Execution Time: 57.201 ms

Что означает каждая часть:

  • cost=START..TOTAL — оценка PostgreSQL в условных единицах. Это не миллисекунды.
  • rows=N — сколько строк PostgreSQL ожидал получить.
  • actual time=START..TOTAL — реальное время в миллисекундах, и это два разных числа: первое — сколько прошло до первой выданной строки, второе — до последней. Разница важна там, где над узлом стоит LIMIT: запрос закончится, не досчитав узел до конца, и судить о нём по второму числу нельзя.
  • actual rows=N loops=M — сколько строк реально вернул узел и сколько раз он запускался.
  • Buffers: shared hit=X read=Y — hit это страницы, найденные в кеше самого PostgreSQL, read — те, которых там не оказалось. «Не в кеше базы» не значит «с диска»: скорее всего страница пришла из кеша операционной системы, а это быстро. Настоящее время чтения с диска показывает отдельная строка I/O Timings, и появляется она, только если включён параметр track_io_timing.
  • Planning Time — сколько база потратила на то, чтобы придумать план. Появляется только с ANALYZE.
  • Execution Time — сколько заняло само выполнение, вместе со всеми узлами дерева. Именно эти две строки обычно и сравнивают «до и после» правки; если Planning Time сопоставим с Execution Time, время уходит не на данные, а на раздумья — так бывает у запросов с десятком соединений или на секционированной таблице с сотнями секций.

Читать план нужно снизу вверх. Нижние узлы — это внутренние операции, которые выполняются первыми. Корень — финальный результат.

Первое на что смотреть — расхождение между rows= (оценка) и actual rows= (факт). Если оценка в десять и более раз меньше реального числа строк, PostgreSQL строит план на неверных данных. Помогает команда ANALYZE на таблице — она обновляет статистику.

Второй важный момент — loops. Если узел запускался много раз, реальное время нужно умножить: loops=10000 × actual time=0.5..1.2 означает 12 секунд внутри одного Nested Loop.

Порядок разбора: что смотреть и в какой очерёдности

План удобно читать по одному и тому же маршруту — он отвечает на «где болит» за минуту.

  1. Расхождение оценки и факта. У каждого узла стоят rows= в оценке и actual rows= в факте. Расхождение в разы — корень почти всех плохих планов: база выбирала план под другой объём. Ищите самый нижний узел, где оно началось, — верхние обычно наследуют ошибку.
  2. loops. Фактическое время узла в EXPLAIN ANALYZE печатается на один проход. Узел с actual time=0.05 … loops=50000 занял не полмиллисекунды, а две с половиной секунды. Забыть про множитель — самая частая ошибка чтения планов.
  3. Buffers. Сколько страниц прочитано и откуда: shared hit — из кеша, shared read — с диска. Запрос, который «то быстрый, то медленный», обычно объясняется здесь.
  4. Что за узел наверху съел время. Сортировка с external merge Disk, соединение с Batches: 8, Rows Removed by Filter в миллионах — это три самых частых виновника, и каждый лечится по-своему.

Всё остальное — подробности, к которым переходят, когда эти четыре пункта не дали ответа.

Узлы сканирования таблицы

PostgreSQL выбирает способ читать таблицу исходя из размера выборки, наличия индексов и настроек стоимости.

Seq Scan — полный проход

Seq Scan on orders
  Filter: (status = 'PAID')
  Rows Removed by Filter: 850000

PostgreSQL читает всю таблицу и отбрасывает ненужные строки. Это нормально для маленьких таблиц и для запросов, которые выбирают большую часть строк. Плохо, когда Rows Removed by Filter в миллионы раз больше результата — это сигнал, что нужен индекс по полю фильтра.

Index Scan — проход по индексу

Index Scan using ix_orders_status on orders
  Index Cond: (status = 'PAID')

PostgreSQL идёт по B-tree индексу, потом читает нужные строки из таблицы. Если условие попало в Filter:, а не в Index Cond:, значит индекс по этому полю не используется как ключ поиска — и строки всё равно перебираются.

Index Only Scan — только индекс, без таблицы

Index Only Scan using ix_orders_status_created on orders
  Index Cond: (status = 'PAID')
  Heap Fetches: 12

Если все нужные колонки есть в индексе, PostgreSQL может не обращаться к таблице вообще. Это самый быстрый вариант.

Если Heap Fetches больше нуля, PostgreSQL всё-таки ходит в таблицу — карта видимости устарела и не позволяет обойтись без проверки. Помогает VACUUM на таблице.

Bitmap Scan — двухфазное чтение

Bitmap Heap Scan on orders
  Recheck Cond: (status = 'PAID')
  ->  Bitmap Index Scan on ix_orders_status

Двухфазный процесс: сначала строится битовая карта страниц, которые содержат нужные строки, затем эти страницы читаются по порядку. Это эффективнее случайного чтения при средней доле выборки. Bitmap также позволяет комбинировать несколько индексов (BitmapAnd, BitmapOr).

Строка Recheck Cond: появляется у любого Bitmap Heap Scan, и сама по себе она ни о чём плохом не говорит. А вот когда рядом видно Heap Blocks: ... lossy=N — это уже сигнал: памяти под битовую карту не хватило, и она запомнила не отдельные строки, а страницы целиком. Тогда база читает страницу и перепроверяет условие на каждой строке в ней. Лечится увеличением work_mem.

Соединение таблиц

Как только в запросе появляется второй источник, в дереве возникает узел соединения — тот, что решает, как склеить два потока строк:

живой пример

EXPLAIN ANALYZE
SELECT o.id, c.email, o.total_amount
FROM orders o
JOIN customer c ON c.id = o.customer_id
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Способов склеить три, и выбирают их по размеру входов и наличию индексов.

Nested Loop — цикл внутри цикла

Nested Loop  (rows=100 loops=1)
  ->  Index Scan on a  (rows=100)
  ->  Index Scan on b  (rows=1 loops=100)
        Index Cond: (b.a_id = a.id)

Для каждой строки из внешней таблицы ищется совпадение во внутренней. Хорошо работает, когда внешний результат небольшой, а на внутренней таблице есть индекс по ключу соединения.

Частая ловушка: loops=1000000 при actual time=0.5 — это 500 секунд внутри одного узла. Если внутренняя таблица большая и без индекса, Nested Loop деградирует.

Hash Join — хеш-таблица в памяти

Hash Join
  Hash Cond: (a.id = b.a_id)
  ->  Seq Scan on a
  ->  Hash
        ->  Seq Scan on b

Меньшую таблицу загружают в хеш-таблицу в памяти, затем для каждой строки из большей делают быстрый поиск. Хорошо для соединений двух больших таблиц без подходящих индексов.

Если в выводе Batches: 2 и больше — хеш-таблица не уместилась в памяти, и PostgreSQL выгрузил часть на диск. Помогает увеличить work_mem для сессии:

-- память поднимается только для текущего соединения с базой
SET work_mem = '64MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.email FROM orders o JOIN customer c ON c.id = o.customer_id;

Merge Join — слияние двух сортированных потоков

Merge Join
  Merge Cond: (a.id = b.a_id)
  ->  Sort ...
  ->  Index Scan on ix_b_a_id

Оба потока отсортированы по ключу соединения и сливаются как зиппер. Выгоден на больших объёмах, когда сортировка достаётся даром — например, из подходящего индекса.

Сортировка и группировка

Sort — как понять, что данные идут с диска

Sort
  Sort Key: created_at DESC
  Sort Method: external merge  Disk: 8192kB

external merge Disk: означает, что сортировка не уместилась в памяти и ушла на диск. Это медленно. Решений два: увеличить work_mem или добавить индекс с нужным порядком сортировки, чтобы узел Sort исчез из плана.

Если сортировка в памяти — будет quicksort или top-N heapsort (для ORDER BY ... LIMIT N).

Группировка

Агрегат без GROUP BY, count(*) или sum() по всей выборке, в плане называется просто Aggregate. С GROUP BY планировщику надо разложить строки по группам, и способов два. HashAggregate раскладывает через хеш-таблицу в памяти и быстр, пока данные помещаются; с PostgreSQL 13 он умеет доливаться на диск, и тогда в плане появляется строка Disk Usage, которая и говорит, что памяти не хватило. GroupAggregate считает группы после сортировки, и планировщик выбирает его, когда данные и так приходят отсортированными, например из индекса, или когда так дешевле по его расчётам.

Параллельный план

Gather
  Workers Planned: 2
  Workers Launched: 2
  ->  Parallel Seq Scan on orders

PostgreSQL может разбить работу между несколькими параллельными процессами — в плане они называются Workers. Количество задаётся параметром max_parallel_workers_per_gather (по умолчанию 2). Маленькие таблицы не параллелятся — порог регулирует min_parallel_table_scan_size (по умолчанию 8 МБ).

Узлы, которые встречаются в каждом втором плане

Кроме сканирования и соединений в планах регулярно попадаются ещё несколько узлов, и без них план читается с пробелами.

Memoize (PostgreSQL 14+) — кеш результатов для вложенного цикла: если правая сторона вызывается с повторяющимися ключами, база запоминает ответы. В плане у него есть строки Hits и Misses — по ним видно, окупился кеш или нет.

Incremental Sort (PostgreSQL 13+) — частичная сортировка: данные уже упорядочены по первым колонкам (индексом), осталось досортировать группы по остальным. Признак, что индекс почти подошёл под ORDER BY.

Materialize — база решила один раз посчитать результат узла и сложить в памяти, чтобы читать его многократно; обычен под вложенным циклом.

Gather и Gather Merge — сбор результатов параллельных рабочих процессов; второй ещё и сохраняет порядок. Рядом с ними смотрят Workers Launched: запрошено может быть четыре, а запущен один.

InitPlan и SubPlan — подзапросы. InitPlan считается один раз (не зависит от внешней строки), SubPlan — для каждой строки, и именно он превращает безобидный подзапрос в списке выбора в миллион вызовов.

never executed — узел, до которого выполнение не дошло: обычно вторая ветка Append или правая сторона соединения, отсечённая LIMIT. Это не ошибка, а подсказка: база остановилась раньше, чем собиралась.

CTE: с PostgreSQL 12 больше не барьер

До 12-й версии WITH был барьером оптимизации: шаг считался целиком и отдельно, условия снаружи внутрь не проталкивались. С 12-й обычный шаг встраивается в запрос как подзапрос — и план того же самого запроса на новой версии вдруг становится другим. Управляют этим явно: WITH x AS MATERIALIZED (…) возвращает старое поведение и считает шаг один раз, AS NOT MATERIALIZED требует встроить. В плане это видно по узлу CTE Scan: он есть при материализации и отсутствует при встраивании.

JIT: секунды на пустом месте

С PostgreSQL 11 компиляция выражений включена по умолчанию и срабатывает, когда оценённая стоимость запроса выше порога jit_above_cost. Слово «оценённая» здесь ключевое: если оценка завышена (а она завышается по всем причинам из статьи про статистику), база тратит время на компиляцию для запроса, который выполнится за миллисекунды. В плане это отдельный блок:

JIT:
  Functions: 42
  Timing: Generation 5.2 ms, Inlining 120.4 ms, Optimization 350.1 ms, Emission 220.7 ms, Total 696.4 ms

Семьсот миллисекунд на подготовку запроса, который читает сто строк, — типичная находка. Лечится либо исправлением оценки, либо поднятием порогов, либо SET jit = off для конкретной нагрузки.

Как увидеть план запроса, который уже отработал

EXPLAIN показывает план запроса, который вы запускаете руками. Настоящая же проблема обычно случилась ночью и с другими параметрами. Для таких случаев есть auto_explain: расширение записывает в журнал план каждого запроса, который выполнялся дольше заданного времени.

shared_preload_libraries = 'auto_explain'
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.log_nested_statements = on

Оговорка про цену: log_analyze включает измерение времени на каждом узле для всех попавших под порог запросов, и на нагруженном сервере это заметно. Поэтому порог ставят высоким, а на совсем горячих базах добавляют auto_explain.log_timing = off — узлы останутся, точные времена исчезнут.

Не путайте с pg_stat_statements: он отвечает на другой вопрос — какие запросы в сумме съели больше всего времени, — и планов не хранит вовсе. Обычный порядок такой: pg_stat_statements находит виновника, auto_explain показывает его план.

ANALYZE меняет то, что измеряет

Ещё одна оговорка, из-за которой цифры иногда не сходятся: EXPLAIN ANALYZE измеряет время на каждом узле, а само измерение стоит денег. На плане с миллионами строк и глубокой вложенностью накладные расходы на таймеры раздувают общее время заметно — бывает, что запрос «под EXPLAIN ANALYZE» идёт вдвое дольше, чем сам по себе. Если нужны строки и факт, но не времена, их отключают: EXPLAIN (ANALYZE, TIMING OFF, BUFFERS). Проверить, дорого ли обходятся таймеры на конкретной машине, можно утилитой pg_test_timing из поставки PostgreSQL.

Инструменты для сложных планов

Когда в плане пять и более уровней, текстовый вид перестаёт читаться. Помогают готовые просмотрщики:

  • explain.depesz.com — цветовая подсветка узлов, показывает самые дорогие шаги.
  • explain.dalibo.com (PEV2) — графическое дерево плана прямо в браузере.
  • pg_stat_statements — расширение, которое копит статистику по запросам на боевой базе, без ручного EXPLAIN.

Для PEV2 удобно получить план в формате JSON:

-- тот же план, но машиночитаемый
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT o.id, o.total_amount FROM orders o WHERE o.status = 'PAID';
Дополнительно: при первом чтении можно пропустить

Глубже: LATERAL и MATERIALIZED в планерасширенное

Две конструкции запросов, которые чаще всего озадачивают в плане.

LATERAL позволяет подзапросу в FROM видеть колонки таблиц слева от него, и это ответ на задачу «для каждого покупателя три последних заказа»:

живой пример

SELECT c.id, o.id AS order_id, o.created_at
FROM customer c
CROSS JOIN LATERAL (
    SELECT id, created_at FROM orders
    WHERE customer_id = c.id
    ORDER BY created_at DESC
    LIMIT 3
) o;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

В плане это Nested Loop с подзапросом справа, который выполняется для каждой строки слева, и при индексе (customer_id, created_at DESC) каждый запуск читает три строки. Оконная функция с ROW_NUMBER решает то же, но проходит все заказы; LATERAL выигрывает, когда покупателей мало, а заказов у каждого много.

Про CTE. С PostgreSQL 12 WITH по умолчанию встраивается в запрос, если на него ссылаются один раз, и планировщик оптимизирует всё целиком; раньше CTE всегда был барьером: вычислялся отдельно и целиком. Барьер можно вернуть явно, WITH t AS MATERIALIZED (...), когда CTE дорогой и используется дважды или когда нужно заставить планировщик выполнить фильтр внутри до соединения. NOT MATERIALIZED наоборот заставляет встроить. В плане материализованный CTE виден узлом CTE Scan, встроенный растворяется в общем дереве.

Коротко

  • EXPLAIN (ANALYZE, BUFFERS) — рабочий вариант. Запрос при этом выполняется, поэтому UPDATE и DELETE оборачивают в BEGIN … ROLLBACK.
  • План читают снизу вверх, листья отрабатывают первыми. Время узла — actual time × loops: так Nested Loop с миллионом повторов превращается в минуты.
  • rows= и actual rows= разошлись в десять раз и больше — планировщик считал по устаревшей статистике, помогает ANALYZE на таблице.
  • Условие ушло в Filter: вместо Index Cond: — индекс не ключ поиска. Heap Fetches > 0 на Index Only Scan убирает VACUUM.
  • Нехватку work_mem выдают Batches > 1 в Hash Join, external merge Disk: в Sort и lossy=N в Bitmap Heap Scan.
  • Большой Buffers: read= — страниц не было в кеше базы, но это ещё не диск: время диска покажет I/O Timings.
  • LATERAL это подзапрос, который видит строку слева и выполняется на каждую (Nested Loop); CTE с PostgreSQL 12 встраивается, MATERIALIZED возвращает барьер и узел CTE Scan.
  • Порядок разбора: расхождение оценки и факта, потом loops (время узла печатается на один проход), потом Buffers, потом самый дорогой узел.
  • В плане регулярно встречаются Memoize, Incremental Sort, Gather Merge, Materialize, InitPlan/SubPlan и пометка never executed; CTE Scan показывает, материализован ли WITH.
  • План ночного запроса ловят auto_explain, а не EXPLAIN; блок JIT с сотнями миллисекунд на коротком запросе — признак завышенной оценки, а TIMING OFF убирает искажение от самих таймеров.

Что почитать дальше