Что тормозит запросы

Разбираем четыре главных греха, из-за которых запрос из миллисекунд превращается в минуты, и учимся переписывать их правильно.

Оптимизация запроса — это не «сделать код красивее», а перенести работу с перебора строк вручную на движок СУБД, который умеет обрабатывать миллионы записей одним набором операций.

В 1С есть коварная особенность: неоптимальный запрос почти всегда работает правильно. На тестовой базе из пятидесяти документов он отдаёт верный результат за доли секунды, разработчик радуется и уходит домой. А через полгода в рабочей базе накопились сотни тысяч документов — и та же обработка проведения висит по три минуты, кассир нервничает, а вы получаете задачу «почему тормозит». Поэтому узнавать вредные конструкции нужно в лицо, ещё на этапе написания кода. Ниже — четыре самых частых греха новичка и способы их искупить.

Грех первый: запрос внутри цикла

Это чемпион по замедлению. Идея кажется логичной: «пройдусь по строкам документа и для каждой строки схожу в базу за нужными данными». Проблема в том, что каждый вызов Выполнить() — это отдельное обращение к серверу СУБД: подготовка, отправка, ожидание ответа. Сто строк — сто обращений. Тысяча строк — тысяча.

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

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

// ХОРОШО: один запрос на весь документ
МассивНоменклатуры = Документ.Товары.ВыгрузитьКолонку("Номенклатура");

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

Цены = Запрос.Выполнить().Выгрузить();
// дальше в цикле только ищем цену в готовой таблице Цены — без обращений к базе

Разница на базе с большим справочником — десятки раз. Правило простое: если видите Выполнить() внутри Цикл — почти наверняка это ошибка.

Грех второй: соединение с вложенным запросом

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

// ПЛОХО: соединение с вложенным запросом
ВЫБРАТЬ
    Товары.Номенклатура,
    Продажи.Выручка
ИЗ
    Справочник.Номенклатура КАК Товары
    ЛЕВОЕ СОЕДИНЕНИЕ (
        ВЫБРАТЬ Ном КАК Номенклатура, СУММА(Сумма) КАК Выручка
        ИЗ РегистрНакопления.Продажи
        СГРУППИРОВАТЬ ПО Ном
    ) КАК Продажи
    ПО Продажи.Номенклатура = Товары.Ссылка

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

// ХОРОШО: временная таблица + индекс поля соединения
ВЫБРАТЬ
    Продажи.Ном КАК Номенклатура,
    СУММА(Продажи.Сумма) КАК Выручка
ПОМЕСТИТЬ Вт_Продажи
ИЗ
    РегистрНакопления.Продажи КАК Продажи
СГРУППИРОВАТЬ ПО
    Продажи.Ном
ИНДЕКСИРОВАТЬ ПО
    Номенклатура
;
ВЫБРАТЬ
    Товары.Номенклатура КАК Номенклатура,
    Продажи.Выручка КАК Выручка
ИЗ
    Справочник.Номенклатура КАК Товары
    ЛЕВОЕ СОЕДИНЕНИЕ Вт_Продажи КАК Продажи
    ПО Продажи.Номенклатура = Товары.Ссылка

Временные таблицы для 1С — родной механизм: их создаёт менеджер временных таблиц, и по проиндексированному полю соединение снова становится быстрым.

Грех третий: ПОДОБНО по подстроке

Оператор ПОДОБНО (аналог SQL LIKE) ищет строки по шаблону. И тут решает положение символа-джокера %. Если % стоит только в конце — «поиск по началу строки» — СУБД может воспользоваться индексом. Если % стоит и в начале — «поиск по подстроке» — индекс бесполезен, и база честно перебирает все записи.

// БЫСТРО: поиск по началу — работает индекс
ГДЕ Контрагенты.Наименование ПОДОБНО &Начало   // параметр = "Ромаш%"

// МЕДЛЕННО: поиск по подстроке — полный перебор
ГДЕ Контрагенты.Наименование ПОДОБНО &Кусок    // параметр = "%ромаш%"

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

Грех четвёртый: условие в ГДЕ вместо параметров виртуальной таблицы

Виртуальные таблицы регистров (Остатки, Обороты, СрезПоследних) принимают параметры отбора прямо в скобках. И это принципиально: параметр отрабатывает до расчёта итогов, а условие в ГДЕпосле. Разница огромна.

// ПЛОХО: отбор по складу в ГДЕ — сначала считаем ВСЕ склады, потом отсекаем
ВЫБРАТЬ Остатки.Номенклатура, Остатки.КоличествоОстаток
ИЗ РегистрНакопления.ТоварыНаСкладах.Остатки КАК Остатки
ГДЕ Остатки.Склад = &Склад

// ХОРОШО: отбор в параметрах ВТ — движок считает итоги только по нужному складу
ВЫБРАТЬ Остатки.Номенклатура, Остатки.КоличествоОстаток
ИЗ РегистрНакопления.ТоварыНаСкладах.Остатки(, Склад = &Склад) КАК Остатки

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

Как это работает

Под капотом любой запрос 1С транслируется в SQL и уходит на сервер СУБД (обычно PostgreSQL или MS SQL Server). Сервер строит план выполнения — пошаговую инструкцию, как добыть данные. Всё, что мы называем «оптимизацией», сводится к одному: помочь СУБД построить план, где данные берутся по индексу (точечно), а не сканированием таблицы (перебором всех строк).

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

Частые ошибки

  • «Перепишу цикл на запрос, но оставлю Выполнить() внутри» — самообман. Вынести нужно именно вызов к базе, а не просто причесать текст.
  • Получать в выборку всё подряд ВЫБРАТЬ * и колонки «на всякий случай» — лишние поля тянут лишние соединения и данные по сети. Выбирайте ровно то, что используете.
  • Считать, что раз на тестовой базе быстро — значит быстро всегда. Проверяйте на объёме, близком к рабочему, или хотя бы прикидывайте, сколько строк будет в проде.
  • Индексировать временную таблицу, в которой три строки. Индекс сам по себе стоит времени на построение; на крошечных наборах он вреден. Индексируйте, когда строк действительно много и по полю идёт соединение.

Итоги

  • Неоптимальный запрос обычно правильный — беда всплывает только на объёме. Узнавайте вредные конструкции при написании.
  • Запрос внутри цикла — главный грех. Заменяйте пакетной выборкой одним запросом на весь набор.
  • Соединение с вложенным запросом лишает СУБД индексов. Выносите подзапрос во временную таблицу и индексируйте поле соединения.
  • ПОДОБНО «по началу строки» ("текст%") может идти по индексу, «по подстроке» ("%текст%") — всегда полный перебор.
  • Отбор кладите в параметры виртуальной таблицы, а не в ГДЕ: он отработает до расчёта итогов.
Проверьте себя
1. Почему запрос внутри цикла Для Каждого ... Цикл резко замедляет обработку на большой базе?
AКаждый вызов Выполнить() — отдельное обращение к серверу СУБД, и число обращений растёт вместе с числом строк
B1С запрещает выполнять запросы в цикле и каждый раз выдаёт предупреждение, которое тормозит выполнение
CЦикл Для Каждого в 1С сам по себе работает медленнее, чем цикл Пока, независимо от содержимого
DВнутри цикла запрос выполняется на клиенте, а вне цикла — на сервере
2. Какой из вариантов ПОДОБНО сможет воспользоваться индексом и отработает быстрее?
AНаименование ПОДОБНО "%ромаш%" (поиск по подстроке)
BНаименование ПОДОБНО "Ромаш%" (поиск по началу строки)
CОба одинаково быстры, положение % не влияет на скорость
DОба одинаково медленны, ПОДОБНО индекс не использует никогда