PostgreSQL 19: Коммитфест 2026-03 Часть 5.1 (QPT)


Завершаем цикл статей с обзором изменений 19-й версии. Предыдущие статьи были посвящены первым четырем коммитфестам: 2025-07, 2025-09, 2025-11, 2026-01.
А сегодня начнем обзор мартовского коммитфеста 2026 года. Почему «начнем»? По опыту прошлых лет, мартовский обзор всегда очень объемный. Именно в последнем коммитфесте принимается большое число важных изменений. Обзор не только сложно подготовить, но и непросто дочитать до конца 🙂. Поэтому для 19-й версии он разбит на четыре статьи.
В первую статью вошли изменения, так или иначе связанные с оптимизацией запросов, — все, что можно отнести к нашему учебному курсу QPT. Вторая будет посвящена изменениям, которые больше относятся к курсам DBA1 и DBA2. Третья — к DBA3 и DBS. В четвертой будут рассмотрены новинки SQL, а также изменения, относящиеся к курсам DEV1 и DEV2.
Revertfest
Но начать обзор все-таки стоит с того, что в 19-ю версию не попадет. А список новых возможностей, включенных в состав 19-й версии до заморозки кода и отмененных до выпуска, впечатляет:
pg_dumpall: поддержка всех форматов вывода pg_dump (7ca548f23a6)
Встроенные функции для получения DDL на создание ролей, баз данных и табличных пространств (db169985c)
Включение/отключение подсчета контрольных сумм без остановки кластера (c05d5ce12)
На момент написания статьи не все открытые вопросы были закрыты, а предварительная дата выпуска намечена на 29 октября. Это значит, что список новых возможностей еще может сократиться.
Commitfest
С этой оговоркой приступаем к обзору изменений, связанных с оптимизацией запросов. Вот его содержание:
EXPLAIN (analyze, timing): сокращение накладных расходов на измерение времени
Планировщик: оценка кардинальности для выражений NOT IN (NULL, …)
Планировщик: антисоединение в запросах с внешним левым соединением
Стабилизация плана запроса и советы планировщику
commit: 5883ff30b02, c10edb102ad
Основная идея нового расширения pg_plan_advice — предоставить возможность стабилизировать планы выполнения запросов. Если, по мнению пользователя, запрос выполняется хорошо, то должен быть инструмент, позволяющий зафиксировать правильный план и предотвратить его изменение при последующих выполнениях. Для этого сначала нужно увидеть и запомнить решения планировщика, принятые при построении плана. А затем иметь возможность указывать планировщику, как строить план для этого запроса.
pg_plan_advice использует специальный мини-язык, на котором показывает основные решения, принятые для построения плана.
Расширение не создает объектов в базе данных, поэтому для его использования требуется лишь загрузить в память одноименный модуль. После этого можно добавлять в EXPLAIN параметр plan_advice, который и будет показывать принятые планировщиком решения (Generated Plan Advice):
LOAD 'pg_plan_advice';
EXPLAIN (costs off, plan_advice)
SELECT * FROM bookings;
QUERY PLAN
------------------------
Seq Scan on bookings
Generated Plan Advice:
SEQ_SCAN(bookings)
NO_GATHER(bookings)
(4 rows)
Для этого простого запроса было принято два решения: последовательно сканировать таблицу bookings и не использовать параллельный план.
Получив основные решения планировщика, можно для стабилизации плана эти же решения «посоветовать» в следующий раз. Записываем советы в параметр расширения:
SET pg_plan_advice.advice = 'SEQ_SCAN(bookings) NO_GATHER(bookings)';
Теперь в плане запроса можно увидеть, принял ли планировщик советы или нет (Supplied Plan Advice):
EXPLAIN (costs off, plan_advice)
SELECT * FROM bookings;
QUERY PLAN
-------------------------------------
Seq Scan on bookings
Supplied Plan Advice:
SEQ_SCAN(bookings) /* matched */
NO_GATHER(bookings) /* matched */
Generated Plan Advice:
SEQ_SCAN(bookings)
NO_GATHER(bookings)
(7 rows)
В нашем случае оба совета были приняты (/* matched */).
Но ничего не мешает нам экспериментировать с планом. Если мы считаем, что план запроса не оптимален, то можно посоветовать выполнять его иначе. Например, использовать параллельное сканирование таблицы:
SET pg_plan_advice.advice = 'SEQ_SCAN(bookings) GATHER(bookings)';
И планировщик слушается:
EXPLAIN (costs off, plan_advice) SELECT * FROM bookings;
QUERY PLAN
-------------------------------------
Gather
Workers Planned: 2
-> Parallel Seq Scan on bookings
Supplied Plan Advice:
SEQ_SCAN(bookings) /* matched */
GATHER(bookings) /* matched */
Generated Plan Advice:
SEQ_SCAN(bookings)
GATHER(bookings)
(9 rows)
Это очень простой пример советов. Мини-язык включает широкие возможности по выбору способов и порядка соединений наборов строк, выбору методов доступа к таблицам и многое другое. Подробное описание — в документации к расширению. Есть и ограничения, часть из которых, возможно, будет снята в следующих версиях.
Но если для тестирования различных планов запросов работать с pg_plan_advice.advice вполне комфортно, то использовать этот метод для стабилизации плана крайне неудобно. Нужно перед каждым запросом выставлять свое значение параметра. А хотелось бы иметь инструмент, позволяющий автоматически применять разные советы для разных запросов. И такой инструмент также будет поставляться с PostgreSQL 19 в виде еще одного расширения pg_stash_advice.
CREATE EXTENSION pg_stash_advice;
LOAD 'pg_stash_advice';
При помощи функций расширения создаем в памяти специальное хранилище (stash), в котором размещаем пары: идентификатор запроса и советы для него.
Для начала получим идентификатор запроса, его покажет параметр verbose:
RESET pg_plan_advice.advice;
EXPLAIN (verbose, costs off)
SELECT * FROM bookings;
QUERY PLAN
---------------------------------------------
Seq Scan on bookings.bookings
Output: book_ref, book_date, total_amount
Query Identifier: -2160201451481084067
(3 rows)
Все готово к созданию хранилища:
SELECT pg_create_advice_stash('demo');
SELECT pg_set_stashed_advice(
'demo',
'-2160201451481084067',
'SEQ_SCAN(bookings) GATHER(bookings)'
);
Мы загрузили в хранилище советы для одного запроса. Другие функции расширения показывают содержимое хранилища, позволяют удалять и добавлять советы для других запросов. Можно создавать и разные хранилища для разных групп запросов. Возможно, будет полезно иметь разные хранилища для разных баз данных кластера.
Для автоматического применения советов из хранилища нужно задать имя хранилища для тех сеансов, в которых выполняются запросы. Это делается в параметре pg_stash_advice.stash_name. Параметр можно установить как в postgresql.conf, так и более точечно: на уровне базы данных (ALTER DATABASE SET), роли или отдельного сеанса.
SET pg_stash_advice.stash_name = 'demo';
Теперь в текущем сеансе планировщик будет заглядывать в хранилище demo и искать в нем советы для запроса:
EXPLAIN (costs off) SELECT * FROM bookings;
QUERY PLAN
-------------------------------------
Gather
Workers Planned: 2
-> Parallel Seq Scan on bookings
Supplied Plan Advice:
SEQ_SCAN(bookings) /* matched */
GATHER(bookings) /* matched */
(6 rows)
Конечно, заглядывать в хранилище перед выполнением каждого запроса — это дополнительные накладные расходы. Однако такой подход имеет серьезное преимущество по сравнению с реализацией подсказок в виде комментариев в тексте запроса. Можно повлиять на план выполнения не меняя исходного кода запросов, который может быть не доступен.
Еще один важный вопрос для промышленного применения. Как восстановить хранилище в памяти после перезапуска сервера?
Для этого нужно загрузить оба модуля через shared_preload_libraries. А также выбрать способ установки имени хранилища. Например, на уровне базы данных:
ALTER SYSTEM SET shared_preload_libraries = 'pg_plan_advice, pg_stash_advice';
ALTER DATABASE demo SET pg_stash_advice.stash_name = 'demo';
Перезагрузим сервер для применения изменений и восстановим хранилище:
$ pg_ctl restart -l logfile
demo=# SELECT pg_create_advice_stash('demo');
demo=# SELECT pg_set_stashed_advice(
'demo',
'-2160201451481084067',
'SEQ_SCAN(bookings) GATHER(bookings)'
);
При включенном по умолчанию параметре pg_stash_advice.persist каждые 30 секунд (pg_stash_advice.persist_interval) фоновый процесс расширения сохраняет советы в файле:
\! cat $PGDATA/pg_stash_advice.tsv
stash demo
entry demo -2160201451481084067 SEQ_SCAN(bookings) GATHER(bookings)
Фоновый процесс был запущен автоматически, вместе с загрузкой модуля при старте сервера, хотя его можно запускать и вручную функцией pg_start_stash_advice_worker. При запуске процесс читает текущее содержимое файла и загружает его в память.
Теперь можно не бояться перезапуска сервера:
$ pg_ctl restart -l logfile
$ psql -c 'EXPLAIN (costs off) SELECT * FROM bookings'
QUERY PLAN
-------------------------------------
Gather
Workers Planned: 2
-> Parallel Seq Scan on bookings
Supplied Plan Advice:
SEQ_SCAN(bookings) /* matched */
GATHER(bookings) /* matched */
(6 rows)
auto_explain: добавление параметров расширений в EXPLAIN
commit: 0442f1c9eff, e972dff6c30
В 18-й версии появился интерфейс для расширений, позволяющий добавлять новые параметры для команды EXPLAIN. Но у auto_explain не было возможности задействовать такие параметры при выводе плана.
Новый параметр auto_explain.log_extension_options позволяет включить произвольные параметры сторонних расширений EXPLAIN для автоматического журналирования.
Для демонстрации настроим сеанс psql так, чтобы сообщения сервера отправлялись на экран. Загрузим в сеанс модуль pg_plan_advice, мы планируем журналировать решения планировщика.
SET client_min_messages = 'LOG';
LOAD 'pg_plan_advice';
SET pg_plan_advice.always_store_advice_details = on;
Загружаем модуль auto_explain и настраиваем параметр auto_explain.log_extension_options, содержимое которого будет автоматически добавляться в EXPLAIN:
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 0;
SET auto_explain.log_extension_options = 'plan_advice';
Теперь любой запрос будет не только выводить план, но и принятые решения (Generated Plan Advice):
SELECT count(*) FROM bookings;
LOG: duration: 194.624 ms plan:
Query Text: SELECT count(*) FROM bookings;
Finalize Aggregate (cost=57792.69..57792.70 rows=1 width=8)
-> Gather (cost=57792.48..57792.69 rows=2 width=8)
Workers Planned: 2
-> Partial Aggregate (cost=56792.48..56792.49 rows=1 width=8)
-> Parallel Seq Scan on bookings (cost=0.00..51682.78 rows=2043878 width=0)
Generated Plan Advice:
SEQ_SCAN(bookings)
GATHER(bookings)
count
---------
4905238
(1 row)
EXPLAIN (analyze, timing): сокращение накладных расходов на измерение времени
commit: 7d9b74df53e, 5a79e78501f, 0022622c93d, bcb2cf41f96, 294520c4448, 16fca482548, 544000288ec, 2c16deee2f7, 7fc36c5db55
Замер времени выполнения узла плана в EXPLAIN с параметрами analyze и timing раньше опирался на системный вызов clock_gettime, накладные расходы которого искажали картину.
В 19-й версии измерение времени возможно через прямое чтение регистра TSC процессора (rdtsc) с автоопределением частоты через CPUID, что снижает накладные расходы на профилирование. Слабое место — на виртуальных машинах и гипервизорах частота TSC не всегда надежна, поэтому предусмотрена возможность вручную устанавливать способ определения источника времени.
Проверить, какой источник времени рекомендуется использовать, можно командой pg_test_timing:
$ pg_test_timing
…
… часть вывода опущена
…
TSC clock source will be used by default, unless timing_clock_source is set to 'system'.
И как следует из сообщения, источник времени можно переопределить через новый параметр timing_clock_source:
\dconfig+ timing_clock_source
List of configuration parameters
Parameter | Value | Type | Context | Access privileges
---------------------+------------+------+-----------+-------------------
timing_clock_source | auto (tsc) | enum | superuser |
(1 row)
Планировщик: IS DISTINCT FROM для непустых значений
commit: 0a379612540, 0aaf0de7fed, f41ab51573a
Планировщик умеет трансформировать IS DISTINCT FROM в более простое для вычисления неравенство <>, когда с обеих сторон выражения находятся непустые константы. Но что, если вместо констант в выражении используются столбцы таблицы? Например в таком запросе, специально подобранном для этого случая:
18=# EXPLAIN (costs off)
SELECT t.*
FROM tickets t, segments s
WHERE t.ticket_no IS NOT DISTINCT FROM s.ticket_no;
QUERY PLAN
-----------------------------------------------------------------------
Gather
Workers Planned: 2
-> Nested Loop
Join Filter: (NOT (t.ticket_no IS DISTINCT FROM s.ticket_no))
-> Parallel Seq Scan on tickets t
-> Materialize
-> Seq Scan on segments s
(7 rows)
В 18-й версии у планировщика нет информации о том, может ли столбец ticket_no в какой-либо из двух таблиц соединения содержать NULL. Поэтому выполняется честная и более тяжелая проверка IS DISTINCT FROM с отрицанием (см. Join Filter). К тому же в этом случае планировщик ограничен в вариантах выполнения соединения.
В 19-й версии условие IS NOT DISTINCT FROM для двух обязательных столбцов будет заменено на эквивалентное =, что не только упростит проверку, но и позволит выбрать более подходящий план:
19=# EXPLAIN (costs off)
SELECT t.*
FROM tickets t, segments s
WHERE t.ticket_no IS NOT DISTINCT FROM s.ticket_no;
QUERY PLAN
------------------------------------------
Hash Join
Hash Cond: (s.ticket_no = t.ticket_no)
-> Seq Scan on segments s
-> Hash
-> Seq Scan on tickets t
(5 rows)
Планировщик: оценка кардинальности для выражений NOT IN (NULL, …)
commit: c95cd2991f1
Как известно, если в списке констант в выражении NOT IN (список) есть хотя бы одно значение NULL, то результат выражения всегда NULL. А значит не обязательно перебирать все константы для определения кардинальности выражения. Можно смело считать кардинальность равной 0.
Именно так теперь и работает оценка, что сокращает время планирования.
Планировщик: антисоединение для NOT IN с подзапросом
commit: 383eb21ebff
Для выражений NOT IN с подзапросом планировщик раньше не умел использовать антисоединение:
18=# EXPLAIN (costs off) SELECT *
FROM tickets t
WHERE t.ticket_no NOT IN (
SELECT s.ticket_no FROM segments s WHERE s.fare_conditions = 'Economy'
);
QUERY PLAN
-------------------------------------------------------------------
Gather
Workers Planned: 2
-> Parallel Seq Scan on tickets t
Filter: (NOT (ANY (ticket_no = (SubPlan 1).col1)))
SubPlan 1
-> Materialize
-> Seq Scan on segments s
Filter: (fare_conditions = 'Economy'::text)
(8 rows)
Что часто приводило к неоптимальному плану. Этот запрос к демобазе с перелетами за год на домашнем компьютере пришлось остановить минут через 40. А все из-за потенциальных значений NULL: если подзапрос вернет хотя бы одну строку NULL, то все выражение должно вернуть NULL. Антисоединение этого делать не умеет.
Но что, если столбцы с обеих сторон выражения гарантированно не могут быть NULL? В сети можно найти много рекомендаций переписать условие NOT IN с подзапросом на NOT EXISTS с коррелированным подзапросом.
В 19-й версии планировщик будет рассматривать планы с антисоединением для запросов NOT IN с подзапросом и гарантией отсутствия NULL. В частности, для этого запроса, где оба столбца объявлены как NOT NULL, используется Hash Right Anti Join, а выполнение занимает около 8 секунд:
19=# EXPLAIN (costs off) SELECT *
FROM tickets t
WHERE t.ticket_no NOT IN (
SELECT s.ticket_no FROM segments s WHERE s.fare_conditions = 'Economy'
);
QUERY PLAN
-----------------------------------------------------
Hash Right Anti Join
Hash Cond: (s.ticket_no = t.ticket_no)
-> Seq Scan on segments s
Filter: (fare_conditions = 'Economy'::text)
-> Hash
-> Seq Scan on tickets t
(6 rows)
Планировщик: антисоединение в запросах с внешним левым соединением
commit: cf74558feb8
Планировщик уже умел выполнять запросы вида «LEFT JOIN + проверка IS NULL по правой стороне» через антисоединение, но только если проверка выполнялась по непустому столбцу, участвующему в соединении. Например, мы хотим найти все перелеты (segments), на которые еще не выписаны посадочные талоны:
18=# EXPLAIN (costs off)
SELECT *
FROM segments s
LEFT JOIN boarding_passes bp USING (ticket_no, flight_id)
WHERE bp.ticket_no IS NULL;
QUERY PLAN
------------------------------------------------------------------------------------
Gather
Workers Planned: 2
-> Parallel Hash Anti Join
Hash Cond: ((s.ticket_no = bp.ticket_no) AND (s.flight_id = bp.flight_id))
-> Parallel Seq Scan on segments s
-> Parallel Hash
-> Parallel Seq Scan on boarding_passes bp
(7 rows)
Однако, если в условии вместо ticket_no или flight_id проверять на NULL другой обязательный столбец (seat_no), то планировщик выбирает более дорогое внешнее левое соединение:
18=# EXPLAIN (costs off)
SELECT *
FROM segments s
LEFT JOIN boarding_passes bp USING (ticket_no, flight_id)
WHERE bp.seat_no IS NULL;
QUERY PLAN
------------------------------------------------------------------------------------
Gather
Workers Planned: 2
-> Parallel Hash Left Join
Hash Cond: ((s.ticket_no = bp.ticket_no) AND (s.flight_id = bp.flight_id))
Filter: (bp.seat_no IS NULL)
-> Parallel Seq Scan on segments s
-> Parallel Hash
-> Parallel Seq Scan on boarding_passes bp
(8 rows)
В 19-й версии дополнительно проверяется, что столбец seat_no не может быть пустым, а значит можно использовать антисоединение:
19=# EXPLAIN (costs off)
SELECT *
FROM segments s
LEFT JOIN boarding_passes bp USING (ticket_no, flight_id)
WHERE bp.seat_no IS NULL;
QUERY PLAN
------------------------------------------------------------------------------------
Gather
Workers Planned: 2
-> Parallel Hash Anti Join
Hash Cond: ((s.ticket_no = bp.ticket_no) AND (s.flight_id = bp.flight_id))
-> Parallel Seq Scan on segments s
-> Parallel Hash
-> Parallel Seq Scan on boarding_passes bp
(7 rows)
Расширенная статистика по виртуальным вычисляемым столбцам
commit: f7f4052a4e9
Статистика для виртуальных вычисляемых столбцов не собирается, что может приводить к большим ошибкам в расчете кардинальности.
CREATE TABLE test (
id int,
amount numeric,
tax numeric GENERATED ALWAYS AS (amount*0.22) VIRTUAL,
CONSTRAINT check_tax CHECK (tax > 0)
);
INSERT INTO test
SELECT g.x, random(1,1000_000.00)
FROM generate_series(1,1000_000) AS g(x);
ANALYZE test;
Если в запросе задать условие по столбцу tax, то планировщик не сможет правильно оценить количество возвращаемых записей:
EXPLAIN(analyze, buffers off, timing off, summary off)
SELECT * FROM test WHERE tax < 10;
QUERY PLAN
------------------------------------------------------------------------------------------
Seq Scan on test (cost=0.00..21239.33 rows=333333 width=44) (actual rows=49.00 loops=1)
Filter: ((amount * 0.22) < '10'::numeric)
Rows Removed by Filter: 999951
(3 rows)
В принципе можно собрать расширенную статистику по вычисляемому выражению столбца. Но это не совсем удобно, особенно с учетом того, что выражение для столбца можно изменить и при этом нужно не забыть пересоздать расширенную статистику.
В 19-й версии стало возможным собирать расширенную статистику, указывая просто название виртуального столбца:
CREATE STATISTICS s ON tax FROM test;
ANALYZE test;
Теперь оценка кардинальности значительно аккуратнее:
EXPLAIN(analyze, buffers off, timing off, summary off)
SELECT * FROM test WHERE tax < 10;
QUERY PLAN
-----------------------------------------------------------------------------------------------------
Gather (cost=1000.00..12666.10 rows=100 width=44) (actual rows=49.00 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on test (cost=0.00..11656.10 rows=42 width=44) (actual rows=16.33 loops=3)
Filter: ((amount * 0.22) < '10'::numeric)
Rows Removed by Filter: 333317
(6 rows)
postgres_fdw: импорт статистики внешних таблиц
commit: 28972b6fc3d
Команда ANALYZE для внешней таблицы использует разные варианты семплирования: либо на удаленной стороне, либо локально. Это определяется параметром analyze_sampling на уровне внешнего сервера или таблицы.
Теперь в postgres_fdw появился параметр import_stats, включение которого приведет к копированию существующей статистики внешней таблицы (pg_stats) при выполнении ANALYZE. Следить за актуальностью статистики на внешнем сервере должен пользователь, поэтому по умолчанию параметр отключен.
У нас есть внешняя таблица remote_tickets:
\det
List of foreign tables
Schema | Table | Server
--------+----------------+---------------
public | remote_tickets | remote_server
(1 row)
Для копирования статистики с внешнего сервера включим параметр import_stats на уровне таблицы:
ALTER FOREIGN TABLE remote_tickets OPTIONS (import_stats 'on');
\det+x
List of foreign tables
-[ RECORD 1 ]------------------------------------------------------------------
Schema | public
Table | remote_tickets
Server | remote_server
FDW options | (schema_name 'bookings', table_name 'tickets', import_stats 'on')
Description |
После выполнения ANALYZE статистика внешней таблицы копируется на локальный сервер:
ANALYZE remote_tickets;
SELECT attname, null_frac, avg_width, n_distinct
FROM pg_stats
WHERE tablename = 'remote_tickets';
attname | null_frac | avg_width | n_distinct
----------------+-----------+-----------+-------------
book_ref | 0 | 7 | -0.12510937
outbound | 0 | 1 | 2
passenger_id | 0 | 17 | -0.24015339
passenger_name | 0 | 13 | 151215
ticket_no | 0 | 14 | -1
(5 rows)
JIT-компиляция по умолчанию отключена
commit: 7f8c88c2b87
JIT-компиляция появилась еще в 11-й версии, но по умолчанию была отключена. В 12-й версии jit включили по умолчанию. Но надо признать, что для большинства быстрых и коротких запросов, характерных для OLTP-систем, JIT-компиляция не дает особых преимуществ. И ее часто отключают. Например в нашем курсе QPT по оптимизации запросов мы отключаем jit, чтобы сообщения о JIT-компиляции не загромождали планы запросов.
В 19-й версии jit по умолчанию отключен:
\dconfig jit
List of configuration parameters
Parameter | Value
-----------+-------
jit | off
(1 row)
На этом пока все. В следующем обзоре рассмотрим изменения, относящиеся к курсам DBA1 и DBA2.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.