Условие
В компании замеряется «30-дневная активная база» — для любого дня D это число уникальных клиентов, совершивших визит за 30 дней до D включительно (то есть на интервале [D − 29, D]).
Таблица чеков с полями: cityname, date, orderid, clientid, sales. Данные с 2022-01-01 по 2022-06-30.
Посчитайте подневную динамику 30-дневной активной базы по каждому городу. Сортировка — по городу, дате.
Пример вывода:
| cityname | date | active_base |
|---|---|---|
| city1 | 2022-02-01 | 534 |
| city1 | 2022-06-30 | 976 |
| city2 | 2022-02-01 | 3450 |
| city2 | 2022-06-30 | 4210 |
Решение
Подход
«Подневная 30-day distinct» — это rolling distinct count. SQL это плохо умеет нативно: COUNT(DISTINCT) OVER (...) без фрейма не работает в PostgreSQL/MySQL стандартно. Идиоматичных подхода два:
- Self-join: для каждой даты
Dприсоединить чеки сdate BETWEEN D-29 AND Dи взятьCOUNT(DISTINCT clientid). - Window-trick через арриваль и убытие пользователя в окне — сложнее.
Вариант (1) проще читать, но дороже по ресурсам; (2) — оптимизирован.
Реализация (вариант 1, читаемый)
WITH days AS (
SELECT DISTINCT cityname,
d::date AS day
FROM checks
CROSS JOIN generate_series(DATE '2022-01-30', DATE '2022-06-30', INTERVAL '1 day') AS g(d)
),
joined AS (
SELECT dy.cityname, dy.day, c.clientid
FROM days dy
LEFT JOIN checks c
ON c.cityname = dy.cityname
AND c.date BETWEEN dy.day - INTERVAL '29 day' AND dy.day
)
SELECT cityname, day AS date,
COUNT(DISTINCT clientid) AS active_base
FROM joined
GROUP BY cityname, day
ORDER BY cityname, day;Заметим:
2022-01-30— первая дата, для которой существует полное окно[2022-01-01, 2022-01-30]. Обычно «активная база» определяется ровно для дат, где окно полное; иначе формально для2022-01-15это будет «15-дневная база».
Реализация (вариант 2, оптимизированный — Postgres ≥ 15 / MSSQL)
Идея: для каждого clientid вместо «есть ли заказ в окне» считаем «остаётся ли клиент активным сегодня». Каждый клиент влияет на окно [order_date, order_date + 29]. Можно превратить в проблему «count of intervals overlapping each day» через +1 при входе, -1 при выходе, а затем кумулятивная сумма.
WITH events AS (
SELECT cityname, clientid, date AS day, +1 AS delta FROM checks
UNION ALL
SELECT cityname, clientid, date + INTERVAL '30 day' AS day, -1 AS delta FROM checks
),
-- distinct users only once per (city, day). Берем "первое появление в окне"
-- более аккуратно делается через DENSE_RANK по последнему чеку.
...На практике вариант 1 + индекс (cityname, date, clientid) достаточно. Если данных много — материализуйте промежуточный массив.
Альтернатива на MySQL (без generate_series)
Сгенерируйте календарь во вспомогательной таблице calendar(day DATE), дальше идентично варианту 1.
Подводные камни
- Окно
[D−30, D)vs[D−29, D]: классическое определение «30 дней включая сегодня» — это[D−29, D](всего 30 дней). Перепроверьте требование заказчика. COUNT(DISTINCT)вOVER: не работает в большинстве СУБД с rolling-фреймом — поэтому нужен self-join или приёмы выше.- Cross join по городам: если города не в каждом дне присутствуют,
LEFT JOINс фильтром по городу обязателен — иначе некоторые комбинации потеряются. - Часовой пояс /
datevstimestamp: еслиdate— timestamp, нужно явноdate(date). - Производительность: задача
O(N × 30). На больших данных — материализация ежедневного снапшота активных клиентов.
Эталонный ответ
CTE с генерацией календаря по городу × дню → LEFT JOIN с чеками, фильтр на [day−29, day] → COUNT(DISTINCT clientid) сгруппировать по (city, day) → отсортировать.