В практике разработки на платформе 1С:Предприятие одной из наиболее частых и критических ошибок производительности является выполнение запроса в цикле (также известная как проблема N+1). Обычно это происходит, когда разработчику необходимо получить связанные данные: например, сначала выбрать список контрагентов, а затем для каждого из них в цикле получать список документов, остатки или контактную информацию. Такой подход перегружает СУБД постоянными мелкими транзакциями, генерирует избыточный сетевой трафик и драматически снижает скорость работы системы.
В рамках этой статьи мы подробно разберем, как решить задачу пакетного сбора данных для рассылки одним запросом. Мы проанализируем классическое решение с использованием конструкции ИТОГИ ПО, исправим скрытые синтаксические и логические ошибки в кодовой базе форума, а также рассмотрим профессиональные альтернативы, такие как группировка данных в памяти через объект Соответствие и использование временных таблиц.
Представим сценарий: нам нужно отправить электронные письма 100 контрагентам. Для каждого контрагента требуется собрать список его неоплаченных документов. Если мы выполняем запрос в цикле:
Для Каждого СтрокаКонтрагент Из ТаблицаКонтрагентов Цикл
Запрос = Новый Запрос;
Запрос.Текст = "ВЫБРАТЬ ... ГДЕ Контрагент = &Контрагент";
Запрос.УстановитьПараметр("Контрагент", СтрокаКонтрагент.Ссылка);
// Выполнение и обработка...
КонецЦикла;
В этом случае платформа совершит 100 отдельных обращений к СУБД. Если контрагентов станет 1000, то обращений будет 1000. База данных тратит колоссальные ресурсы на компиляцию плана запроса, открытие и закрытие соединений, а также организацию транзакционных блокировок. Правильный подход — получить все данные одним запросом, а затем распределить их программно.
Рассмотрим, как реализовать это правильно.
Самый надежный способ связать «один ко многим» на уровне запроса 1С — использовать предложение ИТОГИ ПО. Оно позволяет структурировать выборку в виде дерева, где на верхнем уровне будут находиться родительские сущности (контрагенты), а на подчиненном уровне — связанные с ними детальные записи (документы).
Проанализируем пример запроса, объединяющего данные контрагентов, их email-адреса и список документов, требующих обработки:
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| СтатусДокументов.Документ.Контрагент КАК Контрагент,
| СтатусДокументов.Документ.Контрагент.АдресЭлектроннойПочты КАК АдресПочты,
| СтатусДокументов.Документ КАК ДокументСсылка,
| СтатусДокументов.Документ.Номер КАК НомерДокумента
|ИЗ
| РегистрСведений.СтатусДокумента.СрезПоследних(&Период, Статус = ЗНАЧЕНИЕ(Перечисление.СтатусыДокументов.СтопОплата)) КАК СтатусДокументов
|ГДЕ
| СтатусДокументов.Документ.Проведен
|УПОРЯДОЧИТЬ ПО
| Контрагент,
| НомерДокумента
|ИТОГИ ПО
| Контрагент";
Обратите внимание на секцию ИТОГИ ПО Контрагент. Именно она указывает платформе, что результат нужно сгруппировать по контрагентам. Теперь разберем, как правильно обойти этот результат в коде 1С. Для этого используется специальный метод Выбрать() с параметром ОбходРезультатаЗапроса.ПоГруппировкам.
Рассмотрим пошаговый алгоритм обхода:
РезультатЗапроса = Запрос.Выполнить();
// Шаг 1. Получаем выборку верхнего уровня (Группировка по Контрагентам)
ВыборкаКонтрагенты = РезультатЗапроса.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам, "Контрагент");
Пока ВыборкаКонтрагенты.Следующий() Цикл
// Получаем почту и наименование текущего контрагента из полей группировки
ТекущийКонтрагент = ВыборкаКонтрагенты.Контрагент;
АдресПолучателя = ВыборкаКонтрагенты.АдресПочты;
Если Не ЗначениеЗаполнено(АдресПолучателя) Тогда
ЗаписьЖурналаРегистрации("Рассылка", УровеньЖурналаРегистрации.Предупреждение, , ,
"Не найден email для контрагента: " + СокрЛП(ТекущийКонтрагент));
Продолжить;
КонецЕсли;
СписокДокументовТекстом = "";
// Шаг 2. Получаем подчиненную выборку детальных записей по текущему контрагенту
ВыборкаДетали = ВыборкаКонтрагенты.Выбрать();
Пока ВыборкаДетали.Следующий() Цикл
СписокДокументовТекстом = СписокДокументовТекстом + " " + СокрЛП(ВыборкаДетали.НомерДокумента) + ";";
КонецЦикла;
// Шаг 3. Формируем и отправляем письмо
ТемаПисьма = "Уведомление о приостановке оплат";
ТекстПисьма = "Уважаемый " + СокрЛП(ТекущийContragent) + "! Ваши документы на стоп-оплате:" + СписокДокументовТекстом;
ОтправитьПисьмо(АдресПолучателя, ТемаПисьма, ТекстПисьма);
КонецЦикла;
При таком подходе мы делаем ровно один запрос к базе данных, независимо от количества контрагентов и документов в системе. Платформа берет на себя всю работу по структурированию дерева выборки.
В сообщении №17 автор темы привел свой итоговый вариант реализации, который содержит несколько критических ошибок, способных вызвать падение системы в реальной среде эксплуатации. Проанализируем эти ошибки, чтобы не допускать их в своей практике:
ТекстПисьма = "Уважаемый " + ДокументКонтрагентНаименованиеПолное.... Это вызовет ошибку "Переменная не определена". К любым переменным запроса при иерархическом обходе необходимо обращаться строго через объект выборки, то есть: РезультатЗапроса.ДокументКонтрагентНаименованиеПолное (или ВыборкаКонтрагенты.ДокументКонтрагентНаименованиеПолное).ИЗ указан псевдоним СтатусДокумента, а в условиях ГДЕ автор обращается к несуществующей таблице СтатусДокументаСрезПоследних. Это приведет к синтаксической ошибке при компиляции запроса.УПОРЯДОЧИТЬ ПО используется поле ВходящаяДата, которое забыли включить в секцию ВЫБРАТЬ. Платформа 1С не позволит выполнить такой запрос.ОправитьПисьмо(...) вместо ОтправитьПисьмо(...).Ниже представлен исправленный, гарантированно рабочий вариант этого кода:
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| СтатусДокумента.Документ.Контрагент.НаименованиеПолное КАК КонтрагентНаименование,
| СтатусДокумента.Документ.Контрагент.АдресЭлектроннойПочты КАК АдресПочты,
| СтатусДокумента.Документ.ВходящийНомер КАК ВходящийНомер,
| СтатусДокумента.Документ.ВходящаяДата КАК ВходящаяДата
|ИЗ
| РегистрСведений.СтатусДокумента.СрезПоследних КАК СтатусДокумента
|ГДЕ
| СтатусДокумента.Документ.Проведен
| И СтатусДокумента.Статус = ЗНАЧЕНИЕ(Перечисление.СтатусыДокументов.СтопОплата)
|УПОРЯДОЧИТЬ ПО
| КонтрагентНаименование,
| ВходящаяДата
|ИТОГИ ПО
| КонтрагентНаименование,
| АдресПочты";
РезультатЗапроса = Запрос.Выполнить();
ВыборкаКонтрагентов = РезультатЗапроса.Выбрать(ОбходРезультатаЗапроса.ПоГруппировкам, "КонтрагентНаименование");
Пока ВыборкаКонтрагентов.Следующий() Цикл
// Получаем адрес почты из группировки (обращение через объект ВыборкаКонтрагентов!)
АдресЭлектроннойПочты = ВыборкаКонтрагентов.АдресПочты;
Если Не ЗначениеЗаполнено(АдресЭлектроннойПочты) Тогда
Сообщить("Пропущена отправка: нет адреса для " + ВыборкаКонтрагентов.КонтрагентНаименование);
Продолжить;
КонецЕсли;
ТекстПисьма = "Уважаемый " + ВыборкаКонтрагентов.КонтрагентНаименование + "! Ваши документы со статусом Стоп-Оплата: ";
ВыборкаДокументов = ВыборкаКонтрагентов.Выбрать();
Пока ВыборкаДокументов.Следующий() Цикл
ТекстПисьма = ТекстПисьма + " №" + ВыборкаДокументов.ВходящийНомер + " от " + Формат(ВыборкаДокументов.ВходящаяДата, "ДФ=dd.MM.yyyy") + ";";
КонецЦикла;
ОтправитьПисьмо(АдресЭлектроннойПочты, "Важная информация", ТекстПисьма);
КонецЦикла;
Хотя исправленный запрос работает корректно и решает проблему запроса в цикле, у него есть скрытая проблема производительности — неявные соединения (обращения через точку).
Конструкция вида СтатусДокумента.Документ.Контрагент.НаименованиеПолное заставляет СУБД строить каскад из нескольких последовательных левых соединений (LEFT JOIN): от регистра сведений к документу, затем от документа к справочнику Контрагенты. Если база данных содержит сотни тысяч записей, такой запрос начнет работать крайне медленно.
Выясним причину замедления и оптимизируем запрос. Для этого заменим неявные обращения явными левыми соединениями и перенесем фильтр по статусу в параметры виртуальной таблицы СрезПоследних. Это позволит СУБД отсечь ненужные записи на самом раннем этапе выполнения запроса.
Рассмотрим оптимизированный текст запроса:
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| СпрКонтрагенты.НаименованиеПолное КАК КонтрагентНаименование,
| СпрКонтрагенты.АдресЭлектроннойПочты КАК АдресПочты,
| Док.ВходящийНомер КАК ВходящийНомер,
| Док.ВходящаяДата КАК ВходящаяДата
|ИЗ
| РегистрСведений.СтатусДокумента.СрезПоследних(, Статус = ЗНАЧЕНИЕ(Перечисление.СтатусыДокументов.СтопОплата)) КАК СтатусДокумента
|
| // Явное соединение с таблицей документов
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.ВходящийДокумент КАК Док
| ПО СтатусДокумента.Документ = Док.Ссылка
|
| // Явное соединение со справочником Контрагенты
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.Контрагенты КАК СпрКонтрагенты
| ПО Док.Контрагент = СпрКонтрагенты.Ссылка
|ГДЕ
| Док.Проведен
|УПОРЯДОЧИТЬ ПО
| КонтрагентНаименование,
| ВходящаяДата
|ИТОГИ ПО
| КонтрагентНаименование,
| АдресПочты";
Преимущества этого решения:
ВНУТРЕННЕЕ СОЕДИНЕНИЕ вместо неявных точек гарантирует, что оптимизатор запросов СУБД построит наиболее эффективный план выполнения.Иногда иерархический обход с конструкцией ИТОГИ ПО кажется разработчикам слишком громоздким для отладки. Существует элегантное альтернативное решение — выгрузить плоский результат простого запроса в таблицу значений и сгруппировать его в памяти 1С с помощью объекта Соответствие.
Суть метода заключается в следующем: мы обходим плоскую таблицу документов и для каждого уникального контрагента создаем ключ в объекте Соответствие, значением которого будет массив связанных документов.
Рассмотрим, как реализовать этот подход по шагам:
// 1. Получаем плоский список данных (без ИТОГИ ПО)
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
| СпрКонтрагенты.Ссылка КАК Контрагент,
| СпрКонтрагенты.АдресЭлектроннойПочты КАК АдресПочты,
| Док.ВходящийНомер КАК ВходящийНомер
|ИЗ
| РегистрСведений.СтатусДокумента.СрезПоследних(, Статус = ЗНАЧЕНИЕ(Перечисление.СтатусыДокументов.СтопОплата)) КАК СтатусДокумента
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.ВходящийДокумент КАК Док ПО СтатусДокумента.Документ = Док.Ссылка
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Справочник.Контрагенты КАК СпрКонтрагенты ПО Док.Контрагент = СпрКонтрагенты.Ссылка
|ГДЕ
| Док.Проведен";
ТаблицаДанных = Запрос.Выполнить().Выгрузить();
// 2. Группируем данные в памяти с помощью Соответствия
КартаРассылки = Новый Соответствие;
Для Каждого СтрокаТаблицы Из ТаблицаДанных Цикл
СтруктураКонтрагента = КартаРассылки.Получить(СтрокаТаблицы.Контрагент);
Если СтруктураКонтрагента = Неопределено Тогда
// Если контрагента еще нет в карте, инициализируем структуру
СтруктураКонтрагента = Новый Структура;
СтруктураКонтрагента.Вставить("АдресПочты", СтрокаТаблицы.АдресПочты);
СтруктураКонтрагента.Вставить("СписокНомеров", Новый Массив);
КартаРассылки.Вставить(СтрокаТаблицы.Контрагент, СтруктураКонтрагента);
КонецЕсли;
СтруктураКонтрагента.СписокНомеров.Добавить(СтрокаТаблицы.ВходящийНомер);
КонецЦикла;
// 3. Выполняем рассылку по сгруппированным данным
Для Каждого КлючИЗначение Из КартаРассылки Цикл
ТекущийКонтрагент = КлючИЗначение.Ключ;
ДанныеКонтрагента = КлючИЗначение.Значение;
Если Не ЗначениеЗаполнено(ДанныеКонтрагента.АдресПочты) Тогда
Продолжить;
КонецЕсли;
// Сворачиваем массив номеров документов в одну строку
СтрокаДокументов = СтрСоединить(ДанныеКонтрагента.СписокНомеров, ", ");
ТекстПисьма = "Добрый день, " + ТекущийКонтрагент.Наименование + "! Ваши документы: " + СтрокаДокументов;
ОтправитьПисьмо(ДанныеКонтрагента.АдресПочты, "Список документов", ТекстПисьма);
КонецЦикла;
Этот подход чрезвычайно прост в отладке, работает со скоростью O(N) в памяти платформы и позволяет легко внедрять любую дополнительную бизнес-логику фильтрации, не усложняя текст SQL-запроса.
В сообщениях №4 и №8 на форуме прозвучала рекомендация использовать конструкцию отбора Контрагент В (&СписокКонтрагентов). Это рабочее решение, но лишь до тех пор, пока количество контрагентов не превышает нескольких сотен.
Если передать в условие В (&Параметр) массив, содержащий более 1000 элементов, СУБД может аварийно завершить работу, либо построить крайне неэффективный план выполнения (так как СУБД преобразует этот массив в длинный список условий OR).
Профессиональный подход к передаче списков в запрос — использование Временных Таблиц (ВТ). Давайте разберем по шагам, как это реализовать:
// У нас есть исходная ТаблицаЗначений с колонками "Контрагент" и "Адрес"
ТаблицаАдресов = ПолучитьТаблицуАдресов();
МенеджерВТ = Новый МенеджерВременныхТаблиц;
Запрос = Новый Запрос;
Запрос.МенеджерВременныхТаблиц = МенеджерВТ;
// Передаем таблицу значений в качестве параметра и помещаем её во временную таблицу ВТАдреса
Запрос.УстановитьПараметр("ВнешняяТаблицаАдресов", ТаблицаАдресов);
Запрос.Текст =
"ВЫБРАТЬ
| ВТ.Контрагент КАК Контрагент,
| ВТ.Адрес КАК Адрес
|ПОМЕСТИТЬ ВТАдреса
|ИЗ
| &ВнешняяТаблицаАдресов КАК ВТ
|;
|
|////////////////////////////////////////////////////////////////////////////////
|// Теперь соединяем временную таблицу с базой данных
|ВЫБРАТЬ
| ВТАдреса.Контрагент.НаименованиеПолное КАК КонтрагентНаименование,
| ВТАдреса.Адрес КАК АдресПочты,
| Док.Ссылка КАК Документ
|ИЗ
| ВТАдреса КАК ВТАдреса
| ВНУТРЕННЕЕ СОЕДИНЕНИЕ Документ.РеализацияТоваровУслуг КАК Док
| ПО ВТАдреса.Контрагент = Док.Контрагент
|ГДЕ
| Док.Проведен
|ИТОГИ ПО
| КонтрагентНаименование";
Результат = Запрос.Выполнить();
Использование временных таблиц гарантирует стабильную скорость работы независимо от размера передаваемого массива данных и предохраняет сервер СУБД от перегрузок памяти.
Когда вы проектируете алгоритмы массового сбора данных и последующего взаимодействия с внешними веб-сервисами (почта, SMS-шлюзы, API интеграции), придерживайтесь следующих правил:
Попытка...Исключение, чтобы сбой при отправке сообщения одному контрагенту не прерывал рассылку всем остальным адресатам в цикле.