Вложенные запросы vs соединения

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

Вложенный запрос (подзапрос) — это запрос, записанный внутри другого запроса: его результат используется как источник данных в ИЗ или как список значений в условии ГДЕ.

Соединения (СОЕДИНЕНИЕ) и вложенные запросы часто решают одну и ту же задачу — «сопоставить данные из разных таблиц». Начинающему бывает неочевидно, что выбрать. Правда в том, что дело не только во вкусе: одна и та же логика, записанная соединением или подзапросом, может выполняться совершенно по-разному по скорости. Разберём оба места, где живут вложенные запросы, и выработаем чутьё, когда какой приём уместен.

Зачем это нужно на практике

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

Вложенный запрос в ИЗ

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

ВЫБРАТЬ
    ПродажиПоТоварам.Товар КАК Товар,
    ПродажиПоТоварам.Выручка КАК Выручка
ИЗ
    (ВЫБРАТЬ
        Продажи.Номенклатура КАК Товар,
        СУММА(Продажи.Сумма) КАК Выручка
    ИЗ
        РегистрНакопления.Продажи.Обороты(&Начало, &Конец, ) КАК Продажи
    СГРУППИРОВАТЬ ПО
        Продажи.Номенклатура) КАК ПродажиПоТоварам
ГДЕ
    ПродажиПоТоварам.Выручка > 100000

Здесь мы сначала посчитали выручку по каждому товару во вложенном запросе, а внешний запрос уже фильтрует готовые итоги. Это удобно, когда нужно наложить условие на результат агрегации (хотя ту же роль часто играет ИМЕЮЩИЕ).

Вложенный запрос в ГДЕ через В(...)

Второе место — условие ГДЕ с оператором В. Он проверяет, входит ли значение поля в набор, а набор можно задать вложенным запросом. Классика — «товары, которых нет в заказах»:

ВЫБРАТЬ
    Номенклатура.Ссылка КАК Товар,
    Номенклатура.Наименование КАК Наименование
ИЗ
    Справочник.Номенклатура КАК Номенклатура
ГДЕ
    НЕ Номенклатура.Ссылка В
        (ВЫБРАТЬ РАЗЛИЧНЫЕ
            ЗаказыТовары.Номенклатура
        ИЗ
            Документ.ЗаказПокупателя.Товары КАК ЗаказыТовары)

Читается почти как человеческая фраза: «выбрать номенклатуру, ссылка которой НЕ входит в список товаров из заказов». Ключевое слово РАЗЛИЧНЫЕ убирает дубли внутри подзапроса, чтобы список значений был компактным.

Когда подзапрос читается лучше соединения

Вложенный запрос выигрывает по ясности, когда вам нужен факт «есть / нет» или простой фильтр по списку. Сравните две записи одной задачи «контрагенты из чёрного списка». Через соединение:

ВЫБРАТЬ
    Контрагенты.Ссылка
ИЗ
    Справочник.Контрагенты КАК Контрагенты
        ВНУТРЕННЕЕ СОЕДИНЕНИЕ РегистрСведений.ЧёрныйСписок КАК ЧС
        ПО Контрагенты.Ссылка = ЧС.Контрагент

Через подзапрос:

ВЫБРАТЬ
    Контрагенты.Ссылка
ИЗ
    Справочник.Контрагенты КАК Контрагенты
ГДЕ
    Контрагенты.Ссылка В
        (ВЫБРАТЬ ЧС.Контрагент ИЗ РегистрСведений.ЧёрныйСписок КАК ЧС)

Оба верны. Но у соединения есть коварство: если контрагент попал в чёрный список дважды (две записи регистра), внутреннее соединение размножит строку контрагента — в результате появятся дубли. Подзапрос с В так не делает: он лишь проверяет вхождение, строку не удваивает. Поэтому для фильтра «оставить те, что есть в другом наборе» подзапрос и безопаснее, и понятнее.

Когда подзапрос бьёт по скорости

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

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

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

Замена подзапроса на временную таблицу

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

// ХОРОШО: считаем один раз во временную таблицу
ВЫБРАТЬ
    Цены.Номенклатура КАК Номенклатура,
    МАКСИМУМ(Цены.Период) КАК Период
ПОМЕСТИТЬ ВТПоследниеЦены
ИЗ
    РегистрСведений.Цены КАК Цены
СГРУППИРОВАТЬ ПО
    Цены.Номенклатура
;

ВЫБРАТЬ
    Товары.Ссылка КАК Товар,
    ВТПоследниеЦены.Период КАК ДатаЦены
ИЗ
    Справочник.Номенклатура КАК Товары
        ЛЕВОЕ СОЕДИНЕНИЕ ВТПоследниеЦены КАК ВТПоследниеЦены
        ПО Товары.Ссылка = ВТПоследниеЦены.Номенклатура

Логика та же, но производительность принципиально другая: тяжёлый расчёт вынесен из соединения. Это главный рефакторинг-приём, когда «отчёт с подзапросом внутри соединения» начинает тормозить.

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

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

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

  • Забыли псевдоним у подзапроса в ИЗ. Вложенный запрос в ИЗ обязан иметь имя через КАК, иначе к его полям не обратиться и запрос не скомпилируется.
  • Внутреннее соединение вместо фильтра. Использовали ВНУТРЕННЕЕ СОЕДИНЕНИЕ там, где нужно было просто «оставить существующие», и получили дубли из-за нескольких строк в правой таблице. Для проверки вхождения безопаснее В(...).
  • Тяжёлый подзапрос прямо в соединении. Подзапрос с группировкой по большому регистру, поставленный в соединение, — типичная причина медленного отчёта. Выносите такой расчёт во временную таблицу.
  • НЕ ... В(...) с NULL внутри. Если подзапрос в В может вернуть NULL, условие НЕ ... В способно повести себя неожиданно (сравнение с NULL не даёт «истина»). Отсеивайте NULL в подзапросе или добавляйте условие на заполненность.

Итоги

  • Вложенный запрос можно поставить в ИЗ (как источник, обязателен псевдоним КАК) и в ГДЕ через В(...) (как список значений).
  • Для фильтра «оставить те, что есть в другом наборе» подзапрос с В читаемее соединения и не размножает строки.
  • Коррелированный или тяжёлый подзапрос внутри соединения — частая причина медленных отчётов.
  • Лечится выносом подзапроса во временную таблицу: расчёт делается один раз, соединение ускоряется.
  • «Подзапрос или соединение» — вопрос не стиля, а количества повторных вычислений и риска дублей.
Проверьте себя
1. Почему для задачи «оставить контрагентов, которые есть в чёрном списке» подзапрос с В(...) часто безопаснее внутреннего соединения?
AПодзапрос всегда выполняется быстрее любого соединения
BВнутреннее соединение может размножить строки, если в правой таблице несколько записей, а В(...) только проверяет вхождение
CОператор В(...) автоматически строит индекс
DСоединения запрещено использовать со справочниками
2. Какой приём чаще всего спасает медленный отчёт, где тяжёлый подзапрос стоит внутри соединения?
AЗаменить ЛЕВОЕ СОЕДИНЕНИЕ на ПОЛНОЕ СОЕДИНЕНИЕ
BДобавить УПОРЯДОЧИТЬ ПО в конец запроса
CВынести подзапрос во временную таблицу через ПОМЕСТИТЬ и соединяться уже с ней
DУбрать РАЗЛИЧНЫЕ из подзапроса