Запрос выполняется за 25 мс, но планируется 500 мс. База небольшая, в запросе 9 таблиц, default_statistics_target всего 500. Откуда такой разрыв?
Недавно в pgsql-performance поступил именно такой кейс. Разбор показал неожиданного виновника: статистика по колонкам таблиц в pg_statistic.

В Postgres для оценки трудоёмкости этапов выполнения запроса мы используем MCV, Histogram, distinct и др статистики, хранящиеся в таблице pg_statistic. Если по-умолчанию, статистическая выборка не превышает 100 элементов, то для больших таблиц нам требуется сильно больше. И 1000 - 2500 элементов не выглядит большим числом для репрезентации, например, 10 млрд. строк. Соответственно возникает вопрос: а не случится ли так, что большой массив элементов, например, MCV существенно повлияет на трудоёмкость планирования запроса, даже если выполняется он ничтожно малое время?

Наблюдаем типичный 1С-ный автогенерируемый запрос (см. query.sql). Даже default_statistics_target ниже рекомендуемых для 1С величин - всего-то 500 элементов. В этом запросе Я насчитал 12 джойнов, однако 9 из них раскиданы по примитивным подпланам и пространство поиска ограничено всего тремя джойнами - не бог весть что. Взглянув в EXPLAIN можно увидеть, что в процессе планирования трогается всего 5 буферных страниц - это совсем немного. При этом на альтернативном форке Postgres, которых развелось нынче много, при практически идентичном эксплейне время планирования сильно меньше - порядка 80 мс. Будем использовать эту альтернативную реализацию в качестве контрольного образца ;).

Первое подозрение очевидно - разработчики могли расширить пространство поиска оптимизатора и он просто перебирает множество дополнительных путей. Поэтому я сразу пошёл смотреть в flamegraph. Он оказался весьма похож для варианта с долгим планированием и с быстрым в альтернативном форке Postgres'a. Детальное изучение показало, что пространство поиска в обоих случаях расширено стандартными для русскоязычных форков фичами Joinsel и 'Append over IndexScan', но на этом всё. Также можно заметить, что узким местом является операция сравнения byteaeq(), которая триггерится функцией оценки стомости индекса cost_index() и toast_raw_datum_size(). А дёргает её оптимизатор, перебирая все возможные варианты индексов по всем возможным выражениям - а это не только те выражения, что явно указаны в запросе, но и математически выводимые из операций равенства "классы эквивалентности".
Но много ли в запросе выражений?
Три колонки: inforg10621::fld10622rref, inforg10621::fld15131rref и inforg8199::fld8200_rrref задействованы в 20 выражениях, 15 из которых - join clauses. Учитывая количество индексов - только эти две таблицы имеют на двоих восемь индексов - становится понятно, что количество рассматриваемых комбинаций может быть большим.
Но как подтвердить догадку? Как много раз оптимизатор в реальности обращался к статистике по таблицам? Такой информации в текущем PostgreSQL не предусмотрено. Поэтому я обратился к своему проекту - pg_index_stats, который используя хуки ядра Postgres (relation_stats_hook и get_index_stats_hook) собирает такую статистику и выводит на EXPLAIN. Итого имеем в нашем случае следующий результат, а для альтернативного форка несколько другой:

Колонка

Обращений (1C)

Обращений (alt)

MCV

Histogram

ndistinct

width

inforg10621.fld10622rref

217

138

500

501

1073

17

inforg8199.fld8201rref

195

105

500

501

1104

20

inforg10621.fld809

156

156

1

1

3

inforg8199.fld809

128

128

1

1

3

reference312.fld809

36

36

1

1

3

reference127.fld809

31

31

1

1

3

inforg10621.fld15131rref

12

12

161

161

17

inforg8199.fld8200_rtref

10

10

16

12

28

8

inforg8199.fld8200_type

10

10

1

1

5

Можно заметить, что к статистике по четырем полям обращались больше 100 раз каждое, а для колонки fld10622rref оптимизатор фетчил, распаковывал и использовал статистику 217 раз! И если в случае с числовым почти уникальным fld809 это не особо важно, поскольку в нем отсутствует гистограмма и MCV, то в других случаях приходится детостить и разжимать солидные массивы. Альтернативный форк обращается к статистике примерно в два раза реже - прилично, хотя не до конца объясняет существенный разрыв во времени планирования.

Ок, давайте копать дальше. А что за статистика у нас имеется и в каком объёме?
Дамп статистики по обеим версиям Postgres (здесь, здесь) показывает, что для наших таблиц действительно имеются массивы MCV и Histograms до 500 элементов по нескольким колонкам. Распакованный размер их составляет десятки килобайт (а в сжатом больше 2кб) и очевидно, что для извлечения их требуется детост и декомпрессия перед использованием. Неужели так много раз нам требуется вытащить конкретную большую статистику и использовать её? В конце-концов, в Postgres имеется кэширование, которое позволяет вычислять селективность конкретного выражения только один раз ... .

Однако у нас есть два очевидных кандидата - колонки fld10622rref и fld8201rref. Попробуем проверить догадку, обнулив механически статистику по ним и посмотрим, что получится.

UPDATE pg_statistic
SET
    stanumbers1 = CASE WHEN stakind1 = 1 THEN NULL ELSE stanumbers1 END,
    stavalues1  = CASE WHEN stakind1 = 1 THEN NULL ELSE stavalues1 END,
    stakind1    = CASE WHEN stakind1 = 1 THEN 0 ELSE stakind1 END,
    stanumbers2 = CASE WHEN stakind2 = 1 THEN NULL ELSE stanumbers2 END,
    stavalues2  = CASE WHEN stakind2 = 1 THEN NULL ELSE stavalues2 END,
    stakind2    = CASE WHEN stakind2 = 1 THEN 0 ELSE stakind2 END
WHERE (starelid = '_inforg10621'::regclass AND staattnum = (
  SELECT attnum FROM pg_attribute
  WHERE (attrelid = '_inforg10621'::regclass AND attname = '_fld10622rref')))
  OR (starelid = '_inforg8199'::regclass AND staattnum = (
    SELECT attnum FROM pg_attribute
    WHERE (attrelid = '_inforg8199'::regclass AND attname = '_fld8201rref')));

EXPLAIN показывает время планирования на уровне 30мс:

Planning:
   Buffers: shared hit=5
   Memory: used=4030kB  allocated=4096kB
 Planning Time: 31.347 ms
 Execution Time: 0.237 ms

Если удалить вообще всю статистику командой:

DELETE * FROM pg_statistic;

то мы увидим теоретический минимум времени планирования этого запроса:

 Planning:
   Buffers: shared hit=5
   Memory: used=3932kB  allocated=4096kB
 Planning Time: 18.477 ms
 Execution Time: 0.421 ms

что вполне согласуется с временем планирования на альтернативном форке.

Итак, повышенное время выполнения вызвано статистикой, однако чем конкретно: накладными расходами на распаковку статистики или многократным проходом по длинным спискам элементов MCV или гистограммы? - вероятно, здесь имеется вклад от обоих факторов.
Косвенно, влияние многократного хождения по массиву MCV подтверждает тот факт, что если сменить тип хранения колонок в pg_statistic с EXTENDED на EXTERNAL, то не меняется ровным счётом ничего:

DELETE * FROM pg_statistic;
SET allow_system_table_mods = 'on';
ALTER TABLE pg_statistic ALTER COLUMN stavalues1 SET STORAGE EXTERNAL;
...
VACUUM ANALYZE;

Итак, можно заключить, что проблема возникла из-за увеличения пространства поиска оптимизатора, вызванного в свою очередь увеличением номенклатуры индексов и размера статистики таблиц. И то и другое вполне легально и может возникнуть не только в 1С. Другое дело, что не вполне очевидно, как часто такое замедление может привести к реальным проблемам - запрос не потребляет много дисковых ресурсов или памяти, поэтому особо не мешает соседям.

И что же делать?

Первое, что стоит сделать - это реализовать систему кэширования часто используемой объёмной статистики. Это можно реализовать даже расширением (по аналогии с тем, как я выше собирал обращения к статистике в pg_index_stats). Подобный код не будет слишком сложным - обычный модуль, аллоцирующий сегмент DSM под хэш-таблицу и распакованную статистику.
Также, стоит поискать баланс и вероятно хранить MCV в отсортированном виде (если позволяет тип данных) начиная с некоторого размера массива, чтобы обеспечивать быстрый матчинг элементов слева и справа при эстимации JOIN выражений и быстрый поиск конкретного значения при эстимации фильтров.
Второе - можно уменьшить размер статистики на проблемной таблице или колонке:

ALTER TABLE table_name ALTER COLUMN column_name SET STATISTICS 0;

Тут сразу возникает вопрос: а как детектировать проблемное место? Универсального ответа нет - прогонять EXPLAIN сомнительного запроса с и без статистики, а потом проделать тот же анализ, что я делал выше.
Ну и конечно, писать вендору - ведь нет предела совершенству!

THE END.
Турция, Истамбул, 26 января 2026 г.

Комментарии (12)


  1. OlegIct
    26.01.2026 20:54

    default_statistics_target всего 500

    в 5 раз больше, чем значение по умолчанию, это много. Какое время планирования при значении по умолчанию?

    What can you do? Not much, but still:1. Reduce the statistical target on tables causing the problem

    Вы дали правильное решение. Жалоба человека в pgsql-performance свелась к: «я поменял значения по умолчанию и у меня стало медленно работать, что делать»


    1. danolivo Автор
      26.01.2026 20:54

      Умолчания говорят ровно ни о чем - для больших таблиц этого явно недостаточно - если их хранить в отсортированном виде, а статистику кэшировать, то, полагаю, тысяч до 5 элементов таких явных эффектов мы бы не заметили.


  1. alex7six
    26.01.2026 20:54

    Мы в Tantor Postgres с подобной проблемой сталкивались.
    Подробнее о решении здесь и здесь.
    Есть еще одна проблема долгого планирования, которая связана не со статистикой, а с перебором варианта соединения таблиц. Текст запроса - https://disk.360.yandex.ru/d/q57qF5eoy6KQNg, план выполнения - https://explain.tensor.ru/archive/explain/4a10aec20a04cd5cfa312221e61a47f1:0:2025-08-06#explain
    Planning time при разных значениях join_collapse_limit и from_collapse_limit:
    12 - 591 ms
    10 - 155 ms
    8 - 58 ms
    Возможно здесь могло помочь кеширование плана запроса или оптимизация перебора вариантов соединения таблиц? Это не единственный такой пример, уже много таких случаев встречал


    1. danolivo Автор
      26.01.2026 20:54

      Мы в Tantor Postgres с подобной проблемой сталкивались.

      Вижу. Только вы предлагаете сортировать перед сравнением, а я - хранить в отсортированном виде - в данной задаче размер массива не гигантский, но количество переиспользований велико.

      Возможно здесь могло помочь кеширование плана запроса 

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


      1. pg_ilia
        26.01.2026 20:54

        Вижу. Только вы предлагаете сортировать перед сравнением, а я - хранить в отсортированном виде - в данной задаче размер массива не гигантский, но количество переиспользований велико.

        На момент написания статьи и в версии 17.6 мы действительно использовали сортировку перед сравнением. Однако сейчас реализован подход, описанный по второй ссылке : мы строим хэш-таблицу по MCV-статистике одного столбца и затем ищем совпадения по значениям MCV другого столбца. После этого изменения сопоставление MCV больше не опирается на вложенные циклы с большим количеством повторных вызовов byteaeq(), и в профилировании его вклад в CPU больше не выделяется.


        1. danolivo Автор
          26.01.2026 20:54

          Отлично. Тогда остается только вторая часть вопроса: кэшируете ли вы статистику (детостированную, распакованную, хэшированную)? Поскольку, как показано в данном примере, 200+ раз делать одно и то же для одной колонки может быть накладно.

          Копаясь в исходниках этих двух форков я заметил, что в одном из них реализовано переиспользование статистики, хоть и в достаточно простом виде, а в другом - нет (((.


  1. pg_ilia
    26.01.2026 20:54

    Отлично. Тогда остается только вторая часть вопроса: кэшируете ли вы статистику (детостированную, распакованную, хэшированную)? Поскольку, как показано в данном примере, 200+ раз делать одно и то же для одной колонки может быть накладно.

    Нет, сейчас мы не кэшируем статистику. После перехода на хэш-поиск для сопоставления MCV время планирования заметно сократилось. Фактически основная проблема была решена сменой алгоритма, поэтому мы пока не видим дальнейшие оптимизации целесообразными.


    1. danolivo Автор
      26.01.2026 20:54

      хм, сомнительно. Кэширование статистики в ‘референсном форке’ Postgres снизило время планирования с 500мс, до 80. Это значит, что хэширование может дать 80-> 20. Вроде как эффект одного уровня? У меня нет вашего кода чтобы проверить, но выглядит как потенциально полезная фича.


      1. pg_ilia
        26.01.2026 20:54

        Я когда впервые увидел проблему с долгим временем планирования при вычислении селективности в джойнах, я тоже пробовал кэшировать детостанные данные на время двойного цикла в eqseljoin. Но применение хэш-поиска сокращает время сильнее. Поэтому на хэш-поиске и остановился. Быть может реально в 'референсном форке' лучше будет.

        Скидываю код с хэш-поиском


        1. danolivo Автор
          26.01.2026 20:54

          Проверил. Здесь этот патч портированный на 17.5 - неплохо было бы кстати бэкпортировать его и в 1С-ных энтерпрайзах. Получилось как и прогнозировалось - порядка 70мс на планировании. Лучше, но не всё - вспомним, что статистики могут быть весьма тяжёлые (и скалярный MCV это только одна из них). Поэтому меня пока греет идея хранить 'горячие' данные в подготовленном виде.


          1. alex7six
            26.01.2026 20:54

            Мы в Tantor Postgres его уже давно портировали. Задача возникла как раз также из кейса 1С. У меня даже проблемный план сохранился, вот он - https://explain.tensor.ru/archive/explain/81c9c7b81127e66de65c8ebe146e15f4:0:2025-05-30


            1. danolivo Автор
              26.01.2026 20:54

              Действительно, в указанном примере распаковывать статистику приходится нечасто, а гистограммы вообще не нужны. Значит он мог детектировать только проблему обхода MCV массива.