События с доставкой at-least-once завышают дневную активность — дедуплицируйте идемпотентно
Журнал событий доставляет at-least-once, поэтому один event_id может прийти не раз. Этот запрос дневной активности считает дублированные строки, завышая числа, что питают retention. Исправьте так, чтобы повторные запуски были стабильны независимо от числа доставок события.
SELECT event_date, COUNT(user_id) AS active_users
FROM events -- (event_id, user_id, event_date), доставка at-least-once
GROUP BY event_date;
Найдите и исправьте завышение — дедуплицируйте идемпотентно перед агрегацией.
COUNT(user_id) учитывает каждую доставленную строку, поэтому дубли завышают его. Сначала схлопывают до одной строки на event_id — DISTINCT ON (event_id) или ROW_NUMBER() = 1 — затем COUNT(DISTINCT user_id). Дедуп по уникальному id доставки идемпотентен.
- ✗Дедуплицировать агрегированный вывод, а не сырые строки событий
- ✗Считать, что COUNT(*) или COUNT(DISTINCT user_id) сами убирают повторы доставки
- ✗Винить пустые ключи, а не законную повторную доставку at-least-once
- →Почему дедуп по event_id идемпотентен, а COUNT(DISTINCT user_id) недостаточно?
- →Как выбрать, какую из дублей оставить, если их данные различаются?
COUNT(user_id) counts one per delivered row, so a duplicate delivery of the same event_id is counted twice. Deduplicate on the delivery-unique key before aggregating — keep exactly one row per event_id:
WITH deduped AS (
SELECT DISTINCT ON (event_id) event_id, user_id, event_date
FROM events
ORDER BY event_id
)
SELECT event_date, COUNT(DISTINCT user_id) AS active_users
FROM deduped
GROUP BY event_date
ORDER BY event_date;
DISTINCT ON (event_id) collapses redundant deliveries to one canonical row; ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY ...) = 1 is the portable equivalent. This is idempotent — replaying the same events yields the same deduplicated set, so the metric is stable across re-runs. Note why the shortcuts fail: SELECT DISTINCT on the aggregated output cannot un-inflate a count that already double-counted; and COUNT(*)/COUNT(DISTINCT user_id) on the raw stream still see the redelivered rows — only keying on event_id removes them robustly, whatever the downstream metric (active users, retention, event volume).