Learning Platform
Урок 09.01 · 20 мин
Средний
Window functionsOVERAggregate vs windowRunning total

Зачем вообще window functions

Если бы меня спросили, какая одна фича в SQL отделяет аналитика-новичка от уверенного middle — я бы без раздумий назвал оконные функции. Это инструмент, которым в реальной работе пользуются ежедневно: «процент от общего», «номер заказа клиента подряд», «running total выручки», «топ-3 продукта в каждой категории», «дельта между этим и предыдущим месяцем». Всё это — задачи окна.

GROUP BY с агрегатами справляется только с половиной таких задач, и при этом обязательно «схлопывает» строки. Окно же делает агрегат, не теряя строк — для каждой исходной строки рядом возникает дополнительная колонка с агрегатом по «соседям». Именно этим оно и ценно.

Где GROUP BY ломается

Возьмём типовой запрос аналитика: «какую долю от общей выручки за день дал каждый отдельный заказ».

С GROUP BY мы можем посчитать

сумму выручки за день
— но мы тут же теряем сами заказы. Получится одна строка на день, а нам нужно рядом с каждым заказом видеть его долю.

GROUP BY коллапсирует, окно — нет

Слева: GROUP BY схлопывает 3 заказа дня в 1 строку. Справа: окно сохраняет все 3 строки и добавляет агрегат как ещё одну колонку.

GROUP BY dateодна строка на день
2025-01-05total_day = 128 300
заказы #1 потеряны как сущностиПосле GROUP BY нет колонки order_id — она была свёрнута. Мы не можем спросить: какую долю дал заказ N1?
window OVER (PARTITION BY date)все строки сохранены
order 1, 2025-01-05amount=128 300, day_total=128 300
order 2, 2025-02-14amount=3 900, day_total=3 900
доля = amount / day_totalТеперь у нас в одной строке и сам заказ, и контекст дня — можно делить

Раньше, до появления window functions в стандарте SQL:2003, такие задачи решали через self-join или коррелированный подзапрос — оба варианта медленные и трудночитаемые. Сегодня это однострочник.

Каждый платёж и его доля в общей выручке за день — без GROUP BY:

PostgreSQL

Обрати внимание: SUM(...) OVER (PARTITION BY ...) — это полноценная агрегация, но она применяется к окну вокруг каждой строки, а сама строка остаётся в результате. Это и есть главная идея.

Анатомия func() OVER (...)

Любая оконная функция состоит из двух частей.

  1. Функция — то, что считаем: SUM, AVG, COUNT, ROW_NUMBER, RANK, LAG, LEAD, FIRST_VALUE, и так далее. Это может быть и обычный агрегат, и одна из специальных «оконных» функций (ROW_NUMBER и компания не существуют вне окна — без OVER они синтаксически невалидны).
  2. OVER (...) — определение окна. Внутри скобок может быть три части, в строгом порядке:
    • PARTITION BY — на какие группы разбить таблицу.
    • ORDER BY — как упорядочить строки внутри группы.
    • frame — какое подмножество строк внутри группы реально участвует в подсчёте (ROWS BETWEEN ... AND ...).
Анатомия func() OVER (...)

Три необязательных компонента OVER в строгом порядке. Любой может отсутствовать — но порядок при их совместном использовании фиксирован.

func()что считать (SUM, AVG, ROW_NUMBER, LAG, ...)
OVER (начало определения окна
PARTITION BY ...как разбить — необязательно
ORDER BY ...как упорядочить — необязательно
ROWS BETWEEN ...frame — необязательноFrame по умолчанию: RANGE UNBOUNDED PRECEDING — все строки от начала окна до текущей по логике ORDER BY. Разберём детально в уроке 4.
)конец определения

Пустое OVER () — тоже валидно. Это окно, в которое попадают все строки результата. Используется, например, для «доли от общего по всей выборке».

Пустое OVER () = окно по всем строкам. Считаем долю каждого платежа в общей выручке:

PostgreSQL

Сравни с тем, что было бы без окна. Если бы мы писали через подзапрос — пришлось бы сначала считать SELECT SUM(amount_cents) FROM payments отдельным запросом, потом подставлять. С окном — одна строка кода и один проход по данным.

Окно vs группа: интуиция

Чтобы навсегда запомнить разницу, держи в голове такую картинку.

  • GROUP BY превращает таблицу в новую таблицу с меньшим числом строк. На выходе — одна строка на каждое уникальное значение группы.
  • Window functions оставляют таблицу того же размера, но добавляют новые колонки с агрегатами по «соседям» (с теми, кто попадает в окно текущей строки).

Это разные семантические операции, не «два способа сделать одно и то же». Когда тебе нужна сводка — используешь GROUP BY. Когда тебе нужны детали плюс контекст — окно.

Часто эти операции комбинируют: сначала GROUP BY, потом окно поверх агрегированного результата. Этот шаблон мы разберём в уроке 6.

Running total — классическая задача окна

Ещё один кейс, который без окна писать больно: накопительная сумма по времени. Каждая строка должна показывать сумму всех предыдущих платежей плюс свой.

Running total: накопительная выручка по времени, без GROUP BY:

PostgreSQL

Здесь нет PARTITION BY — значит, окно одно на всю выборку. Есть ORDER BY paid_at — значит, для каждой строки в окно попадают все строки от начала выборки и до текущей включительно (это дефолтное поведение frame, о котором будет урок 4).

Попробуй переписать тот же запрос без window functions — увидишь, как страшен self-join: каждой строке нужно «доставить» массив всех предыдущих, потом просуммировать. Это O(N^2) запрос там, где окно делает за O(N log N) или быстрее.

Где окно может появиться в запросе

Window functions можно использовать только в SELECT и в ORDER BY. В WHERE, GROUP BY, HAVING — нельзя. Это не каприз, а следствие порядка выполнения запроса: окно вычисляется после агрегации и фильтрации. К моменту, когда WHERE отсеивает строки, окно ещё не существует.

Чтобы отфильтровать по результату окна — нужно обернуть запрос в подзапрос или CTE.

Окно нельзя в WHERE — оборачиваем в подзапрос. Найдём платежи, которые дали >5% дневной выручки:

PostgreSQL

Та же логика, что и с агрегатами в HAVING: «фильтровать после агрегации» в SQL всегда требует либо HAVING (для GROUP BY), либо обёртки в подзапрос (для окон).

Проверка знанийKnowledge check
У нас 100 заказов. Запрос: SELECT order_id, SUM(amount) OVER () FROM orders. Сколько строк вернёт запрос, и что будет в колонке SUM(amount) OVER () в каждой строке?
ОтветAnswer
Вернёт 100 строк — окно не схлопывает. Во всех 100 строках в колонке SUM(amount) OVER () будет одно и то же значение — общая сумма всех 100 заказов. Это поведение пустого OVER (): окно охватывает все строки, для каждой исходной строки агрегат вычисляется по всем 100 строкам — то есть одно и то же значение.

Нюанс: что считается «окном» по умолчанию

Когда у тебя есть OVER (ORDER BY ...) без явного ROWS/RANGE — frame по умолчанию равен RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Это значит: «все строки от начала окна до текущей включительно, по логике ORDER BY». Поэтому running total в примере выше работает «сам собой».

Если убрать ORDER BY — frame по умолчанию становится «вся партиция целиком». Поэтому SUM(x) OVER () без ORDER BY даёт сумму всей выборки, а SUM(x) OVER (ORDER BY date) — нарастающую сумму.

Разница между RANGE и ROWS для frame будет в уроке 4 — там много неочевидного и важного.

Оконные функции в ClickHouse Window-функции в Spark DataFrame API

Чек-лист

  • Window functions считают агрегаты по соседям строки, не схлопывая саму строку. Это главное отличие от GROUP BY.
  • Анатомия: func() OVER (PARTITION BY ... ORDER BY ... frame). Любая часть OVER может отсутствовать.
  • OVER () — окно по всем строкам. Используется для «доли от общего».
  • OVER (ORDER BY ...) — окно от начала и до текущей строки включительно. Базовый инструмент для running total.
  • Окно работает только в SELECT и ORDER BY. Чтобы фильтровать по результату окна — оборачивай в подзапрос или CTE.
  • Окно вычисляется после WHERE, GROUP BY и HAVING — но до финального ORDER BY и LIMIT.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. В чём ключевое отличие window functions от GROUP BY?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7