Что такое оконные функции и зачем они нужны
Оконная функция выполняет вычисление по группе строк, связанных с текущей строкой, но при этом не схлопывает результат в одну строку (как GROUP BY). Каждая строка остаётся в результате — к ней просто добавляется новый столбец.
Простой пример: у вас таблица заказов. Вы хотите к каждому заказу добавить сумму всех заказов этого же клиента. С GROUP BY вы получите одну строку на клиента. С оконной функцией — все строки останутся, но в каждой появится итог.
Общий синтаксис
PARTITION BY столбец_группировки
ORDER BY столбец_сортировки
ROWS BETWEEN ... AND ... ← фрейм (опционально)
)
ROW_NUMBER, RANK, DENSE_RANK
Три функции, которые присваивают строкам номер внутри окна. Разница — в обработке одинаковых значений (дубликатов).
Пример: топ-3 заказа каждого клиента
Классическая задача: для каждого клиента найти 3 самых крупных заказа. Без оконных функций — боль. С ROW_NUMBER — три строчки.
WITH ranked AS ( SELECT user_id, order_id, amount, order_date, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY amount DESC ) AS rn FROM orders ) SELECT * FROM ranked WHERE rn <= 3;
| user_id | order_id | amount | order_date | rn |
|---|---|---|---|---|
| 101 | A-55 | 8 200 | 2025-03-12 | 1 |
| 101 | A-41 | 5 400 | 2025-02-08 | 2 |
| 101 | A-63 | 3 100 | 2025-04-01 | 3 |
| 202 | B-12 | 12 000 | 2025-01-20 | 1 |
| 202 | B-34 | 7 600 | 2025-03-05 | 2 |
| 202 | B-28 | 4 900 | 2025-02-18 | 3 |
Когда использовать RANK вместо ROW_NUMBER
Если при одинаковых значениях нужно, чтобы оба получили одинаковый ранг — используйте RANK. Пример: рейтинг продавцов по выручке. Если у двоих одинаковая выручка — оба на 2-м месте, следующий — на 4-м.
SELECT seller_id, revenue, RANK() OVER (ORDER BY revenue DESC) AS rank_val, DENSE_RANK() OVER (ORDER BY revenue DESC) AS dense_rank_val, ROW_NUMBER() OVER (ORDER BY revenue DESC) AS row_num FROM sellers;
| seller_id | revenue | rank_val | dense_rank_val | row_num |
|---|---|---|---|---|
| S-1 | 50 000 | 1 | 1 | 1 |
| S-2 | 42 000 | 2 | 2 | 2 |
| S-3 | 42 000 | 2 | 2 | 3 |
| S-4 | 38 000 | 4 | 3 | 4 |
Видно: RANK пропустил 3 (после двух вторых мест — сразу 4). DENSE_RANK — не пропустил (после двух вторых — сразу 3). ROW_NUMBER дал уникальные номера обоим.
LAG и LEAD: доступ к предыдущей и следующей строке
LAG() — значение из предыдущей строки. LEAD() — из следующей. Незаменимы для расчёта изменений: «на сколько выросла выручка по сравнению с прошлым месяцем?», «сколько времени между двумя действиями пользователя?».
Пример: месячный рост выручки
WITH monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(amount) AS revenue FROM orders GROUP BY 1 ) SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS prev_revenue, ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month)) / LAG(revenue) OVER (ORDER BY month), 1) AS mom_growth_pct FROM monthly;
| month | revenue | prev_revenue | mom_growth_pct |
|---|---|---|---|
| 2025-01 | 1 200 000 | NULL | NULL |
| 2025-02 | 1 350 000 | 1 200 000 | +12.5% |
| 2025-03 | 1 280 000 | 1 350 000 | −5.2% |
| 2025-04 | 1 510 000 | 1 280 000 | +18.0% |
Первая строка — NULL, потому что у января нет «предыдущего» месяца. Это нормальное поведение LAG. Можно задать значение по умолчанию: LAG(revenue, 1, 0) — третий аргумент.
Пример: время между действиями пользователя (сессии)
Задача sessionization — одна из классических на собесах. Определяем сессии: если между двумя действиями прошло больше 30 минут — это новая сессия.
WITH with_prev AS ( SELECT user_id, event_time, LAG(event_time) OVER ( PARTITION BY user_id ORDER BY event_time ) AS prev_time FROM events ), with_flag AS ( SELECT *, CASE WHEN prev_time IS NULL OR event_time - prev_time > INTERVAL '30 minutes' THEN 1 ELSE 0 END AS new_session FROM with_prev ) SELECT user_id, event_time, SUM(new_session) OVER ( PARTITION BY user_id ORDER BY event_time ) AS session_id FROM with_flag;
SUM, AVG, COUNT OVER: агрегатные оконные функции
Любую агрегатную функцию (SUM, AVG, COUNT, MAX, MIN) можно использовать как оконную — просто добавив OVER(). Это даёт мощные возможности: running total, скользящее среднее, доля от общего.
Running total — накопительная сумма
SELECT order_date, SUM(amount) AS daily_revenue, SUM(SUM(amount)) OVER ( ORDER BY order_date ) AS running_total FROM orders GROUP BY order_date ORDER BY order_date;
SUM(SUM(amount)) — выглядит странно, но работает: внутренний SUM группирует по дню (GROUP BY), внешний SUM OVER — накапливает по окну. Это стандартный паттерн running total.
Доля от общего — процент каждой категории
SELECT category, SUM(amount) AS revenue, ROUND(100.0 * SUM(amount) / SUM(SUM(amount)) OVER (), 1) AS pct_of_total FROM orders GROUP BY category ORDER BY revenue DESC;
OVER() без PARTITION BY и без ORDER BY — окно = вся таблица. SUM OVER() даёт общую сумму, к которой мы делим выручку каждой категории. Элегантно и часто встречается на собесе.
Скользящее среднее (Moving Average)
SELECT day, daily_users, ROUND(AVG(daily_users) OVER ( ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ), 0) AS ma_7d FROM daily_stats;
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW — это фрейм: берём текущую строку и 6 предыдущих (итого 7 дней). AVG по этому окну — скользящее среднее. Снимает дневной шум и показывает тренд.
Фреймы окна: ROWS и RANGE
Фрейм определяет, какие строки внутри окна участвуют в вычислении. По умолчанию (если есть ORDER BY) — от начала окна до текущей строки. Но можно задать явно.
| Фрейм | Что делает | Пример использования |
|---|---|---|
| ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | От начала до текущей строки (по умолчанию с ORDER BY) | Running total, накопительный LTV |
| ROWS BETWEEN N PRECEDING AND CURRENT ROW | Последние N+1 строк | Скользящее среднее за 7 дней |
| ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | Всё окно целиком | Процент от общей суммы |
| ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING | Предыдущая + текущая + следующая строка | Сглаживание выбросов |
Задачи с собеседований: оконные функции
Задача 1: Разбейте пользователей по децилям по сумме покупок
Эта задача встречается на собесах в Яндексе и Авито. NTILE(10) разбивает строки на 10 равных групп.
WITH user_revenue AS ( SELECT user_id, SUM(amount) AS total_revenue FROM orders GROUP BY user_id ) SELECT NTILE(10) OVER (ORDER BY total_revenue DESC) AS decile, COUNT(*) AS users, SUM(total_revenue) AS revenue, ROUND(100.0 * SUM(total_revenue) / SUM(SUM(total_revenue)) OVER (), 1) AS pct_revenue FROM ( SELECT user_id, total_revenue, NTILE(10) OVER (ORDER BY total_revenue DESC) AS decile FROM user_revenue ) t GROUP BY decile ORDER BY decile;
Типичный результат: топ-10% пользователей приносят 60–80% выручки. Это правило Парето в действии — и его часто спрашивают на кейсовых интервью.
Задача 2: Найдите клиентов, у которых сумма следующего заказа меньше предыдущего
SELECT user_id, order_date, amount, LAG(amount) OVER ( PARTITION BY user_id ORDER BY order_date ) AS prev_amount FROM orders HAVING amount < LAG(amount) OVER ( PARTITION BY user_id ORDER BY order_date ); -- Так не работает! Оконные функции нельзя в WHERE/HAVING -- Правильный способ: WITH with_prev AS ( SELECT user_id, order_date, amount, LAG(amount) OVER ( PARTITION BY user_id ORDER BY order_date ) AS prev_amount FROM orders ) SELECT * FROM with_prev WHERE amount < prev_amount;
Задача 3: Когортный Retention через оконную функцию
Комбинация оконных функций с когортным анализом — верх мастерства на собесе. FIRST_VALUE берёт размер когорты, чтобы посчитать процент.
WITH cohort_activity AS ( SELECT DATE_TRUNC('month', u.registration_date) AS cohort, DATE_PART('month', AGE(DATE_TRUNC('month', a.activity_date), DATE_TRUNC('month', u.registration_date)) )::INT AS period, COUNT(DISTINCT u.user_id) AS active_users FROM users u JOIN user_activity a ON u.user_id = a.user_id GROUP BY 1, 2 ) SELECT cohort, period, active_users, ROUND(100.0 * active_users / FIRST_VALUE(active_users) OVER ( PARTITION BY cohort ORDER BY period ), 1 ) AS retention_pct FROM cohort_activity ORDER BY cohort, period;
Шпаргалка: какую функцию когда использовать
| Задача | Функция | Пример |
|---|---|---|
| Топ-N по группам | ROW_NUMBER | Последний заказ каждого клиента |
| Рейтинг с дубликатами | RANK / DENSE_RANK | Рейтинг продавцов по выручке |
| Сравнение с предыдущим | LAG | MoM рост выручки |
| Время до следующего | LEAD | Когда будет следующий заказ |
| Накопительный итог | SUM OVER (ORDER BY) | Running total, кумулятивный LTV |
| Доля от общего | SUM OVER () | % выручки по категориям |
| Скользящее среднее | AVG OVER (ROWS N PRECEDING) | MA-7 по DAU |
| Разбивка на группы | NTILE(N) | Децили пользователей по LTV |
| Первое/последнее в группе | FIRST_VALUE / LAST_VALUE | Базовый размер когорты для retention |
| Определение сессий | LAG + SUM OVER | Sessionization по 30-мин gap |
Вопросы про оконные функции на собесе
- В чём разница между PARTITION BY и GROUP BY?
- Чем отличаются ROW_NUMBER, RANK и DENSE_RANK? Приведите пример, когда результат будет разным.
- Для каждого пользователя найдите его последний заказ. Напишите SQL.
- Посчитайте month-over-month рост DAU. Напишите запрос с LAG.
- Разбейте пользователей по децилям по сумме покупок. Какой процент выручки даёт топ-10%?
- Определите сессии пользователей с gap > 30 минут. Напишите запрос.
- Постройте когортную таблицу retention. Используйте оконные функции для расчёта процента.
- Можно ли использовать оконную функцию в WHERE? Почему?
- Ваш запрос работает 10 минут на 100 млн строк. Как оптимизировать?
Связанные материалы
Главное про оконные функции
Оконные функции — это то, что отделяет junior от middle в SQL. Они позволяют решать задачи, которые без них требуют громоздких подзапросов или вообще невозможны: ранжирование по группам, сравнение с предыдущим значением, скользящие средние, sessionization.
На собеседовании в BigTech вас точно спросят задачу на оконные функции. Самые частые паттерны: «топ-N по группам» (ROW_NUMBER), «рост метрики» (LAG), «доля от общего» (SUM OVER()). Если знаете эти три — вы закрываете 80% задач.
Следующий шаг: откройте любой SQL-тренажёр и решите 10 задач на оконные функции. Теория без практики забывается за неделю, а после 10 задач паттерны входят в мышечную память.