Как избежать запроса в цикле в 1С: группировка данных с помощью ИТОГИ ПО и альтернативные методы оптимизации

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

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

В рамках этой статьи мы подробно разберем, как решить задачу пакетного сбора данных для рассылки одним запросом. Мы проанализируем классическое решение с использованием конструкции ИТОГИ ПО, исправим скрытые синтаксические и логические ошибки в кодовой базе форума, а также рассмотрим профессиональные альтернативы, такие как группировка данных в памяти через объект Соответствие и использование временных таблиц.

В чем опасность запроса в цикле?

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


Для Каждого СтрокаКонтрагент Из ТаблицаКонтрагентов Цикл
    Запрос = Новый Запрос;
    Запрос.Текст = "ВЫБРАТЬ ... ГДЕ Контрагент = &Контрагент";
    Запрос.УстановитьПараметр("Контрагент", СтрокаКонтрагент.Ссылка);
    // Выполнение и обработка...
КонецЦикла;

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

Рассмотрим, как реализовать это правильно.

Решение №1: Использование конструкции ИТОГИ ПО и обход по группировкам

Самый надежный способ связать «один ко многим» на уровне запроса 1С — использовать предложение ИТОГИ ПО. Оно позволяет структурировать выборку в виде дерева, где на верхнем уровне будут находиться родительские сущности (контрагенты), а на подчиненном уровне — связанные с ними детальные записи (документы).

Проанализируем пример запроса, объединяющего данные контрагентов, их email-адреса и список документов, требующих обработки:


Запрос = Новый Запрос;
Запрос.Текст = 
    "ВЫБРАТЬ
    |    СтатусДокументов.Документ.Контрагент КАК Контрагент,
    |    СтатусДокументов.Документ.Контрагент.АдресЭлектроннойПочты КАК АдресПочты,
    |    СтатусДокументов.Документ КАК ДокументСсылка,
    |    СтатусДокументов.Документ.Номер КАК НомерДокумента
    |ИЗ
    |    РегистрСведений.СтатусДокумента.СрезПоследних(&Период, Статус = ЗНАЧЕНИЕ(Перечисление.СтатусыДокументов.СтопОплата)) КАК СтатусДокументов
    |ГДЕ
    |    СтатусДокументов.Документ.Проведен
    |УПОРЯДОЧИТЬ ПО
    |    Контрагент,
    |    НомерДокумента
    |ИТОГИ ПО
    |    Контрагент";

Обратите внимание на секцию ИТОГИ ПО Контрагент. Именно она указывает платформе, что результат нужно сгруппировать по контрагентам. Теперь разберем, как правильно обойти этот результат в коде 1С. Для этого используется специальный метод Выбрать() с параметром ОбходРезультатаЗапроса.ПоГруппировкам.

Рассмотрим пошаговый алгоритм обхода:


РезультатЗапроса = Запрос.Выполнить();

// Шаг 1. Получаем выборку верхнего уровня (Группировка по Контрагентам)
ВыборкаКонтрагенты = РезультатЗапроса.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам, "Контрагент");

Пока ВыборкаКонтрагенты.Следующий() Цикл
    
    // Получаем почту и наименование текущего контрагента из полей группировки
    ТекущийКонтрагент = ВыборкаКонтрагенты.Контрагент;
    АдресПолучателя = ВыборкаКонтрагенты.АдресПочты;
    
    Если Не ЗначениеЗаполнено(АдресПолучателя) Тогда
        ЗаписьЖурналаРегистрации("Рассылка", УровеньЖурналаРегистрации.Предупреждение, , ,
            "Не найден email для контрагента: " + СокрЛП(ТекущийКонтрагент));
        Продолжить;
    КонецЕсли;
    
    СписокДокументовТекстом = "";
    
    // Шаг 2. Получаем подчиненную выборку детальных записей по текущему контрагенту
    ВыборкаДетали = ВыборкаКонтрагенты.Выбрать();
    Пока ВыборкаДетали.Следующий() Цикл
        СписокДокументовТекстом = СписокДокументовТекстом + " " + СокрЛП(ВыборкаДетали.НомерДокумента) + ";";
    КонецЦикла;
    
    // Шаг 3. Формируем и отправляем письмо
    ТемаПисьма = "Уведомление о приостановке оплат";
    ТекстПисьма = "Уважаемый " + СокрЛП(ТекущийContragent) + "! Ваши документы на стоп-оплате:" + СписокДокументовТекстом;
    
    ОтправитьПисьмо(АдресПолучателя, ТемаПисьма, ТекстПисьма);
    
КонецЦикла;

При таком подходе мы делаем ровно один запрос к базе данных, независимо от количества контрагентов и документов в системе. Платформа берет на себя всю работу по структурированию дерева выборки.

Анализ и исправление ошибок финального кода с форума

В сообщении №17 автор темы привел свой итоговый вариант реализации, который содержит несколько критических ошибок, способных вызвать падение системы в реальной среде эксплуатации. Проанализируем эти ошибки, чтобы не допускать их в своей практике:

  1. Обращение к полям выборки напрямую без контекста: В коде автора написано: ТекстПисьма = "Уважаемый " + ДокументКонтрагентНаименованиеПолное.... Это вызовет ошибку "Переменная не определена". К любым переменным запроса при иерархическом обходе необходимо обращаться строго через объект выборки, то есть: РезультатЗапроса.ДокументКонтрагентНаименованиеПолное (или ВыборкаКонтрагенты.ДокументКонтрагентНаименованиеПолное).
  2. Опечатки в именах таблиц и псевдонимов: В секции ИЗ указан псевдоним СтатусДокумента, а в условиях ГДЕ автор обращается к несуществующей таблице СтатусДокументаСрезПоследних. Это приведет к синтаксической ошибке при компиляции запроса.
  3. Сортировка по отсутствующим полям: В секции УПОРЯДОЧИТЬ ПО используется поле ВходящаяДата, которое забыли включить в секцию ВЫБРАТЬ. Платформа 1С не позволит выполнить такой запрос.
  4. Опечатки в вызовах процедур: Написано ОправитьПисьмо(...) вместо ОтправитьПисьмо(...).

Ниже представлен исправленный, гарантированно рабочий вариант этого кода:


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

Пока ВыборкаКонтрагентов.Следующий() Цикл
    
    // Получаем адрес почты из группировки (обращение через объект ВыборкаКонтрагентов!)
    АдресЭлектроннойПочты = ВыборкаКонтрагентов.АдресПочты;
    Если Не ЗначениеЗаполнено(АдресЭлектроннойПочты) Тогда
        Сообщить("Пропущена отправка: нет адреса для " + ВыборкаКонтрагентов.КонтрагентНаименование);
        Продолжить;
    КонецЕсли;
    
    ТекстПисьма = "Уважаемый " + ВыборкаКонтрагентов.КонтрагентНаименование + "! Ваши документы со статусом Стоп-Оплата: ";
    
    ВыборкаДокументов = ВыборкаКонтрагентов.Выбрать();
    Пока ВыборкаДокументов.Следующий() Цикл
        ТекстПисьма = ТекстПисьма + " №" + ВыборкаДокументов.ВходящийНомер + " от " + Формат(ВыборкаДокументов.ВходящаяДата, "ДФ=dd.MM.yyyy") + ";";
    КонецЦикла; 
    
    ОтправитьПисьмо(АдресЭлектроннойПочты, "Важная информация", ТекстПисьма);
    
КонецЦикла;

Глубокая оптимизация запроса: боремся с неявными соединениями

Хотя исправленный запрос работает корректно и решает проблему запроса в цикле, у него есть скрытая проблема производительности — неявные соединения (обращения через точку).

Конструкция вида СтатусДокумента.Документ.Контрагент.НаименованиеПолное заставляет СУБД строить каскад из нескольких последовательных левых соединений (LEFT JOIN): от регистра сведений к документу, затем от документа к справочнику Контрагенты. Если база данных содержит сотни тысяч записей, такой запрос начнет работать крайне медленно.

Выясним причину замедления и оптимизируем запрос. Для этого заменим неявные обращения явными левыми соединениями и перенесем фильтр по статусу в параметры виртуальной таблицы СрезПоследних. Это позволит СУБД отсечь ненужные записи на самом раннем этапе выполнения запроса.

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


Запрос = Новый Запрос;
Запрос.Текст = 
    "ВЫБРАТЬ
    |    СпрКонтрагенты.НаименованиеПолное КАК КонтрагентНаименование,
    |    СпрКонтрагенты.АдресЭлектроннойПочты КАК АдресПочты,
    |    Док.ВходящийНомер КАК ВходящийНомер,
    |    Док.ВходящаяДата КАК ВходящаяДата
    |ИЗ
    |    РегистрСведений.СтатусДокумента.СрезПоследних(, Статус = ЗНАЧЕНИЕ(Перечисление.СтатусыДокументов.СтопОплата)) КАК СтатусДокумента
    |    
    |    // Явное соединение с таблицей документов
    |    ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.ВходящийДокумент КАК Док
    |    ПО СтатусДокумента.Документ = Док.Ссылка
    |    
    |    // Явное соединение со справочником Контрагенты
    |    ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.Контрагенты КАК СпрКонтрагенты
    |    ПО Док.Контрагент = СпрКонтрагенты.Ссылка
    |ГДЕ
    |    Док.Проведен
    |УПОРЯДОЧИТЬ ПО
    |    КонтрагентНаименование,
    |    ВходящаяДата
    |ИТОГИ ПО
    |    КонтрагентНаименование,
    |    АдресПочты";

Преимущества этого решения:

Решение №2: Группировка в памяти через объект "Соответствие" (Альтернативный подход)

Иногда иерархический обход с конструкцией ИТОГИ ПО кажется разработчикам слишком громоздким для отладки. Существует элегантное альтернативное решение — выгрузить плоский результат простого запроса в таблицу значений и сгруппировать его в памяти 1С с помощью объекта Соответствие.

Суть метода заключается в следующем: мы обходим плоскую таблицу документов и для каждого уникального контрагента создаем ключ в объекте Соответствие, значением которого будет массив связанных документов.

Рассмотрим, как реализовать этот подход по шагам:


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

ТаблицаДанных = Запрос.Выполнить().Выгрузить();

// 2. Группируем данные в памяти с помощью Соответствия
КартаРассылки = Новый Соответствие;

Для Каждого СтрокаТаблицы Из ТаблицаДанных Цикл
    
    СтруктураКонтрагента = КартаРассылки.Получить(СтрокаТаблицы.Контрагент);
    
    Если СтруктураКонтрагента = Неопределено Тогда
        // Если контрагента еще нет в карте, инициализируем структуру
        СтруктураКонтрагента = Новый Структура;
        СтруктураКонтрагента.Вставить("АдресПочты", СтрокаТаблицы.АдресПочты);
        СтруктураКонтрагента.Вставить("СписокНомеров", Новый Массив);
        
        КартаРассылки.Вставить(СтрокаТаблицы.Контрагент, СтруктураКонтрагента);
    КонецЕсли;
    
    СтруктураКонтрагента.СписокНомеров.Добавить(СтрокаТаблицы.ВходящийНомер);
    
КонецЦикла;

// 3. Выполняем рассылку по сгруппированным данным
Для Каждого КлючИЗначение Из КартаРассылки Цикл
    
    ТекущийКонтрагент = КлючИЗначение.Ключ;
    ДанныеКонтрагента = КлючИЗначение.Значение;
    
    Если Не ЗначениеЗаполнено(ДанныеКонтрагента.АдресПочты) Тогда
        Продолжить;
    КонецЕсли;
    
    // Сворачиваем массив номеров документов в одну строку
    СтрокаДокументов = СтрСоединить(ДанныеКонтрагента.СписокНомеров, ", ");
    
    ТекстПисьма = "Добрый день, " + ТекущийКонтрагент.Наименование + "! Ваши документы: " + СтрокаДокументов;
    ОтправитьПисьмо(ДанныеКонтрагента.АдресПочты, "Список документов", ТекстПисьма);
    
КонецЦикла;

Этот подход чрезвычайно прост в отладке, работает со скоростью O(N) в памяти платформы и позволяет легко внедрять любую дополнительную бизнес-логику фильтрации, не усложняя текст SQL-запроса.

Решение №3: Использование временных таблиц при передаче списка параметров

В сообщениях №4 и №8 на форуме прозвучала рекомендация использовать конструкцию отбора Контрагент В (&СписокКонтрагентов). Это рабочее решение, но лишь до тех пор, пока количество контрагентов не превышает нескольких сотен.

Если передать в условие В (&Параметр) массив, содержащий более 1000 элементов, СУБД может аварийно завершить работу, либо построить крайне неэффективный план выполнения (так как СУБД преобразует этот массив в длинный список условий OR).

Профессиональный подход к передаче списков в запрос — использование Временных Таблиц (ВТ). Давайте разберем по шагам, как это реализовать:


// У нас есть исходная ТаблицаЗначений с колонками "Контрагент" и "Адрес"
ТаблицаАдресов = ПолучитьТаблицуАдресов(); 

МенеджерВТ = Новый МенеджерВременныхТаблиц;
Запрос = Новый Запрос;
Запрос.МенеджерВременныхТаблиц = МенеджерВТ;

// Передаем таблицу значений в качестве параметра и помещаем её во временную таблицу ВТАдреса
Запрос.УстановитьПараметр("ВнешняяТаблицаАдресов", ТаблицаАдресов);
Запрос.Текст = 
    "ВЫБРАТЬ
    |    ВТ.Контрагент КАК Контрагент,
    |    ВТ.Адрес КАК Адрес
    |ПОМЕСТИТЬ ВТАдреса
    |ИЗ
    |    &ВнешняяТаблицаАдресов КАК ВТ
    |;
    |
    |////////////////////////////////////////////////////////////////////////////////
    |// Теперь соединяем временную таблицу с базой данных
    |ВЫБРАТЬ
    |    ВТАдреса.Контрагент.НаименованиеПолное КАК КонтрагентНаименование,
    |    ВТАдреса.Адрес КАК АдресПочты,
    |    Док.Ссылка КАК Документ
    |ИЗ
    |    ВТАдреса КАК ВТАдреса
    |    ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.РеализацияТоваровУслуг КАК Док
    |    ПО ВТАдреса.Контрагент = Док.Контрагент
    |ГДЕ
    |    Док.Проведен
    |ИТОГИ ПО
    |    КонтрагентНаименование";

Результат = Запрос.Выполнить();

Использование временных таблиц гарантирует стабильную скорость работы независимо от размера передаваемого массива данных и предохраняет сервер СУБД от перегрузок памяти.

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

Когда вы проектируете алгоритмы массового сбора данных и последующего взаимодействия с внешними веб-сервисами (почта, SMS-шлюзы, API интеграции), придерживайтесь следующих правил:

← На главную