Как избавиться от запросов в цикле и ускорить обработку данных

· 9 мин

Содержание

Обработка подбирает цены по тысяче строк табличной части и висит минуту, хотя сам отчёт пустяковый. Загляните в код — почти наверняка внутри Для Каждого … Цикл стоит Запрос.Выполнить(). Это и есть запрос в цикле: на каждую строку программа отдельно дёргает базу данных, и тысяча строк превращается в тысячу обращений к СУБД.

Лечится это переносом выборки наружу: один запрос достаёт данные сразу по всему набору, а цикл только раскладывает готовый результат. Ниже — как обойти запрос в цикле в 1С 8.3 несколькими способами, от простой замены на выборку с параметром-массивом до пакетов запросов и временных таблиц, и как потом замерить, что код действительно ускорился.

Почему обращение к базе внутри цикла тормозит

Каждый вызов Запрос.Выполнить() — это полный цикл работы с сервером СУБД: сформировать текст, отправить его, дождаться разбора плана, выполнения и возврата строк. Накладные расходы на одно обращение невелики, но в цикле они умножаются на число итераций.

Разница видна на числах. Пусть одно обращение к базе занимает 3 мс:

  • 1 запрос на 1000 строк — около 3 мс плюс время на выборку, итого десятки миллисекунд.
  • 1000 запросов по одной строке — около 3000 мс только на обращения, не считая остального.

Запрос в цикле — не ошибка синтаксиса, а ошибка архитектуры: код работает, но нагрузка растёт линейно с объёмом данных. На тестовой базе из десятка строк проблему вообще не видно, а в бою на реальных объёмах обработка встаёт.

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

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

Замена цикла на выборку с параметром-массивом

Самый частый случай — в цикле по табличной части или массиву для каждого элемента достаётся одно значение: цена, остаток, реквизит справочника. Такой код сворачивается в один запрос с условием В (&Массив).

Было (запрос в цикле):

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

Стало (один запрос на весь набор):

МассивНоменклатуры = Товары.ВыгрузитьКолонку("Номенклатура");

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

ЦеныПоТоварам = Новый Соответствие;
Выборка = Запрос.Выполнить().Выбрать();
Пока Выборка.Следующий() Цикл
    ЦеныПоТоварам.Вставить(Выборка.Номенклатура, Выборка.Цена);
КонецЦикла;

Для Каждого Строка Из Товары Цикл
    Цена = ЦеныПоТоварам.Получить(Строка.Номенклатура);
    Строка.Цена = ?(Цена = Неопределено, 0, Цена);
КонецЦикла;

Логика разбита на три шага:

  1. Собрать массив ключей методом ВыгрузитьКолонку (или перебором, если ключ составной).
  2. Достать всё одним запросом с условием В (&Массив) в параметре виртуальной таблицы.
  3. Переложить результат в Соответствие и в основном цикле брать значение по ключу из памяти.

Соответствие ищет значение по ключу почти мгновенно. Не используйте вместо него поиск ТаблицаЗначений.НайтиСтроки() в цикле — это снова перебор, только не в базе, а в памяти, и на больших наборах он тоже заметно тормозит.

Условие Номенклатура В (&МассивНоменклатуры) правильно ставить прямо в параметры виртуальной таблицы СрезПоследних, а не в ГДЕ после выборки. Так отбор уходит внутрь расчёта среза, и СУБД не считает срез по всему регистру.

Временные таблицы для сложных отборов

Когда отбор идёт не по простому массиву, а по набору пар значений или по данным из табличной части документа, параметра В (&Массив) уже мало. Здесь помогает временная таблица: набор строк из памяти помещается в базу и дальше участвует в соединениях наравне с обычными таблицами.

Временную таблицу создаёт конструкция ПОМЕСТИТЬ ИмяТаблицы на базе МенеджерВременныхТаблиц:

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

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

Дальше во втором запросе того же менеджера таблица ВТ_Строки соединяется с регистрами и справочниками — и всё считается одним проходом на стороне СУБД, без выгрузки в клиент.

Временная таблица оправдана, когда:

  • 🔹 отбор идёт по нескольким полям одновременно (товар + склад + характеристика);
  • 🔹 один и тот же набор строк участвует в нескольких запросах подряд;
  • 🔹 промежуточный результат крупный, и его невыгодно тащить в память клиента.

Явно создавайте индекс по полям соединения: ИНДЕКСИРОВАТЬ ПО Номенклатура, Склад в конце запроса с ПОМЕСТИТЬ. Без индекса СУБД соединяет временную таблицу перебором, и на больших наборах это съедает весь выигрыш.

Чтобы передать в запрос табличную часть документа как источник, параметр типа «таблица значений» должен иметь описанные колонки с типами — иначе платформа не поймёт, какие поля в ней есть. Проще всего выгрузить табличную часть методом Выгрузить() и передать результат в параметр.

Пакет запросов и соединения вместо повторных вызовов

Если в коде идёт несколько запросов подряд — сначала остатки, потом цены, потом свойства — их не обязательно выполнять по очереди. Несколько запросов объединяются в один пакет: тексты разделяются символом ;, а выполняются все разом методом ВыполнитьПакет().

Запрос.Текст =
"ВЫБРАТЬ … ПОМЕСТИТЬ ВТ_Остатки ИЗ …
|;
|ВЫБРАТЬ … ПОМЕСТИТЬ ВТ_Цены ИЗ …
|;
|ВЫБРАТЬ … ИЗ ВТ_Остатки КАК Остатки
|   ЛЕВОЕ СОЕДИНЕНИЕ ВТ_Цены КАК Цены
|   ПО Остатки.Номенклатура = Цены.Номенклатура";

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

Пакет экономит на обращениях и позволяет использовать промежуточные временные таблицы в следующих запросах. ВыполнитьПакет() возвращает массив результатов — по одному на каждый запрос пакета.

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

Запрос.Текст =
"ВЫБРАТЬ
|   Строки.Номенклатура КАК Номенклатура,
|   Номенклатура.Артикул КАК Артикул,
|   ЕСТЬNULL(Остатки.КоличествоОстаток, 0) КАК Остаток
|ИЗ
|   &Строки КАК Строки
|   ЛЕВОЕ СОЕДИНЕНИЕ Справочник.Номенклатура КАК Номенклатура
|       ПО Строки.Номенклатура = Номенклатура.Ссылка
|   ЛЕВОЕ СОЕДИНЕНИЕ РегистрНакопления.ТоварыНаСкладах.Остатки(
|       &Дата, Номенклатура В (&Массив)) КАК Остатки
|       ПО Строки.Номенклатура = Остатки.Номенклатура";

Функция ЕСТЬNULL(Поле, 0) подставляет ноль вместо NULL, когда по товару нет остатка: левое соединение возвращает NULL для несвязанных строк, и без обёртки арифметика с таким полем даст NULL, а не число.

Общее правило выбора приёма:

ПриёмКогда применятьЧто убирает
В (&Массив)отбор по одному полюцикл с одиночными запросами
Временная таблицаотбор по набору полей, многократное использованиеповторные обращения к базе
Пакет запросовнесколько запросов подрядлишние вызовы Выполнить()
Левое соединениенужны реквизиты связанных таблицподзапрос на каждую строку

Оператор ОБЪЕДИНИТЬ ВСЕ складывает строки нескольких выборок в одну, если структура колонок совпадает. ОБЪЕДИНИТЬ без ВСЕ дополнительно убирает дубли строк — это лишняя сортировка, которая замедляет запрос. Если дубли невозможны или не мешают, всегда пишите ОБЪЕДИНИТЬ ВСЕ.

Вложенные циклы и обращения к регистрам

Самый тяжёлый случай — вложенный цикл, когда внутренний перебор ещё и ходит в базу. Например, по каждому заказу перебираются строки, и на каждую строку достаётся остаток. Такой код даёт обращений к СУБД в разы больше числа документов.

Разворачивается он тем же способом, что и обычный цикл, только набор ключей собирается по всем уровням сразу:

  1. Сначала одним запросом достаньте все строки всех заказов (соединением шапки с табличной частью или выборкой из регистра).
  2. Нужные остатки, цены и свойства доберите пакетом по общему массиву номенклатуры.
  3. Разложите результаты по Соответствию или по дереву значений и обходите их уже в памяти.

Для остатков и цен используйте виртуальные таблицы регистров с отбором в параметрах:

  • РегистрНакопления.ТоварыНаСкладах.Остатки(&Дата, Номенклатура В (&Массив)) — остатки на дату сразу по набору товаров.
  • РегистрСведений.ЦеныНоменклатуры.СрезПоследних(&Дата, Номенклатура В (&Массив)) — актуальные цены одним срезом.

Виртуальная таблица остатков сама сворачивает движения до итога на дату. Не считайте остаток вручную циклом по Обороты или по таблице движений — платформа делает это эффективнее, и отбор в параметрах виртуальной таблицы работает быстрее, чем ГДЕ после неё.

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

Отдельно стоит следить за неявными запросами. Обращение к реквизиту ссылки прямо в коде — Строка.Номенклатура.Артикул — платформа при каждом вызове тихо читает объект из базы. В цикле это такой же запрос в цикле, только незаметный. Нужные реквизиты лучше получать запросом заранее через точку (Номенклатура.Артикул в тексте запроса), а в цикле брать их из готовой выборки.

Как замерить, что код стал быстрее

Оптимизацию нельзя вести «на глаз» — нужно видеть время до и после. В 1С для этого есть встроенные инструменты.

Замер производительности в Конфигураторе. Запустите отладку, в меню Отладка → Замер производительности включите замер, выполните обработку, остановите. Платформа покажет таблицу строк кода с числом вызовов и временем каждой строки — обращение в цикле сразу видно по большому числу вызовов.

Таймер в коде. Для грубой оценки на клиенте или сервере обрамите участок замером времени:

Начало = ТекущаяУниверсальнаяДатаВМиллисекундах();
// … оптимизируемый участок …
Длительность = ТекущаяУниверсальнаяДатаВМиллисекундах() - Начало;
Сообщить(СтрШаблон("Выполнено за %1 мс", Формат(Длительность, "ЧГ=0")));

Консоль запросов. Отдельный запрос удобно отлаживать и замерять в консоли запросов: видно текст, план и время выполнения. Это помогает понять, какой именно запрос тяжёлый, ещё до правки кода.

Технологический журнал и счётчики СУБД. В клиент-серверной базе число обращений к базе и длинные запросы видно в технологическом журнале (событие DBMSSQL) и в счётчиках производительности СУБД. Там же ловятся блокировки, которые плодит запрос в цикле.

Поиск по коду. Самые частые места проблемы находятся глобальным поиском в Конфигураторе. Ищите .Выполнить( и Новый Запрос — а затем смотрите, не стоит ли вызов внутри Цикл. Создание объекта Новый Запрос в теле цикла — почти всегда признак, что запрос выполняется на каждой итерации. Такие участки и правьте в первую очередь: именно они дают основной тормоз на больших данных.

Проверка: убрали ли запрос из цикла
Выполнено: 0 / 5

Замеряйте на объёме, близком к боевому. На десятке строк разницы не будет, а проблема проявляется именно на тысячах записей.

Частые вопросы

Сколько значений можно передать в параметр `В (&Массив)`?
Жёсткого лимита в языке запросов нет, но очень большой массив (десятки тысяч значений) СУБД обрабатывает медленно и может упереться в ограничения. Если ключей много, вместо массива используйте временную таблицу с индексом — она масштабируется лучше.
Чем `Соответствие` лучше таблицы значений для хранения результата?
Соответствие ищет по ключу за постоянное время, а ТаблицаЗначений.НайтиСтроки() перебирает строки. В цикле на большом наборе это снова превращается в квадратичную нагрузку, только уже в памяти клиента.
Можно ли передать в запрос табличную часть документа напрямую?
Да, через параметр: выгрузите её методом Выгрузить() и передайте результат. В запросе такой параметр используется как источник ИЗ &Параметр или помещается во временную таблицу. Главное — чтобы у колонок были описаны типы.
Почему отбор лучше ставить в параметры виртуальной таблицы, а не в `ГДЕ`?
Параметры виртуальной таблицы (Остатки, СрезПоследних) применяются до расчёта итогов, поэтому СУБД считает срез или остаток только по нужным записям. Условие в ГДЕ отсекает строки уже после расчёта по всему регистру.
Всегда ли `ОБЪЕДИНИТЬ ВСЕ` быстрее, чем `ОБЪЕДИНИТЬ`?
Да, когда дубли строк не важны. ОБЪЕДИНИТЬ без ВСЕ дополнительно удаляет повторы, а это сортировка и сравнение всех строк. Если повторов быть не может, ВСЕ экономит это время.