Запрос в цикле 1С: почему линтер ловит не то и как чинить по-настоящему

Сервис обмена с кассами лежал. Не падал — лежал. На каждое обращение от кассового сервера ответ приходил через 12–18 секунд, при норме в 1–2. Касса в это время не пробивает чеки. Очередь в торговом зале растёт. Старший смены звонит в IT. IT звонит нам.

Подключаемся, открываем технологический журнал, смотрим длительные вызовы. Один HTTP-метод обработчика обмена — 47 тысяч запросов к базе на одно обращение. Не сорок семь — сорок семь тысяч. Внутри — классика: Новый Запрос в цикле, выполнение в цикле, разбор результата в цикле, и так по каждому документу из пакета.

Самое неприятное — линтер на CI это уже ловил. Правило CreateQueryInCycle срабатывало на этом модуле каждую сборку. Просто на него перестали смотреть.

Почему запрос в цикле — это всегда плохо

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

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

Решение, которое подсказывает любой учебник, — собрать все условия отбора в один запрос. Сделать пакетный запрос или временную таблицу с параметрами. Получить весь набор данных одним обращением и дальше уже в цикле обрабатывать выборку, которая лежит в памяти. Это работает в 90% случаев. И это первое, что нужно проверить, открыв подозрительный модуль.

Но бывают оставшиеся 10%. Про них и поговорим.

Сравнение запросов 1С — N запросов в цикле против одного пакетного запроса

Кейс: 2400 строк, цикломатическая сложность 369

Возвращаемся к нашему HTTP-сервису. Один обработчик принимает массив документов от кассы. Внутри — чеки ККМ, перемещения между точками, заказы интернет-магазина, ценники, РКО, учёт рабочего времени. Шесть типов документов в одном вызове. Каждый — со своей логикой: где-то нужно найти контрагента по ИНН, где-то — серию маркировки по штрихкоду, где-то — распределить по партиям FIFO.

Линтер показывает десять срабатываний правила CreateQueryInCycle. Открываем модуль — и понимаем, почему его никто не чинит. Одна функция на 2400 строк. Цикломатическая сложность — 369. Это значит, что в функции 369 ветвлений. Любой человек, который туда заглядывает, выходит обратно через минуту.

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

Разбили монолит на восемь функций по типам документов. Вынесли повторяющуюся логику в хелперы: поиск единиц прослеживаемости, распределение по партиям FIFO, декодирование маркировки. Цикломатическая сложность каждой функции опустилась ниже 20. Заодно вычистили пустые блоки Исключение, добавили недостающие ветки Иначе, убрали неиспользуемые переменные.

Минус 500 строк. Большинство диагностик линтера исчезли просто за счёт того, что код стал читаемым. Осталось одно: то самое CreateQueryInCycle, и теперь видно, на каких именно местах.

Вынос объекта запроса перед циклом — это ещё не починка

Правило CreateQueryInCycle формулируется прямолинейно: не создавай объект Запрос внутри цикла. Создай его перед, а внутри только устанавливай параметры и выполняй. Логика понятна — создание объекта дороже, чем установка параметра.

Пошли по всем десяти срабатываниям. Вынесли Новый Запрос и присвоение текста перед циклом. Внутри оставили УстановитьПараметр и Выполнить. Перезапустили сборку.

Диагностика на месте.

Открываем исходники BSL Language Server, смотрим, как реализовано правило. Оказывается, оно ловит не только Новый Запрос внутри цикла, но и любой вызов Выполнить() внутри цикла. И формально оно право: даже если объект запроса один, выполняешь его N раз — обращаешься к базе N раз. Это и есть антипаттерн, который убивает производительность.

Вынос объекта перед циклом — это половина правильного решения. Полное решение — сделать так, чтобы Выполнить() вызывался один раз, а не N раз. И тут мы упираемся в архитектуру.

Сервис принимает массив документов от кассы. У каждого — свой тип, свои параметры, своя логика записи. Один документ создаёт чек ККМ, другой — перемещение. Объединить их в один запрос невозможно: разные таблицы, разные поля, разные правила. Это не лень разработчика — это объективное ограничение задачи.

Антипаттерны запросов 1С в цикле — три варианта и их стоимость

Что делать, когда пакетировать невозможно

Когда честно нельзя свести цикл к одному запросу, остаётся четыре приёма. Мы применяли все четыре в этом проекте.

Первый приём — вытащить запрос за пределы цикла, но не сам по факту. Часто внутри цикла обращение к базе происходит не за уникальными для итерации данными, а за общим справочным. «Найти контрагента по ИНН». «Получить курс валюты на дату». Эти данные можно собрать до цикла одним запросом по всем уникальным значениям из массива, положить в соответствие по ключу, и в цикле уже обращаться к памяти, а не к базе. У нас это сократило количество запросов с 47 тысяч до примерно 1200.

Второй приём — пакетировать однотипные документы. Если в массиве лежит 80 чеков ККМ, не обязательно делать 80 запросов на проверку остатков. Можно собрать все номенклатуры из всех чеков в одну временную таблицу и сделать один запрос на остатки. После этого внутри цикла обработки чека вы ходите не в базу, а в результирующую таблицу. Это пересекается с темой антипаттернов запросов 1С — там этот приём разбирается в чистом виде.

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

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

В нашем HTTP-сервисе сработали все четыре. После всех правок количество обращений к базе на одно входящее сообщение упало с 47 тысяч до 1240. Время ответа — с 14 секунд до 850 миллисекунд. Кассы перестали виснуть.

Подавление линтера: ключ, которого не существует

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

BSL Language Server поддерживает секцию diagnosticSeverityOverride. По документации — туда пишут уровень для конкретной диагностики. Прописали CreateQueryInCycle: OFF. Запустили сборку. Диагностика на месте. Ни ошибки в логе, ни предупреждения о нераспознанном ключе. Конфиг просто молча принял настройку и проигнорировал её.

Полез в исходники линтера. В версии 0.28.5 ключ diagnosticSeverityOverride присутствует в документации, но движок его не обрабатывает для этого правила. Правильный способ — секция parameters со значением false для конкретной диагностики. Не переопределение severity, а полное отключение через параметры самого правила. Разница — один уровень вложенности в JSON.

Рефакторинг 2400-строчного модуля занял час. Вынос запросов и реальная оптимизация — ещё два часа. Поиск работающего ключа конфигурации линтера — дольше, чем оба шага вместе. Когда инструмент молча принимает невалидный конфиг, отладка превращается в археологию.

Результаты оптимизации запросов в цикле 1С — 47000 запросов сократились до 1240

Как искать «запрос в цикле» в чужом коде

В новом проекте, куда вы заходите впервые, не нужно пытаться прочитать всю конфигурацию. Достаточно нескольких приёмов диагностики.

Технологический журнал. Включите событие DBMSSQL или EXCP с фильтром по длительности больше 100 миллисекунд. Запустите проблемную операцию. В логе вы увидите тысячи однотипных строк подряд — это и есть запрос в цикле. Обратите внимание на текст запроса и уникальные значения параметров — так найдёте нужное место в коде.

Замер производительности. Встроенный замер показывает количество вызовов каждой строки кода. Если строка Выполнить() вызвана 47000 раз — поздравляем, нашли. Часто эта строка лежит на 8 уровне вложенности и без замера её невозможно увидеть глазами.

BSL Language Server. Если у вас уже настроен CI, посмотрите, какие правила срабатывают. Если линтер не настроен — самое время. Правила CreateQueryInCycle, UseLessForEach, FormDataToValue ловят базовые антипаттерны. Не для того, чтобы в каждом коммите чинить — чтобы видеть динамику и знать, где смотреть в первую очередь.

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

Что осталось за кадром

Чистый «запрос в цикле» — это видимая часть айсберга. Под ней лежат несколько менее очевидных братьев.

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

Или запрос внутри запроса через виртуальную таблицу с параметрами. Если вы делаете запрос к РегистрНакопления.Остатки с параметром «Период», который меняется в цикле родительской обработки, — вы фактически выполняете N подзапросов. Решение — собрать все периоды в одну временную таблицу и присоединить её к виртуальной таблице, чтобы СУБД сделала всё одним планом.

Или запись в цикле без транзакции. Каждый Записать() на объекте — это отдельная транзакция, отдельный BEGIN/COMMIT, отдельный fsync на диске. Обернули десять тысяч записей в одну транзакцию — получили двадцатикратное ускорение, потому что СУБД не сбрасывает лог на диск после каждой операции.

Все эти случаи объединяет одно: они выглядят как корректный код, и линтер ими не интересуется. Их находишь только замером или ТЖ. Если у вас в проекте регулярно «1С тормозит, а код не меняли», скорее всего, причина в одном из таких мест — и стоит начать с настройки APDEX, чтобы видеть деградацию заранее, а не в момент звонка от старшего смены.

Что забрать с собой

«Запрос в цикле» — главный антипаттерн производительности 1С, и линтер ловит его не полностью. Срабатывание правила CreateQueryInCycle почти никогда не означает, что достаточно вынести Новый Запрос за цикл. Чаще нужно пересмотреть саму архитектуру: загружать данные пакетом до цикла, кэшировать справочные значения, объединять однотипные документы.

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

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