Зачем нужны оконные функции
Оконные функции — это GROUP BY на стероидах: вместо схлопывания строк, они добавляют расчётный столбец к каждой строке, оставляя саму строку на месте.
Простой пример. Допустим у тебя таблица salaries (employee, dept, salary):
employee dept salary
Айгерим data 250000
Ерлан data 300000
Дана data 220000
Алишер dev 350000
Камила dev 280000
С GROUP BY dept ты узнаешь среднее по отделу — 3 строки, теряя имена. С оконкой можно сохранить все 5 строк и рядом показать среднее.
select employee, dept, salary,
avg(salary) over (partition by dept) as dept_avg
from salaries;
Результат:
employee dept salary dept_avg
Айгерим data 250000 256667
Ерлан data 300000 256667
Дана data 220000 256667
Алишер dev 350000 315000
Камила dev 280000 315000
Теперь видно кто выше/ниже среднего по своему отделу.
Синтаксис
function_name(arg1, ...) OVER (
[PARTITION BY col1, col2, ...]
[ORDER BY col3 ASC|DESC, ...]
[frame_clause]
)
- PARTITION BY — на какие группы разбить (как GROUP BY, но строки не схлопываются)
- ORDER BY — порядок внутри партиции (важен для running totals, LAG/LEAD, RANK)
- frame_clause — какие строки внутри партиции учитывать (см. ниже)
Главные категории оконных функций
1. Агрегаты (aggregate window functions)
SUM, AVG, COUNT, MIN, MAX — обычные агрегаты но с OVER().
-- Зарплата каждого сотрудника + сколько он "забирает" от фонда отдела
select employee, dept, salary,
salary / sum(salary) over (partition by dept) * 100 as pct_of_dept
from salaries;
2. Ранжирование (ranking)
ROW_NUMBER, RANK, DENSE_RANK, NTILE.
Разница между ROW_NUMBER, RANK, DENSE_RANK:
salary ROW_NUMBER RANK DENSE_RANK
300000 1 1 1
250000 2 2 2
250000 3 2 2 ← тот же ранг
220000 4 4 3 ← RANK пропускает, DENSE_RANK нет
Когда что использовать:
- ROW_NUMBER — "топ-1 в каждой группе" (любым tiebreaker)
- DENSE_RANK — медали (1, 2, 3, без пропусков)
- RANK — официальные рейтинги (с пропусками после ties)
- NTILE(4) — разбить на квартили
3. Соседние строки (offset)
LAG, LEAD — взять значение из предыдущей или следующей строки.
-- День-над-днём изменение выручки
select day, revenue,
lag(revenue) over (order by day) as prev_day,
revenue - lag(revenue) over (order by day) as day_over_day_diff
from daily_revenue;
LAG(col, n, default) — n шагов назад, default если выходит за пределы.
4. Первая/последняя в окне
FIRST_VALUE, LAST_VALUE, NTH_VALUE.
-- Сравнить зарплату каждого с самой высокой в отделе
select employee, dept, salary,
first_value(salary) over (
partition by dept order by salary desc
) as top_salary_in_dept
from salaries;
Frame clause: главная ловушка
По умолчанию рамка окна — ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (при наличии ORDER BY).
Это значит: для running totals — работает интуитивно.
-- Накопительная выручка по дням
select day, revenue,
sum(revenue) over (order by day) as running_total
from daily_revenue;
-- day=1: revenue
-- day=2: revenue(1) + revenue(2)
-- day=3: revenue(1) + revenue(2) + revenue(3)
Но для FIRST_VALUE / LAST_VALUE это создаёт сюрприз:
last_value(salary) over (partition by dept order by salary desc)
-- Вернёт... salary самой текущей строки. WTF?
Потому что default frame = "от начала до текущей строки". А LAST_VALUE берёт последнюю в этой подмножестве — то есть текущую.
Решение: явно расширить frame:
last_value(salary) over (
partition by dept order by salary desc
rows between unbounded preceding and unbounded following
)
Правило большого пальца: для running totals — оставляй дефолт. Для LAST_VALUE и подобных — указывай BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
3 классические задачи
Задача 1: Топ-N в каждой группе
«Найди топ-3 самых дорогих заказа в каждом городе»
with ranked as (
select *,
row_number() over (partition by city order by total desc) as rn
from orders
)
select * from ranked where rn <= 3;
Задача 2: Сравнение с предыдущим месяцем
«Покажи выручку за каждый месяц + рост по сравнению с прошлым»
select month, revenue,
lag(revenue) over (order by month) as prev_month,
(revenue - lag(revenue) over (order by month)) /
nullif(lag(revenue) over (order by month), 0) * 100 as growth_pct
from monthly_revenue;
Задача 3: Running average последних 7 дней
«Скользящее среднее DAU за 7 дней»
select day, dau,
avg(dau) over (
order by day
rows between 6 preceding and current row
) as dau_7day_avg
from daily_metrics;
Производительность
Оконные функции обычно медленнее чем GROUP BY, потому что движок не может просто схлопнуть строки — он должен сохранить их и дополнительно посчитать оконку.
Оптимизация:
- Один и тот же
OVER (...)несколько раз? Используй WINDOW alias:select ... from t window w as (partition by dept order by salary) -- потом: sum(x) over w, avg(y) over w - Индекс на колонках из
PARTITION BY+ORDER BYускоряет. - Не оборачивай оконку в подзапрос если можно избежать (PostgreSQL умеет хорошо оптимизировать, MySQL хуже).
TL;DR
- Окна = добавляют столбец, не схлопывают строки
ROW_NUMBERдля топ-1,RANK/DENSE_RANKдля медалейLAG/LEADдля сравнения с соседямиSUM() OVER (ORDER BY ...)для running totals- Frame clause критичен для
LAST_VALUE(без него вернёт текущую строку) - Для повторяющегося окна —
WINDOW alias