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

Реальные паттерны окон: пропуски, острова, сессии

Gaps-and-islands находит последовательные серии разностью row_number; сессионизация флагует новую сессию при разрыве LAG по timestamp сверх порога, затем SUM-ирует флаги. Каждый ORDER BY окна добавляет узел Sort — упорядочивай окна, чтобы делить сортировки.

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

Продукт спрашивает: «сколько отдельных сессий было у каждого пользователя, если сессия кончается после 30 минут неактивности?» В таблице events нет session_id — сессии неявны в разрывах между timestamp’ами. Наивный ответ — цикл в коде приложения, вытягивающий каждое событие и отслеживающий состояние; на 50M событий он бежит часами. Оконный ответ — три строки: LAG по timestamp, флагуй строки, где разрыв превышает 30 минут, затем гони накопительный SUM этих флагов. Это и есть сессионизация, жемчужина оконных паттернов.

Сессионизация: LAG по timestamp, флаг разрывов, накопительный SUM флагов

Сессионизация назначает номер сессии каждому событию так, чтобы события внутри разрыва неактивности принадлежали одной сессии. Рецепт — три оконных шага над (PARTITION BY user_id ORDER BY ts):

  1. LAG(ts) даёт timestamp предыдущего события для каждой строки.
  2. Флаг границы равен 1, когда ts - prev_ts > interval '30 min' (или это первое событие), иначе 0.
  3. Накопительный 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 все строки одной серии последовательных значений делят одно значение-минус-_______ , так что группировка по этому производному ключу схлопывает каждую серию в одну итоговую строку.

Вспомните перед уходом
  1. 01
    Опиши три шага сессионизации с оконными функциями.
  2. 02
    Что такое трюк разности row_number для gaps-and-islands и почему он работает?
  3. 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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.