Нужно вывести для каждого клиента сумму его заказов. Почему замена коррелированного подзапроса на прямое соединение с таблицей заказов может изменить результат?
Прямое соединение не эквивалентно коррелированному подзапросу, если у клиента может быть несколько заказов: оно размножает строку клиента по числу совпадений. Чтобы сохранить одну строку на клиента, перед соединением нужно агрегировать заказы по идентификатору клиента либо выполнять агрегацию после соединения с учётом изменившейся кардинальности.
Коррелированные подзапросы позволяют выразить вычисление относительно текущей строки внешнего запроса: для каждого клиента отдельно найти связанные заказы и получить их сумму. Такой синтаксис удобен для формулировки бизнес-правила, но не всегда является лучшей формой выполнения.
Производные таблицы, группировка и преобразование запроса в наборную форму позволяют сначала обработать связанные строки, а затем присоединить уже один результат на клиента. Это уменьшает риск размножения строк и часто даёт оптимизатору более очевидную структуру вычислений.
Пусть у клиента нет заказов, один заказ или несколько заказов. Коррелированный агрегатный подзапрос возвращает одно значение для каждой строки клиента, тогда как обычное соединение возвращает отдельную строку для каждого совпавшего заказа.
Из-за этого могут измениться не только количество строк, но и другие агрегаты. Например, сумма платежей клиента, соединённая с заказами, может повториться для каждой позиции заказа и быть ошибочно просуммирована ещё раз.
Коррелированный подзапрос вычисляет агрегат в контексте текущего клиента. Агрегат без GROUP BY возвращает одно значение; если подходящих заказов нет, SUM обычно возвращает NULL, который при необходимости заменяют на ноль с помощью COALESCE.
Безопасная наборная замена состоит из двух шагов: сначала сгруппировать заказы по клиенту, затем присоединить полученную производную таблицу к клиентам. Важно выбрать LEFT JOIN, если нужно сохранить клиентов без заказов.
Здесь производная таблица содержит не более одной строки на client_id, поэтому соединение не размножает клиентов. Если вместо неё присоединить исходную таблицу orders, клиент с тремя заказами появится три раза.
Другой корректный вариант — выполнить соединение, затем группировать внешний результат по клиенту. Однако при наличии дополнительных таблиц это требует внимательно проверять их кардинальность: соединение с позициями или платежами может создать декартово размножение внутри группы.
Есть и семантические ограничения. SUM над отсутствующими строками, COUNT(*), COUNT(column) и AVG имеют разные результаты; при переписывании нужно сохранить нужную обработку пустого множества. Кроме того, коррелированный подзапрос может быть оптимизирован сервером самостоятельно, поэтому одинаковая логика не означает одинаковый физический план.
В отчёте требовалась одна строка на клиента и общая сумма заказов. Первый вариант напрямую соединял клиентов с заказами, а затем добавлял поля заказа. Он был простым, но клиенты с несколькими заказами появлялись многократно, а последующая агрегация платежей давала завышенные значения.
Второй вариант использовал коррелированный SUM. Он точно выражал требование «получить сумму для текущего клиента», но при большом числе клиентов мог быть менее предсказуемым с точки зрения плана, особенно если оптимизатор не преобразовывал подзапрос в наборную операцию.
Выбранным решением стала предварительная агрегация заказов по клиенту и LEFT JOIN к клиентам. Оно явно закрепило кардинальность «один клиент — не более одной агрегированной строки», сохранило клиентов без заказов и позволило безопасно присоединять другие уже агрегированные показатели.
Что произойдёт, если заменить LEFT JOIN на INNER JOIN в такой переписанной форме?
Клиенты без заказов исчезнут, потому что для них в агрегированной производной таблице нет строки. Коррелированный подзапрос при этом всё ещё вернул бы строку клиента со значением NULL или нулём после COALESCE. Поэтому тип соединения — часть семантики переписывания, а не только вопрос производительности.
Почему добавление DISTINCT после прямого соединения не является универсальным исправлением?
DISTINCT удаляет полностью одинаковые строки, но не устраняет ошибочную кардинальность, если строки отличаются полями заказа или другими присоединёнными атрибутами. Он также не отменяет повторное участие одной и той же суммы в последующей агрегации и может скрыть ошибку в модели соединения.
Всегда ли предварительная агрегация быстрее коррелированного подзапроса?
Нет. Оптимизатор может преобразовать коррелированный агрегат в эквивалентную наборную операцию, а при небольшом числе внешних строк и подходящем индексе адресные обращения могут быть эффективны. Предварительная агрегация делает семантику и ожидаемую кардинальность явными, но её фактическая стоимость зависит от объёма данных, индексов, распределения ключей и плана конкретной СУБД.