Что такое оконные функции (Window Functions)?
4. FILTER для условной агрегации 5. EXCLUDE для сложных статистик (PG 11+) 6. Минимизируйте количество уникальных PARTITION BY 7. Incremental Sort (PG 13+) используйте с индекса...
🟢 Junior Level
Оконные функции — это функции, которые выполняют вычисления над набором строк, связанных с текущей строкой, не схлопывая результат в одну строку (как GROUP BY).
Главное преимущество: вы получаете и детали каждой строки, и агрегаты — в одном запросе. С GROUP BY пришлось бы делать подзапрос или JOIN. С оконными функциями: один запрос, один проход по данным.
Простая аналогия: GROUP BY — это когда вы складываете все яблоки в одну кучку и считаете их. Оконная функция — это когда вы для каждого яблока пишете, сколько всего яблок в его сорте, но само яблоко остаётся на месте.
Пример:
-- Для каждого заказа показать его сумму и среднюю по пользователю
SELECT
user_id,
amount,
AVG(amount) OVER(PARTITION BY user_id) as user_avg
FROM orders;
-- Результат:
-- user_id | amount | user_avg
-- 1 | 100 | 150 ← среднее по пользователю 1
-- 1 | 200 | 150 ← то же среднее, строка не схлопнулась
-- 2 | 300 | 250 ← среднее по пользователю 2
-- 2 | 200 | 250
Основные оконные функции:
ROW_NUMBER()— номер строкиRANK(),DENSE_RANK()— рангSUM() OVER(),AVG() OVER()— агрегаты без GROUP BYLAG(),LEAD()— доступ к предыдущей/следующей строке
🟡 Middle Level
Анатомия оконной функции
FUNCTION() OVER (
PARTITION BY column1, column2 -- Разделение на группы (как GROUP BY)
ORDER BY column3 -- Сортировка внутри группы
ROWS BETWEEN ... AND ... -- Границы окна (frame)
)
Ключевые отличия от GROUP BY:
| GROUP BY | Window Functions |
|---|---|
| Схлопывает строки | Сохраняет строки |
| Одна строка на группу | Каждая строка + вычисление |
| Нельзя смешать детали и агрегат | Можно! |
Типы оконных функций
1. Агрегатные:
SELECT
name,
salary,
department,
AVG(salary) OVER(PARTITION BY department) as dept_avg,
SUM(salary) OVER(PARTITION BY department) as dept_total,
COUNT(*) OVER(PARTITION BY department) as dept_count
FROM employees;
2. Ранжирующие:
SELECT
name,
salary,
ROW_NUMBER() OVER(ORDER BY salary DESC) as row_num,
RANK() OVER(ORDER BY salary DESC) as rank,
DENSE_RANK() OVER(ORDER BY salary DESC) as dense_rank
FROM employees;
3. Смещения:
-- Сравнение с предыдущей строкой
SELECT
month,
revenue,
LAG(revenue, 1) OVER(ORDER BY month) as prev_month,
LEAD(revenue, 1) OVER(ORDER BY month) as next_month,
revenue - LAG(revenue, 1) OVER(ORDER BY month) as growth
FROM monthly_sales;
ROWS vs RANGE vs GROUPS
-- ROWS: физические строки (самый быстрый)
SELECT
date,
amount,
AVG(amount) OVER(ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) as moving_avg_7d
FROM sales;
-- RANGE: логические значения (медленнее, но точнее)
SELECT
date,
amount,
SUM(amount) OVER(ORDER BY date RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW) as weekly_sum
FROM sales;
Ключевая разница на примере: Если в столбце date есть дубликаты (два заказа в один день), то ROWS BETWEEN 6 PRECEDING AND CURRENT ROW возьмёт ровно 6 предыдущих СТРОК (игнорируя даты), а RANGE BETWEEN ... 7 days PRECEDING возьмёт все строки за последние 7 дней (даже если их 20). ROWS = 6 строк, RANGE = 7 дней.
Типичные ошибки
- Фильтрация по результату окна в том же запросе
-- ❌ ОШИБКА: окна считаются ПОСЛЕ WHERE -- Порядок выполнения SQL: -- FROM → WHERE → GROUP BY → HAVING → SELECT (включая оконные функции) → ORDER BY → LIMIT -- Поскольку WHERE выполняется ДО SELECT, он не видит результат оконной функции. -- Поэтому нужна обёртка: внутренний запрос вычисляет окно, внешний — фильтрует. SELECT * FROM ( SELECT user_id, amount, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) as rn FROM orders ) sub WHERE rn = 1; -- ✅ Правильно: в подзапросе - Смешивание разных PARTITION BY
-- ❌ Дорого: две разные сортировки SELECT AVG(amount) OVER(PARTITION BY user_id) as user_avg, SUM(amount) OVER(PARTITION BY department) as dept_total FROM orders; -- → Две фазы сортировки! - COUNT(*) OVER() на больших данных
-- ❌ Заставляет прочитать ВСЕ строки раздела SELECT id, name, COUNT(*) OVER() as total FROM users LIMIT 10; -- ✅ Лучше отдельный запрос для total SELECT COUNT(*) FROM users; -- Один раз SELECT id, name FROM users LIMIT 10; -- Отдельно
Практические примеры
Running Total (накопительный итог):
SELECT
date,
amount,
SUM(amount) OVER(ORDER BY date ROWS UNBOUNDED PRECEDING) as running_total
FROM transactions;
Процент от группы:
SELECT
name,
salary,
department,
ROUND(100.0 * salary / SUM(salary) OVER(PARTITION BY department), 2) as pct_of_dept
FROM employees;
🔴 Senior Level
Когда НЕ использовать оконные функции
- Когда нужен один агрегат (общая сумма, среднее) — используйте GROUP BY, это проще и быстрее
- Когда делаете
COUNT(*) OVER()только для пагинации — отдельныйSELECT COUNT(*)быстрее, т.к. не читает все строки - Когда много разных PARTITION BY в одном запросе — каждая уникальная комбинация = отдельная фаза сортировки, что убивает производительность
Физика выполнения и оптимизации
Порядок вычисления в SQL:
1. FROM / JOIN
2. WHERE
3. GROUP BY
4. HAVING
5. SELECT / Window Functions ← Окна считаются ЗДЕСЬ
6. DISTINCT
7. ORDER BY
8. LIMIT
Важно: Окна вычисляются ПОСЛЕ WHERE, GROUP BY и HAVING. Поэтому нельзя фильтровать по результату окна в том же запросе.
ROWS vs RANGE vs GROUPS: Deep Dive
ROWS:
-- Физические строки: 6 ПРЕДЫДУЩИХ + ТЕКУЩАЯ
AVG(amount) OVER(ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
-- Быстро: просто берёт N строк
-- Проблема: если есть дубликаты в ORDER BY → недетерминировано
RANGE:
-- Логические значения: все строки с тем же значением
SUM(amount) OVER(ORDER BY amount RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
-- Медленнее: проверяет значения, не только позиции
-- Нужно для корректной работы с дубликатами
GROUPS (PG 11+):
-- Группы строк с одинаковым значением
AVG(amount) OVER(ORDER BY date GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING)
-- Объединяет "peer" строки в группы
На PG 10 и ниже GROUPS недоступен. Альтернатива: подзапрос с RANGE или ручная фильтрация.
EXCLUDE (PG 11+)
-- Исключение текущей строки из расчёта
SELECT
name,
salary,
AVG(salary) OVER(
PARTITION BY department
ORDER BY salary
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
EXCLUDE CURRENT ROW
) as others_avg
FROM employees;
Варианты EXCLUDE:
EXCLUDE CURRENT ROW— без текущей строкиEXCLUDE GROUP— без текущей + peer строкEXCLUDE TIES— без peer, но с текущейEXCLUDE NO OTHERS— по умолчанию
На PG 10 и ниже EXCLUDE недоступен. Альтернатива: CASE WHEN для исключения текущей строки или подзапрос.
Incremental Sort (PG 13+)
-- Индекс по (department, created_at)
CREATE INDEX idx_orders_dept_date ON orders(department, created_at);
-- Запрос с PARTITION BY + ORDER BY
SELECT
department,
created_at,
amount,
SUM(amount) OVER(PARTITION BY department ORDER BY created_at) as running_total
FROM orders;
-- PG 13+: Incremental Sort
-- Данные уже отсортированы по department (из индекса)
-- → Сортируем только по created_at внутри каждого department
-- → В 5-10 раз быстрее полной сортировки!
-- Incremental Sort работает, потому что данные уже отсортированы по первой
-- части ключа (из индекса). Вместо полной сортировки по (department, created_at)
-- база сортирует только по created_at внутри каждой группы department.
-- Если групп 10, каждая сортировка в 10 раз меньше.
-- На PG 12 и ниже — полная сортировка всех данных, даже если часть уже отсортирована.
Цена Window Frame
-- Дешёвый фрейм: от начала до текущей строки (дефолт)
SUM(amount) OVER(ORDER BY date ROWS UNBOUNDED PRECEDING)
-- → Инкрементальный расчёт, база не "вычитает" вышедшие строки
-- Дорогой фрейм: скользящее окно
AVG(amount) OVER(ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
-- → Для каждой строки пересчитывается окно из 7 элементов
-- → Требует управления состоянием (добавление новой, удаление старой)
Именованные окна (WINDOW clause)
-- DRY для оконных функций
SELECT
department,
AVG(salary) OVER w as avg_sal,
SUM(salary) OVER w as total_sal,
COUNT(*) OVER w as emp_count
FROM employees
WINDOW w AS (PARTITION BY department ORDER BY hire_date);
-- → Одно определение окна для всех функций
-- → Меньше сортировок, быстрее выполнение
FILTER с оконными функциями
SELECT
department,
salary,
SUM(salary) FILTER (WHERE status = 'ACTIVE')
OVER(PARTITION BY department) as active_payroll
FROM employees;
Edge Cases
- Пустое окно
-- Без PARTITION BY и ORDER BY SELECT name, salary, SUM(salary) OVER() as total_payroll -- Сумма по всей таблице FROM employees; - NULL в ORDER BY
-- NULL по умолчанию LAST в DESC, FIRST в ASC SELECT name, salary, RANK() OVER(ORDER BY salary DESC NULLS LAST) as rank FROM employees; - Вложенные окна — НЕЛЬЗЯ
-- ❌ ОШИБКА: оконные функции нельзя вкладывать SELECT AVG(SUM(amount) OVER(PARTITION BY user_id)) OVER() FROM orders; -- ✅ Решение: CTE WITH user_totals AS ( SELECT user_id, SUM(amount) as total FROM orders GROUP BY user_id ) SELECT AVG(total) FROM user_totals;
Performance Tuning
Минимизация уникальных окон:
-- ❌ Дорого: 3 разных PARTITION BY → 3 сортировки
SELECT
AVG(amount) OVER(PARTITION BY user_id) as u_avg,
AVG(amount) OVER(PARTITION BY department) as d_avg,
AVG(amount) OVER(PARTITION BY region) as r_avg
FROM orders;
-- ✅ Лучше: одинаковые PARTITION BY → 1 сортировка
SELECT
AVG(amount) OVER(PARTITION BY user_id) as u_avg,
SUM(amount) OVER(PARTITION BY user_id) as u_total,
COUNT(*) OVER(PARTITION BY user_id) as u_count
FROM orders;
Индекс для WindowAgg:
-- Индекс по (PARTITION BY columns, ORDER BY columns)
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);
-- Запрос без Sort
SELECT user_id, created_at, amount,
SUM(amount) OVER(PARTITION BY user_id ORDER BY created_at) as running_total
FROM orders;
-- Plan: WindowAgg → Index Scan (без Sort!)
Production Experience
Реальный сценарий #1: Running Total для отчёта
- Финтех: ежедневный баланс по 1 млн транзакций
- Запрос: 30 секунд (Sort на диске)
- Решение:
- Индекс по
(account_id, date) ROWS UNBOUNDED PRECEDING(дешёвый фрейм)- work_mem = 256MB
- Индекс по
- Результат: 2 секунды
Реальный сценарий #2: Смешанные окна убивают производительность
- Аналитика: 5 оконных функций с разными PARTITION BY
- Время: 2 минуты (5 сортировок!)
- Решение: разбили на 2 CTE
- Результат: 15 секунд
Monitoring
-- 1. Проверка плана с оконными функциями
EXPLAIN (ANALYZE, BUFFERS)
SELECT ... OVER(PARTITION BY ... ORDER BY ...);
-- Ищите: WindowAgg → Sort (или Index Scan если есть индекс)
-- 2. Проверка памяти для Sort
EXPLAIN (ANALYZE)
SELECT ... OVER(...);
-- "external merge Disk: X kB" → spill! Увеличьте work_mem
-- 3. Поиск медленных запросов с окнами
SELECT query, mean_exec_time
FROM pg_stat_statements
WHERE query LIKE '%OVER(%'
ORDER BY mean_exec_time DESC
LIMIT 10;
Best Practices
- Индекс по
(PARTITION BY, ORDER BY)→ без Sort - ROWS вместо RANGE где возможно (быстрее)
- WINDOW clause для переопределения окон
- FILTER для условной агрегации
- EXCLUDE для сложных статистик (PG 11+)
- Минимизируйте количество уникальных PARTITION BY
- Incremental Sort (PG 13+) используйте с индексами
- Подзапрос/CTE для фильтрации по результату окна
Резюме для Senior
- Оконные функции считаются ПОСЛЕ WHERE, GROUP BY, HAVING
- ROWS быстрее RANGE, но RANGE корректнее с дубликатами
- Incremental Sort (PG 13+) экономит при partial сортировке
- EXCLUDE (PG 11+) для сложных статистик
- FILTER поддерживается в оконных функциях
- Цена фрейма:
UNBOUNDED PRECEDINGдешевле скользящего окна - Минимизируйте уникальные PARTITION BY → меньше Sort фаз
- Индекс по
(PARTITION BY, ORDER BY)→ WindowAgg без Sort
🎯 Шпаргалка для интервью
Обязательно знать:
- Оконные функции: вычисляют по группе строк, НЕ схлопывая (в отличие от GROUP BY)
- Синтаксис:
FUNCTION() OVER(PARTITION BY ... ORDER BY ... ROWS/RANGE BETWEEN ...) - 3 типа: агрегатные (SUM, AVG), ранжирующие (ROW_NUMBER, RANK), смещения (LAG, LEAD)
- ROWS = физические строки (быстрее), RANGE = логические значения (корректнее с дубликатами)
- GROUPS (PG 11+): группирует «peer» строки с одинаковым значением
- EXCLUDE (PG 11+):
EXCLUDE CURRENT ROW,EXCLUDE GROUP,EXCLUDE TIES - Фильтрация по результату окна → ТОЛЬКО в подзапросе/CTE (окна считаются ПОСЛЕ WHERE)
- Индекс по
(PARTITION BY, ORDER BY)→ WindowAgg без Sort - Incremental Sort (PG 13+): данные частично отсортированы → сортируем остаток
- Именованные окна (WINDOW clause): DRY для оконных функций
- Оконные функции нельзя вкладывать → CTE
Частые уточняющие вопросы:
- «Почему нельзя фильтровать по оконной функции в WHERE?» → Окна считаются ПОСЛЕ WHERE
- «ROWS vs RANGE — что выбрать?» → ROWS быстрее, RANGE корректнее с дубликатами
- «Как ускорить оконную функцию?» → Индекс по
(PARTITION BY, ORDER BY) - «Что такое Incremental Sort?» → PG 13+: сортирует только недостающую часть
Красные флаги (НЕ говорить):
- ❌ «Оконные функции схлопывают строки» (нет, это GROUP BY)
- ❌ «Можно фильтровать по оконной функции в WHERE» (нет, после WHERE считаются)
- ❌ «RANGE всегда лучше ROWS» (ROWS быстрее, RANGE только при дубликатах)
Связанные темы:
- [[Что делает ROW_NUMBER()]] → уникальная нумерация
- [[Что делает RANK() и DENSE_RANK()]] → ранжирование
- [[Что делает GROUP BY]] → отличие от оконных функций
- [[В чём разница между WHERE и HAVING]] → порядок выполнения