Почему запрос в 1С выполняется бесконечно долго даже при пустой временной таблице?

Программист 1С v8.3 (Управляемые формы) 1С:ERP Управление предприятием Бухгалтерский учет IT и автоматизация бизнеса
← На главную

Часто разработчики 1С сталкиваются с парадоксальной ситуацией: есть запрос, который фильтрует данные по предварительно подготовленной временной таблице. В процессе выполнения алгоритма эта временная таблица оказывается пустой, однако запрос не возвращает мгновенный пустой результат, а "вешается" на десятки минут или даже часов. Более того, подобное поведение может привести к катастрофическому разрастанию системной базы данных СУБД (например, tempdb в MS SQL Server) до сотен гигабайт или даже терабайта.

Разберем подобную ситуацию на реальном примере из типовой конфигурации 1С:ERP Управление предприятием (и 1С:ЗУП), где при открытии или заполнении документа "Резервы по оплате труда" система зависает. Проанализируем исходный код, выясним скрытые механизмы работы оптимизатора СУБД и по шагам применим эффективные методы оптимизации.

Анализ проблемного запроса

Рассмотрим структуру запроса, который стал причиной зависания:


ВЫБРАТЬ
    ДатыПолученияДанныхПоИсточникамДанных.ОбщийГрафик КАК ОбщийГрафик,
    ПодневныеДанныеУчетаВремени.Дата КАК Дата,
    // ... выбор множества других полей ...
ИЗ
    ВТФильтрДля КАК ДатыПолученияДанныхПоИсточникамДанных
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.ГрафикиРаботыСотрудников КАК ГрафикиРаботыСотрудников
        ПО (ГрафикиРаботыСотрудников.Ссылка = ДатыПолученияДанныхПоИсточникамДанных.ОбщийГрафик)
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ ПланВидовРасчета.Начисления.ВидыВремени КАК ТаблицаНаборовВидовВремени
        ПО (ТаблицаНаборовВидовВремени.Ссылка = ДатыПолученияДанныхПоИсточникамДанных.КлючНабораВидовВремени)
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.ГрафикиРаботыПоВидамВремени КАК ПодневныеДанныеУчетаВремени
        ПО (ПодневныеДанныеУчетаВремени.ГрафикРаботы = ДатыПолученияДанныхПоИсточникамДанных.ИсточникДанныхФактическогоВремени)
            И (ПодневныеДанныеУчетаВремени.ВидУчетаВремени = ТаблицаНаборовВидовВремени.ВидВремени)
            И (ПодневныеДанныеУчетаВремени.Дата МЕЖДУ ДатыПолученияДанныхПоИсточникамДанных.ДатаНачала И ДатыПолученияДанныхПоИсточникамДанных.ДатаОкончания)
        ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.ДанныеПроизводственногоКалендаря КАК ДанныеПроизводственногоКалендаря
        ПО (ГрафикиРаботыСотрудников.ПроизводственныйКалендарь = ДанныеПроизводственногоКалендаря.ПроизводственныйКалендарь)
            И (ВЫБОР
                КОГДА ПодневныеДанныеУчетаВремени.ПереходящаяЧастьТекущейСмены
                    ТОГДА ДОБАВИТЬКДАТЕ(ПодневныеДанныеУчетаВремени.Дата, ДЕНЬ, 1)
                ИНАЧЕ ПодневныеДанныеУчетаВремени.Дата
            КОНЕЦ = ДанныеПроизводственногоКалендаря.Дата)
            И (ДанныеПроизводственногоКалендаря.ВидДня = ЗНАЧЕНИЕ(Перечисление.ВидыДнейПроизводственногоКалендаря.Праздник))
            И (ТаблицаНаборовВидовВремени.ОпределяющийЗаПраздничныеДни)
ГДЕ
    (НЕ ТаблицаНаборовВидовВремени.ОпределяющийЗаПраздничныеДни
            ИЛИ НЕ ДанныеПроизводственногоКалендаря.Дата ЕСТЬ NULL)
СГРУППИРОВАТЬ ПО
    // ... поля группировки ...

Даже если таблица ВТФильтрДля пустая, запрос выполняется бесконечно долго. Выясним причину такого поведения.

Почему СУБД зависает при пустой ВТ?

Когда платформа 1С отправляет текст запроса на сервер баз данных (например, MS SQL), оптимизатор СУБД должен сначала построить план выполнения. Он делает это на основе статистики и структуры связей таблиц до того, как начнет "прогонять" строки.

  1. Неоптимальные условия соединения (Non-SARGable): В секции ЛЕВОЕ СОЕДИНЕНИЕ мы видим конструкцию ВЫБОР КОГДА ... ТОГДА ДОБАВИТЬКДАТЕ(...). Использование функций прямо в условии соединения лишает СУБД возможности использовать индексы. Системе приходится вычислять это выражение для каждой возможной комбинации строк перед проверкой условия.
  2. Декартово произведение и Hash/Loop Join: Оптимизатор, видя сложные условия и не имея возможности использовать индексы, может выбрать неудачный план. Он решает сначала соединить огромный регистр ГрафикиРаботыПоВидамВремени (например, 2 миллиона записей) с ДанныеПроизводственногоКалендаря в памяти, и лишь затем попытаться отфильтровать этот гигантский массив пустой временной таблицей.
  3. Разрастание tempdb: Из-за соединения "каждого с каждым" для вычисления функции ДОБАВИТЬКДАТЕ, СУБД не хватает оперативной памяти. Она начинает активно сбрасывать промежуточные результаты на диск в системную базу tempdb. Это объясняет, почему база tempdb за ночь может разрастись до 1 ТБ, а запрос так и не завершится.
  4. Parameter Sniffing (Кэширование планов): Возможно, при первом запуске таблица ВТФильтрДля была не пустой, и СУБД скомпилировала тяжелый план выполнения. В дальнейшем, даже когда таблица пуста, СУБД берет старый план из кэша, что приводит к катастрофическому падению производительности.

Бизнес-логика: откуда берутся "тяжелые" условия?

Проанализируем причину появления таких запросов с точки зрения прикладной логики ERP (или ЗУП). Документ "Резервы по оплате труда" автоматически формируется регламентными операциями при закрытии месяца. Если в системе есть кадровые ошибки или не до конца настроенные графики, при автоматическом создании документа некоторые служебные реквизиты (например, Идентификатор строки) могут остаться пустыми.

При пустом идентификаторе типовые модули расчета передают в алгоритмы выборки пустые массивы. Запрос лишается жестких отборов и начинает пытаться рассчитать или отфильтровать графики времени "по всему предприятию за все время". Это запускает цепную реакцию, приводящую к декартову произведению.

Пошаговая оптимизация запроса

Рассмотрим детально, как нужно переписать такой запрос, чтобы он работал молниеносно при любых объемах данных.

Шаг 1. Избавление от неявных подзапросов в левом соединении

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

Сначала поместим результат всех внутренних соединений в новую временную таблицу:


ВЫБРАТЬ
    ДатыПолученияДанныхПоИсточникамДанных.ОбщийГрафик,
    ПодневныеДанныеУчетаВремени.Дата,
    ПодневныеДанныеУчетаВремени.ПереходящаяЧастьТекущейСмены
    // ... остальные поля ...
ПОМЕСТИТЬ ВТ_ПредварительныеДанные
ИЗ
    ВТФильтрДля КАК ДатыПолученияДанныхПоИсточникамДанных
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.ГрафикиРаботыСотрудников КАК ГрафикиРаботыСотрудников
        ПО ...
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ ПланВидовРасчета.Начисления.ВидыВремени КАК ТаблицаНаборовВидовВремени
        ПО ...
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.ГрафикиРаботыПоВидамВремени КАК ПодневныеДанныеУчетаВремени
        ПО ...
;

Шаг 2. Исключение функций из условия соединения

Конструкцию ВЫБОР КОГДА ... ДОБАВИТЬКДАТЕ(...) нельзя использовать в секции ПО. Подготовим "Дату связи" заранее, прямо на этапе формирования временной таблицы:


ВЫБРАТЬ
    // ... базовые поля ...
    ВЫБОР
        КОГДА ПодневныеДанныеУчетаВремени.ПереходящаяЧастьТекущейСмены
            ТОГДА ДОБАВИТЬКДАТЕ(ПодневныеДанныеУчетаВремени.Дата, ДЕНЬ, 1)
        ИНАЧЕ ПодневныеДанныеУчетаВремени.Дата
    КОНЕЦ КАК ДатаДляСвязиСКалендарем
ПОМЕСТИТЬ ВТ_ПредварительныеДанные
ИЗ
    ВТФильтрДля ...
ИНДЕКСИРОВАТЬ ПО
    ДатаДляСвязиСКалендарем
;

Обратите внимание на инструкцию ИНДЕКСИРОВАТЬ ПО. Индексы во временной таблице критически важны. Они подскажут оптимизатору СУБД правильный метод соединения (например, Nested Loops вместо Hash Match).

Шаг 3. Исключение условия ИЛИ в секции ГДЕ

В исходном запросе присутствует конструкция: ГДЕ (НЕ ... ИЛИ НЕ ... ЕСТЬ NULL). Условие ИЛИ (OR) заставляет систему сканировать данные повторно. Заменим эту логику. Поскольку мы уже вынесли данные во временную таблицу, итоговый запрос будет выглядеть просто и прозрачно:


ВЫБРАТЬ
    ПредварительныеДанные.ОбщийГрафик,
    ПредварительныеДанные.Дата,
    // ...
ИЗ
    ВТ_ПредварительныеДанные КАК ПредварительныеДанные
        ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.ДанныеПроизводственногоКалендаря КАК ДанныеПроизводственногоКалендаря
        ПО ПредварительныеДанные.ДатаДляСвязиСКалендарем = ДанныеПроизводственногоКалендаря.Дата
            И ДанныеПроизводственногоКалендаря.ВидДня = ЗНАЧЕНИЕ(Перечисление.ВидыДнейПроизводственногоКалендаря.Праздник)
ГДЕ
    // Фильтруем только те записи, которые нам действительно нужны 
    (НЕ ПредварительныеДанные.ОпределяющийЗаПраздничныеДни
        ИЛИ ДанныеПроизводственногоКалендаря.Дата ЕСТЬ НЕ NULL)

В идеале, если производительность все еще недостаточна из-за ИЛИ в ГДЕ, такой запрос разбивают на два блока через ОБЪЕДИНИТЬ ВСЕ:

  1. Первый запрос выбирает данные, где НЕ ПредварительныеДанные.ОпределяющийЗаПраздничныеДни = ИСТИНА.
  2. Второй запрос выбирает данные, где ДанныеПроизводственногоКалендаря.Дата ЕСТЬ НЕ NULL.

Итоги

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

  1. Мы устранили скрытые декартовы произведения. СУБД больше не пытается соединять миллионы записей из регистров до их фильтрации.
  2. Мы избавились от функций ВЫБОР и ДОБАВИТЬКДАТЕ в секции соединения, сделав условия поиска SARGable (то есть позволяющими использовать индексы таблиц СУБД).
  3. Мы решили проблему разрастания tempdb, так как теперь сервер не хранит гигантские промежуточные таблицы при вычислении связей.
  4. Мы локализовали ошибку на уровне конфигурации, проверив заполнение служебного поля Идентификатор строки в документе "Резервы по оплате труда".

Всегда помните золотое правило написания запросов в 1С: соединения таблиц должны происходить строго по проиндексированным полям (или полям простых типов) без использования математических функций, преобразований типов и конструкций "ВЫБОР" непосредственно в условии соединения ПО.

← На главную