Оптимизатор хочет протолкнуть фильтр внутрь производной таблицы с DISTINCT. Эквивалентны ли исходный и преобразованный запросы, и почему?
WITH rows(id, status) AS (
VALUES (1, 'paid'), (1, 'cancelled'), (2, 'paid')
)
SELECT id, status
FROM (
SELECT DISTINCT id, status
FROM rows
) AS d
WHERE status = 'paid';
SELECT DISTINCT id, status
FROM rows
WHERE status = 'paid';
Да, эти запросы эквивалентны: фильтр по столбцу, который доступен после DISTINCT, можно безопасно применить до удаления дубликатов. Операции выборки строк и устранения дубликатов коммутируют в этом случае, поэтому результатом будут строки (1, 'paid') и (2, 'paid').
Такое преобразование относится к оптимизации декларативных запросов. SQL описывает требуемый результат, а не обязательный порядок физических операций, поэтому оптимизатор может менять порядок реляционных операций, если сохраняется семантика.
Основная исходная проблема — большие промежуточные наборы данных. Если отфильтровать строки до DISTINCT, сортировке или хешированию для устранения дубликатов приходится обрабатывать меньше данных.
Производная таблица сначала формирует множество уникальных пар id, status, а внешний запрос оставляет только строки со статусом paid. При переносе фильтра внутрь производной таблицы важно не изменить набор значений, участвующих в удалении дубликатов.
Неверное преобразование может изменить результат, если фильтр зависит от данных, которых больше нет в производной таблице, или если между операциями находятся группировка, оконная функция, ограничение числа строк либо другая операция с особой семантикой.
В исходном запросе выполняется логическая последовательность:
rows выбираются уникальные пары id, status.status не равен paid.В преобразованном варианте сначала удаляются строки с другим статусом, затем устраняются дубликаты. DISTINCT сравнивает только id и status, а предикат использует status, присутствующий в этом наборе. Поэтому каждая строка, которая могла попасть в итог после фильтра, сохраняет те же значения до удаления дубликатов.
При наличии NULL результат также сохраняет эквивалентность: предикат status = 'paid' даёт для NULL значение UNKNOWN, и такая строка не проходит фильтр независимо от того, применён он до или после DISTINCT.
Это не означает, что любой фильтр можно переносить через любую операцию. Например, условие по результату COUNT, ROW_NUMBER() или LIMIT обычно нельзя протолкнуть без специального доказательства. Также нельзя ссылаться во внутреннем фильтре на столбец, не проецируемый производной таблицей.
Практическая выгода — уменьшение объёма входа для сортировки или хеш-структуры DISTINCT, а иногда и уменьшение чтения таблицы за счёт индекса. Недостаток ручного переписывания в том, что разработчик может ошибочно принять два запроса за эквивалентные; обычно безопаснее оставить оптимизатору возможность выполнить такое преобразование.
В отчёте требовались уникальные пары customer_id, region, после чего выбирался один регион. Исходная производная таблица строила уникальный набор по всей таблице продаж, хотя отчёт использовал только строки текущего квартала.
Рассматривались два варианта. Перенос фильтра по кварталу внутрь производной таблицы уменьшал объём данных перед DISTINCT, но требовал убедиться, что фильтр не меняет бизнес-смысл уникальности. Добавление индекса могло ускорить чтение, однако не гарантировало уменьшения промежуточного набора и зависело от распределения данных.
Был выбран безопасный вариант с фильтром внутри производной таблицы: он ссылался только на исходный столбец периода, а DISTINCT применялся к тем же проецируемым столбцам. В результате уменьшились объём сортировки и пиковое потребление памяти, при этом набор уникальных пар сохранился.
DISTINCT?Да, это может изменить результат. Например, если производная таблица строится как SELECT DISTINCT id, внешний запрос уже не видит status; протолкнуть условие status = 'paid' внутрь можно только как отдельное доказанное преобразование, а не как механическое перемещение фильтра. Для одного id наличие платной строки и выбор только платных строк — разные операции.
LIMIT внутри производной таблицы?Нет. LIMIT ограничивает конкретный набор строк, часто после неявно или явно заданного порядка. Если сначала отфильтровать строки, в ограниченный набор могут попасть другие записи. Поэтому перенос предиката через LIMIT требует специальных условий, например доказательства, что фильтр не исключает строки из уже выбранного результата.
Оконная функция вычисляется над определённым набором строк. Если фильтр применить до неё, изменится это окно и, например, значение ROW_NUMBER() или RANK(). Если применить фильтр после неё, нумерация рассчитывается по полному набору, поэтому перенос допустим только в случаях, где доказано сохранение входного набора окна или эквивалентность конкретного преобразования.