Tento článek je momentálně v ruštině — anglický překlad se připravuje.
Оконные функции вы наверняка уже встречали: SUM() OVER (...), ROW_NUMBER() и компания. Но как только дело доходит до нарастающих итогов и скользящих средних, появляется третий компонент окна, который многие пропускают, — рамка (frame). Именно рамка решает, какие именно строки внутри партиции участвуют в расчёте для текущей строки. Непонимание рамки порождает тихие, неочевидные баги: например, LAST_VALUE, который упрямо возвращает не то значение. Разберёмся по порядку на схеме orders.
CREATE TABLE orders (
id bigint PRIMARY KEY,
customer_id bigint NOT NULL,
created_at date NOT NULL,
amount numeric(10,2) NOT NULL
);
Из чего состоит окно
Полное оконное определение — это три части: PARTITION BY (на какие группы бьём), ORDER BY (как упорядочиваем внутри группы) и рамка (ROWS/RANGE/GROUPS BETWEEN ...). Рамка задаёт диапазон строк относительно текущей.
SELECT
id,
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders;
Ключевые границы рамки:
UNBOUNDED PRECEDING — от начала партиции;
N PRECEDING / N FOLLOWING — N строк назад/вперёд;
CURRENT ROW — текущая строка;
UNBOUNDED FOLLOWING — до конца партиции.
Важно: если вы написали ORDER BY, но не указали рамку, СУБД подставит её сама — и почти всегда не ту, что вы ждёте. Об этом ниже.
Нарастающий итог: ROWS
Классика — накопительная сумма заказов по дате. Рамка «от начала до текущей строки» делает ровно это:
SELECT
created_at,
amount,
SUM(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM orders
ORDER BY created_at, id;
Обратите внимание на id в ORDER BY — он делает порядок детерминированным. Если две строки имеют одинаковый created_at, без тай-брейкера порядок между ними не определён, и результат может «плавать» от запуска к запуску.
Нарастающий итог по каждому клиенту отдельно — добавляем PARTITION BY:
SELECT
customer_id,
created_at,
SUM(amount) OVER (
PARTITION BY customer_id
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS customer_running_total
FROM orders;
Скользящее среднее: окно фиксированной ширины
Скользящее среднее за 3 строки (текущая + две предыдущие) — это рамка 2 PRECEDING AND CURRENT ROW:
SELECT
created_at,
amount,
AVG(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM orders;
Хотите центрированное среднее (по одной строке слева и справа)? Используйте BETWEEN 1 PRECEDING AND 1 FOLLOWING.
Гоча: в начале партиции окно «недоукомплектовано» — для первой строки в нём всего одна запись, для второй — две. AVG это нормально учитывает (делит на фактическое число строк), а вот для честного среднего «только по полным окнам» придётся фильтровать или считать COUNT(*) OVER (...) и отбрасывать неполные.
ROWS против RANGE — это разные вещи
Главное различие. ROWS считает физические строки. RANGE работает по значениям колонки из ORDER BY: в рамку попадают все строки с тем же значением (peers), что и текущая.
SELECT
created_at,
amount,
SUM(amount) OVER (ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_rows,
SUM(amount) OVER (ORDER BY created_at
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_range
FROM orders;
Для двух строк за 2026-01-10 by_rows даст разные значения (строки идут по очереди), а by_range — одинаковое для обеих, потому что они peers по дате и схлопываются в один шаг. Это часто и есть источник «странных» дублей в нарастающем итоге.
PostgreSQL также поддерживает диапазоны по значениям: RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW — настоящее «скользящее окно за 7 календарных дней», даже если в какие-то дни заказов не было. И ещё есть GROUPS — рамка в единицах групп peer-значений.
Различия движков: MySQL поддерживает ROWS и RANGE (с 8.0), но не GROUPS и не RANGE по INTERVAL в том же виде. ClickHouse поддерживает оконные функции, но RANGE-рамки с интервалами там ограничены — для скользящих окон по времени чаще берут ROWS или специальные функции вроде arrayJoin/groupArray. Всегда проверяйте документацию конкретной версии.
Ловушка LAST_VALUE и рамка по умолчанию
Самый коварный момент. Когда есть ORDER BY, но рамка не указана явно, по стандарту SQL действует:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
То есть окно тянется только до текущей строки, а не до конца партиции. Для FIRST_VALUE это незаметно (первая строка всегда в окне), а вот LAST_VALUE ломается:
SELECT
created_at,
amount,
LAST_VALUE(amount) OVER (ORDER BY created_at, id) AS wrong_last
FROM orders;
«Последнее значение» по умолчанию — это последняя строка в рамке, а рамка кончается на текущей строке. Получаете не последний заказ, а просто текущий. Лечится явной рамкой до конца партиции:
SELECT
created_at,
amount,
LAST_VALUE(amount) OVER (
ORDER BY created_at, id
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS correct_last
FROM orders;
Альтернатива без танцев с рамкой — FIRST_VALUE с обратной сортировкой, либо MAX(...) OVER (PARTITION BY ...) без ORDER BY (тогда рамка покрывает всю партицию).
Запомните три вещи: всегда добавляйте тай-брейкер в ORDER BY; помните, что рамка по умолчанию — RANGE ... CURRENT ROW, а не «вся партиция»; и для LAST_VALUE почти всегда дописывайте ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Оконные функции вы наверняка уже встречали:
SUM() OVER (...),ROW_NUMBER()и компания. Но как только дело доходит до нарастающих итогов и скользящих средних, появляется третий компонент окна, который многие пропускают, — рамка (frame). Именно рамка решает, какие именно строки внутри партиции участвуют в расчёте для текущей строки. Непонимание рамки порождает тихие, неочевидные баги: например,LAST_VALUE, который упрямо возвращает не то значение. Разберёмся по порядку на схемеorders.CREATE TABLE orders ( id bigint PRIMARY KEY, customer_id bigint NOT NULL, created_at date NOT NULL, amount numeric(10,2) NOT NULL );Из чего состоит окно
Полное оконное определение — это три части:
PARTITION BY(на какие группы бьём),ORDER BY(как упорядочиваем внутри группы) и рамка (ROWS/RANGE/GROUPS BETWEEN ...). Рамка задаёт диапазон строк относительно текущей.SELECT id, created_at, amount, SUM(amount) OVER ( ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- рамка ) AS running_total FROM orders;Ключевые границы рамки:
UNBOUNDED PRECEDING— от начала партиции;N PRECEDING/N FOLLOWING— N строк назад/вперёд;CURRENT ROW— текущая строка;UNBOUNDED FOLLOWING— до конца партиции.Важно: если вы написали
ORDER BY, но не указали рамку, СУБД подставит её сама — и почти всегда не ту, что вы ждёте. Об этом ниже.Нарастающий итог: ROWS
Классика — накопительная сумма заказов по дате. Рамка «от начала до текущей строки» делает ровно это:
SELECT created_at, amount, SUM(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_total FROM orders ORDER BY created_at, id;Обратите внимание на
idвORDER BY— он делает порядок детерминированным. Если две строки имеют одинаковыйcreated_at, без тай-брейкера порядок между ними не определён, и результат может «плавать» от запуска к запуску.Нарастающий итог по каждому клиенту отдельно — добавляем
PARTITION BY:SELECT customer_id, created_at, SUM(amount) OVER ( PARTITION BY customer_id ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS customer_running_total FROM orders;Скользящее среднее: окно фиксированной ширины
Скользящее среднее за 3 строки (текущая + две предыдущие) — это рамка
2 PRECEDING AND CURRENT ROW:SELECT created_at, amount, AVG(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) AS moving_avg_3 FROM orders;Хотите центрированное среднее (по одной строке слева и справа)? Используйте
BETWEEN 1 PRECEDING AND 1 FOLLOWING.Гоча: в начале партиции окно «недоукомплектовано» — для первой строки в нём всего одна запись, для второй — две.
AVGэто нормально учитывает (делит на фактическое число строк), а вот для честного среднего «только по полным окнам» придётся фильтровать или считатьCOUNT(*) OVER (...)и отбрасывать неполные.ROWS против RANGE — это разные вещи
Главное различие.
ROWSсчитает физические строки.RANGEработает по значениям колонки изORDER BY: в рамку попадают все строки с тем же значением (peers), что и текущая.-- Два заказа в один день: created_at = '2026-01-10' SELECT created_at, amount, SUM(amount) OVER (ORDER BY created_at ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_rows, SUM(amount) OVER (ORDER BY created_at RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS by_range FROM orders;Для двух строк за
2026-01-10by_rowsдаст разные значения (строки идут по очереди), аby_range— одинаковое для обеих, потому что они peers по дате и схлопываются в один шаг. Это часто и есть источник «странных» дублей в нарастающем итоге.PostgreSQL также поддерживает диапазоны по значениям:
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW— настоящее «скользящее окно за 7 календарных дней», даже если в какие-то дни заказов не было. И ещё естьGROUPS— рамка в единицах групп peer-значений.Различия движков: MySQL поддерживает
ROWSиRANGE(с 8.0), но неGROUPSи неRANGEпоINTERVALв том же виде. ClickHouse поддерживает оконные функции, ноRANGE-рамки с интервалами там ограничены — для скользящих окон по времени чаще берутROWSили специальные функции вродеarrayJoin/groupArray. Всегда проверяйте документацию конкретной версии.Ловушка LAST_VALUE и рамка по умолчанию
Самый коварный момент. Когда есть
ORDER BY, но рамка не указана явно, по стандарту SQL действует:RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWТо есть окно тянется только до текущей строки, а не до конца партиции. Для
FIRST_VALUEэто незаметно (первая строка всегда в окне), а вотLAST_VALUEломается:-- ОШИБКА: вернёт amount ТЕКУЩЕЙ строки, а не последней SELECT created_at, amount, LAST_VALUE(amount) OVER (ORDER BY created_at, id) AS wrong_last FROM orders;«Последнее значение» по умолчанию — это последняя строка в рамке, а рамка кончается на текущей строке. Получаете не последний заказ, а просто текущий. Лечится явной рамкой до конца партиции:
SELECT created_at, amount, LAST_VALUE(amount) OVER ( ORDER BY created_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS correct_last FROM orders;Альтернатива без танцев с рамкой —
FIRST_VALUEс обратной сортировкой, либоMAX(...) OVER (PARTITION BY ...)безORDER BY(тогда рамка покрывает всю партицию).Запомните три вещи: всегда добавляйте тай-брейкер в
ORDER BY; помните, что рамка по умолчанию —RANGE ... CURRENT ROW, а не «вся партиция»; и дляLAST_VALUEпочти всегда дописывайтеROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.