Накопительные итоги, скользящие средние, LAG/LEAD
Накопительный SUM, скользящие средние через ROWS BETWEEN n PRECEDING и LAG/LEAD для дельт период-к-периоду — плюс классическая ловушка LAST_VALUE, где фрейм по умолчанию кончается на CURRENT ROW, поэтому LAST_VALUE возвращает текущую строку, а не последнюю в партиции.
Колонка «крупнейшая сделка с начала квартала» на дашборде продаж показывает неверное число на каждой строке, кроме последней. В запросе было LAST_VALUE(amount) OVER (ORDER BY sale_date). Читается как по-английски — «последнее значение» — но возвращает сумму текущей строки, а не финальной в квартале, потому что фрейм по умолчанию кончается на CURRENT ROW. LAST_VALUE — самый частый сообщаемый баг оконных функций, и фикс — одна клауза фрейма. Этот урок — инструментарий накопления: накопительные итоги, скользящие средние, построчные дельты и эта ловушка.
Накопительные итоги и скользящие средние
Накопительный итог — это SUM по фрейму, который растёт от начала партиции до текущей строки. Из урока о фреймах: всегда пиши ROWS для последовательного построчного итога:
SELECT region, sale_date, amount,
SUM(amount) OVER (
PARTITION BY region ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM sales;Скользящее среднее — та же идея с ограниченным фреймом — среднее по текущей строке и нескольким перед ней:
-- скользящее среднее по 3 строкам назад от дневной суммы
SELECT sale_date, amount,
AVG(amount) OVER (
ORDER BY sale_date
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM sales;Единственное изменение от накопительного итога — начальная граница: 2 PRECEDING вместо UNBOUNDED PRECEDING. Ближе к началу партиции фрейм просто держит меньше строк (1, затем 2, затем полные 3) — корректное краевое поведение, не баг.
LAG и LEAD: построчные дельты
LAG(col, n) читает колонку из строки за n до текущей в порядке окна; LEAD(col, n) читает за n после. Это как считать изменение период-к-периоду без self-join:
-- изменение выручки месяц-к-месяцу
SELECT month, revenue,
revenue - LAG(revenue) OVER (ORDER BY month) AS mom_change,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 1) AS mom_pct
FROM monthly_revenue;LAG(revenue) на первой строке — NULL (предыдущей строки нет) — задай дефолт через LAG(revenue, 1, 0), если хочешь 0; и защити деление через NULLIF, чтобы нулевое предыдущее значение не дало деление на ноль. LAG/LEAD полностью игнорируют фрейм; они навигируют по смещению строки внутри упорядоченной партиции.
FIRST_VALUE, LAST_VALUE и ловушка
FIRST_VALUE(col) возвращает колонку из первой строки фрейма, LAST_VALUE(col) — из последней. Ловушка: с ORDER BY в окне фрейм по умолчанию — RANGE UNBOUNDED PRECEDING AND CURRENT ROW, поэтому последняя строка фрейма и есть текущая строка. LAST_VALUE поэтому возвращает значение текущей строки, а не финальное в партиции — чего почти никто не хочет.
-- НЕВЕРНО: возвращает сумму текущей строки, потому что фрейм кончается на CURRENT ROW
LAST_VALUE(amount) OVER (PARTITION BY region ORDER BY sale_date)
-- ВЕРНО: растяни фрейм до конца партиции
LAST_VALUE(amount) OVER (
PARTITION BY region ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)FIRST_VALUE обычно выглядит корректно случайно: фрейм начинается с UNBOUNDED PRECEDING, что действительно первая строка, так что фрейм по умолчанию её уже покрывает. Кусается именно LAST_VALUE — его цель живёт на дальнем краю партиции, за потолком CURRENT ROW фрейма по умолчанию. Чини растяжкой фрейма до UNBOUNDED FOLLOWING или обойди через MAX/MIN по всей партиции (без ORDER BY, так что фрейм — вся партиция), когда хочешь экстремум, а не буквально последнюю-по-порядку строку.
▸Частая ошибка
Причина, по которой LAST_VALUE путает людей, в том, что имя функции описывает намерение, а фрейм решает реальность. Две продакшен-безопасные привычки: (1) всякий раз, когда пишешь LAST_VALUE с ORDER BY, сразу добавляй ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, иначе тихо получишь текущую строку; (2) если тебе реально нужно «значение, связанное с max/min какой-то колонки», предпочти FIRST_VALUE(col) OVER (... ORDER BY key DESC) — упорядочь так, чтобы нужная строка была первой, куда фрейм по умолчанию уже дотягивается — или используй форму DISTINCT ON / MAX. Та же дисциплина «числа не лгут», что и в уроке о фреймах: не верь имени функции, проверяй фрейм.
LAST_VALUE(amount) OVER (PARTITION BY region ORDER BY sale_date) возвращает неверное значение на большинстве строк. Почему?
Нужно изменение выручки месяц-к-месяцу. Какое выражение верно?
Заполни пропуск: чтобы LAST_VALUE возвращал истинную финальную строку партиции, а не текущую строку, растяни конечную границу фрейма до UNBOUNDED _______.
- 01В чём разница во фрейме между накопительным итогом и скользящим средним по 3 строкам?
- 02Почему LAST_VALUE часто возвращает неверное значение и как это чинить?
- 03Как считать изменение месяц-к-месяцу и какие краевые случаи защищать?
Это инструментарий накопления. Накопительный итог — это SUM по ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW; скользящее среднее — то же с ограниченным началом вроде 2 PRECEDING; оба опираются на явный фрейм ROWS из прошлого урока, чтобы ничьи их не искажали. LAG и LEAD шагают назад и вперёд по смещению строки, чтобы считать дельты период-к-периоду без self-join — помни, что первая/последняя строка даёт NULL, и защищай процентную математику через NULLIF. Фирменная ловушка — LAST_VALUE: его имя обещает финальную строку партиции, но с ORDER BY фрейм по умолчанию кончается на CURRENT ROW, поэтому он возвращает текущую строку; растяни фрейм до UNBOUNDED FOLLOWING (или переупорядочь и используй FIRST_VALUE, или MAX/MIN по всей партиции). FIRST_VALUE выглядит нормально лишь потому, что фрейм по умолчанию уже начинается с первой строки партиции. Теперь, встречая LAST_VALUE в запросе и видя корректные числа на dev, — всё равно добавь ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING: в продакшене он сломается в первый же день, когда появится tied-timestamp. Следующий урок собирает эти примитивы в реальные паттерны — gaps-and-islands, сессионизацию, дедупликацию — и стоимость производительности при стопке нескольких окон.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.