8 приёмов оптимизации запросов 1С: с кодом "до/после" и боевыми замерами
Иван Недомолков · Dərc edilib: · Yenilənib:
Есть класс проектов, где нельзя менять логику: обработка живая, пишет документы, шлёт данные во внешние системы, и цена любой ошибки - неверные цифры у работающего бизнеса. Наш недавний проект был ровно таким: ценообразование для маркетплейсов, две обработки на ~25 000 строк, требование “ускорить, не изменив ни одной итоговой цены”.
Итог - ускорение в 2-6 раз по всем контурам, а отдельные запросы быстрее в сотни раз. И вот что важно: ни одна правка не тронула формулы. Все тормоза жили в одном месте - в том, как код обращается к данным. Это типовые болезни любой выросшей обработки, и лечатся они типовыми приёмами.
Ниже - все восемь, с кодом “до/после” и фактическими замерами (методика замеров - в конце, там же чек-лист для ревью собственного кода).
1. Срез последних без отбора - главный пожиратель
Виртуальная таблица “срез последних” без параметров материализует весь регистр, даже если дальше вы соединяете её со списком из ста товаров. На больших регистрах цен это десятки миллионов записей на каждый вызов.
// ДО: срез по всему регистру цен, фильтр - снаружи, соединением
РегистрСведений.Цены.СрезПоследних(, ТипЦен.Код = "000000164")
// ПОСЛЕ: отбор внутри параметров среза - и по типу цен, и по товарам
РегистрСведений.Цены.СрезПоследних(
,
ТипЦен = &ТипЦенКабинета
И Номенклатура В (ВЫБРАТЬ Т.Номенклатура ИЗ ВТ_Товары КАК Т))
Наш рекорд на этом приёме: запрос в блоке акций маркетплейса работал 39 минут - и не потому, что “данных много”. Монитор СУБД показал 2 353 секунды длительности и 124 секунды процессора, то есть 95 % времени запрос не занимал процессор. По расписанию он стоял сразу за массовой записью цен, а полный срез читал весь регистр, в который этот этап только что писал. Отбор по нужным товарам сузил чтение, и ожидание ушло вместе с объёмом: 39 минут → 6 секунд, результат идентичен. Тот же анти-паттерн нашёлся ещё в пяти запросах соседних блоков.
Мораль шире приёма: срез без отбора не просто медленный - он конфликтный. Он читает всё, а значит, пересекается со всеми, кто пишет. Такие срезы мы ищем первыми, когда берёмся за оптимизацию 1С: один анти-паттерн обычно сидит сразу в нескольких обработках, и правится он пачкой.
2. Строковый код вместо ссылки
Условие вида ТипЦен.Код = "000000164" выглядит невинно, но для СУБД это скрытое соединение со справочником плюс фильтр по строке без индекса (в SQL уезжает RTRIM(Код) = ...).
// ДО (в тексте запроса): скрытый join + текстовый фильтр на каждый код
ГДЕ Цены.ТипЦен.Код = "000000164"
// ПОСЛЕ: ссылку вычисляем один раз в коде, в запрос - параметром
ТипЦен = Справочники.ТипыЦенНоменклатуры.НайтиПоКоду("000000164");
Запрос.УстановитьПараметр("ТипЦен", ТипЦен);
// в запросе:
ГДЕ Цены.ТипЦен = &ТипЦен
Замер: основной запрос маркетплейс-обработки с семью строковыми кодами - 289 секунд, из которых один срез по кодам стоил 215. После замены кодов ссылками - 46 секунд (×6,3).
3. Запрос (и “точка”) в цикле
Самый массовый анти-паттерн: “получить старую цену”, “дочитать артикул”, “найти пользователя” - на каждой из десятков тысяч строк.
// ДО: обращение к базе на каждую строку
Для Каждого Стр Из Таблица Цикл
СтараяЦена = ПолучитьПоследнююЦену(Стр.Номенклатура); // запрос!
Артикул = Стр.Номенклатура.Артикул; // дочтение "через точку"
КонецЦикла;
// ПОСЛЕ: один пакетный запрос до цикла → Соответствие → O(1) внутри
СоответствиеЦен = ПолучитьЦеныПакетом(СписокТоваров);
РеквизитыНом = ПолучитьРеквизитыПакетом(СписокТоваров);
Для Каждого Стр Из Таблица Цикл
СтараяЦена = СоответствиеЦен.Получить(Стр.Номенклатура);
Артикул = РеквизитыНом.Получить(Стр.Номенклатура).Артикул;
КонецЦикла;
Замер: этап записи цен на самом тяжёлом контуре - 810 → 43 секунды. На маркетплейс-обработке вынос дочтений убрал ~31 000 неявных обращений к базе за прогон.
Важный нюанс про “точку в цикле”, который мы отдельно проверяли замерами: платит не число итераций, а число различных ссылок - повторные обращения к уже прочитанной ссылке обслуживает кэш объектов сеанса почти бесплатно. Прежде чем переписывать цикл, посчитайте РАЗЛИЧНЫЕ: на 200 различных ссылках выигрыша не будет, на 20 000 - будет драматический.
4. Поиск по таблице значений в цикле - O(N²)
НайтиСтроки() без индекса - линейный перебор. Внутри цикла по той же таблице это квадрат: на ~19 000 строк - сотни миллионов сравнений.
// ДО: НайтиСтроки перебирает всю таблицу на каждой итерации → O(N²)
Для Каждого Стр Из Таблица Цикл
Дети = Таблица.НайтиСтроки(Новый Структура("Родитель", Стр.Ссылка));
КонецЦикла;
// ПОСЛЕ: одна строка - индекс строит хеш, поиск становится O(1)
Таблица.Индексы.Добавить("Родитель");
Замер: этап построчного расчёта - 113 → 21 секунда. Одна строка кода.
5. ВЫБОР в условии соединения
Ветвление режима прямо в условии ПО или ГДЕ лишает СУБД возможности использовать индекс - условие становится невычислимым заранее (не-SARGable).
Цена такого скана не только в секундах: пока запрос читает таблицу целиком, он держит на ней блокировки, и в пик это выливается во взаимоблокировки. Отдельный разбор с замером - почему дедлок бывает симптомом отсутствующего индекса.
// ДО: режим выбирается внутри условия соединения
ПО ... И (ВЫБОР КОГДА &Режим = "OZON" ТОГДА Цены.ТипЦен.Код = "164"
КОГДА &Режим = "WB" ТОГДА Цены.ТипЦен.Код = "191" КОНЕЦ)
// ПОСЛЕ: ветку вычисляем в коде, в запрос уходит готовая ссылка
ПО ... И Цены.ТипЦен = &ТипЦенКабинета
Правило простое: всё, что можно вычислить до запроса, - вычисляйте до запроса. Запрос должен получать значения, а не логику их выбора.
6. Один регистр - десять соединений
Регистр значений свойств подключался отдельным ЛЕВОЕ СОЕДИНЕНИЕ под каждое свойство: десять свойств - десять сканирований одной и той же таблицы, одно из которых давало 51 млн логических чтений.
// ДО: 10 соединений одной таблицы
ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.ЗначенияСвойств КАК Св1 ПО ... И Св1.Свойство = &Св1
ЛЕВОЕ СОЕДИНЕНИЕ РегистрСведений.ЗначенияСвойств КАК Св2 ПО ... И Св2.Свойство = &Св2
// ... и ещё восемь
// ПОСЛЕ: одно чтение + "разворот" агрегатами, ВТ индексируется
ВЫБРАТЬ
Объект,
МАКСИМУМ(ВЫБОР КОГДА Свойство = &Св1 ТОГДА Значение КОНЕЦ) КАК Свойство1,
МАКСИМУМ(ВЫБОР КОГДА Свойство = &Св2 ТОГДА Значение КОНЕЦ) КАК Свойство2
ПОМЕСТИТЬ ВТ_Свойства
ИЗ РегистрСведений.ЗначенияСвойств
ГДЕ Свойство В (&СписокСвойств)
СГРУППИРОВАТЬ ПО Объект
ИНДЕКСИРОВАТЬ ПО Объект
Приём универсален для любых “свойств/характеристик/доп. реквизитов”, которые традиционно тянут по одному.
7. Кэш справочных данных
Диапазоны стоимости доставки читались запросом на каждую строку одного из режимов расчёта. Один запрос за прогон в Соответствие - и построчный этап сжался с 225 до 34 секунд. Родной брат приёма №3, но заслуживает отдельного пункта: кэшируйте не только “данные по списку товаров”, но и маленькие справочные таблицы, которые не меняются в течение прогона.
8. Транзакции и файлы: меньше, крупнее, один раз
Не запросами едиными:
- Коммит на каждую запись регистра → единая транзакция на пакет (с фолбэком на пообъектную запись при ошибке - сбой одной строки не роняет процесс).
- Мелкие порции документов (по 300 строк) → укрупнение до 900-1000: меньше проведений и меньше фоновых заданий интеграции, которые конфигурация запускает на каждый документ.
- Файлы: Excel на 83 МБ писался на сетевую шару с повторной сериализацией для архивной копии. Локальный temp + потоковая обработка XML + копирование готового файла вместо повторной генерации: 493 → 263 секунды.
Методика: почему этим цифрам можно верить
Каждая правка проходила одинаковый контур на копии боевой базы:
- Зафиксировать вход (снапшот внешних данных, границы дат) - расчёт детерминирован.
- Снять эталон: полная таблица расчёта (~100 колонок на позицию) + состав документов.
- Внести одну правку.
- Прогнать и сравнить с эталоном побайтово.
- Принять только если: результат идентичен, этап не медленнее, новых ошибок в логе нет.
И раскроем карты: черновую работу - чтение кода, поиск узких мест по статистике СУБД, подготовку правок и прогоны замеров - в этом проекте выполнял ИИ-агент (Claude Code) под управлением нашего инженера. Жёсткий контур сверки - ровно то, что позволяет доверить агенту скорость, оставив людям решения и ответственность.
Больше 30 таких прогонов. Скучно? Да. Но именно эта скука дважды поймала то, что глазами не видно: опечатку, ломавшую расчёт в одном контуре из четырёх, и подтверждение, что застарелый чужой баг мы не задели. Как строить такую сверку для отчётов - разбирали отдельно: SHA-256 вместо “на глаз похоже”.
Отдельно про то, как считались сами секунды. Замер “три прогона, первый выбрасываем, из оставшихся берём минимум” на боевой базе врёт в одну сторону: минимум ловит самый удачный момент, и если удачный момент достался блоку “после”, вы получите ускорение, которого нет. На соседнем проекте так родились минус 13 %, исчезнувшие при перепроверке чередованием до, после, до, после. Приёмы из этой статьи там сработали корректно, а дорогое место в отчёте оказалось не тем, которое чинили.
И второй столп методики: узкие места ищутся по монитору СУБД, а не по интуиции. Фактические elapsed/CPU/чтения по каждому запросу - единственный честный источник; именно он показал, что “39-минутный запрос” 95 % времени не занимал процессор. По расписанию этот запрос стоял сразу за массовой записью цен. Интуиция посылала бы нас переписывать его на временные таблицы, а хватило отбора в параметрах среза.
Запрос ждёт: как это увидеть до правки
Счётчика ожиданий у запроса нет. Сервер копит по каждому плану длительность (elapsed) и процессорное время (CPU), а долю ожидания получают делением: 1 - CPU/elapsed. Для запроса из блока акций это 1 - 124/2 353, те самые 95 %.
Консоль кластера 1С даёт только первый сигнал: сеанс висит, время СУБД копится, но на работу и ожидание его там не разделить. В SSMS тяжёлые запросы видны в мониторе активности, раздел “Последние ресурсоёмкие запросы”. Цифры на запуск удобнее снять запросом из нашего замера до и после, дополненным разницей между длительностью и CPU:
-- запросы с самым долгим ожиданием: длительность и CPU на запуск, доля ожидания;
-- цифры копятся по завершённым запускам, пока план лежит в кэше;
-- фильтр по DB_ID() нужен, если на инстансе живёт не одна база
SELECT TOP 20
qs.execution_count AS zapuskov,
qs.total_elapsed_time / qs.execution_count / 1000 AS ms_na_zapusk,
qs.total_worker_time / qs.execution_count / 1000 AS cpu_ms_na_zapusk,
(qs.total_elapsed_time - qs.total_worker_time) / 1000000 AS ozhidanie_sek_vsego,
CAST(100.0 * (qs.total_elapsed_time - qs.total_worker_time)
/ NULLIF(qs.total_elapsed_time, 0) AS decimal(7, 1)) AS dolya_ozhidaniya_proc,
qs.total_logical_reads / qs.execution_count AS chteniy_na_zapusk,
SUBSTRING(st.text, (qs.statement_start_offset / 2) + 1,
((CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset END
- qs.statement_start_offset) / 2) + 1) AS tekst_zaprosa
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
CROSS APPLY sys.dm_exec_plan_attributes(qs.plan_handle) AS pa
WHERE pa.attribute = 'dbid' AND CAST(pa.value AS int) = DB_ID()
ORDER BY qs.total_elapsed_time - qs.total_worker_time DESC;
Как читать строки:
| Что в строке | Что это значит | С чего начинать |
|---|---|---|
| CPU близко к длительности, чтений много | запрос работает | план и приёмы из этой статьи |
| CPU - малая доля длительности | запрос ждёт | выяснить, чего ждёт, потом трогать текст |
| CPU больше длительности | план параллельный | разницу как ожидание не читать |
У параллельного плана CPU суммируется по всем потокам и может обогнать длительность, тогда разница уходит в минус. Документация Microsoft прямо пишет, что по ней такой запрос уже трудно отнести к работающим или ждущим. Спрятать ожидание параллельность может, придумать нет, так что 2 353 секунды против 124 читаются однозначно. На SQL Server 2016 и новее рядом выводится max_dop: больше единицы - план шёл в несколько потоков.
Представление видит только завершённые запуски и только планы, которые ещё лежат в кэше, поэтому смотрят вскоре после тяжёлого прогона. Идущий прямо сейчас запрос виден в sys.dm_exec_requests, там wait_type показывает, чего он ждёт в эту секунду. Нужно право VIEW SERVER STATE, на SQL Server 2022 и новее VIEW SERVER PERFORMANCE STATE.
Тип ожидания нашего запроса в тот прогон не сняли, причину восстановили по расписанию. Шаг акций стартовал сразу за массовой записью цен, шедшей тогда 17 минут, а срез без отбора читал весь регистр цен, десятки миллионов записей. Очередь за этой записью - вывод из расписания, счётчиком он не подтверждён. Если подозреваете блокировки, держателя называют по имени способами из статьи кто блокирует базу 1С.
Тот же взгляд нужен, когда регламент в один прогон укладывается в окно, а в другой нет. Ключевой запрос проекта, уже другой, до правок шёл 358 секунд в тишине и 1 179 в пик, после них 130 и 256: старая версия под нагрузкой теряла в 3,3 раза, новая почти в 2. CPU и чтения на запуск от чужой нагрузки почти не зависят, значит, прирост в пик - в основном ожидание.
Три возражения на ревью
“Перенесите шаг подальше от записи цен”. Очередь из этого расписания уйдёт, широкое чтение останется, и следующий пишущий сосед может вернуть ту же картину. Перенос прячет конфликт, а после сужения чтения вопрос отпал сам.
“Добавьте индекс”. Индекс ускоряет работу самого запроса, а этот из 39 минут работал две. Сначала выясняют, чего он ждёт. Если стоит за чужой записью, помогает сузить чтение, у нас это сделал отбор в параметрах среза.
“Читайте регистр напрямую, через МАКСИМУМ(Период)”. Получится ручная копия виртуальной таблицы, и сопровождать её придётся вам. Проще оставить срез платформе и кормить его отборами в параметрах.
И проверьте у больших периодических регистров сведений свойство РазрешитьИтогиСрезПоследних. По справке платформы при включённом свойстве запросы по срезу идут через таблицу “Срез последних”, кроме случаев, когда в параметрах среза или в ограничении доступа стоит условие по полю, которое не измерение и не разделитель. Реквизит через точку вроде ТипЦен.Код измерением не является. На нашем регистре это не замерялось, так что это пункт проверки.
Чек-лист для ревью своего кода
Пройдитесь по своей самой тяжёлой обработке:
- Есть срез виртуальной таблицы без отбора? → отбор внутрь параметров.
- Встречается
.Код = "строка"в запросах? → ссылка-параметр. - Запрос или дочтение “через точку” внутри цикла? → пакет до цикла (но сначала посчитайте РАЗЛИЧНЫЕ ссылки).
НайтиСтроки()в цикле по большой ТЗ? →Индексы.Добавить().ВЫБОРв условии соединения или ГДЕ? → вычислить в коде, передать параметром.- Один регистр джойнится несколько раз? → одно чтение + разворот агрегатами.
- Справочные данные читаются повторно? → кэш в
Соответствиена прогон. - Коммит/фоновое задание на каждую запись? → укрупнить порции, обернуть в транзакцию.
- Ищете срезы из пункта 1 поиском? → кроме
СрезПоследних(,ищитеСрезПервых(,, совпадение проверяйте на отбор. - Готовите первую правку? → эталон результата с сортировкой и контрольной суммой плюс список шагов, которые пишут в те же таблицы прямо перед тяжёлым.
Пункты 1-9 - потенциальные разы, а не проценты, пункт 10 - страховка результата. И ни один не требует трогать бизнес-логику.
Итог
Выросшие обработки почти никогда не тормозят “из-за сложных расчётов” - они тормозят из-за расточительного доступа к данным, накопившегося за годы дописываний. Хорошая новость: это чинится локальными правками с доказуемой безопасностью - если рядом есть замерный стенд и дисциплина побайтовой сверки.
Полная история проекта с таблицами всех замеров - в кейсе “Ценообразование для маркетплейсов: в 2-6 раз быстрее”. Если ваши регламентные обработки тоже перестали влезать в свои окна - начните с бесплатного экспресс-аудита: найдём фактические узкие места по монитору СУБД и оценим достижимое ускорение. Услуга целиком - оптимизация производительности 1С.
Готовые обработки по теме
Оптимизатор временных таблиц автоматически расставляет УНИЧТОЖИТЬ в пакетных запросах, а Трансформатор SQL в запрос 1С переводит текст медленного SQL из профайлера обратно в термины конфигурации.
Разбирать чужую обработку глазами, когда непонятно, с какого места начинать, помогает Анализ кода внешних обработок: запрос внутри цикла он ловит правилом с весом 60 и показывает его сразу с номером строки. На семнадцати наших собственных обработках с площадки он нашёл 239 замечаний, 186 из них критичные - что из этого вышло и что искать в чужом .epf руками, разобрано в анализе кода внешних обработок.
Как связать запрос из трассировки с объектами 1С
Перед изменением запроса расшифруйте физические имена таблиц и полей по структуре хранения именно той базы, где снята трассировка. Один объект метаданных может храниться в нескольких таблицах: основная запись, табличные части, итоги или регистрация изменений. Подстановка одного имени по знакомому префиксу не восстанавливает весь смысл запроса.
Сохраните исходный SQL и отдельную расшифрованную копию. Нераспознанные имена оставляйте помеченными, без догадок. Временные таблицы и служебные структуры могут потребовать анализа того пакета, который их создаёт. Используйте Трансформатор SQL в термины 1С как помощь при чтении; результат расшифровки не предназначен для выполнения вместо исходного запроса.
После установления объектов сопоставьте текст с планом и параметрами конкретного запуска. Для численно неверного результата сначала проверьте контекст итогов СКД. Ускорение запроса с ошибочной агрегацией не исправляет отчёт.