The Daily Newsstand · Free, Always
Thursday, October 1, 2026

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

Translate

Завершаем цикл статей с обзором изменений 19-й версии. Предыдущие статьи были посвящены первым четырем коммитфестам: 2025-07, 2025-09, 2025-11, 2026-01.

А сегодня начнем обзор мартовского коммитфеста 2026 года. Почему «начнем»? По опыту прошлых лет, мартовский обзор всегда очень объемный. Именно в последнем коммитфесте принимается большое число важных изменений. Обзор не только сложно подготовить, но и непросто дочитать до конца 🙂. Поэтому для 19-й версии он разбит на четыре статьи.

В первую статью вошли изменения, так или иначе связанные с оптимизацией запросов, — все, что можно отнести к нашему учебному курсу QPT. Вторая будет посвящена изменениям, которые больше относятся к курсам DBA1 и DBA2. Третья — к DBA3 и DBS. В четвертой будут рассмотрены новинки SQL, а также изменения, относящиеся к курсам DEV1 и DEV2.

Revertfest

Но начать обзор все-таки стоит с того, что в 19-ю версию не попадет. А список новых возможностей, включенных в состав 19-й версии до заморозки кода и отмененных до выпуска, впечатляет:

На момент написания статьи не все открытые вопросы были закрыты, а предварительная дата выпуска намечена на 29 октября. Это значит, что список новых возможностей еще может сократиться.

Commitfest

С этой оговоркой приступаем к обзору изменений, связанных с оптимизацией запросов. Вот его содержание:

Стабилизация плана запроса и советы планировщику

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.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.