До выхода релиза СУБД Tantor Postgres 18 осталось уже совсем немного, и мы хотим наперед рассказать о его новых возможностях для работы с приложениями на платформе "1С:Предприятие". В обзоре разберем улучшения планировщика, по традиции коснемся работы временных таблиц и не обойдем вниманием вспомогательные утилиты, которые упрощают поиск и диагностику проблем в высоконагруженных системах. За каждым пунктом - реальные запросы 1С, реальные рабочие базы и сотни часов тестирования!

Улучшения, касающиеся скорости выполнения запросов

Доработка вывода EXPLAIN

Когда анализируешь план запроса, то понимаешь, что планировщик - это своего рода чёрный ящик. Ты видишь результат, но не видишь важную информацию, которая скрыта за логикой его работы. Есть информация об оценке количества строк, планируемых циклах, но этого не всегда достаточно, чтобы понять, почему был сделан именно такой выбор, и это часто усложняет поиск оптимального решения. Было бы здорово иметь инструмент, который показывал бы планы, "перебранные" планировщиком во время планирования запроса: это значительно бы ускорило расследование причин выбора неоптимальных планов и долгого выполнения запросов. Создание такого инструмента есть у нас в планах, ну а пока мы добавили в отображение плана запросов вывод новой полезной информации. 

Узел memoize

Данный узел плана запроса предназначен для кеширования результатов дочернего узла, чтобы не выполнять дочерний узел большое количество раз. Чаще всего он используется для связки Nested Loop + Index Scan, где он кеширует результаты Index Scan. Вот пример такого узла:

->  Nested Loop  (cost=0.23..5768.06 rows=23005 width=96) (actual time=0.210..247.437 rows=169739 loops=1)
                                 ->  Seq Scan on tt29 t5  (cost=0.00..283.57 rows=6870 width=88) (actual time=0.013..2.786 rows=6870 loops=1)
                                       Filter: (_fld2488 = '0'::numeric)
                                 ->  Memoize  (cost=0.23..1.33 rows=1 width=96) (actual time=0.016..0.032 rows=25 loops=6870)
                                       Cache Key: t5._fld87675rref, t5._fld87676rref, t5._fld87677rref, t5._fld87678rref, t5._fld87679rref
                                       Cache Mode: logical
                                       Estimates: capacity=4037 distinct keys=4037 lookups=6870 hit percent=41.24%
                                       Hits: 0  Misses: 6870  Evictions: 0  Overflows: 0  Memory Usage: 22715kB
                                       ->  Index Only Scan using _inforg87674_3 on _inforg87674 t4  (cost=0.22..1.32 rows=1 width=96) (actual time=0.015..0.021 rows=25 loops=6870)
                                             Index Cond: ((_fld2488 = '0'::numeric) AND (_fld87675rref = t5._fld87675rref) AND (_fld87676rref = t5._fld87676rref) AND (_fld87677rref = t5._fld87677rref) AND (
_fld87678rref = t5._fld87678rref) AND (_fld87679rref = t5._fld87679rref) AND (_active = true))
                                             Heap Fetches: 0

Именно здесь Memoize неэффективен: количество loops в узле Index Only Scan равно количеству loops в узле Memoize, то есть кеш ни разу не сработал, все 6870 циклов завершились промахом (Hits: 0, Misses: 6870). Это означает, что каждый вызов узла Memoize приводил к реальному выполнению дочернего Index Only Scan, и никакого эффекта от использования Memoize не было. Но почему тогда планировщик выбрал Memoize?

Для ответа на этот вопрос мы добавили отображение информации, которую вы могли заметить выше: Estimates: capacity=4037 distinct keys=4037 lookups=6870 hit percent=41.24%Давайте разберем, какую информацию дают нам эти новые поля:

  • capacity - количество записей, сколько кэш физически способен вместить в памяти одновременно, прежде чем новые записи начнут вытеснять старые; 

  • distinct keys - ожидаемое количество уникальных значений Cache Key, которые придут на вход узла Memoize. В нашем случае Cache Key состоит из следующих полей: t5._fld87675rref, t5._fld87676rref, t5._fld87677rref, t5._fld87678rref, t5._fld87679rref;

  • lookups - ожидаемое количество обращений для поиска в кэше;

  • hit percent - ожидаемый процент попаданий в кэш, он рассчитывается по формуле (1 - distinct keys / lookups) * 100.

Именно ввиду того, что процент попаданий в кэш был равен 41.42%, планировщик и принял решение использовать Memoize. Что могло бы помочь планировщику принять здесь более правильное решение, может быть, расширенная статистика по полям cache key? Но это поля временной таблицы, и заранее определить, по каким полям ее создать, мы не можем, т.к. нет статистики использования. А может, решение можно принять на основе оценки селективности по какому-то из полей ключа, допустим, какое-то из полей *rref содержит уникальные значения?

В общем, теперь мы лучше понимаем решения планировщика, и это позволяет собрать больше информации об использовании этого узла для 1С-систем, чтобы изменить логику срабатывания Memoize и улучшить качество планирования в будущих редакциях Tantor Postgres.

Узел Index Scan

Этот узел плана запроса предназначается для метода доступа к данным путем сканирования индекса. Как правило, это один из самых эффективных методов доступа, но в случае с 1С его использование иногда бывает ошибочным. Рассмотрим такой пример:

->  Memoize  (cost=0.18..2.28 rows=1 width=17) (actual time=0.260..0.473 rows=1 loops=552414)"
"              Cache Key: t1._q_000_f_000, t1._q_000_f_002"
"              Cache Mode: logical"
"              Estimates: capacity=55241 distinct keys=55241 lookups=552414 hit percent=90.00%"
"              Hits: 73604  Misses: 478810  Evictions: 0  Overflows: 0  Memory Usage: 59034kB"
"              ->  Index Scan using tmpind_814 on tt977 t3  (cost=0.17..2.27 rows=1 width=17) (actual time=0.298..0.544 rows=1 loops=478810)"
"                    Index Cond: (_q_001_f_002 = t1._q_000_f_002)"
"                    Filter: (t1._q_000_f_000 = _q_001_f_001)"
"                    Estimated Fetched Rows: 116"
"                    Rows Removed by Filter: 1739"

Снова видим ошибочный Memoize, но сейчас обратим внимание на другие факты, а именно, по узлу Index Scan using tmpind_814 on tt977 t3:

  • loops=478810 - цикл поиска в индексе выполняется почти полмиллиона раз;

  • (cost=0.17..2.27 rows=1 width=17) - планировщик "думает", что после применения условий соединения будет возвращена одна строка, и эта оценка является верной;

  • Rows Removed by Filter: 1739 - на каждом цикле поиска, несмотря на то что возвращается только одна строка, удаляется 1739 строк, а это сильно увеличивает общее время Index scan с учетом количества его циклов.

С учетом последнего факта выходит, что такой Index Scan неэффективен. Но как понять это, если на этапе планирования информации о Rows Removed by Filter: 1739 у планировщика нет?

Для решения этой проблемы мы добавляем отображение новой информации, а именно, появляется новая строка Estimated Fetched Rows: 116 - оценка количества строк, которые будут прочитаны с диска. Как это использовать для выбора оптимального плана - рассмотрим далее в статье.

Узел Hash Join

Каждому 1С-нику, который изучал планы запросов, знаком термин "Вложенные циклы". Термин этот частенько приводится в негативном контексте, дескать вложенные циклы якобы медленно работают. Но сам по себе этот оператор отнюдь не плох и не бесполезен - плохими могут быть обстоятельства, при которых он может быть использован. Пример такого рассмотрен выше. Когда мы в цикле будем 478 810 раз делать фильтр 1 739 строк, это приведет почти к миллиарду отфильтрованных строк. Работать будет долго. Еще один пример может быть на самом узле Nested Loop:

->  Nested Loop  (cost=1.28..1292811.22 rows=1 width=456) (actual time=68.361..2343267.989 rows=99248 loops=1)
                                                                                    Output: t24._fld98447rref, t24._recordertref, t24._recorderrref, t24._period, t24._fld98458, t24._fld98457, t24._fld98460rref, t24._fld98472_type, t24._fld98472_rtref, t24._fld98477, t24._recordkind, t24._fld98451_type, t24._fld98451_rtref, t24._fld98451_rrref, t24._fld98455_type, t24._fld98455_rtref, t24._fld98455_rrref, t24._fld98473rref, t24._fld98472_rrref, t24._fld98467_type, t24._fld98467_rtref, t24._fld98467_rrref, t24._fld98463rref, t24._fld98453rref, t24._fld98466_type, t24._fld98466_rtref, t24._fld98466_rrref, t25._fld59096rref, t25._fld59090rref, t25._fld59093_type, t25._fld59093_rtref, t25._fld59093_rrref, t25._fld59091rref, t25._fld59092rref, t25._fld59094rref, t31._q_001_f_000_type, t31._q_001_f_000_rtref, t31._q_001_f_000_rrref
                                                                                    Join Filter: (t24._fld98449rref = t25._idrref)
                                                                                    Rows Removed by Join Filter: 7909370864
                                                                                    Buffers: shared hit=356778

Здесь при соединении таблиц t24 и t25 по условию (t24._fld98449rref = t25._idrref) оператор Nested Loop фильтрует почти 8 млрд строк! Выглядит еще хуже, чем предыдущий случай. А что мы знаем о Hash Join? Рассмотрим вывод информации в данном узле:

->  Hash Join  (cost=624161.59..650656.37 rows=46262 width=219) (actual time=7260.753..8850.787 rows=80773 loops=1)"
"        Hash Cond: (t9._fld9322_rrref = t6._idrref)"

Выглядит идеально, никаких Rows Removed by Join Filter тут и в помине нет! Значит ли это, что выбор соединения таблиц оптимален? Хотелось бы, чтобы это было действительно так, но оказывается, что для этого узла эта информация не выводится - и мы добавили ее отображение:

 ->  Hash Join  (cost=803708.37..830073.21 rows=41490 width=219) (actual time=7089.452..8770.657 rows=80773 loops=1)"
"        Hash Cond: (t9._fld9322_rrref = t6._idrref)"
"        Rows Removed by Hash Matching: 3935016"

Теперь, имея информацию о Rows Removed by Hash Matching, мы можем объективно сравнивать узлы соединения между собой. Ведь раньше картина была несправедливой: Nested Loop "честно" показывал Rows Removed by Join Filter и казался плохим кандидатом, тогда как Hash Join скрывал аналогичную информацию и выглядел идеальным выбором. К тому же, информация о Rows Removed by Hash Matching может нам больше сказать об условиях соединения: возможно, стоит пересмотреть их при большом значении в данном поле.

Таким образом, все три рассмотренных доработки Explain (MemoizeIndex ScanHash Join) направлены на то, чтобы сделать выбор планировщика того или иного плана запроса более понятным для нас.

Изменение логики расчета стоимости операций Index/Bitmap index scan

Мы выше рассмотрели добавление новой информации в узел Index Scan. Объясним теперь, как мы это использовали с пользой для 1С.

У нескольких клиентов встречался вот такой проблемный запрос в рамках процесса "Закрытие месяца" по контексту "ОбщийМодуль.РасчетСебестоимостиНДС.Модуль : 96 : РаспределитьНДСПоСебестоимости(ПараметрыРасчета, "ПриходыТоваровНДС2_4_ДляРешенияСЛУ");". Он выполнялся от десяти минут до нескольких часов в зависимости от объема таблиц, в нем участвующих. План и текст запроса можно подробно изучить по ссылке:

Разберем, что происходит:

  1. Идет поиск в индексе tmpind_814 временной таблицы tt977 путем использования метода доступа к данным Index Scan;

  2. Поиск в индексе осуществляется в цикле 478 010 раз;

  3. На каждой итерации поиска в индексе применение фильтра Filter: (t1._q_000_f_000 = q001_f_001) приводит к тому, что после нахождения в индексе нужных записей исполнитель запроса отфильтровывает еще 1 739 строк;

  4. Это в свою очередь приводит к тому, что данный узел возвращает суммарно 478 010 строк, отфильтровывая 832 650 590 строк, т.е. значительная часть строк, прочитанная в рамках операции Index Scan, оказалась не нужна;

  5. Фильтрация такого количества строк выполняется долго, и суммарно узел выполняется 242 секунды;

  6. В плане есть еще один такой же узел с аналогичной проблемой, который в рамках операции Index Scan отфильтровывает 928 356 000 строк и выполняется 272 секунды;

  7. Все вкупе приводит к долгому выполнению запроса - 517 секунд.

Получается, оператор Index Scan неэффективен? Не совсем так, ведь он чаще всего в базах на платформе "1С:Предприятие" используется для доступа к данным, но иногда его использование действительно неэффективно. Давайте разбираться, в каких случаях. Посмотрим на условия этого оператора:

Index Scan using tmpind_814 on pg_temp.tt977 t3  (cost=0.17..2.29 rows=1 width=17) (actual time=0.278..0.507 rows=1 loops=478810)
                       Index Cond: (t3._q_001_f_002 = t1._q_000_f_002)
                       Filter: (t1._q_000_f_000 = t3._q_001_f_001)

Index Cond - это условие, которое проверяется прямо в индексе. Индекс tmpind_814 построен по полю q001_f_002, и Executor (исполнитель запроса) ищет в нем конкретное значение из t1._q_000_f_002

Filter - это условие, которое проверяется уже после того, как строка найдена по индексу через условие Index Cond и прочитана из таблицы. Executor берет строку, и сравнивает поле q001_f_001 со значением из t1._q_000_f_000. Если не совпадает, то такая строка отбрасывается. Получается, что строки, которые отбрасываются через Filter, все равно читаются из таблицы, и Executor тратит на это I/O и CPU для того, чтобы в итоге сказать, что ему эти строки были как бы и не нужны. Если бы условие из Filter было частью индекса, ненужные строки отсеивались бы ещё на этапе поиска в индексе - и это было бы на порядок эффективнее.

Можем ли мы заранее понять, что условие из Filter будет отбрасывать большое количество строк, т.е. оценить его эффективность? Оказывается, что да, и логика тут довольно проста. Упростим теперь наше условие до следующего:

-> Index scan
    Index Cond: (col_a = ?)
    Filter: (col_b = ?)

Если селективность поля col_a низкая (n_distinct(col_a) мало), а селективность поля col_b - высокая (n_distinct(col_b) велико), то можно сделать вывод, что Index Scan будет приводить к большому количеству фильтруемых строк (Rows Removed by Filter). Эту информацию можно получить на этапе планирования. Взглянем еще раз на наш проблемный узел плана запроса:

Мы добавили в него вывод  Estimated Fetched Rows: 117 - оценка количества строк, которые будут прочитаны с диска (1). Также у нас есть оценка планировщика относительно того, сколько строк останется после применения всех предикатов из Index Cond и Filter - rows = 1 (2). Разница этих значений и сообщит нам количество строк, которое будет отфильтровано. Имеем следующую формулу:

Estimated Rows Removed by Filter = Estimated Fetched Rows - rows = 117 - 1 = 116

Мы добавили в Tantor Postgres новый параметр cpu_filter_cost, который добавляет штраф за каждую фильтруемую строку, делая выбор сканирования индекса с большим значением Estimated Rows Removed by Filter менее предпочтительным.

В итоге наш проблемный запрос из расчета себестоимости вместо 517 секунд выполняется за 2 секунды. Без изменений в коде 1С, без новых индексов, только за счёт правильной логики работы СУБД:

Планировщик запроса понял, что выполнять неэффективный Index Scan будет дорого, рассмотрел другие методы доступа, и в итоге выбрал метод доступа данных к временной таблице tt967 через полное ее сканирование (1) с построением хэш-таблицы по ней (2), чтобы не использовать неэффективную (в данном случае) связку Nested Loop + Memoize + Index Scan.

А вот пример, где оптимизация помогает планировщику выбрать более подходящий индекс, поля поиска в котором (Index Cond) имеют более высокую селективность - см. ссылку на план. В нем есть узел Index Scan с большим количеством Rows Removed by Filter:

Рассмотрим селективность полей из условий данного узла (поле Period не рассматриваем, т.к. по нему индекс использоваться не может ввиду применения к нему функции datetrunc, а поле _RecorderTRef имеет очевидно низкую селективность исходя из его логики):

erp12k=# SELECT attname, n_distinct
FROM pg_stats
WHERE schemaname = 'public' AND tablename = '_inforg115169' AND attname IN ('_recorderrref', '_fld115171rref', '_fld115170rref');
    attname     | n_distinct
----------------+-------------
 _fld115170rref | -0.62830347
 _fld115171rref |          53
 _recorderrref  |       16765
(3 строки)
 
erp12k=# SELECT COUNT(1)
FROM _inforg115169;
  count
---------
 1553867
(1 строка)

Таблица inforg115169 содержит 1 553 867 записей, а поле fld115170rref содержит 62,8% уникальных значений (если n_distinct > 0, то это количество уникальных значений, а если меньше нуля, то это процент строк, имеющих уникальное значение) и является более селективным, чем поле recorderrref с его 16 765 уникальных значений. При этом по полю fld115170rref также есть индекс. После применения нашей оптимизации план запроса становится более оптимальным благодаря тому, что мы добавляем штраф индексу, менее эффективному в данном случае - inforg1151692. Планировщик выбирает индекс inforg1151694 и ускоряет запрос в 15 раз:

Таким образом, новый параметр cpu_filter_cost в Tantor Postgres решает системную проблему 1С-запросов: планировщик больше не выбирает Index Scan вслепую и учитывает реальную стоимость фильтрации и ускоряет тяжёлые запросы в десятки и сотни раз.

Управление моделью оценки кардинальности

Планировщик PostgreSQL умеет оценивать количество строк, которое вернёт запрос, - это называется оценкой кардинальности. От точности этой оценки напрямую зависит, какой план будет выбран. Но в любом форке PostgreSQL модель такой оценки всего одна, и её нельзя изменить. Давайте разберём, какие модели вообще существуют, чем они отличаются, и почему это важно для 1С.

Тестовый пример.

Дана таблица persons, в которой хранится список сотрудников, город, возраст, имя и зарплата:

CREATE TABLE persons (
    id      SERIAL PRIMARY KEY,
    city    TEXT    NOT NULL,
    age     INTEGER NOT NULL,
    name    TEXT    NOT NULL,
    salary  NUMERIC(10,2)
);

Распределение в таблице неравномерное, как обычно и бывает в таблицах 1С: в городе Москва сотрудников больше всего и преобладает возраст 18-35 лет. И вот запрос, которым мы будем сравнивать различные модели оценки кардинальности:

SELECT count(*) FROM persons
WHERE city = 'Moscow' AND age = 30;

Фактическое количество строк по данным условиям - 5121. Селективность значения Moscow поля city равна 0.0855, а значения 30 поля age = 0.0193. Всего строк в таблице persons - 1 млн.

Перейдем к сравнению моделей.

Полностью независимая (fully independent)

Формула оценки кардинальности:

Здесь N — общее количество строк, SELi​ — селективность i-го предиката. Здесь все просто: перемножаем селективности каждого из предикатов.
Выполним наш тестовый пример, добавив к нему EXPLAIN, и получим следующую оценку строк:

"Aggregate  (cost=23908.12..23908.13 rows=1 width=8)"
"  ->  Seq Scan on persons  (cost=0.00..23904.00 rows=1650 width=0)"
"        Filter: ((age = 30) AND (city = 'Moscow'::text))"

Проверим, что оценка соответствует формуле, подставив значения селективности в формулу: 1 000 000 0.0855 0.0193 = 1 650.15, что равно оценке планировщика.

Частично коррелированная (Partially correlated)

Эта модель оценки кардинальности также известна под названием exponential backoff. Формула оценки:

Предикаты сортируются от наименее к наиболее селективному (то есть SEL1 ≥ SEL2 ≥ ... ≥ SELn​):

  • первый (наименее селективный) предикат берётся в степени 1; 

  • второй — в степени 1/2;

  • третий — в степени 1/4 и т.д.

Таким образом, каждый следующий предикат вносит всё меньший вклад, отражая предположение о частичной корреляции.

Выполним EXPLAIN:

"Aggregate  (cost=23918.11..23918.12 rows=1 width=8)"
"  ->  Seq Scan on persons  (cost=0.00..23904.00 rows=5643 width=0)"
"        Filter: ((age = 30) AND (city = 'Moscow'::text))"

Проверим, что оценка соответствует формуле, подставив значения селективности в формулу: 1 000 000 0.0855^(1/2) 0.0193 = 5 643.40, что равно оценке планировщика.

Полностью коррелированная (Fully correlated)

Формула оценки кардинальности:

Эта формула проста: выбирается минимальную селективность и умножается на количество строк.

Выполним EXPLAIN:

"Aggregate  (cost=23952.25..23952.26 rows=1 width=8)"
"  ->  Seq Scan on persons  (cost=0.00..23904.00 rows=19300 width=0)"
"        Filter: ((age = 30) AND (city = 'Moscow'::text))"

Проверим, что оценка соответствует формуле, подставив значения селективности в формулу: 1 000 000 * 0.0193 = 19 300, что равно оценке планировщика.

Итого мы имеем следующие результаты:

Фактическое кол-во строк*

Полностью независимая

Частично коррелированная

Полностью коррелированная

5 121

1 650

5 643

 19 300

Как видим, полностью независимая модель недооценивает количество строк, полностью коррелированная - переоценивает, а частично коррелированная - оценивает наиболее точно с небольшой погрешностью.

Решение в Tantor Postgres

Интересно, а какова модель оценки кардинальности в MS SQL Server? До 2012 версии включительно была полностью независимая (как и в PostgreSQL), а начиная с 2014 версии - по умолчанию частично коррелированная (документация - Cardinality estimation (CE) feedback). Выглядит логично, ведь в 1С при сложных соединениях таблиц данные, как правило, тоже коррелированы, а не независимы.

Прежде чем фича попала в релиз, мы провели огромное количество нагрузочных тестов на конфигурациях ERP, ЗУП, Документооборот, процедуры "Закрытие месяца" (117 часов нагрузочного тестирования на реальных конфигурациях ERP, ЗУП, Документооборот), и по результатам оценки производительности места распределились следующим образом:

  1. Частично коррелированная;

  2. Полностью независимая;

  3. Полностью коррелированная.

Эталоном для оценки результатов здесь была полностью независимая модель оценки кардинальности, т.к. она всегда была в Tantor Postgres без возможности изменения. При использовании частично коррелированной модели появляются ключевые операции, которые становятся лучше эталонного результата (а увеличение default_statistics_target улучшает результаты дополнительно). А вот при использовании полностью коррелированной модели появляются ключевые операции с результатом хуже эталонного.

В итоге мы в Tantor Postgres 18 добавили новый параметр cardinality_estimation с двумя значениями:

  • CE17 - полностью независимая, т.е. так, как было ранее;

  • CE18 - частично коррелированная. При настройке инстанса Tantor Postgres платформа Tantor будет рекомендовать установить именно эту модель оценки кардинальности для 1С.

Приведем пример, где эта оптимизация дает эффект. Запрос из расчета себестоимости 1С:ERP:

Планировщик более точно оценивает результат соединения двух временных таблиц: 232 115 против 524 строк, и выбирает более оптимальный оператор агрегации данных, в результате чего запрос выполняется в четыре раза быстрее.

Ускорение планирования запросов

В нашей прошлой подобной статье "СУБД Tantor Postgres 17.6: обзор улучшений для 1С" рассматривался случай, когда при высоких значениях default_statistics_target время планирования запроса увеличивалось на 75%, что приводило к сильному замедлению выполнения простых запросов с соединением нескольких таблиц. Причина заключалась в неоптимальном способе сравнения MCV-списков статистики полей соединения. Аналогичную проблему мы обнаружили для еще одного типичного для 1С случая, там, где используется конструкция ""ГДЕ поле1 В (&МассивПараметров)“ (“WHERE field1 IN [...]" на SQL)". 

Рассмотрим на примере:

Это простейший запрос, который должен выбрать тысячу ссылок на документ РеализацияТоваровУслуг (параметр &МассивНомеров содержит массив из тысячи номеров). Если по полю ОснованиеНомер есть индекс, то выполняться он должен моментально, но время планирования такого запроса будет деградировать по мере увеличения default_statistics_target:

default_statistics_target

Время планирования (мс)

100

3.828 

1 000

45.559

10 000

245.626

В итоге для пользователей на больших базах 1С, где default_statistics_target обычно целенаправленно увеличивают, все будет не так быстро. К тому же, таких запросов может быть очень много. А причина здесь в том, что при планировании запроса планировщик сравнивает все значения MCV-списка поля РТУ.ОснованиеНомер со всеми значениями из массива &МассивНомеров:

для каждого значения в MCV-списке РТУ.ОснованиеНомер:
    для каждого значения в &МассивНомеров:
        если значения равны:
            вычислить вклад в селективность 

Решение проблемы аналогично предложенному в прошлой статье: вместо вложенных циклов с квадратичной сложностью O(N×M) применяем хэш-соединение: строим хэш-таблицу по меньшему списку, затем проходим по большему списку и проверяем вхождение каждого элемента за O(1), и это снижает итоговую сложность до O(N+M).

После внедрения оптимизации в Tantor Postgres получаем следующие результаты:

default_statistics_target

Время планирования (мс)

Время планирования после оптимизации (мс)

100

3.828 

1.074

1 000

45.559

1.086

10 000

245.626

2.167

Как видим, время планирования значительно ускоряется и становится стабильным независимо от значения параметра default_statistics_target!

Ускорение агрегации данных

Тяжелые аналитические запросы в информационных системах на платформе "1С:Предприятие" предполагают интенсивное выполнение различных вычислений. Рассмотрим пример, характерный для таких процедур как "Закрытие месяца" и "Расчет себестоимости":

SELECT
    T1.col1,
    T1.col2,
    SUM(T1.measure1),
    SUM(T1.measure2)
FROM table1 T1
INNER JOIN table2 T2
    ON T1.col1 = T2.col1
    AND T1.col2 = T2.col2
GROUP BY
    T1.col1,
    T1.col2

Что делает этот запрос:

  1. Из таблицы T1 читаются строки; 

  2. Для каждой строки T1 ищутся совпадающие строки в T2 по ключевым полям указанным в INNER JOIN;

  3. В результат попадают только строки, для которых найдено совпадение в T2;

  4. Результат соединения агрегируется, числовые поля суммируются в разрезе группируемых полей.

Посмотрим реальный план по такому шаблону запроса из 1С:

Что мы видим:

  1. Соединение двух таблиц методом Hash Join выполняется 31 сек.;

  2. В результате соединения этих 2х таблиц получается 116 млн строк;

  3. Агрегация этих 116 млн строк методом HashAggregate выполняется 67 сек.;

  4. После агрегации из 116 млн получается всего 55 строк.

Суть оптимизации состоит в следующем: вместо того, чтобы сначала соединить все строки двух таблиц и только потом агрегировать полученный результат, можно выполнить частичную агрегацию до соединения - то есть сгруппировать таблицу T1 ещё до того, как она будет соединена с T2. В случае с исходным примером это выглядит так:

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

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

  1. Читаем 8 566 записей из таблицы t1;

  2. Перед соединением с таблицей t2 сначала выполним предагрегацию данных в таблице t1;

  3. В результате предагрегации вместо 8 566 строк получаем всего 63 строки для соединения с таблицей t2;

  4. Соединение двух таблиц методом Hash Join выполняется в 100 раз быстрее - 315 мс вместо 31 сек.;

  5. В результате соединения этих двух таблиц получается 835 тыс. строк вместо 116 млн; 

  6. Агрегация этих 835 тыс. строк методом HashAggregate выполняется одну секунду;

  7. После агрегации получается на выходе также 55 строк.

Время выполнения запроса сократилось с 100 до 2 секунд, отличный результат!

Ускорение поиска Index Scan 

Вернемся опять к оператору Index Scan и рассмотрим следующий типичный для 1С пример, когда запрос выбирает максимальное значение поля Период из регистра накопления с отбором по регистратору:

ВЫБРАТЬ ПЕРВЫЕ 1
    ПрочиеРасходы.Период КАК Период
ИЗ
    РегистрНакопления.ПрочиеРасходы КАК ПрочиеРасходы
ГДЕ
    ПрочиеРасходы.Регистратор = &Регистратор
 
УПОРЯДОЧИТЬ ПО
    ПрочиеРасходы.Период

На SQL этот запрос выглядит так:

SELECT
    T1._Period
FROM _AccumRg50090 T1
    WHERE ((T1._Fld2488 = CAST(0 AS NUMERIC))) AND ((T1._RecorderTRef = '\\000\\000\\004\\262'::bytea AND T1._RecorderRRef = '\\211\\302\\030f\\332\\261R\\333\\021\\356\\336\\374\\035\\2265\\252'::bytea))
ORDER BY (T1._Period) LIMIT 1

Выполнив этот запрос, мы получим следующий план выполнения:

"Limit  (cost=206.18..309.16 rows=1 width=8) (actual time=3358.040..3358.043 rows=1 loops=1)"
"  ->  Index Only Scan using _accumrg50090_1 on _accumrg50090 t1  (cost=0.23..659671.90 rows=6406 width=8) (actual time=3358.038..3358.039 rows=1 loops=1)"
"        Index Cond: ((_fld2488 = '0'::numeric) AND (_recordertref = '\\x000004b2'::bytea) AND (_recorderrref = '\\x89c21866dab152db11eedefc1d9635aa'::bytea))"
"        Heap Fetches: 0"
"Planning Time: 0.193 ms"
"Execution Time: 3358.078 ms"

Запрос выполняется более 3 секунд, хотя мы используем поиск по индексу (Index Only Scan using accumrg500901), выполняем поиск в индексе всего один раз (loops=1) и находим всего одну строку (rows=1). Почему же поиск в индексе такой медленный?  Ответ лежит в составе полей индекса accumrg500901Fld2488, Period, RecorderTRef, RecorderRRef, LineNo. У нас в условиях запроса пропущено поле Period, и из-за этого Executor не может перейти сразу к поиску нужных значений RecorderTRef, RecorderRRef и ему приходится сканировать весь индекс:

В Tantor Postgres 18 появилась технология Index Skip Scan, которая позволяет выполнить этот запрос моментально, при этом план запроса и выбранный для поиска индекс не изменяются. Посмотрим, как это работает.

При исполнении запроса Executor пытается итеративно найти значения пропущенного условия по полю Period и добавить их в условия запроса. Можно это представить так, что в секцию WHERE добавляется условие по полю Period: "WHERE ((T1._Fld2488 = CAST(0 AS NUMERIC))) AND (T1._Period = ANY(...)) AND ((T1._RecorderTRef = '\\000\\000\\004\\262'::bytea AND T1._RecorderRRef = '\\211\\302\\030f\\332\\261R\\333\\021\\356\\336\\374\\035\\2265\\252'::bytea))", где ANY(...) - это не фиксированный заранее известный список значений, а результат итеративного обхода индекса.

Рассмотрим как происходит итеративный обход индекса:

  1. Ищет в индексе первое вхождений условия по полям RecorderTRef, RecorderRRef. Находит значение _Period = '2024-03-31 23:59:59';

  2. Выбирает все записи удовлетворяющие условию "WHERE ((T1._Fld2488 = CAST(0 AS NUMERIC))) AND (T1._Period = '2024-03-31 23:59:59') AND ((T1._RecorderTRef = '\\000\\000\\004\\262'::bytea AND T1._RecorderRRef = '\\211\\302\\030f\\332\\261R\\333\\021\\356\\336\\374\\035\\2265\\252'::bytea))";

  3. Далее нужно найти следующее значение поля Period, чтобы найти в нем вхождения по полям RecorderTRef, RecorderRRef. Но это можно сделать оптимальнее, чем в п.1, применив поиск в индексе по уже найденному условию: "WHERE ((T1.Fld2488 = CAST(0 AS NUMERIC))) AND (T1._Period > '2024-03-31 23:59:59') AND ((T1._RecorderTRef = '\\000\\000\\004\\262'::bytea AND T1._RecorderRRef = '\\211\\302\\030f\\332\\261R\\333\\021\\356\\336\\374\\035\\2265\\252'::bytea))". Здесь используется выражение T1._Period > '2024-03-31 23:59:59', чтобы найти следующее подходящее значение для поля _Period;

  4. Итеративное повторение пунктов 1-3.

В виде схемы это можно представить так:

Теперь выполним наш исходный запрос на Tantor Postgres 18 и получим следующий план:

"Limit  (cost=0.36..0.43 rows=1 width=8) (actual time=13.230..13.232 rows=1.00 loops=1)"
"  ->  Index Only Scan using _accumrg50090_1 on _accumrg50090 t1  (cost=0.23..426.52 rows=6221 width=8) (actual time=13.228..13.228 rows=1.00 loops=1)"
"        Index Cond: ((_fld2488 = '0'::numeric) AND (_recordertref = '\\x000004b2'::bytea) AND (_recorderrref = '\\x89c21866dab152db11eedefc1d9635aa'::bytea))"
"        Estimated Fetched Rows: 6221"
"        Heap Fetches: 0"
"        Index Searches: 354"
"Planning Time: 0.237 ms"
"Execution Time: 13.263 ms"

Было 3 сек., стало 13 мс - в 250 раз быстрее. На том же индексе, без изменений в запросе! Видим, что в плане запроса появилась новая информация - "Index Searches: 354" - это количество раз, когда Executor заново позиционировался (делал новый "поиск") внутри индекса в рамках одного сканирования (до этой оптимизации было просто последовательное сканирование индекса).

P.S. Данная оптимизация может быть особенно полезна в решениях на платформе 1С:Предприятие, где используется несколько областей данных, и есть пользователи, которые заходят в базу и работают без установленного разделителя. В этом случае при обращении к "разделенным" таблицам без установленного в параметрах сеанса значения разделителя у таких пользователей выборка из таблицы будет превращаться в ее полное сканирование, но данная технология сможет подставить в условия запроса отбор по списку возможных значений разделителя и получить результат запроса намного быстрее.

Улучшения, касающиеся временных таблиц

Развитие параллелизма при использовании временных таблиц

В рамках прошлой статьи мы рассмотрели наши оптимизации по снятию ограничений на использование параллелизма, если в запросе участвуют временные таблицы. Это уже был огромный прогресс, но одно ограничение оставалось - узлы плана запроса, в которых участвовают временные таблицы, не могли использовать параллелизм. И в Tantor Postgres 18 мы сняли и это ограничение.

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

Агрегация данных

Эффект: ↑ в 2.56 раза быстрее. Достигается за счет того, что проходит не только чтение временной таблицы в 7 потоках, но и сортировка прочитанных данных, чтобы применить более эффективную в данном случае стратегию GroupAggregate вместо HashAggregate (для дополнительного погружения во внутренности механизмов агрегации данных рекомендую доклад "Устройство группировки в PostgreSQL").

Агрегация данных с последующей сортировкой

Эффект: ↑ в 2.34 раза быстрее. Без параллелизма по сравнению с предыдущим случаем добавляется этап сортировки, который также требует значительных ресурсов. С параллелизмом дополнительная сортировка после агрегации не нужна, т.к. используется GroupAggregate.

Агрегация данных с объединением и последующей сортировкой

Эффект: ↑ в 3.36 раза быстрее. Дополнительные расходы без паралллелизма в данном случае вносит чтение данных из временной таблицы. Но параллелизм временных таблиц способен ускорить не только тяжелые аналитические запросы, он даже на простых запросах он дает эффект. Например, мы можем использовать параллелизм для чтения из обычной таблицы, которая связана с временной таблицей условием в WHERE. Это довольно распространенный для 1С вариант:

Эффект: ↑ в 3.07 раза быстрее. Условие, которое ранее блокировало использование параллелизма в этом случае:

FROM _AccumRg48060 T2
    WHERE ((T2._Fld2488 = CAST(0 AS NUMERIC))) AND (T2._Active = TRUE AND (T2._Fld48062RRef IN
    (SELECT
    T3._Q_000_F_001RRef AS Q_001_F_000RRef
    FROM pg_temp.tt4 T3))

Временные таблицы в 1С - везде. Теперь параллелизм - тоже везде. 

Использование реплики для выноса читающей нагрузки

Платформа 1С поддерживает кластеризацию серверов приложений, что позволяет распределять прикладную нагрузку между несколькими серверами. Однако все они работают с единой базой данных, которая и становится узким местом при росте числа пользователей. Сколько бы серверов приложений вы ни добавили в кластер, все они будут конкурировать за ресурсы одного сервера СУБД.

Начиная с версии 8.3.14, в платформе 1С появился механизм копий баз данных, позволяющий часть запросов на чтение направлять на реплику. Доступны два вида репликации: стандартный (логическая репликация), за который отвечает платформа 1С, и внешний (физическая репликация), которая использует встроенный механизм потоковой репликации PostgreSQL. Однако здесь возникает принципиальная проблема: PostgreSQL запрещает создание временных таблиц на read-only реплике, а платформа 1С активно использует их практически в каждом запросе. В результате большинство запросов автоматически перенаправляется обратно на мастер, и механизм по факту не работает.

MS SQL Server в этом плане более гибок — его реплики Always On позволяют создавать временные объекты в tempdb, что делает внешнюю репликацию на SQL Server более привлекательным вариантом для 1С. 

PostgreSQL годами был закрыт для горизонтального масштабирования 1С. В Tantor Postgres 18 мы это исправили.

Решение в Tantor Postgres 18

Начиная с версии 17.5 в СУБД Tantor Postgres появился параметр enable_temp_memory_catalog, который позволяет хранить метаданные временных объектов в локальной памяти сеанса, а не в общем системном каталоге (pg_class, pg_attribute и т.д.). Это устраняет фундаментальный конфликт с механизмом потоковой репликации: реплика не генерирует собственных изменений в системном каталоге, WAL-записи с мастера применяются без конфликтов, а коллизии OID исключены.

В Tantor Postgres 18 добавлен параметр enable_temp_table_on_replica, который в связке с enable_temp_memory_catalog снимает ограничение на создание временных таблиц и индексов на реплике. При их включении на реплике теперь механизм копий БД успешно выполняет любой запрос из 1C в режиме Read-only. Для подтверждения корректности его работы мы провели несколько нагрузочных тестов.

Конфигурация – Документооборот 2.1, размер базы — 210 Гб. Эмулируется работа 1000 пользователей, длительность теста – 3 часа, за это время пользователи выполняют 80 тысяч ключевых операций. Профиль нагрузки — смешанный:

  • 50% — открытие форм списков и документов, проведение документов, запись справочников;

  • 30% — формирование отчетов;

  • 20% — работа с обработками.

Первый запуск теста делаем без использования копии БД, чтобы оценить нагрузку на мастер. График нагрузки на CPU на мастере:

Теперь настроим копию БД и запустим тест еще раз. Нагрузка распределится следующим образом.

Мастер:

Реплика:

Как видим, часть нагрузки теперь ушла на реплику, что дает возможность вместо вертикального масштабирования использовать горизонтальное. По итогам нагрузочного теста APDEX при использовании копии БД остался на том же уровне, как и без ее использования. То есть основная задача выполняется на отлично: нагрузка разносится без потери производительности!

Более подробно сам механизм и результаты тестирования рассмотрены в нашей статье "Горизонтальное масштабирование 1С: переносим отчеты на реплику без потери производительности".

Развитие инструментов диагностики

Расширение для автоматического дампа проблемных запросов

Из патча фирмы 1С мы портировали расширение auto_dump, которое значительно упрощает воспроизведение проблемных ситуаций при тестировании и работе СУБД. Под проблемными ситуациями я понимаю следующие случаи:

  • Выполнение запроса приводит к ошибке СУБД и требуется воспроизведение ситуации

  • Запрос выполняется долго и требуется выполнить его с разными настройками, чтобы оценить как они повлияют на скорость его выполнения

Давайте посмотрим, как это работает. Недавно мне потребовалось сдампить запрос обновления итогов регистра бухгалтерии. Вот как он выглядит (я обрезал часть полей, чтобы он не занимал весь экран):

UPDATE _AccRgAT381787
SET _Fld81747 = _AccRgAT381787._Fld81747 + T2._Fld81747,
_TurnoverDt81761 = _AccRgAT381787._TurnoverDt81761 + T2._TurnoverDt81761,
_TurnoverCt81762 = _AccRgAT381787._TurnoverCt81762 + T2._TurnoverCt81762
... -- другие поля
FROM pg_temp.tt19 T2
WHERE (T2._Period = _AccRgAT381787._Period
AND T2._AccountRRef = _AccRgAT381787._AccountRRef
AND T2._Fld81743RRef = _AccRgAT381787._Fld81743RRef
... -- другие условия соединения
;

Тут может возникнуть вопрос - зачем вообще нужно дампить запрос и причем тут auto_dump? Дампить запрос нужно, чтобы воспроизвести его выполнение в psql или pgadmin без выполнения каких-то действий в 1С. Это удобно при взаимодействии с разработчиками Tantor Postgres при постановке задач (например, выполнение такого-то запроса при таком сочетании настроек приводит к ошибке или длительному выполнению, нужно разобраться в причинах на уровне исходного кода) или самостоятельном расследовании проблемы (например, выполнение запроса обновления итогов регистра бухгалтерии происходит долго из-за выбора неоптимального плана планировщиком, нужно рассмотреть варианты настроек и возможных индексов, при которых запрос будет выполняться быстро). 

Auto_dump нужен в случаях, когда в воспроизводимом запросе есть временная таблица, которая существует только на время выполнения этого запроса. Если скопировать текст такого запроса и попытаться его выполнить, вы получите ошибку, что дескать временная таблица  pg_temp.tt19 не существует. Auto_dump решает эту проблему: он дампит временную таблицу путем автоматического составления sql-команд, которые создадут временную таблицу и наполнят ее данными.

Чтобы собрать данные, добавляем в конфигурацию инстанса следующие настройки:

shared_preload_libraries = 'auto_dump'
auto_dump.enable = 'on'
auto_dump.output_directory = '/var/lib/postgresql/auto_dump_files/'
auto_dump.dump_on_query_string = 'UPDATE _AccRgAT381787 SET _Fld81747' 

Здесь:

  • shared_preload_libraries = 'auto_dump' - загружаем библиотеку auto_dump, требуется рестарт инстанса;

  • auto_dump.enable = 'on' - включаем использование auto_dump;

  • auto_dump.output_directory - прописываем путь, куда будем дампить запросы на диск;

  • auto_dump.dump_on_query_string - описываем часть текста запроса, при выполнении которого будет сделан дамп. Сопоставление запроса идет по шаблону like '%auto_dump.dump_on_query_string%'. В параметре можно указать сразу несколько искомых частей запросов, разделив их символом '|'. Таким образом можно сдампить сразу несколько разных запросов.

Запускаем 1С, выполняем проведение документа и при выполнении искомого запроса в каталоге /var/lib/postgresql/auto_dump_files/ будет создан каталог с дампом запроса, который будет содержать следующие файлы:

  •   query.sql - текст запроса, для которого делается дамп;

  •   create_temporary.sql - команды по созданию временных таблиц и (или) индексов;

  •   iinsert_temporary.sql - команды по заполнению временных таблиц;

  •   plan_explain.txt - план запроса в формате вывода команды EXPLAIN;

  •   plan_analyze.txt - план запроса в формате вывода команды EXPLAIN ANALYZE.

Для воспроизведения запроса нам достаточно, подключившись к исследуемой базе, выполнить последовательно следующие 3 команды (в psql):

\i /var/lib/postgresql/auto_dump_files/958937-2026_04_07_14_58_08_01/create_temporary.sql - создаст все временные таблицы, участвующие в запросе, дамп которого мы собирали
\i /var/lib/postgresql/auto_dump_files/958937-2026_04_07_14_58_08_01/insert_temporary.sql - наполнит временные таблицы данными
\i /var/lib/postgresql/auto_dump_files/958937-2026_04_07_14_58_08_01/query.sql - выполнит запрос, для которого делался дамп

Воспроизведение запроса успешно завершено без необходимости что-либо нажимать в 1С.

Нам это расширение очень помогло при реализации задач по параллелизму временных таблиц и изменению модели оценки кардинальности. В первом случае мы дампили запросы, чтобы на этапе alpha-версии Tantor Postgres 18 воспроизводить различные конфликтные ситуации работы параллелизма временных таблиц с другими нашими оптимизациями. Во втором случае мы дампили большое количество сложных запросов, чтобы смотреть, как они будут выполняться при различных моделях оценки кардинальности. 

В рамках этой статьи мы рассмотрели вариант дампа по части текста запроса, но вообще у auto_dump'a есть и другие триггеры для срабатывания сбора дампа запроса (например, разница в процентах между ожидаемым планировщиком и фактическим количеством строк). Их описание можно найти в документации.

Первичная диагностика проблем высоконагруженных БД

Представьте ситуацию, с которой наверняка сталкивался каждый DBA или эксперт 1С в высоконагруженной системе 1С: пользователи жалуются, что база тормозит, а по счетчикам оборудования и PostgreSQL аномалий нет: CPU не перегружен, дисков хватает, блокировок нет, автовакуум справляется. Что делать в такой ситуации?

Первая мысль - профилировать. Инструменты для этого есть: perf и расширение pg_wait_sampling. Но оба варианта имеют существенное ограничение: они требуют рестарта сервера. Для высоконагруженной базы 1С это означает неизбежный простой. Пользователи сразу будут жаловаться, что вместо решения проблемы им делают хуже. 

Мы разработали собственное расширение - pg_sample_profile. Его ключевое преимущество - простота внедрения: не нужно перезапускать сервер или использовать специальную сборку СУБД, достаточно выполнить CREATE EXTENSION pg_sample_profile на работающей системе, а в shared_preload_libraries ничего прописывать не нужно! При этом расширение дает расширенную аналитику: помимо стандартных данных о событиях ожидания оно дополнительно выводит backend_typedatabase_iduser_id и state - информацию, которая может упростить разбор инцидентов. И наконец, pg_sample_profile легко встраивается в существующие платформы мониторинга PostgreSQL.

Посмотрим, как оно работает на практике. Тестовый пример у нас будет из статьи "CSN vs MVCC Postgres" - мы сэмулируем ожидания типа "ProcArray" и увидим их с помощью нашего инструмента профилирования. Для эмуляции будем использовать следующий сценарий из 1С: запускается 1 000 клиентов 1С и каждый 500 раз создает временную таблицу с одной записью, создает индекс на этой временной таблице и в конце очищает временную таблицу (select fasttruncate()). 

Запускаем тест и собираем ожидания следующим запросом:

SELECT
    (wait_profile).backend_type,
    (wait_profile).state,
    (wait_profile).event_type,
    (wait_profile).event,
    sum((wait_profile).counts) AS counts
   
FROM
    (SELECT pg_sample_all('30 seconds', '1 milliseconds', false) as wait_profile
) subquery
group by
(wait_profile).backend_type,
    (wait_profile).state,
    (wait_profile).event_type,
    (wait_profile).event
 ORDER BY counts DESC

Логика работы pg_sample_profile

По логике работы pg_sample_profile близок к утилите perf: запускается на заданный период с заданным интервалом сэмплирования, с возможностью указать конкретные процессы или собирать данные по всем сразу. Только в отличие от perf - работает прямо из SQL.

В нашем примере мы использовали функцию pg_sample_all, которая собирает статистику по всем процессам СУБД. Разберём её параметры:

pg_sample_all('30 seconds',       -- duration: как долго собираем данные
              '1 milliseconds',   -- sample_period: как часто делаем снимок состояния
              false)                -- with_pids: включать ли PID процесса в вывод

Получим следующие данные:

Разберем полученную информацию:

  • backend_type - тип процесса PostgreSQL. Например, client backend - это пользовательское соединение (в нашем случае сессии 1С), checkpointer, walwriter, autovacuum launcher- фоновые служебные процессы СУБД;

  • state - состояние процесса в момент выборки: active означает, что процесс активно что-то делал (или ждал), idle - процесс находился в простое;

  • event_type - класс события ожидания. LWLock - ожидание легковесных блокировок внутри СУБД, IPC - межпроцессное взаимодействие, IO - дисковые операции, Client - ожидание данных от клиента, CPU time - процесс был занят вычислениями (не ждал, а работал), Activity - фоновый процесс ждёт своей следующей плановой задачи;

  • event - конкретное событие внутри класса. Например, ProcarrayGroupUpdate - групповое обновление массива транзакций, SInvalWrite / SInvalRead - операции с очередью инвалидационных сообщений, ProcArray - доступ к массиву активных процессов;

  • counts - количество раз, которое данное ожидание было зафиксировано за период наблюдения. Чем выше значение, тем больший вклад это ожидание вносит в суммарное время работы системы.

Но как администратору понять, что конкретное число означает на практике? Видеть, что одно значение больше другого - это полезно, но недостаточно для оценки серьёзности проблемы. Здесь на помощь приходит метрика AAS (Average Active Sessions) - среднее количество сессий, одновременно находившихся в данном ожидании на протяжении всего периода наблюдения. Формула простая:

AAS = counts / (duration / sample_period)

В нашем примере период сбора - 30 сек., интервал сэмплирования - 1 мс, значит, количество сэмплов равно 30 000. Считаем AAS для ProcarrayGroupUpdate:

AAS = 1 441 171 / 30 000 ≈ 48

Это означает, что в среднем 48 сессий одновременно стояли в этом ожидании на протяжении всего периода наблюдения.

Включим CSN (csn_enable = on) и повторим тест еще раз:

После включения CSN это ожидание ProcarrayGroupUpdate полностью исчезло из вывода - AAS упал до 0. Таким образом, включение CSN устраняет корневую причину ожиданий на ProcArray - и именно pg_sample_profile позволил нам это чётко увидеть: без инструмента профилирования мы бы продолжали смотреть на «зелёные» счетчики, не понимая, где же искать проблему.

Сжатие данных

В Tantor Postgres 18 реализовано сжатие данных на уровне менеджера хранения - CSM (Compression Storage Manager).  Данный механизм сжимает целые страницы данных непосредственно при записи на диск, прозрачно для всего остального стека СУБД.

Анализ реальных баз данных 1С показал, что страницы таблиц в среднем сжимаются до 11,7% от исходного размера (с 8192 до ~958 байт). Опираясь на этот факт, CSM хранит файл страницы в виде блоков меньшего фиксированного размера - 1024, 2048 или 4096 байт (параметр compression_page), сохраняя при этом стандартную страничную структуру PostgreSQL. 

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

-- Создание таблицы со сжатием
CREATE TABLE имя_таблицы (...)
    WITH (compression = lz4, compression_page = 2048);
 
-- Включение сжатия на существующей таблице
ALTER TABLE имя_таблицы
    SET (compression = zstd, compression_page = 2048);

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

Тестирование расчета себестоимости 1С:ERP

Чтобы иметь возможность наглядно демонстрировать, как наши оптимизации улучшают производительность 1С, мы проводим тестирование процедуры расчета себестоимости на Tantor Postgres начиная с версии 15. Тестирование проводится на базе 1С:ERP размером 646 ГБ, причем данные не синтетические, это реальная база крупной производственной компании. В процессе расчета себестоимости на этой базе нет таких запросов, ускорив которые, можно получить двукратное и более ускорение времени выполнение.

Было 111 минут, стало 81. И это без единого изменения в коде 1С!

Настройки планировщика для Tantor Postgres 18:


seq_page_cost = '1'
random_page_cost = '1.1'
from_collapse_limit = '20'
join_collapse_limit = '20'
 
enable_delayed_temp_file = 'on'
enable_join_pushdown = 'on'
enable_index_path_selectivity = 'on'
enable_filter_predicates_reordering = 'on'
enable_or_expansion = 'on'
 
max_parallel_workers = 20
max_parallel_workers_per_gather = 4
extended_parallel_processing = on
enable_parallel_insert = 'on'
write_page_cost = 0.5
 
cpu_filter_cost = 0.05
cardinality_estimation = 'ce18'

Если хотите протестировать закрытие месяца или расчет себестоимости, или просто попробовать нашу СУБД, - она доступна бесплатно в течение 90 дней по ссылке. Релиз 18 будет доступен уже в самые ближайшие недели.


Другие статьи по данной теме:

СУБД Tantor Postgres 17.5: обзор улучшений для 1С 

СУБД Tantor Postgres 17.6: обзор улучшений для 1С

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


  1. bzq
    15.05.2026 20:27

    from_collapse_limit = '20'
    join_collapse_limit = '20'

    Вот так делать не надо. Эти настройки не работают, раньше включится geco. А если не включится, то постгрес умрёт, если в запросе реально много таблиц будет. Количество комбинаций из 20 таблиц — это реально очень много.


    1. danolivo
      15.05.2026 20:27

      Так вроде ж специально выставляют этот лимит так высоко, чтобы GEQO включился и до 20 джойнов находил разумные комбинации вместо слепого следования синтаксическому порядку, нет?