Эта история началась с подаренной коллегой своей новой книги: читая 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 г.