Оконные функции: ROW_NUMBER, LAG, RANK и агрегаты
Оконные функции — одна из самых мощных и практичных фич SQL, которую многие разработчики открывают для себя слишком поздно. Они позволяют вычислять значение для каждой строки с учётом набора соседних строк (окна) — без схлопывания в GROUP BY, без подзапросов, без обработки в коде. ROW_NUMBER(), RANK(), DENSE_RANK() для ранжирования. LAG(), LEAD() для сравнения со смежными периодами. SUM() OVER(), AVG() OVER() для нарастающих итогов и скользящих средних. FIRST_VALUE(), LAST_VALUE(), NTH_VALUE() для доступа к значениям в окне. Каждая задача типа 'топ-N внутри группы', 'процент от категории', 'изменение за период' — это одна оконная функция вместо сложного подзапроса или нескольких запросов.
Почему это важно: Без оконных функций разработчики решают аналитические задачи одним из двух неоптимальных способов. Первый: загружают все данные в приложение и обрабатывают в памяти — 100MB данных в Ruby/Python, медленно и нагружает память сервера. Второй: пишут сложные коррелированные подзапросы или несколько JOIN, которые нечитаемы и часто неэффективны. Оконные функции выполняют аналитику там, где живут данные — в базе. PostgreSQL оптимизирует их через единственный проход по данным с сортировкой. Для каждого аналитического дашборда, каждого отчёта, каждого API с метриками — оконные функции сокращают количество запросов и код в разы.
Главная идея
Синтаксис оконной функции: функция() OVER (определение_окна). Определение окна состоит из трёх необязательных частей: PARTITION BY — разбивает строки на независимые группы (как GROUP BY, но строки не схлопываются). ORDER BY — задаёт порядок строк внутри раздела (обязателен для ранжирования и нарастающих итогов). Frame clause — определяет границы окна относительно текущей строки: ROWS UNBOUNDED PRECEDING (от начала раздела), ROWS BETWEEN 6 PRECEDING AND CURRENT ROW (скользящие 7 строк). Без PARTITION BY окно — вся таблица. Без frame clause при наличии ORDER BY PostgreSQL использует RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW по умолчанию. Оконные функции выполняются после WHERE, GROUP BY, HAVING — но до ORDER BY и LIMIT внешнего запроса. Результат оконной функции нельзя использовать в WHERE той же команды — нужен подзапрос или CTE.
Как это выглядит на практике
- Тебе нужно показать топ-5 товаров по продажам в каждой категории на странице категорий. Первое решение: загрузить все продажи в Ruby, сгруппировать по категориям, отсортировать, взять топ-5 каждой. При 1000 категорий — медленно и расточительно.
- Узнаёшь про оконные функции. Пишешь ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales DESC) и фильтруешь WHERE rn <= 5 через CTE. Один запрос возвращает ровно нужные данные — 50ms вместо 5 секунд.
- Аналитик просит добавить в дашборд процентное изменение выручки к предыдущему месяцу. Раньше писал два запроса и вычислял в коде. С LAG() OVER (ORDER BY month): revenue / LAG(revenue) OVER (ORDER BY month) - 1 — один запрос.
- Появляется задача: для каждого заказа показать порядковый номер покупки данного клиента (первая, вторая, третья...). ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at) решает за секунду то, что иначе требовало CTE с самоджойном.
- Нужен нарастающий итог выручки с начала года. SUM(amount) OVER (ORDER BY day ROWS UNBOUNDED PRECEDING) — три слова меняют обычную агрегацию в нарастающий итог.
- Скользящее среднее за 30 дней для сглаживания сезонных колебаний: AVG(revenue) OVER (ORDER BY day ROWS BETWEEN 29 PRECEDING AND CURRENT ROW). Данные не покидают БД.
- Задача дедупликации: в таблице дубликаты пользователей, нужно оставить последнего. DELETE FROM users WHERE id NOT IN (SELECT MAX(id) OVER (PARTITION BY email) FROM users) — один запрос.
- Понимаешь что оконные функции нельзя использовать в WHERE напрямую. Первая попытка: WHERE RANK() OVER (...) <= 3 — синтаксическая ошибка. Решение: выносить в CTE или подзапрос, фильтровать снаружи.
- На code review видишь запрос с LEFT JOIN + подзапрос для получения предыдущего значения (classic self-join anti-pattern). Показываешь коллеге LAG() — код становится в 3 раза короче и понятнее.
- Требование: распределить пользователей по квартилям расходов (25%/50%/75%/100%). NTILE(4) OVER (ORDER BY total_spent) делит на 4 равные группы. Один запрос вместо двух-трёх с PERCENTILE_DISC.
- На производственной нагрузке замечаешь что запрос с несколькими оконными функциями работает медленно. EXPLAIN показывает несколько Sort узлов — каждая оконная функция с разным ORDER BY требует отдельную сортировку. Используешь WINDOW clause для переиспользования определения окна.
- Интервью: просят написать запрос 'найти второй по величине заказ каждого пользователя'. Пишешь: WITH ranked AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC) AS rn FROM orders) SELECT * FROM ranked WHERE rn = 2. Решение за 30 секунд.
Примеры кода
Ранжирование: ROW_NUMBER, RANK, DENSE_RANK
-- Топ-3 товара по цене в каждой категории
WITH ranked_products AS (
SELECT
p.id,
p.name,
p.price,
c.name AS category,
ROW_NUMBER() OVER (PARTITION BY p.category_id ORDER BY p.price DESC) AS row_num,
RANK() OVER (PARTITION BY p.category_id ORDER BY p.price DESC) AS rank,
DENSE_RANK() OVER (PARTITION BY p.category_id ORDER BY p.price DESC) AS dense_rank
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.is_active = true
)
SELECT *
FROM ranked_products
WHERE row_num <= 3
ORDER BY category, row_num;
-- Разница между ROW_NUMBER, RANK, DENSE_RANK при ровных значениях:
-- Если два товара стоят одинаково (цена = 1000):
-- ROW_NUMBER: 1, 2, 3, 4 (всегда уникальные, произвольный порядок при ровных)
-- RANK: 1, 1, 3, 4 (пропуск 2 после двух первых мест)
-- DENSE_RANK: 1, 1, 2, 3 (без пропусков)
-- Перцентили: в каком квантиле находится каждый пользователь по расходам?
SELECT
user_id,
total_spent,
NTILE(4) OVER (ORDER BY total_spent) AS quartile,
PERCENT_RANK() OVER (ORDER BY total_spent) AS percentile
FROM (
SELECT user_id, SUM(amount) AS total_spent
FROM orders
WHERE status = 'completed'
GROUP BY user_id
) user_totals
ORDER BY total_spent DESC;
ROW_NUMBER() — уникальный номер, при ровных значениях порядок непредсказуем. RANK() — одинаковые места при ровных значениях, затем пропуск. DENSE_RANK() — одинаковые места без пропуска. Для пагинации и дедупликации — ROW_NUMBER(). Для спортивных рейтингов — RANK(). NTILE(n) делит на n равных групп — удобно для квартилей и децилей.
LAG, LEAD и сравнение с предыдущими периодами
-- Динамика выручки: абсолютное и процентное изменение к предыдущему месяцу
WITH monthly_revenue AS (
SELECT
DATE_TRUNC('month', created_at) AS month,
SUM(amount) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at)
)
SELECT
month,
revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY month) AS absolute_change,
ROUND(
(revenue - LAG(revenue, 1) OVER (ORDER BY month))
* 100.0
/ NULLIF(LAG(revenue, 1) OVER (ORDER BY month), 0),
2
) AS pct_change,
LEAD(revenue, 1) OVER (ORDER BY month) AS next_month_revenue
FROM monthly_revenue
ORDER BY month;
-- Разница с одним из лучших показателей: отклонение от максимума
SELECT
month,
revenue,
MAX(revenue) OVER () AS max_revenue,
revenue - MAX(revenue) OVER () AS vs_max,
FIRST_VALUE(revenue) OVER (ORDER BY month) AS first_month,
revenue - FIRST_VALUE(revenue) OVER (ORDER BY month) AS vs_start
FROM monthly_revenue
ORDER BY month;
LAG(column, n) — значение из строки на n позиций назад в окне. LEAD(column, n) — вперёд. Третий аргумент — значение по умолчанию если нет предыдущей/следующей строки: LAG(revenue, 1, 0). NULLIF в процентном изменении защищает от деления на ноль. FIRST_VALUE() и LAST_VALUE() дают первое/последнее значение в окне.
Нарастающие итоги и скользящие агрегаты
-- Нарастающий итог выручки и скользящее среднее за 7 дней
WITH daily AS (
SELECT
DATE(created_at) AS day,
SUM(amount) AS revenue
FROM orders
WHERE status = 'completed'
AND created_at >= NOW() - INTERVAL '90 days'
GROUP BY DATE(created_at)
)
SELECT
day,
revenue,
-- Нарастающий итог с начала периода
SUM(revenue) OVER (
ORDER BY day
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total,
-- Скользящее среднее за 7 дней (текущий + 6 предыдущих)
ROUND(AVG(revenue) OVER (
ORDER BY day
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
), 2) AS moving_avg_7d,
-- Процент от общей выручки за период
ROUND(revenue * 100.0 / SUM(revenue) OVER (), 2) AS pct_of_total,
-- Нарастающий процент (cumulative distribution)
ROUND(
SUM(revenue) OVER (ORDER BY day ROWS UNBOUNDED PRECEDING)
* 100.0 / SUM(revenue) OVER (),
2
) AS cumulative_pct
FROM daily
ORDER BY day;
-- Найти дни с аномально низкой выручкой (ниже 50% от скользящего среднего)
WITH daily_with_avg AS (
SELECT
day,
revenue,
AVG(revenue) OVER (
ORDER BY day ROWS BETWEEN 13 PRECEDING AND CURRENT ROW
) AS moving_avg_14d
FROM daily
)
SELECT day, revenue, ROUND(moving_avg_14d, 0) AS avg_14d
FROM daily_with_avg
WHERE revenue < moving_avg_14d * 0.5
ORDER BY day;
Frame clause определяет границы окна: ROWS UNBOUNDED PRECEDING — от первой строки раздела; ROWS BETWEEN 6 PRECEDING AND CURRENT ROW — скользящие 7 строк. Разница ROWS vs RANGE: ROWS считает физические строки, RANGE считает по значению ORDER BY — при одинаковых датах RANGE включает все строки с той же датой. Для скользящих агрегатов по времени — всегда используй ROWS.
Что происходит под капотом
- Оконные функции выполняются в отдельной фазе плана после WHERE, JOIN, GROUP BY, HAVING. PostgreSQL сначала строит промежуточный набор строк, затем применяет оконные функции. Именно поэтому их нельзя использовать в WHERE той же команды.
- Sort узел: каждая оконная функция с уникальным ORDER BY требует отдельную сортировку O(n log n). WINDOW clause позволяет переиспользовать определение: WINDOW w AS (PARTITION BY category_id ORDER BY price). Это экономит повторные сортировки.
- Frame clause ROWS vs RANGE: ROWS считает физические строки. RANGE считает логически по значению ORDER BY — если несколько строк имеют одинаковое значение ORDER BY, RANGE включает их все в текущую строку. Для временных рядов с возможными одинаковыми датами — ROWS безопаснее.
- ROW_NUMBER() не имеет frame clause — она всегда смотрит на весь раздел. RANK() и DENSE_RANK() аналогично. LAG() и LEAD() — фиксированное смещение, frame clause не применяется.
- Производительность: оконная функция над 1M строк с сортировкой — это Sort O(n log n) + Window Agg O(n). Для скользящих агрегатов PostgreSQL использует incremental mode: не пересчитывает окно с нуля для каждой строки, а обновляет состояние (добавляет входящую, убирает выходящую строку).
- WindowAgg узел в EXPLAIN: Sequential Window Aggregation проходит по отсортированным данным слева направо, поддерживая состояние агрегата. Для PARTITION BY — перезапускает агрегат при смене значения партиционирования.
- FILTER с оконными функциями: SUM(amount) FILTER (WHERE status = 'completed') OVER (PARTITION BY user_id) — условная агрегация внутри окна. Мощный инструмент для условных нарастающих итогов.
- Distinct в оконных функциях: COUNT(DISTINCT product_id) OVER (...) не поддерживается в большинстве СУБД включая PostgreSQL. Обходной путь — плотная нумерация через DENSE_RANK() и подсчёт уникальных рангов.
Типичные ошибки и заблуждения
- Ошибка: оконные функции можно использовать в WHERE. Нет — они недоступны в WHERE и HAVING той же команды SELECT. Порядок выполнения: WHERE/JOIN/GROUP BY/HAVING → оконные функции → ORDER BY/LIMIT. Решение: CTE или подзапрос.
- Ошибка: PARTITION BY обязателен. Без PARTITION BY окно — вся таблица результата. Это корректно и полезно для процента от итога, нарастающего итога по всей таблице, глобального ранжирования.
- Ошибка: RANK и DENSE_RANK дают одинаковый результат. RANK: 1,2,2,4 — пропуск после ровных мест (как в спорте). DENSE_RANK: 1,2,2,3 — без пропусков. ROW_NUMBER: 1,2,3,4 — всегда уникальные.
- Ошибка: оконные функции медленнее двух запросов. Два запроса = два прохода данных + сетевой round-trip + обработка в коде. Одна оконная функция = один проход с сортировкой. При больших данных оконная функция выигрывает.
- Ошибка: LAST_VALUE() возвращает последнее значение раздела. По умолчанию frame clause = RANGE UNBOUNDED PRECEDING TO CURRENT ROW — LAST_VALUE видит только до текущей строки. Для истинного последнего значения: LAST_VALUE(x) OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING).
- Ошибка: можно написать SUM(DISTINCT x) OVER (...). PostgreSQL не поддерживает DISTINCT в оконных агрегатах. Используй подзапрос с дедупликацией или DENSE_RANK трюк.
Ключевые выводы
- OVER (PARTITION BY ... ORDER BY ...) — определение окна; PARTITION BY не обязателен.
- ROW_NUMBER() для уникальной нумерации; RANK()/DENSE_RANK() для рейтингов с ровными значениями.
- LAG(x, n) / LEAD(x, n) — значения предыдущей/следующей строки без self-join.
- Frame clause ROWS BETWEEN управляет границами окна для скользящих агрегатов.
- Оконные функции недоступны в WHERE — фильтруй результат через CTE или подзапрос.
Термины урока
Связь с работой backend-разработчика
Запомни три главных паттерна оконных функций: топ-N внутри группы (ROW_NUMBER + PARTITION BY + WHERE rn <= N), сравнение с предыдущим периодом (LAG() + ORDER BY date), нарастающий итог или скользящее среднее (SUM/AVG + ROWS BETWEEN). Эти три паттерна покрывают 90% аналитических требований в backend-API. Всё это — один запрос, один round-trip, данные не покидают БД.
Мини-разбор реальной ситуации
Аналитический дашборд для маркетинга: топ-5 продуктов по выручке в каждой категории + процент от выручки категории + изменение за неделю. Первоначальная реализация: 3 отдельных запроса + merge в Ruby, 200 строк кода, 1.2 секунды. Переписано с оконными функциями: WITH ranked AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY revenue DESC) AS rn, revenue * 100.0 / SUM(revenue) OVER (PARTITION BY category_id) AS pct_of_category, revenue - LAG(revenue) OVER (PARTITION BY category_id ORDER BY week) AS week_delta FROM product_weekly_stats) SELECT * FROM ranked WHERE rn <= 5. Один запрос, 45 миллисекунд.
Что запомнить
- Оконные функции выполняются ПОСЛЕ WHERE/GROUP BY — нельзя фильтровать по ним в той же команде, выноси в CTE.
- LAST_VALUE() по умолчанию смотрит только до текущей строки — добавляй ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
- PARTITION BY — не обязателен: без него окно = вся таблица (полезно для процентов и нарастающих итогов по всей таблице).
Итог
Оконные функции — это аналитический SQL на максимальной мощности. Они заменяют коррелированные подзапросы, self-join, обработку в коде — делая сложные аналитические запросы читаемыми и эффективными. После освоения OVER, PARTITION BY, ORDER BY и frame clause ты начинаешь видеть аналитические задачи иначе: не 'как написать несколько запросов и соединить результаты', а 'как выразить всю логику в одном запросе с нужными окнами'.