Как вынести запрос из цикла и ускорить обработку в 1С

· 10 мин

Содержание

Запрос внутри цикла — самая частая причина, по которой обработка в 1С работает секунды вместо миллисекунд. Лечится одним приёмом: собрать нужные значения в массив, выполнить один запрос с условием В (&Массив) и разложить результат в Соответствие для доступа по ключу. Вместо тысячи обращений к базе — одно.

Ниже — рабочие способы убрать запрос из цикла: от простой выборки всех данных сразу до временных таблиц, пакетных запросов и записи наборами. Плюс как найти такие места в чужом коде через замер производительности.

Код в примерах — для платформы 8.3 (встроенный язык, язык запросов). Приёмы одинаково работают в БП 3.0, ЗУП 3.1, УТ 11 и в любой конфигурации на управляемых формах.

Чем опасен запрос внутри цикла

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

Классический антипаттерн выглядит так:

Для Каждого СтрокаТЧ Из Документ.Товары Цикл

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

	Результат = Запрос.Выполнить();
	// ... обработка одной цены

КонецЦикла;

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

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

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

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

Выбираем все данные одним запросом

Первый и самый частый приём — вынести запрос наружу, передать в него сразу весь список значений и получить всё одним обращением. Собираем ключи из табличной части, передаём массивом:

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

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

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

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

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

СоответствиеЦен = Новый Соответствие;
Для Каждого СтрокаЦен Из ТаблицаЦен Цикл
	СоответствиеЦен.Вставить(СтрокаЦен.Номенклатура, СтрокаЦен.Цена);
КонецЦикла;

// теперь цикл по товарам не трогает базу вообще
Для Каждого СтрокаТЧ Из Документ.Товары Цикл
	Цена = СоответствиеЦен.Получить(СтрокаТЧ.Номенклатура);
	Если Цена <> Неопределено Тогда
		// ... обработка
	КонецЕсли;
КонецЦикла;

Поиск в Соответствие по ключу — операция в памяти, она почти бесплатна. База читается ровно один раз, независимо от числа строк. Если по одному ключу бывает несколько значений, вместо цены кладите в соответствие массив строк или вложенную структуру.

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

Соединяем свои строки с базой через временную таблицу

Когда данных из цикла много или по ним нужно соединение с регистрами, массив в параметре становится неудобным. Тогда табличную часть (или любую таблицу значений) кладут в временную таблицу и соединяют её с данными базы прямо в запросе.

Работает это через МенеджерВременныхТаблиц. Сначала помещаем свои строки во временную таблицу и индексируем поле соединения:

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

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

Чтобы таблицу значений можно было использовать источником в запросе, её колонки должны быть типизированы. Выгрузить() у табличной части даёт типизированную таблицу автоматически; если вы строите таблицу вручную через Новый ТаблицаЗначений, задавайте типы колонок явно через Колонки.Добавить("Номенклатура", Новый ОписаниеТипов("СправочникСсылка.Номенклатура")).

Второй запрос того же менеджера уже соединяет временную таблицу с остатками:

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

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

Индекс по полю соединения ускоряет СОЕДИНЕНИЕ на сервере. ЛЕВОЕ СОЕДИНЕНИЕ плюс ЕСТЬNULL нужны, чтобы строки без остатка не потерялись, а превратились в ноль. Весь расчёт теперь делает СУБД одним проходом — на стороне 1С остаётся только разобрать готовую таблицу.

Пакетные запросы и работа с таблицей значений

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

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

Пакет = Запрос.ВыполнитьПакет();
ТаблицаЦен = Пакет[1].Выгрузить();

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

Результат запроса почти всегда разумно сразу перевести в ТаблицаЗначений через Выгрузить() и дальше работать с ней в памяти: искать, группировать, итожить. По таблице значений можно построить индекс для быстрого поиска:

ТаблицаЦен.Индексы.Добавить("Номенклатура");
НайденныеСтроки = ТаблицаЦен.НайтиСтроки(Новый Структура("Номенклатура", ТекНоменклатура));

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

Запись данных: наборы и транзакции вместо построчной записи

Зеркальная проблема — запись в цикле. Каждый Объект.Записать() или НаборЗаписей.Записать() — это отдельная транзакция с блокировками. В цикле это так же больно, как и чтение.

Движения регистров пишите набором целиком, а не по одной записи:

Набор = РегистрыНакопления.ТоварыНаСкладах.СоздатьНаборЗаписей();
Набор.Отбор.Регистратор.Установить(Ссылка);

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

Набор.Записать(); // одна запись на весь набор

Когда записать нужно много независимых объектов, оберните цикл в явную транзакцию — тогда платформа зафиксирует изменения одним блоком, а не по штуке:

НачатьТранзакцию();
Попытка
	Для Каждого Элемент Из МассивОбъектов Цикл
		Объект = Элемент.ПолучитьОбъект();
		Объект.Реквизит = НовоеЗначение;
		Объект.Записать();
	КонецЦикла;
	ЗафиксироватьТранзакцию();
Исключение
	ОтменитьТранзакцию();
	ВызватьИсключение;
КонецПопытки;

Длинная транзакция держит блокировки до самого ЗафиксироватьТранзакцию(). Если в неё завернуть десятки тысяч объектов, другие пользователи будут ждать. Пишите разумными порциями — например, по 500–1000 объектов на транзакцию — и давайте блокировкам освобождаться между пачками.

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

Чек-лист: выносим запрос из цикла
Выполнено: 0 / 7

Какой способ под какую задачу

Три приёма выше решают одну задачу по-разному. Выбор зависит от того, сколько ключей и нужно ли соединение с регистрами.

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

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

Отдельно стоит массовое чтение реквизитов ссылок. Если в цикле вы обращаетесь к Элемент.Контрагент.ИНН и Элемент.Контрагент.КПП, каждое обращение через точку — скрытый запрос. Такие реквизиты тоже выбирают одним запросом заранее или читают через ОбщегоНазначения.ЗначенияРеквизитовОбъектов(), который сам делает одно обращение на пачку ссылок.

Как найти запросы в циклах в готовом коде

Чужой код сам не подскажет, где прячется запрос в цикле. Искать нужно замером, а не чтением модуля глазами.

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

Поиск по тексту модулей. Грубая, но быстрая проверка — поиск по конфигурации. Ищите .Выполнить() и ПолучитьОбъект() и смотрите глазами, не стоят ли они внутри Цикл. Это не заменяет замер, но быстро отсеивает очевидные случаи.

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

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

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

Тестируйте только на реальных объёмах. На документе из двух строк разница между «одним запросом» и «запросом в цикле» незаметна — проблема проявляется на сотнях и тысячах строк. Снимайте замер на боевых данных (на копии базы), а не на пустой демо-базе.

Когда запрос в цикле допустим

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

Критерий — замер. Оптимизируют то, что реально тормозит и подтверждено замером производительности, а не каждый цикл подряд «на всякий случай». Сначала находим узкое место, потом правим именно его.

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

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

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