Программирование SQLJOIN, подзапросы и CTEРазработчик аналитических систем

Представьте отчёт по клиентам: условие фильтрации заказов можно применить до группировки или после неё. Как...

Представьте отчёт по клиентам: условие фильтрации заказов можно применить до группировки или после неё. Какой результат следует ожидать при переносе условия через границу агрегации?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Фильтр до группировки удаляет отдельные строки до расчёта агрегата, поэтому меняет состав группы и её итог. Фильтр после группировки проверяет уже рассчитанный результат группы и не влияет на входящие в неё строки. Перенос условия через границу агрегации обычно меняет смысл запроса и безопасен только при доказанной эквивалентности.

Исторический контекст

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

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

Постановка проблемы

Рассмотрим отчёт, который должен показывать клиентов с общей суммой заказов не менее 100. Если условие amount >= 100 применить до группировки, из суммы исчезнут меньшие заказы. Если применить условие total >= 100 после группировки, сначала будет рассчитана полная сумма клиента.

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

Подробное решение

Фильтр до GROUP BY аналогичен операции над исходными строками: он определяет, какие строки попадут в каждую группу. Фильтр после GROUP BY работает над одной строкой на группу и может обращаться к агрегатному результату.

WITH orders(customer_id, amount) AS ( VALUES (1, 60), (1, 60), (2, 150) ) SELECT 'after' AS variant, customer_id, total FROM ( SELECT customer_id, SUM(amount) AS total FROM orders GROUP BY customer_id ) s WHERE total >= 100 UNION ALL SELECT 'before', customer_id, total FROM ( SELECT customer_id, SUM(amount) AS total FROM orders WHERE amount >= 100 GROUP BY customer_id ) s;

В варианте after клиент 1 имеет сумму 120 и попадает в результат. В варианте before его строки по 60 исключаются до группировки, поэтому клиент 1 исчезает; остаётся только клиент 2 с суммой 150.

Условие по агрегату обычно выражают через HAVING или внешний фильтр производной таблицы. Условие по ключу группировки иногда можно безопасно применить до группировки, потому что оно одинаково ограничивает целые группы. Однако для произвольного условия по строкам, особенно если оно затрагивает столбцы, участвующие в агрегате, эквивалентность нужно доказывать.

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

Ситуация из практики

В отчёте нужно было выбрать клиентов, у которых сумма всех заказов за месяц превышает лимит. Один из вариантов помещал фильтр по минимальной сумме отдельного заказа внутрь производной таблицы. Запрос стал быстрее, потому что обрабатывал меньше строк, но итоговые суммы уменьшились и часть клиентов ошибочно перестала превышать лимит.

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

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

Что кандидаты часто упускают

  1. Может ли оптимизатор сам перенести фильтр ниже группировки?

Да, если это преобразование реляционно эквивалентно. Например, предикат по ключу группировки часто можно применить к исходным строкам до GROUP BY, поскольку он не меняет состав оставшихся групп. Предикат по SUM, COUNT или другому агрегату нельзя безусловно заменить фильтром по отдельным строкам: сумма строк, прошедших такой фильтр, может отличаться от полной суммы.

  1. Чем отличается фильтр по агрегату от фильтра по исходной строке?

Фильтр по агрегату оценивается после формирования группы и обычно выражается через HAVING либо внешний запрос. Фильтр по исходной строке оценивается до группировки и определяет, какие значения войдут в SUM, COUNT или другой агрегат. Смешение этих уровней может сделать запрос синтаксически недопустимым или, что опаснее, логически корректным, но дающим другой результат.

  1. Как на перенос фильтра влияют значения NULL в агрегатах?

Большинство агрегатных функций, включая SUM, не учитывают NULL-значения, а сумма группы, состоящей только из NULL, может быть NULL. Условие сравнения с таким результатом не даст TRUE, поэтому группа не пройдёт фильтр; добавление COALESCE изменит это поведение и должно быть частью явно принятой бизнес-семантики, а не случайным исправлением запроса.