Реальные паттерны окон: пропуски, острова, сессии
Gaps-and-islands находит последовательные серии разностью row_number; сессионизация флагует новую сессию при разрыве LAG по timestamp сверх порога, затем SUM-ирует флаги. Каждый ORDER BY окна добавляет узел Sort — упорядочивай окна, чтобы делить сортировки.
Продукт спрашивает: «сколько отдельных сессий было у каждого пользователя, если сессия кончается после 30 минут неактивности?» В таблице events нет session_id — сессии неявны в разрывах между timestamp’ами. Наивный ответ — цикл в коде приложения, вытягивающий каждое событие и отслеживающий состояние; на 50M событий он бежит часами. Оконный ответ — три строки: LAG по timestamp, флагуй строки, где разрыв превышает 30 минут, затем гони накопительный SUM этих флагов. Это и есть сессионизация, жемчужина оконных паттернов.
Сессионизация: LAG по timestamp, флаг разрывов, накопительный SUM флагов
Сессионизация назначает номер сессии каждому событию так, чтобы события внутри разрыва неактивности принадлежали одной сессии. Рецепт — три оконных шага над (PARTITION BY user_id ORDER BY ts):
LAG(ts)даёт timestamp предыдущего события для каждой строки.- Флаг границы равен 1, когда
ts - prev_ts > interval '30 min'(или это первое событие), иначе 0. - Накопительный
SUMфлага превращает эти единицы в монотонно растущий номер сессии — каждый флаг толкает счётчик, каждый не-флаг держит его.
SELECT user_id, ts,
SUM(is_new_session) OVER (
PARTITION BY user_id ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_id
FROM (
SELECT user_id, ts,
CASE WHEN ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts)
> interval '30 minutes'
OR LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) IS NULL
THEN 1 ELSE 0 END AS is_new_session
FROM events
) flagged;Gaps-and-islands: трюк разности row_number
«Острова» — это серии последовательных значений; «пропуски» — дыры между ними. Классическая задача: по датам логинов найти каждую полосу подряд идущих дней. Трюк в том, что для серии последовательных целых (или дат) значение минус его номер строки постоянно внутри серии, потому что оба растут на 1 в ногу. Когда последовательность пропускает, разность меняется — новый остров.
SELECT user_id, MIN(login_date) AS streak_start,
MAX(login_date) AS streak_end, COUNT(*) AS streak_len
FROM (
SELECT user_id, login_date,
login_date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date))::int AS grp
FROM daily_logins
) t
GROUP BY user_id, grp
ORDER BY user_id, streak_start;grp — это якорь: все строки одной непрерывной полосы делят одно значение login_date - row_number, так что GROUP BY grp схлопывает каждую полосу в одну итоговую строку. Это каноническое решение gaps-and-islands — ROW_NUMBER плюс арифметика плюс GROUP BY по производному ключу. Дедупликация (урок 4) и кумулятивные распределения (урок 5) — меньшие члены того же семейства: каждое — «используй окно, чтобы вывести ключ, затем агрегируй или фильтруй по нему».
Производительность: каждый отдельный ORDER BY окна стоит сортировки
Оконные функции не бесплатны. Каждое отдельное определение окна — конкретно каждый уникальный PARTITION BY ... ORDER BY ... — заставляет Postgres материализовать строки в этом порядке, что означает узел Sort, питающий узел WindowAgg в плане. Сложи три окна с тремя разными упорядочиваниями — получишь три сортировки; на большой таблице это доминирующая стоимость.
Оптимизация — заставить окна делить упорядочивание. Postgres переиспользует одну сортировку для всех оконных функций с одинаковым PARTITION BY/ORDER BY, так что запись SUM(...) OVER w, ROW_NUMBER() OVER w, LAG(...) OVER w против одного именованного WINDOW w AS (PARTITION BY user_id ORDER BY ts) позволяет им ехать на одной сортировке вместо трёх. Когда тебе действительно нужны разные упорядочивания, расставь клаузы окон так, чтобы совместимые сортировки были рядом — планировщик иногда может конвейеризовать одну сортировку в следующую, если та префикс. Всегда проверяй через EXPLAIN ANALYZE: считай узлы Sort и WindowAgg и следи за Sort Method: external merge Disk:, что значит, что сортировка вышла за work_mem и бьёт по диску — момент, когда оконный запрос идёт от миллисекунд к секундам.
▸Почему это работает
Почему деление сортировки так важно? Сортировка — это O(n log n) и, хуже, становится O(диск) в момент, когда её рабочий набор превышает work_mem — Postgres сбрасывает во временные файлы, и запрос может замедлиться в 10–100×. Одна сортировка 50M строк, помещающаяся в память, может занять 2 секунды; та же сортировка со сбросом на диск — 40. Три отдельные сбрасывающиеся сортировки — это трижды эта боль. Свести окна на одно упорядочивание и поднять work_mem для сессии, гоняющей тяжёлый аналитический запрос — два рычага. Глава об execution-plans трека databases показывает, как читать узлы Sort/WindowAgg/Incremental Sort и заметить сброс на диск; здесь правило просто: меньше отдельных упорядочиваний окон, проверено в плане.
В сессионизации, после того как ты флагуешь каждую строку 1, когда разрыв превышает порог, и 0 иначе, что превращает эти флаги в id сессии?
Отчёт использует три оконные функции с тремя разными клаузами PARTITION BY/ORDER BY и медленный. Что вероятнее всего покажет EXPLAIN ANALYZE и в чём фикс?
Заполни пропуск: в gaps-and-islands все строки одной серии последовательных значений делят одно значение-минус-_______ , так что группировка по этому производному ключу схлопывает каждую серию в одну итоговую строку.
- 01Опиши три шага сессионизации с оконными функциями.
- 02Что такое трюк разности row_number для gaps-and-islands и почему он работает?
- 03Почему несколько оконных функций иногда делают запрос медленным и как это уменьшить?
Настоящая сила оконных функций в том, что горстка примитивов складывается в аналитические паттерны, реально нужные командам, и все они делят одну форму: используй окно, чтобы вывести ключ, затем агрегируй или фильтруй по нему. Сессионизация — витрина: LAG по timestamp, флагуй строки, где разрыв неактивности пересекает порог, и гони накопительный SUM этих флагов 0/1, чтобы чеканить id сессии на событие, заменяя многочасовой цикл приложения тремя оконными строками. Gaps-and-islands использует трюк value - ROW_NUMBER: последовательные значения делят постоянный производный ключ, так что GROUP BY по нему схлопывает каждую серию в сводку; дедупликация и кумулятивные распределения — меньшие кузены той же идеи. Сеньорская оговорка — производительность: каждый отдельный PARTITION BY/ORDER BY — это отдельный Sort + WindowAgg, а сортировка, переросшая work_mem, сбрасывается на диск и замедляет запрос на порядок — так что дели одно упорядочивание окна между функциями, следи за числом узлов Sort/WindowAgg в EXPLAIN ANALYZE и поднимай work_mem для тяжёлых аналитических сессий. Теперь, встречая задачу «сколько сессий?» или «найди серии», тянись к форме «флаг-затем-cumsum» раньше, чем к циклу — три оконных строки заменяют часы кода в приложении, а EXPLAIN ANALYZE скажет, нужно ли консолидировать сортировки.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.