Эта история началась с подаренной коллегой своей новой книги: читая Jimmy Angelakos’ «PostgreSQL Mistakes and How to Avoid Them», я осознал один напрягающий меня факт — в Postgres команда EXPLAIN выдаёт слишком много информации. И примеры, которые автор обычно приводит, рассматривая тот или иной аспект систем баз данных, усложняют разбор задачи и рассеивают внимание. Так и родилась идея постфильтра для эксплейнов — чтобы сделать их более читабельными и проблемно-ориентированными.

Перегруженность деталями

Каждый, кто работал с PostgreSQL, знает команду EXPLAIN, а точнее EXPLAIN ANALYZE. Обычно она используется в расследовании проблем производительности запроса или демонстрации техники оптимизации. Но есть одна беда: её вывод наполнен множеством специфической информации. Например, при анализе вывода EXPLAIN достаточно редко требуется параметр width. Некоторые поля, например cost, визуально занимают много места, системно-зависимы и в принципе не нужны — однако если мы хотим увидеть величину planned rows, то нам приходится делать эксплейн с опцией COSTS ON и cost попадает в него безальтернативно.

И что, разве это большая проблема — изучать эксплейн с чуть большим количеством информации? Ха! Давайте посмотрим на типичный план запроса в моих расследованиях — вот например два плана одного запроса, один условно плохой, а второй — хороший.

Поиск проблем в таком большом плане занимает время, и каждая лишняя деталь мешает разглядеть проблемное решение в коде. Да, с приходом AI агентов я могу просто попросить Claude сравнить пару планов, выдать мне различия и проанализировать, что не так. Однако это не всегда работает, либо деталей чересчур много, либо нужна автоматизация на большом потоке запросов — в общем, проблема остаётся.

Стабильность регрессионных тестов

Другой аспект — это стандартизация тестов. EXPLAIN меняется между версиями Postgres, и если ваше расширение поддерживает 4-5 крайних версий, то и тесты должны проходить на каждой из них. Таким образом, нужно фильтровать вывод эксплейнов, чтобы гарантировать стабильность прогона в разных программно-аппаратных условиях. Поддержка альтернативного вывода для теста может быстро превратиться в ад.

Пример:

Seq Scan on users (cost=0.00..1000.00 rows=100)
  Filter: status = 'active'
  Buffers: shared hit=50
  Heap Fetches: 5

А на другой машине или версии этот EXPLAIN выглядит чуть иначе:

Seq Scan on users (cost=0.00..995.50 rows=99)
  Filter: status = 'active'
  Buffers: shared hit=48
  Heap Fetches: 3

CI/CD пайплайн падает. Не потому что план изменился, а потому что внутренние различия программных систем сломали точное совпадение.

А вот ещё один простой случай. Возьмём тривиальный запрос с сортировкой:

Sort (rows=1 loops=1)
  Sort Key: id
  Sort Method: quicksort  Memory: 25kB
  ->  Seq Scan on users (rows=1 loops=1)

Значение Memory: 25kB зависит от платформы. На другой машине с другим аллокатором или выравниванием памяти вы получите Memory: 26kB или Memory: 24kB. И ведь в EXPLAIN ANALYZE нет никакой опции, чтобы подавить вывод этого поля — оно печатается всегда, когда узел Sort выполняется в памяти. То же касается узлов Hash, где появляются Memory Usage, Buckets, Batches. Вы не можете попросить PostgreSQL «покажи мне план без деталей памяти» — такой настройки просто не существует.

Именование фантомных объектов в плане запроса

Ещё один пример межверсионной нестабильности — именование SubPlan. В PostgreSQL 17 изменился формат отображения SubPlan и InitPlan узлов в выводе EXPLAIN. Раньше (PG 16 и ниже) InitPlan отображался с суффиксом (returns $0), а ссылки на его результат выглядели как $0:

InitPlan 1 (returns $0)
  ->  Result
Output: $0, (sum(t.value))

Начиная с PG 17 суффикс (returns $N) исчез, а ссылки изменили формат на (InitPlan N).colN:

InitPlan 1
  ->  Result
Output: (InitPlan 1).col1, (sum(t.value))

С SubPlan ситуация аналогичная — суффикс (returns $N) тоже убран, ссылки на параметры переведены в формат (SubPlan N).colN. Но у SubPlan есть ещё одно изменение: в PG 16 строка фильтра показывала просто (SubPlan 1) без деталей сравнения, а в PG 17 стало видно полное выражение — оператор, ключевое слово ALL/ANY и конкретный столбец:

-- PG 16:
Filter: (t.value < (SubPlan 1))

-- PG 17:
Filter: (t.value < ALL (SubPlan 1).col1)

Изменения разумные — стало понятнее, что происходит в плане. Но для тестов это катастрофа: один и тот же запрос на PG 16 и PG 17 даёт разный текстовый вывод. И на самом деле, такие флуктуации распространяются на все виды "фантомных объектов" - сущностей, которых нет в БД и в исходном SQL, однако они появляются как источники данных после преобразований плана запроса - см., например unnamed_subquery.

Постобработка EXPLAIN может стабилизировать внешнее представление плана запроса: нормализовать обе формы к единому виду — убрать имена фантомных объектов из строки InitPlan/SubPlan, привести ссылки на такие объекты к единому стабильному формату и унифицировать отображение testexpr в фильтрах, чтобы результат не зависел от версии.

Планы запросов для статей и документации

Ну и наконец — литература. Демонстрируя читателю, например, преимущества BitmapScan, не обязательно загружать его сознание лишней информацией, такой как loops или width. К тому же пространство в книге физически ограничено форматом А5, и приходится уменьшать шрифт, чтобы вместить мало-мальски сложный план. Представьте: вы пишете статью о новой фиче оптимизатора запросов PostgreSQL и хотите показать планы до и после:

-- До оптимизации:
Hash Join  (cost=230.48..564.12 rows=1120 width=44)
  (actual time=2.814..6.371 rows=1089 loops=1)
  Hash Cond: (o.customer_id = c.id)
  Buffers: shared hit=312 read=47
  ->  Seq Scan on orders o  (cost=0.00..270.00 rows=5000 width=24)
        (actual time=0.018..1.241 rows=5000 loops=1)
        Filter: (status = 'pending')
        Rows Removed by Filter: 15000
        Buffers: shared hit=170
  ->  Hash  (cost=180.00..180.00 rows=4038 width=20)
        (actual time=2.673..2.674 rows=4038 loops=1)
        Buckets: 4096  Batches: 1  Memory Usage: 227kB
        ->  Seq Scan on customers c  
              (cost=0.00..180.00 rows=4038 width=20)
              (actual time=0.009..1.187 rows=4038 loops=1)
              Filter: (region = 'EU')
              Rows Removed by Filter: 5962
              Buffers: shared hit=80 read=47

-- После оптимизации:
Nested Loop  (cost=0.57..1203.45 rows=1120 width=44)
  (actual time=0.038..3.142 rows=1089 loops=1)
  Buffers: shared hit=4401
  ->  Index Scan using idx_orders_status on orders o 
        (cost=0.29..582.03 rows=5000 width=24)
        (actual time=0.021..0.987 rows=5000 loops=1)
        Index Cond: (status = 'pending')
        Buffers: shared hit=1143
  ->  Index Scan using idx_customers_pkey on customers c 
        (cost=0.29..0.12 rows=1 width=20)
        (actual time=0.003..0.003 rows=0 loops=5000)
        Index Cond: (id = o.customer_id)
        Filter: (region = 'EU')
        Rows Removed by Filter: 1
        Buffers: shared hit=3258

Размеры памяти, буферы, оценки затрат — всё это загромождает картину и к тому же будет другим на машине читателя. А ведь важно здесь одно: как изменилась структура плана. Должно быть чисто, компактно. Читатель должен видеть суть оптимизации без лишнего шума.

Тут также можно было бы воспользоваться AI и попросить его сгенерировать “чистые” планы для публикации. Однако абсолютной веры ему нет, как и надежды, что он каждый план проверит на нужной версии СУБД с нужными вам настройками.

Так и появился explain prettier. Исходным триггером его создания была потребность в стабилизации тестов расширения pg_track_optimizer. Основная задача pg_track_optimizer - заглядывать внутрь планов запросов, а значит он естественным образом содержит множество регрессионных тестов, построенных на сравнении листингов эксплейнов. Так что эта фича была интегрирована напрямую в интерфейс расширения и позволила уменьшить количество альтернативных тестов.

Как оно работает

Этот plpgsql-скрипт предоставляет две функции. Одна — pretty_explain_analyze() — чтобы выполнять прогон запроса с постобработкой эксплейна. Предназначена в основном для стабилизации тестов. Вторая — pretty_explain_text() — принимает и обрабатывает готовый эксплейн в текстовом формате и предназначена в основном для расследования инцидентов, где нет доступа к серверу и данным, чтобы выполнить прогон запроса.

По умолчанию скрывается всё, что зависит от платформы: распределения памяти (42kB превращается в NN), количество рабочих потоков (Workers Planned: 8 становится Workers Planned: N), лишние знаки в количестве rows (rows=100.00 упрощается до rows=100), значения actual time округляются до целых. Буферы, выделение памяти хеша и прочие детали реализации удаляются. И так далее. При этом сохраняется всё важное: структура плана — узлы, объединения, сканирования; фильтры и условия; оценки количества строк. Стандартизация фантомных объектов пока не реализована.

Управление выводом

Функция pretty_explain_analyze() позволяет определить параметры EXPLAIN, задавая свою строку опций в аргументе params. Основные настройки следующие:

  • platform_dependent — флаг, который позволяет скрыть все платформозависимые данные из вывода запроса

  • show_details — скрывает строки, описывающие детали исполнения каждой ноды. Такие данные могут зависеть от конфигурации или нет. Основная идея здесь — убрать дополнительную информацию, сохранив только принципиальную структуру нод плана.

  • show_cost, show_width, show_loops — флаги, позволяющие скрывать детали основной строки описания ноды плана в выводе EXPLAIN ANALYZE.

Все флаги по умолчанию выставлены на false — для максимальной лаконичности и стабильности. Но если нужно больше деталей, это легко настроить:

-- Максимальная фильтрация (по умолчанию) — только структура плана
SELECT pretty_explain_analyze('SELECT ...');

-- Показать только платформонезависимые величины (регрессионные тесты),
-- без runtime специфики но с выводом planned rows
SELECT pretty_explain_analyze('SELECT ...',
  platform_dependent => true,
  show_details => true
);

-- Показать косты, но скрыть остальные детали
SELECT pretty_explain_analyze('SELECT ...',
  show_cost => true
);

Пример

Давайте посмотрим, что даёт нам explain prettier. Отфильтруем приведённый выше пример EXPLAIN ANALYZE двух планов с HashJoin и NestLoop, пропустив их через пост-процессинг explain-prettier’a. Получим:

 Hash Join  (rows=1120) (actual time=6 rows=1089)
   Hash Cond: (o.customer_id = c.id)
   ->  Seq Scan on orders o  (rows=5000) (actual time=1 rows=5000)
         Filter: (status = 'pending')
         Rows Removed by Filter: 15000
   ->  Hash  (rows=4038)
         (actual time=3 rows=4038)
         ->  Seq Scan on customers c  
               (rows=4038) (actual time=1 rows=4038)
               Filter: (region = 'EU')
               Rows Removed by Filter: 5962
 Nested Loop  (rows=1120) (actual time=3 rows=1089)
   ->  Index Scan using idx_orders_status on orders o 
         (rows=5000) (actual time=1 rows=5000)
         Index Cond: (status = 'pending')
   ->  Index Scan using idx_customers_pkey on customers c 
         (rows=1) (actual time=0 rows=0)
         Index Cond: (id = o.customer_id)
         Filter: (region = 'EU')
         Rows Removed by Filter: 1

Выглядит читабельнее, попроще и компактнее, не так ли?

Быстрый старт

Для регрессионных тестов:

SELECT pretty_explain_analyze('SELECT * FROM your_query');

Для копирования/вставки из psql:

SELECT pretty_explain_text($$
  [Вставьте вывод EXPLAIN сюда]
$$);

Для сравнения планов:

WITH plan1 AS (SELECT pretty_explain_analyze('SELECT 1')),
plan2 AS (SELECT pretty_explain_text('$$Aggregate  (cost=16.79..16.80 rows=1 width=0) (actual time=3.626..3.627 rows=1.00$$'))
SELECT * FROM plan1 EXCEPT SELECT * FROM plan2;

Критика PostgreSQL

Текст был бы неполным, если бы я обошёл молчанием ванильный подход в этом вопросе. Имея достаточно большой набор регрессионных тестов, сообщество не раз сталкивалось с необходимостью частично маскировать вывод эксплейна. Простым поиском по коду можно обнаружить такие функции, как explain_memoize(), explain_filter() и explain_analyze(). Всё это разбросано по тестам и является локальным решением. В результате, разработчикам новых фич, а чаще даже разработчикам расширений, приходится выдумывать свои маскировочные функции, что выглядит несистемно. Я бы предпочёл одну функцию в ядре, решающую все такого рода задачи.

Заключение

EXPLAIN Prettier решает одну конкретную проблему: планы запросов содержат много информационного балласта для того, чтобы их можно было эффективно сравнивать и тестировать на разных версиях и платформах. Он автоматически убирает незначимые для текущей цели детали реализации, сохраняя структуру плана — и в результате повышается стабильность тестов, документация не устаревает при обновлении версий, планы из production и тестового окружения легче сравнивать, а примеры в статьях станут чище.

Так что любите своих читателей, экономьте время на расследовании инцидентов и упрощайте эксплейны!

THE END.
Испания, Мадрид, 4 апреля 2026 г.