Как избавиться от запросов в цикле и ускорить обработку данных
Содержание
Обработка подбирает цены по тысяче строк табличной части и висит минуту, хотя сам отчёт пустяковый. Загляните в код — почти наверняка внутри Для Каждого … Цикл стоит Запрос.Выполнить(). Это и есть запрос в цикле: на каждую строку программа отдельно дёргает базу данных, и тысяча строк превращается в тысячу обращений к СУБД.
Лечится это переносом выборки наружу: один запрос достаёт данные сразу по всему набору, а цикл только раскладывает готовый результат. Ниже — как обойти запрос в цикле в 1С 8.3 несколькими способами, от простой замены на выборку с параметром-массивом до пакетов запросов и временных таблиц, и как потом замерить, что код действительно ускорился.
Почему обращение к базе внутри цикла тормозит
Каждый вызов Запрос.Выполнить() — это полный цикл работы с сервером СУБД: сформировать текст, отправить его, дождаться разбора плана, выполнения и возврата строк. Накладные расходы на одно обращение невелики, но в цикле они умножаются на число итераций.
Разница видна на числах. Пусть одно обращение к базе занимает 3 мс:
- 1 запрос на 1000 строк — около 3 мс плюс время на выборку, итого десятки миллисекунд.
- 1000 запросов по одной строке — около 3000 мс только на обращения, не считая остального.
Запрос в цикле — не ошибка синтаксиса, а ошибка архитектуры: код работает, но нагрузка растёт линейно с объёмом данных. На тестовой базе из десятка строк проблему вообще не видно, а в бою на реальных объёмах обработка встаёт.
Особенно опасны обращения в цикле в серверном коде, который крутится под многими пользователями: лишние запросы забивают СУБД блокировками и ожиданиями, и тормозит уже вся база, а не одна обработка.
Базовый принцип оптимизации один: данные набираем пакетом, обработку ведём в памяти. Платформа и СУБД эффективно работают с множествами строк — им выгоднее отдать один запрос на весь набор, чем тысячу мелких.
Замена цикла на выборку с параметром-массивом
Самый частый случай — в цикле по табличной части или массиву для каждого элемента достаётся одно значение: цена, остаток, реквизит справочника. Такой код сворачивается в один запрос с условием В (&Массив).
Было (запрос в цикле):
Для Каждого Строка Из Товары Цикл
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ Цена ИЗ РегистрСведений.ЦеныНоменклатуры.СрезПоследних(
| &Дата, Номенклатура = &Номенклатура) КАК Цены";
Запрос.УстановитьПараметр("Дата", ДатаЦен);
Запрос.УстановитьПараметр("Номенклатура", Строка.Номенклатура);
Выборка = Запрос.Выполнить().Выбрать();
Если Выборка.Следующий() Тогда
Строка.Цена = Выборка.Цена;
КонецЕсли;
КонецЦикла;
Стало (один запрос на весь набор):
МассивНоменклатуры = Товары.ВыгрузитьКолонку("Номенклатура");
Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ Номенклатура, Цена ИЗ РегистрСведений.ЦеныНоменклатуры.СрезПоследних(
| &Дата, Номенклатура В (&МассивНоменклатуры)) КАК Цены";
Запрос.УстановитьПараметр("Дата", ДатаЦен);
Запрос.УстановитьПараметр("МассивНоменклатуры", МассивНоменклатуры);
ЦеныПоТоварам = Новый Соответствие;
Выборка = Запрос.Выполнить().Выбрать();
Пока Выборка.Следующий() Цикл
ЦеныПоТоварам.Вставить(Выборка.Номенклатура, Выборка.Цена);
КонецЦикла;
Для Каждого Строка Из Товары Цикл
Цена = ЦеныПоТоварам.Получить(Строка.Номенклатура);
Строка.Цена = ?(Цена = Неопределено, 0, Цена);
КонецЦикла;
Логика разбита на три шага:
- Собрать массив ключей методом
ВыгрузитьКолонку(или перебором, если ключ составной). - Достать всё одним запросом с условием
В (&Массив)в параметре виртуальной таблицы. - Переложить результат в
Соответствиеи в основном цикле брать значение по ключу из памяти.
Соответствие ищет значение по ключу почти мгновенно. Не используйте вместо него поиск ТаблицаЗначений.НайтиСтроки() в цикле — это снова перебор, только не в базе, а в памяти, и на больших наборах он тоже заметно тормозит.
Условие Номенклатура В (&МассивНоменклатуры) правильно ставить прямо в параметры виртуальной таблицы СрезПоследних, а не в ГДЕ после выборки. Так отбор уходит внутрь расчёта среза, и СУБД не считает срез по всему регистру.
Временные таблицы для сложных отборов
Когда отбор идёт не по простому массиву, а по набору пар значений или по данным из табличной части документа, параметра В (&Массив) уже мало. Здесь помогает временная таблица: набор строк из памяти помещается в базу и дальше участвует в соединениях наравне с обычными таблицами.
Временную таблицу создаёт конструкция ПОМЕСТИТЬ ИмяТаблицы на базе МенеджерВременныхТаблиц:
МенеджерВТ = Новый МенеджерВременныхТаблиц;
Запрос = Новый Запрос;
Запрос.МенеджерВременныхТаблиц = МенеджерВТ;
Запрос.Текст =
"ВЫБРАТЬ
| Товары.Номенклатура КАК Номенклатура,
| Товары.Склад КАК Склад,
| Товары.Количество КАК Количество
|ПОМЕСТИТЬ ВТ_Строки
|ИЗ
| &Товары КАК Товары";
Запрос.УстановитьПараметр("Товары", ТаблицаТоваров);
Запрос.Выполнить();
Дальше во втором запросе того же менеджера таблица ВТ_Строки соединяется с регистрами и справочниками — и всё считается одним проходом на стороне СУБД, без выгрузки в клиент.
Временная таблица оправдана, когда:
- 🔹 отбор идёт по нескольким полям одновременно (товар + склад + характеристика);
- 🔹 один и тот же набор строк участвует в нескольких запросах подряд;
- 🔹 промежуточный результат крупный, и его невыгодно тащить в память клиента.
Явно создавайте индекс по полям соединения: ИНДЕКСИРОВАТЬ ПО Номенклатура, Склад в конце запроса с ПОМЕСТИТЬ. Без индекса СУБД соединяет временную таблицу перебором, и на больших наборах это съедает весь выигрыш.
Чтобы передать в запрос табличную часть документа как источник, параметр типа «таблица значений» должен иметь описанные колонки с типами — иначе платформа не поймёт, какие поля в ней есть. Проще всего выгрузить табличную часть методом Выгрузить() и передать результат в параметр.
Пакет запросов и соединения вместо повторных вызовов
Если в коде идёт несколько запросов подряд — сначала остатки, потом цены, потом свойства — их не обязательно выполнять по очереди. Несколько запросов объединяются в один пакет: тексты разделяются символом ;, а выполняются все разом методом ВыполнитьПакет().
Запрос.Текст =
"ВЫБРАТЬ … ПОМЕСТИТЬ ВТ_Остатки ИЗ …
|;
|ВЫБРАТЬ … ПОМЕСТИТЬ ВТ_Цены ИЗ …
|;
|ВЫБРАТЬ … ИЗ ВТ_Остатки КАК Остатки
| ЛЕВОЕ СОЕДИНЕНИЕ ВТ_Цены КАК Цены
| ПО Остатки.Номенклатура = Цены.Номенклатура";
РезультатыПакета = Запрос.ВыполнитьПакет();
Пакет экономит на обращениях и позволяет использовать промежуточные временные таблицы в следующих запросах. ВыполнитьПакет() возвращает массив результатов — по одному на каждый запрос пакета.
Отдельный приём — собирать разнородные данные не несколькими запросами, а соединениями в одном. Там, где раньше был цикл с подзапросом на каждую строку, делаем левое соединение основной таблицы со справочником или регистром. Например, вместо того чтобы в цикле по строкам заказа доставать по каждой номенклатуре её артикул и остаток, берём всё разом:
Запрос.Текст =
"ВЫБРАТЬ
| Строки.Номенклатура КАК Номенклатура,
| Номенклатура.Артикул КАК Артикул,
| ЕСТЬNULL(Остатки.КоличествоОстаток, 0) КАК Остаток
|ИЗ
| &Строки КАК Строки
| ЛЕВОЕ СОЕДИНЕНИЕ Справочник.Номенклатура КАК Номенклатура
| ПО Строки.Номенклатура = Номенклатура.Ссылка
| ЛЕВОЕ СОЕДИНЕНИЕ РегистрНакопления.ТоварыНаСкладах.Остатки(
| &Дата, Номенклатура В (&Массив)) КАК Остатки
| ПО Строки.Номенклатура = Остатки.Номенклатура";
Функция ЕСТЬNULL(Поле, 0) подставляет ноль вместо NULL, когда по товару нет остатка: левое соединение возвращает NULL для несвязанных строк, и без обёртки арифметика с таким полем даст NULL, а не число.
Общее правило выбора приёма:
| Приём | Когда применять | Что убирает |
|---|---|---|
В (&Массив) | отбор по одному полю | цикл с одиночными запросами |
| Временная таблица | отбор по набору полей, многократное использование | повторные обращения к базе |
| Пакет запросов | несколько запросов подряд | лишние вызовы Выполнить() |
| Левое соединение | нужны реквизиты связанных таблиц | подзапрос на каждую строку |
Оператор ОБЪЕДИНИТЬ ВСЕ складывает строки нескольких выборок в одну, если структура колонок совпадает. ОБЪЕДИНИТЬ без ВСЕ дополнительно убирает дубли строк — это лишняя сортировка, которая замедляет запрос. Если дубли невозможны или не мешают, всегда пишите ОБЪЕДИНИТЬ ВСЕ.
Вложенные циклы и обращения к регистрам
Самый тяжёлый случай — вложенный цикл, когда внутренний перебор ещё и ходит в базу. Например, по каждому заказу перебираются строки, и на каждую строку достаётся остаток. Такой код даёт обращений к СУБД в разы больше числа документов.
Разворачивается он тем же способом, что и обычный цикл, только набор ключей собирается по всем уровням сразу:
- Сначала одним запросом достаньте все строки всех заказов (соединением шапки с табличной частью или выборкой из регистра).
- Нужные остатки, цены и свойства доберите пакетом по общему массиву номенклатуры.
- Разложите результаты по
Соответствиюили по дереву значений и обходите их уже в памяти.
Для остатков и цен используйте виртуальные таблицы регистров с отбором в параметрах:
РегистрНакопления.ТоварыНаСкладах.Остатки(&Дата, Номенклатура В (&Массив))— остатки на дату сразу по набору товаров.РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Дата, Номенклатура В (&Массив))— актуальные цены одним срезом.
Виртуальная таблица остатков сама сворачивает движения до итога на дату. Не считайте остаток вручную циклом по Обороты или по таблице движений — платформа делает это эффективнее, и отбор в параметрах виртуальной таблицы работает быстрее, чем ГДЕ после неё.
Если без вложенного обхода не обойтись (например, нужна иерархия), стройте дерево значений в памяти методом ВыбратьИерархически() по уже полученной выборке, а не запросом на каждый узел.
Отдельно стоит следить за неявными запросами. Обращение к реквизиту ссылки прямо в коде — Строка.Номенклатура.Артикул — платформа при каждом вызове тихо читает объект из базы. В цикле это такой же запрос в цикле, только незаметный. Нужные реквизиты лучше получать запросом заранее через точку (Номенклатура.Артикул в тексте запроса), а в цикле брать их из готовой выборки.
Как замерить, что код стал быстрее
Оптимизацию нельзя вести «на глаз» — нужно видеть время до и после. В 1С для этого есть встроенные инструменты.
Замер производительности в Конфигураторе. Запустите отладку, в меню Отладка → Замер производительности включите замер, выполните обработку, остановите. Платформа покажет таблицу строк кода с числом вызовов и временем каждой строки — обращение в цикле сразу видно по большому числу вызовов.
Таймер в коде. Для грубой оценки на клиенте или сервере обрамите участок замером времени:
Начало = ТекущаяУниверсальнаяДатаВМиллисекундах();
// … оптимизируемый участок …
Длительность = ТекущаяУниверсальнаяДатаВМиллисекундах() - Начало;
Сообщить(СтрШаблон("Выполнено за %1 мс", Формат(Длительность, "ЧГ=0")));
Консоль запросов. Отдельный запрос удобно отлаживать и замерять в консоли запросов: видно текст, план и время выполнения. Это помогает понять, какой именно запрос тяжёлый, ещё до правки кода.
Технологический журнал и счётчики СУБД. В клиент-серверной базе число обращений к базе и длинные запросы видно в технологическом журнале (событие DBMSSQL) и в счётчиках производительности СУБД. Там же ловятся блокировки, которые плодит запрос в цикле.
Поиск по коду. Самые частые места проблемы находятся глобальным поиском в Конфигураторе. Ищите .Выполнить( и Новый Запрос — а затем смотрите, не стоит ли вызов внутри Цикл. Создание объекта Новый Запрос в теле цикла — почти всегда признак, что запрос выполняется на каждой итерации. Такие участки и правьте в первую очередь: именно они дают основной тормоз на больших данных.
Замеряйте на объёме, близком к боевому. На десятке строк разницы не будет, а проблема проявляется именно на тысячах записей.
Частые вопросы
Сколько значений можно передать в параметр `В (&Массив)`?
Чем `Соответствие` лучше таблицы значений для хранения результата?
Соответствие ищет по ключу за постоянное время, а ТаблицаЗначений.НайтиСтроки() перебирает строки. В цикле на большом наборе это снова превращается в квадратичную нагрузку, только уже в памяти клиента.Можно ли передать в запрос табличную часть документа напрямую?
Выгрузить() и передайте результат. В запросе такой параметр используется как источник ИЗ &Параметр или помещается во временную таблицу. Главное — чтобы у колонок были описаны типы.Почему отбор лучше ставить в параметры виртуальной таблицы, а не в `ГДЕ`?
Остатки, СрезПоследних) применяются до расчёта итогов, поэтому СУБД считает срез или остаток только по нужным записям. Условие в ГДЕ отсекает строки уже после расчёта по всему регистру.Всегда ли `ОБЪЕДИНИТЬ ВСЕ` быстрее, чем `ОБЪЕДИНИТЬ`?
ОБЪЕДИНИТЬ без ВСЕ дополнительно удаляет повторы, а это сортировка и сравнение всех строк. Если повторов быть не может, ВСЕ экономит это время.