open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 04 · 05

Накопительные итоги, скользящие средние, LAG/LEAD

Накопительный SUM, скользящие средние через ROWS BETWEEN n PRECEDING и LAG/LEAD для дельт период-к-периоду — плюс классическая ловушка LAST_VALUE, где фрейм по умолчанию кончается на CURRENT ROW, поэтому LAST_VALUE возвращает текущую строку, а не последнюю в партиции.

SQL Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Колонка «крупнейшая сделка с начала квартала» на дашборде продаж показывает неверное число на каждой строке, кроме последней. В запросе было 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 _______.

Вспомните перед уходом
  1. 01
    В чём разница во фрейме между накопительным итогом и скользящим средним по 3 строкам?
  2. 02
    Почему LAST_VALUE часто возвращает неверное значение и как это чинить?
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 8 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.