SQL: как найти серии последовательных дней без ошибок на пропусках
Продуктовая команда просит показать, сколько дней подряд пользователи возвращались в сервис. В журнале событий есть 1, 2, 4 и 5 марта. Обычная группировка по пользователю посчитает четыре активных дня, но не заметит разрыв 3 марта. Нужно получить две серии: 1–2 и 4–5 марта.
У событий есть ещё одна особенность: за день пользователь может открыть приложение много раз. Если сначала пронумеровать сырые события, повтор 2 марта сдвинет номера следующих строк и исказит серию. Поэтому первым шагом оставляем по одной строке на пару «пользователь — календарная дата».
Как дата и номер строки находят один остров
Отсортируем уникальные дни пользователя и дадим им номера 1, 2, 3… Для соседних календарных дней значение «числовая дата минус номер строки» одинаково: обе части растут на единицу. После пропуска календарная дата увеличится сильнее номера, и получится новый ключ группы. Это и есть приём gaps and islands: пропуски разделяют непрерывные участки.
В SQLite календарную дату можно перевести в число дней через julianday(). Ниже полный запрос вместе с маленьким входным набором. Даты записаны как YYYY-MM-DD; для событий с временем сначала приведите отметку к нужной календарной зоне, затем выделяйте дату.
CREATE TABLE visits (user_id INTEGER, visited_on TEXT);
INSERT INTO visits VALUES
(7, '2025-03-01'), (7, '2025-03-02'),
(7, '2025-03-02'), (7, '2025-03-04'),
(7, '2025-03-05'), (8, '2025-03-02');
WITH days AS (
SELECT DISTINCT user_id, visited_on FROM visits
), numbered AS (
SELECT user_id, visited_on,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY visited_on
) AS rn
FROM days
), grouped AS (
SELECT user_id, visited_on,
CAST(julianday(visited_on) AS INTEGER) - rn AS grp
FROM numbered
)
SELECT user_id, MIN(visited_on) AS first_day,
MAX(visited_on) AS last_day, COUNT(*) AS days_in_streak
FROM grouped
GROUP BY user_id, grp
ORDER BY user_id, first_day;
Результат для пользователя 7: 2025-03-01—2025-03-02, 2 дня и 2025-03-04—2025-03-05, 2 дня. Для пользователя 8 получится отдельная серия 2025-03-02—2025-03-02, 1 день. Повтор 2 марта не увеличивает длину первой серии благодаря DISTINCT.
Важно, что номер начинается заново для каждого пользователя: за это отвечает PARTITION BY user_id. А GROUP BY user_id, grp не склеивает серии разных людей, даже когда вычисленный ключ случайно совпал. MIN и MAX задают границы, COUNT(*) после удаления дублей становится числом активных дней.
Когда правило соседства другое
Этот запрос ищет именно соседние календарные дни. Если нужен «каждый рабочий день», суббота и воскресенье должны перестать считаться разрывом — понадобится календарь рабочих дней и нумерация по нему. Для сессий с допустимой паузой, например не более 30 минут, удобнее сравнить событие с предыдущим через LAG() и накапливать номер группы по моментам разрыва. Подмена одного правила другим даёт правдоподобную, но неверную длину серии.
На реальном журнале прежде всего проверьте зерно исходной таблицы: событие, день пользователя или уже агрегированная строка. Затем сравните несколько ручных последовательностей, где есть дубль, пропущенный день и граница месяца. По ним сразу видно, отвечает ли запрос выбранному правилу.
Если нужен только самый длинный streak каждого пользователя, не меняйте внутреннюю группировку. Сначала получите все серии, как в примере, затем пронумеруйте их через ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY days_in_streak DESC, last_day DESC) и оставьте номер 1. Так при равной длине победит более поздняя серия. Без второго критерия выбор при равенстве зависит от порядка, который запрос не определил.
Для текущей серии отдельное правило: её последний день должен совпадать с датой, относительно которой вы строите отчёт. Если разрешаете вчерашнюю серию для пользователя, который ещё не успел прийти сегодня, это тоже надо явно записать в условии. Иначе «максимальная историческая серия» легко превращается в ошибочно названную «текущую». Тот же вычисленный ключ группы остаётся полезным; меняется фильтр по последнему дню.
В «Продвинутом SQL» эту идею можно развить до пользовательских сессий, пересекающихся интервалов, retention и календарных рядов с пропусками. В курсе есть оконные функции и SQL-задачи с проверкой на нескольких наборах данных; итоговая работа связывает приёмы в витрину интернет-магазина. Откройте программу, чтобы пройти от короткого запроса к полноценному аналитическому расчёту.
Если дальше нужен скользящий показатель поверх дневного ряда, пригодится статья о скользящем среднем при пропущенных днях.
Продолжить на практике
- Продвинутый SQL: сложные запросы и задачи для собеседований ↗
Продолжение базового SQL: сложные JOIN, оконные рамки, временные ряды, retention, воронки и рекурсивные CTE. Разбирайте примеры и пишите запросы с автоматической проверкой — от отдельных расчётов до витрины интернет-магазина.
