Фреймы окна: ROWS против RANGE против GROUPS
Фрейм — это срез строк, который оконная функция реально агрегирует. По умолчанию это RANGE UNBOUNDED PRECEDING AND CURRENT ROW — он включает все строки-пиры с тем же значением ORDER BY, тихо переполняя накопительные итоги на ничьих. ROWS — нет.
Финансовый отчёт с накопительным итогом идеально сходится в staging и расходится на тысячи в продакшене. Разница: в продакшене есть две продажи в один sale_date. Накопительный итог прыгает на обе суммы на первой из связанных строк, затем стоит ровно на второй — каждый связанный день переполняет ранние строки. В запросе был фрейм по умолчанию. RANGE никто не печатал, но получил его, а RANGE считает строки одной даты пирами (peers) и втягивает их все разом. Это самый дорогой дефолт в оконных функциях. К концу этого урока ты будешь знать, что такое фрейм, почему RANGE тихо переполняет на ничьих и какое однострочное правило делает накопительные итоги корректными в продакшене.
Фрейм — это срез, который реально агрегируется
PARTITION BY решает, какие строки делят окно; ORDER BY упорядочивает их; фрейм решает, какое подмножество этой упорядоченной партиции функция агрегирует для каждой строки. Фрейм — это подвижное окно-внутри-окна. Для накопительного итога на строке 5 фрейм — «строки с 1 по 5»; на строке 6 он скользит до «строк с 1 по 6». У синтаксиса фрейма три части: режим (ROWS / RANGE / GROUPS) и две границы.
Границы, которыми будешь пользоваться постоянно:
UNBOUNDED PRECEDING— первая строка партиции.CURRENT ROW— где сейчас идёт вычисление.n PRECEDING/n FOLLOWING— n позиций назад или вперёд.UNBOUNDED FOLLOWING— последняя строка партиции.
Так что ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — классический накопительный итог; ROWS BETWEEN 2 PRECEDING AND CURRENT ROW — трёхстрочное окно назад (скользящее среднее); ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING — вся партиция. Вместе режим и границы дают точный контроль над тем, какие строки питают вычисление — без их явного указания ты наследуешь дефолт, который может быть не тем, что ты хочешь.
RANGE против ROWS: разница в ничьих
ROWS считает физические строки: 1 PRECEDING значит буквально одну строку перед этой. RANGE работает на значениях выражения ORDER BY: CURRENT ROW в режиме RANGE не значит «эту одну строку» — он значит «все строки, чьё значение ORDER BY равно значению этой строки», то есть всех пиров. Если три продажи делят sale_date = '2026-01-10', эти три строки — пиры, и под RANGE они входят и выходят из фрейма вместе.
Для накопительного SUM, упорядоченного по sale_date, последствие точное и вредное: на связанной дате каждая из связанных строк получает одинаковый накопительный итог — итог включая всех пиров — вместо накопления по одной. Первая связанная строка уже показывает полный вклад дня; остальные его повторяют. Если твой отчёт ждёт, что каждая строка добавит свою сумму по порядку, связанные строки неверны.
-- Фрейм по умолчанию == RANGE; строки с тем же sale_date делят один накопительный итог (переполняют ранние строки)
SELECT sale_date, amount,
SUM(amount) OVER (ORDER BY sale_date) AS running_default
FROM sales;
-- Фрейм ROWS: каждая физическая строка добавляет ровно свою сумму, по порядку
SELECT sale_date, amount,
SUM(amount) OVER (ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_rows
FROM sales;Фрейм по умолчанию и продакшен-правило
Когда ты добавляешь ORDER BY внутрь OVER и не задаёшь фрейм, Postgres использует RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Это дефолт стандарта SQL — и это RANGE, а не ROWS. На данных без ничьих в колонке ORDER BY оба идентичны, и именно поэтому баги проскальзывают: работает на чистом dev-наборе и ломается в день, когда в продакшене две строки с одинаковым timestamp или датой.
Продакшен-правило прямое: для любого накопительного агрегата пиши ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW явно. Это делает фрейм физическим (по одной строке за раз), это обычно ещё и быстрее (Postgres не нужно сканировать вперёд в поисках всех пиров) и убирает тихое поведение на ничьих. Используй RANGE только когда тебе действительно нужны сгруппированные пиры — например, фрейм на основе значения вроде RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW, который ROWS выразить не может. GROUPS — третий режим: его границы считают группы пиров, а не строки или значения — GROUPS 1 PRECEDING тянется назад на одну целую группу-ничью — редкий, но точный инструмент, когда нужны «строки предыдущей отдельной даты, сколько бы их ни было».
▸Граничные случаи
Тонкое следствие: RANGE n PRECEDING легален только когда есть ровно одна колонка ORDER BY и она числовая, дата или интервал — потому что Postgres должен вычислить value - n, чтобы определить границу. RANGE 100 PRECEDING по amount значит «все строки, чья сумма в пределах 100 ниже суммы текущей строки». Попробуй RANGE с многоколоночным ORDER BY и числовым смещением — получишь ошибку; у ROWS такого ограничения нет, потому что он считает позиции, а не значения. Так что ROWS — и более безопасный дефолт, и более широко применимый режим. Глава об execution-plans трека databases показывает, как каждый режим фрейма проявляется как узел WindowAgg и сколько он стоит.
При ORDER BY sale_date и без явного фрейма две продажи делят sale_date = '2026-01-10'. Какой накопительный итог покажут эти две связанные строки под фреймом по умолчанию?
Какой фрейм писать для корректного накопительного итога по одной строке за раз?
Заполни пропуск: под дефолтным фреймом RANGE строки, связанные по значению ORDER BY, считаются _______ и входят во фрейм вместе, из-за чего накопительные итоги переполняют ранние связанные строки; переключение на ROWS считает физические позиции.
- 01Что такое фрейм окна и каковы его две части?
- 02Почему накопительный итог может быть неверным под фреймом по умолчанию и в чём фикс?
- 03Когда RANGE действительно правильный выбор вместо ROWS?
Фрейм — это та часть механики окон, что отделяет тех, кто использует оконные функции, от тех, кто доверяет им в продакшене. После того как PARTITION BY ограничивает окно, а ORDER BY упорядочивает его, фрейм выбирает, какое подмножество этих упорядоченных строк каждая функция реально агрегирует, через режим (ROWS, RANGE, GROUPS) и две границы. По умолчанию, когда добавляешь внутренний ORDER BY, это RANGE UNBOUNDED PRECEDING AND CURRENT ROW — и поскольку RANGE работает на значениях, строки, связанные по колонке ORDER BY, — пиры, входящие во фрейм вместе, так что накопительный итог повторяет значение, включающее пиров, на всех связанных строках и переполняет ранние. Это проходит на dev-данных без ничьих и ломается в продакшене в день, когда две строки делят timestamp. Правило — писать ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW явно для накопительных агрегатов: это корректно, обычно быстрее и широко применимо — а тянуться к RANGE лишь для настоящих окон на основе значений вроде INTERVAL '7 days' PRECEDING. Теперь, когда пишешь любой накопительный агрегат, сразу добавляй клаузу ROWS-фрейма — до запуска запроса, до написания следующего окна — чтобы поведение на ничьих никогда не добралось до продакшена. С понятыми фреймами следующий урок переходит к ранжирующим функциям, где поведение на ничьих возвращается как разница между ROW_NUMBER, RANK и DENSE_RANK.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.