На проекте внедрения машины баз данных Tantor XData у одного из заказчиков мы периодически анализировали топ длительных запросов к СУБД, чтобы следить за производительностью системы. Делаем мы это с помощью модуля «Расширенная аналитика» Платформы Tantor. Вот так выглядел топ запросов за 23 декабря:

На первом месте по длительности был запрос, который выполнялся в среднем 45 секунд целых 657 раз за день. Провалимся в него и посмотрим план запроса:

Для удобства будем оперировать текстовым вариантом плана. Вот так выглядит сам текст запроса:

SELECT
    min(t1._period)
FROM
    _accumrg93090 t1
WHERE
    t1._fld3457 = 0::numeric AND
    t1._recordertref = '\\000\\000\\006U'::bytea AND
    t1._recorderrref = '\\220\\275\\000PV\\221/\\330\\021\\360F2\\0215\\242R'::bytea
HAVING
    NOT min(t1._period) IS NULL;

А вот его план, который был получен путем выполнения запроса в psql:

Result  (cost=41.30..41.31 rows=1 width=8) (actual time=40215.806..40215.807 rows=0 loops=1)
  One-Time Filter: ((InitPlan 1).col1 IS NOT NULL)
  InitPlan 1
    ->  Limit  (cost=0.28..41.30 rows=1 width=8) (actual time=40215.804..40215.805 rows=0 loops=1)
          ->  Index Only Scan using _accumrg93090_1 on _accumrg93090 t1  (cost=0.28..13601149.12 rows=331597 width=8) (actual time=40215.803..40215.803 rows=0 loops=1)
                Index Cond: ((_fld3457 = '0'::numeric) AND (_recordertref = '\\x00000655'::bytea) AND (_recorderrref = '\\x90bd005056912fd811f04631fc40d22c'::bytea))
                Heap Fetches: 0
Planning Time: 0.228 ms
Execution Time: 40215.940 ms

По тексту запроса найдем контекст вызова в 1С. У заказчика была развернута система мониторинга 1С «Алькир», которая в том числе собирала все запросы длительностью более 0.1 секунды. За рассматриваемый день были отобраны все события DBPOSTGRS длительностью более 40 секунд:

И среди найденных событий ТЖ запрос быстро нашелся: он вызывался по следующему контексту:

Исходя из контекста стала понятна логика того, что происходит: внешняя обработка, запускаемая как регламентное задание, удаляет помеченные на изменение объекты, а при их удалении происходит стандартная проверка подсистемы БСП «Даты запрета изменений», которая определяет запросом минимальную дату движения документа по регистру _accumrg93090. И если эта дата будет в закрытом периоде, то удаление объекта должно быть отменено.

Разберем, почему этот запрос выполняется так долго. План показывает проблему: это операция сканирования индекса accumrg930901, которая занимает основное время выполнения запроса:

->  Index Only Scan using _accumrg93090_1 on _accumrg93090 t1  (cost=0.28..13601149.12 rows=331597 width=8) (actual time=40215.803..40215.803 rows=0 loops=1)

Индекс accumrg93090состоит из таких полей: fld3457, period, recordertref, recorderrref, lineno. Красным выделено поле индекса, из-за которого Index scan выполняется так долго: поскольку условие отбора не содержит поля period, при выполнении запроса приходится просматривать индекс целиком, чтобы проверить условия фильтрации по полям recordertref и recorderrref.

В таблице есть более подходящий индекс accumrg930902fld3457, recordertref, recorderrref, lineno. Он почти идентичен первому индексу, но не содержит поля _period, а значит, идеально подходит под условия запроса. Но почему планировщик выбирает не его?

Result  (cost=41.30..41.31 rows=1 width=8) (actual time=40215.806..40215.807 rows=0 loops=1)
  One-Time Filter: ((InitPlan 1).col1 IS NOT NULL)
  InitPlan 1
    ->  Limit  (cost=0.28..41.30 rows=1 width=8) (actual time=40215.804..40215.805 rows=0 loops=1)
          ->  Index Only Scan using _accumrg93090_1 on _accumrg93090 t1  (cost=0.28..13601149.12 rows=331597 width=8) (actual time=40215.803..40215.803 rows=0 loops=1)

Видим, что планировщик верно оценил, что стоимость поиска по данному индексу будет очень большой — 13601149.12, при этом общая стоимость запроса — всего 41.31. Дело в том, что первое поле индекса _fld3457 является общим реквизитом и имеет всего лишь одно уникальное значение:

select n_distinct from pg_stats where tablename = '_accumrg93090' and attname = '_fld3457' \gx
-[ RECORD 1 ]-
n_distinct | 1

Учитывая это, планировщик понимает, что вторым полем в индексе является period, и согласно тексту запроса необходимо выбрать его одно минимальное значение — min(t1.period). Данные в индексе по полю period упорядочены по возрастанию, значит, прочитанные данные операцией Index scan будут упорядочены по возрастанию касательно поля period, и можно применить оператор Limit, чтобы выбрать всего одну строку. На этом операторе стоимость запроса резко падает — с 13601149.12 до 41.30, и общая стоимость плана получается такой же небольшой. При планировании запроса планировщик также перебирает план с использованием индекса accumrg930902, и раз он выбирает не его, то очевидно, что общая стоимость получается выше.

С подобным кейсом мы уже встречались, см. статью «СУБД Tantor Postgres 17.5: Повышение точности статистики без увеличения default_statistics_target». Опыт подсказывал и понимание причины проблемы, и резонные следующие шаги. 

В плане запроса можно обратить внимание на ошибку в оценке количества выбираемых строк:

->  Index Only Scan using _accumrg93090_1 on _accumrg93090 t1  (cost=0.28..13601149.12 rows=331597 width=8) (actual time=40215.803..40215.803 rows=0 loops=1)

Вместо планируемых 331 597 строк фактически было выбрано 0. Это указывает на неравномерное распределение данных по полю recorderrref в таблице. Это поле – ссылка на регистратор, а в еще индексе есть поле lineno(НомерСтроки), и распределение было бы равномерным, если бы, к примеру,  каждый регистратор добавлял бы в таблицу примерно равное количество строк. Однако на практике такого не бывает, распределение данных по этому полю обычно выглядит так:

Заметно весьма неравномерное распределение (long-tail distribution): большинство регистраторов создали всего 1–10 записей каждый, в то время как несколько «тяжеловесов» сгенерировали сотни тысяч строк. Планировщик не видит эту неравномерность, и предполагает 'среднее' распределение.

По таблице _accumrg93090 имеем следующие данные:

  • Количество записей – 761 734 070

  • Количество различных значений _recorderrref – 2 354 514

  • 90% регистраторов _recorderrref имеют в среднем по 250 записей

  • Топ-100 регистраторов _recorderrref = 21% от всех записей

Планировщик при планировании запросов ориентируется на значительно меньшее значение различных recorderrref – всего 123 тыс. (и это при defaultstatistics_target = 1000):

select n_distinct from pg_stats where tablename = '_accumrg93090' and attname = '_recorderrref' \gx
-[ RECORD 1 ]------
n_distinct | 123584

Недооценка может приводить к  тому, что при планировании запроса на этапе определения оптимального плана по-настоящему оптимальные планы могут откидываться из-за высокой стоимости, на которую как раз влияет оценка количества выбираемых строк. Ведь чем больше строк нужно выбрать, тем больше нужно потратить ресурсов CPU, и тем выше будет стоимость оператора. А ведь фактически выбирается 0 строк! Можно предположить, что точнее будет статистика, тем меньше выбираемых строк будет оценивать планировщик, и тем меньше стоимость плана.

В Tantor Postgres увеличить точность скалярных статистик можно параметром STATMULTIPLIER, который позволяет изменить формулу выбора количества данных для расчета статистики следующим образом:

300 * default_statistics_target * STATMULTIPLIER

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

Первым делом мы в 10 раз увеличили размер анализируемой выборки для расчета статистики по полю _recorderrref и выполнили для этого следующую команду:

alter table _accumrg93090 alter column _RecorderRRef  SET STATMULTIPLIER 10;

Далее рассчитали статистику — теперь планировщик делает более точную оценку количества различных _recorderrref в таблице:

select n_distinct from pg_stats where tablename = '_accumrg93090' and attname = '_recorderrref' \gx
-[ RECORD 1 ]------
n_distinct | 830112

830 тыс. вместо 123 тыс. Выполняем запрос еще раз и смотрим план:

Result  (cost=135.97..135.98 rows=1 width=8) (actual time=40005.904..40005.906 rows=0 loops=1)
  One-Time Filter: ((InitPlan 1).col1 IS NOT NULL)
  InitPlan 1
    ->  Limit  (cost=0.28..135.97 rows=1 width=8) (actual time=40005.902..40005.903 rows=0 loops=1)
          ->  Index Only Scan using _accumrg93090_1 on _accumrg93090 t1  (cost=0.28..13597986.28 rows=100216 width=8) (actual time=40005.900..40005.901 rows=0 loops=1)
                Index Cond: ((_fld3457 = '0'::numeric) AND (_recordertref = '\\x00000655'::bytea) AND (_recorderrref = '\\x90bd005056912fd811f04631fc40d22c'::bytea))
                Heap Fetches: 0
Planning Time: 4.513 ms
Execution Time: 40006.036 ms

Что изменилось:

  1. Оценка строк стала более точной: 100216 ближе к нулю, чем 331597 :)

  2. Общая стоимость запроса возросла с 41.31 до 135.98. Почему так? Если кратко — более высокая точность статистики привела к тому, что оператор Limit изменил свою оценку в большую сторону.

Что не изменилось: наш запрос выполняется все так же долго. Увеличим размер анализируемой выборки еще на 5 пунктов:

alter table _accumrg93090 alter column _RecorderRRef  SET STATMULTIPLIER 15;

Оценка количества различных _recorderrref:

select n_distinct from pg_stats where tablename = '_accumrg93090' and attname = '_recorderrref' \gx
-[ RECORD 1 ]------------
n_distinct | 1.085026e+06

Теперь выполним наш запрос:

Aggregate  (cost=114.71..114.72 rows=1 width=8) (actual time=0.017..0.017 rows=0 loops=1)
  Filter: (min(_period) IS NOT NULL)
  Rows Removed by Filter: 1
  ->  Index Scan using _accumrg93090_2 on _accumrg93090 t1  (cost=0.28..114.51 rows=204 width=8) (actual time=0.016..0.016 rows=0 loops=1)
        Index Cond: ((_fld3457 = '0'::numeric) AND (_recordertref = '\\x00000655'::bytea) AND (_recorderrref = '\\x90bd005056912fd811f04631fc40d22c'::bytea))
Planning Time: 4.845 ms
Execution Time: 0.042 ms

Запрос выполнился моментально: вместо исходных 40 215.940 мс — всего лишь 0.042 мс. Почти в миллион раз быстрее!

Теперь выбирается индекс accumrg930902, стоимость выборки по которому ничтожно мала по сравнению с accumrg930901: 114.51 вместо 13601149.12. И общая стоимость запроса — 114.72 — меньше чем 135.98, которая была при STATMULTIPLIER 10 по неоптимальному индексу.

Ради эксперимента мы увеличили STATMULTIPLIER еще несколько раз, чтобы посмотреть, как изменятся оценки планировщика:

STATMULTIPLIER

estimated rows

Стоимость оператора Index Scan using_accumrg93090_2

n_distinct по полю _recorderrref

15

204

114.51

1.085026e+06

20

172

96.84

1.280717e+06

30

142

79.64

1.555516e+06

Вывод очевиден: более точная статистика позволяет планировщику повышать качество планирования.

Как же подобрать значение STATMULTIPLIER? Универсальной рекомендации нет. Конкретно в рассмотренном случае планировщик стал выбирать более оптимальный план, когда значение n_distinct поля recorderrref из pgstats достигло примерно 50% от фактического количества различных значений recorderrref в таблице. Также может пригодиться запрос, который вернет количество ndistinct, рассчитываемое статистикой:

select
    count(distinct _recorderrref) as n_distinct
from
(
    select
        _recorderrref as _recorderrref
    from _accumrg93090
    order by
        random()
    limit SAMPLE_SIZE
);

где SAMPLE_SIZE = 300 default_statistics_target STATMULTIPLIER

О разработке STATMULTIPLIER подробно рассказывал разработчик «Тантор Лабс» Артем Бугаенко на конференции PG Bootcamp Russia 2025. К слову, следующая конференция PG BootCamp пройдет в марте 2026 г. в Москве, и сейчас проходит сбор заявок на выступления от потенциальных спикеров.  

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


  1. gsl23
    15.01.2026 04:31

    В ванильном pg и так есть инстумент для сбора extended stats ( в тч по отдельным колонкам ) :
    CREATE STATISTICS .... ON column_name ...
    Он не отработал ? Иначе не понятно зачем это тогда все.


    1. alex7six Автор
      15.01.2026 04:31

      Расширенная статистика (CREATE STATISTICS) в данном случае не поможет, так как она предназначена для учета корреляций между колонками, а наша проблема была в недооценке n_distinct для отдельной колонки _recorderrref.

      В PostgreSQL для расчета скалярных статистик (n_distinct, most_common_vals) используется параметр default_statistics_target, который ограничивает размер выборки формулой: 300 × default_statistics_target. Увеличив допустим default_statistics_target до 1000 мы увеличим количество хранимых статистик и соответственно время планирования запросов, при этом проблему можем не решить, и придется увеличивать еще.

      STATMULTIPLIER в Tantor Postgres решает эту проблему, позволяя увеличить объем анализируемой выборки для конкретной колонки без увеличения количества хранимых статистик. Формула становится: 300 × default_statistics_target × STATMULTIPLIER. Это похоже на SAMPLE N PERCENT в MSSQL, но с более гранулярным контролем на уровне отдельных колонок.

      Кстати, в Tantor Postgres также есть multicolumn-статистика по полям составных индексов, но это отдельная возможность, не связанная с данным кейсом.


      1. gena_k
        15.01.2026 04:31

        Подскажите, ванильный ALTER TABLE... ALTER [ COLUMN ] имя_столбца SET STATISTICS { integer | DEFAULT } тоже ведь устраняет проблему гранулярного контроля статистикой на уровне столбцов? Кажется, что так. И похоже, что решает более целостно - заботясь только о статистике самого набора данных и не держа в голове возможность изменения default_statistics_target.


        1. pg_vadim
          15.01.2026 04:31

          Судя по этой статье, не устраняет:

          Однако параметр STATISTICS увеличивает в гистограммах число корзин, то есть наиболее часто встречающихся значений (most_common_vals), а также массив с частотой встречаемости этих значений (most_common_freqs). Увеличение числа корзин увеличивает размер, хранимых статистик, размер таблицы системного каталога, влияет на кэш системного каталога в памяти процессов, замедляет создание плана выполнения. Хотелось бы увеличить число строк в выборке при анализе таблицы, не меняя объем хранимых статистик.


          1. gena_k
            15.01.2026 04:31

            Спасибо.


        1. alex7six Автор
          15.01.2026 04:31

          Спасибо за вопрос! Да, ALTER TABLE... ALTER COLUMN SET STATISTICS действительно позволяет гранулярно управлять статистикой на уровне отдельных колонок. Однако это решает другую проблему.

          SET STATISTICS меняет количество хранимых статистик (размер массивов most_common_vals, most_common_freqs, histogram_bounds в pg_stats), но не влияет на размер анализируемой выборки при расчете этих статистик. Выборка по-прежнему остается ограниченной формулой 300 × statistics_target.

          Альтернативный вариант — вручную установить n_distinct через ALTER TABLE... ALTER COLUMN SET (n_distinct = ...), но это статическое значение, которое:

          1. Требует ручного пересчета при изменении распределения данных

          2. Не адаптируется автоматически при росте таблицы

          3. Не учитывает эволюцию данных со временем

          В реальных production-системах распределение данных постоянно меняется, и автоматический расчет с расширенной выборкой (STATMULTIPLIER) оказывается более практичным и надежным решением, чем ручное управление параметрами.


          1. gena_k
            15.01.2026 04:31

            Перекосы в распределении - коварная штука. Не расширяя список значений most_common_vals/_freqs, рискуем потерять часть часть головы распределения, которая не влезла в дефолтные списки. Вам известны проекты, которые более адаптивно собирают статистику, расширяя или сужая при необходимости эти списки значений?