Введение
Агрегатные функции (статья 399) сворачивают множество строк в одну итоговую — оконные функции, напротив, вычисляют значение для каждой строки на основе «окна» связанных с ней строк, не сворачивая результат в меньшее количество строк. В статье разберём ROW_NUMBER(), RANK(), и конструкцию OVER (PARTITION BY ... ORDER BY ...), открывающую целый класс запросов, нетривиально выразимых через обычные GROUP BY.
Концепция
OVER (PARTITION BY столбец ORDER BY столбец) определяет «окно» — для каждой строки результата оконная функция вычисляется на основе других строк, принадлежащих той же партиции (группе, определённой PARTITION BY, концептуально аналогично GROUP BY, но без сворачивания строк), упорядоченных согласно ORDER BY внутри окна. ROW_NUMBER() присваивает каждой строке внутри партиции уникальный, последовательный номер согласно порядку. RANK() похож, но строки с одинаковым значением по критерию сортировки получают одинаковый ранг, с пропуском следующих номеров (1, 2, 2, 4 — для двух строк, делящих второе место). DENSE_RANK() аналогичен RANK(), но без пропуска номеров (1, 2, 2, 3).
Пример кода: Оконные функции
-- ROW_NUMBER — нумерация заказов каждого клиента по дате (от самого нового к старому)
SELECT customer_id, id AS order_id, order_date,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS order_rank
FROM orders;
-- Практичное применение: получить только САМЫЙ ПОСЛЕДНИЙ заказ каждого клиента
SELECT * FROM (
SELECT customer_id, id AS order_id, order_date,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn
FROM orders
) ranked
WHERE rn = 1; -- только первая строка (самая последняя по дате) для каждого клиента
-- RANK против DENSE_RANK — различие при наличии "связанных" по значению строк
SELECT name, total_amount,
RANK() OVER (ORDER BY total_amount DESC) AS rank_with_gaps,
DENSE_RANK() OVER (ORDER BY total_amount DESC) AS rank_without_gaps
FROM orders;
-- LAG/LEAD — доступ к значению предыдущей/следующей строки в рамках партиции
SELECT customer_id, order_date, total_amount,
LAG(total_amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS previous_order_amount,
total_amount - LAG(total_amount) OVER (PARTITION BY customer_id ORDER BY order_date) AS amount_change
FROM orders;
Пояснения к коду
Подзапрос с ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) и последующая фильтрация WHERE rn = 1 — стандартный, широко используемый паттерн «получить топ-N строк в каждой группе», решающий задачу, которая до появления оконных функций требовала значительно более сложных и часто менее эффективных конструкций (коррелированные подзапросы с подсчётом, статья 400, или самообъединение таблицы с собой через сложное условие). LAG(total_amount) OVER (PARTITION BY customer_id ORDER BY order_date) показывает доступ к значению «предыдущей» строки в рамках той же партиции по заданному порядку — это даёт прямую, эффективную возможность вычислить изменение между последовательными заказами одного клиента без необходимости в самостоятельном, более сложном самообъединении таблицы заказов с собой.
Подводные камни
- Использование
RANK()там, где логически нуженROW_NUMBER()(или наоборот), без понимания разницы в обработке «связанных» по значению строк — для задачи «топ-3 заказа по сумме», если несколько заказов имеют ровно одинаковую сумму на границе третьего места,RANK()может вернуть больше трёх строк (все строки с одинаковым рангом 3), тогда какROW_NUMBER()гарантированно вернёт ровно одну строку для каждого числового номера независимо от связанных значений — выбор между ними должен соответствовать желаемой бизнес-логике обработки таких граничных случаев. - Забытое
ORDER BYвнутриOVER()для функций, для которых порядок принципиально важен (ROW_NUMBER(),RANK(),LAG()/LEAD()) — безORDER BYвнутри окна порядок вычисления этих функций не определён, что дляROW_NUMBER()всё ещё дало бы какую-то (но непредсказуемую, недетерминированную между разными выполнениями того же запроса) нумерацию, а не ошибку, что особенно опасно, поскольку отсутствие ошибки маскирует логическую проблему запроса. - Путаница между
PARTITION BYв оконных функциях иGROUP BY— несмотря на концептуальное сходство (оба группируют строки по значению столбца),PARTITION BYне сворачивает строки в одну на группу, какGROUP BY, а сохраняет все исходные строки, просто вычисляя оконную функцию в контексте каждой партиции отдельно; путаница между ними часто проявляется как недоумение, почему запрос сPARTITION BYвозвращает столько же строк, сколько было в исходной таблице, а не меньше (как ожидалось бы от привычки кGROUP BY). - Использование оконных функций для задач, для которых обычной агрегации с
GROUP BYбыло бы достаточно и эффективнее — оконные функции мощнееGROUP BYименно потому, что сохраняют детальные строки, но если итоговая задача действительно требует только агрегированного, свёрнутого результата (без сохранения детальных строк), обычная агрегация — более прямой, понятный и часто более эффективно оптимизируемый СУБД способ выразить ту же задачу, и оконные функции не должны применяться «по умолчанию» просто потому, что они более продвинутые или модные.