Оптимизатор хочет протолкнуть фильтр внутрь производной таблицы с DISTINCT. Эквивалентны ли исходный и прео...

Оптимизатор хочет протолкнуть фильтр внутрь производной таблицы с 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';
Проходите собеседования с ИИ помощником Hintsage

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

Да, эти запросы эквивалентны: фильтр по столбцу, который доступен после DISTINCT, можно безопасно применить до удаления дубликатов. Операции выборки строк и устранения дубликатов коммутируют в этом случае, поэтому результатом будут строки (1, 'paid') и (2, 'paid').

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

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

Основная исходная проблема — большие промежуточные наборы данных. Если отфильтровать строки до DISTINCT, сортировке или хешированию для устранения дубликатов приходится обрабатывать меньше данных.

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

Производная таблица сначала формирует множество уникальных пар id, status, а внешний запрос оставляет только строки со статусом paid. При переносе фильтра внутрь производной таблицы важно не изменить набор значений, участвующих в удалении дубликатов.

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

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

В исходном запросе выполняется логическая последовательность:

  1. Из rows выбираются уникальные пары id, status.
  2. Из них удаляются пары, для которых status не равен paid.

В преобразованном варианте сначала удаляются строки с другим статусом, затем устраняются дубликаты. DISTINCT сравнивает только id и status, а предикат использует status, присутствующий в этом наборе. Поэтому каждая строка, которая могла попасть в итог после фильтра, сохраняет те же значения до удаления дубликатов.

WITH rows(id, status) AS ( VALUES (1, 'paid'), (1, 'cancelled'), (2, 'paid') ) SELECT DISTINCT id, status FROM rows WHERE status = 'paid';

При наличии NULL результат также сохраняет эквивалентность: предикат status = 'paid' даёт для NULL значение UNKNOWN, и такая строка не проходит фильтр независимо от того, применён он до или после DISTINCT.

Это не означает, что любой фильтр можно переносить через любую операцию. Например, условие по результату COUNT, ROW_NUMBER() или LIMIT обычно нельзя протолкнуть без специального доказательства. Также нельзя ссылаться во внутреннем фильтре на столбец, не проецируемый производной таблицей.

Практическая выгода — уменьшение объёма входа для сортировки или хеш-структуры DISTINCT, а иногда и уменьшение чтения таблицы за счёт индекса. Недостаток ручного переписывания в том, что разработчик может ошибочно принять два запроса за эквивалентные; обычно безопаснее оставить оптимизатору возможность выполнить такое преобразование.

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

В отчёте требовались уникальные пары customer_id, region, после чего выбирался один регион. Исходная производная таблица строила уникальный набор по всей таблице продаж, хотя отчёт использовал только строки текущего квартала.

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

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

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

  1. Меняется ли эквивалентность, если фильтр использует столбец, не входящий в DISTINCT?

Да, это может изменить результат. Например, если производная таблица строится как SELECT DISTINCT id, внешний запрос уже не видит status; протолкнуть условие status = 'paid' внутрь можно только как отдельное доказанное преобразование, а не как механическое перемещение фильтра. Для одного id наличие платной строки и выбор только платных строк — разные операции.

  1. Можно ли безусловно протолкнуть фильтр через LIMIT внутри производной таблицы?

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

  1. Почему аналогичное преобразование опаснее при оконной функции?

Оконная функция вычисляется над определённым набором строк. Если фильтр применить до неё, изменится это окно и, например, значение ROW_NUMBER() или RANK(). Если применить фильтр после неё, нумерация рассчитывается по полному набору, поэтому перенос допустим только в случаях, где доказано сохранение входного набора окна или эквивалентность конкретного преобразования.