Привет, Хабр!
Есть особый жанр сообщений в рабочем чате: «А почему в отчёте за март другая выручка, чем на прошлой неделе?». Отвечать на это неприятно, потому что ответ звучит как «Не знаю, сейчас разберусь», а разбираться приходится по всему пайплайну.
Почти всегда виновата модель данных, построенная на молчаливом допущении, что данные приезжают вовремя, приезжают один раз и больше не меняются. Ни одно из трёх допущений в проде не выполняется.
Разберём пять мест, где цифры уезжают. Код в основном SQL плюс немного Python, но суть везде про логику загрузки, а не про конкретный движок.
Инкремент по времени загрузки, который теряет опоздавшие события
Классическая инкрементальная витрина. Каждый час забираем то, что появилось с прошлого запуска.
INSERT INTO events_daily SELECT date_trunc('day', event_time) AS d, count(*) AS cnt, sum(amount) AS revenue FROM raw_events WHERE event_time > (SELECT max(processed_until) FROM etl_state) GROUP BY 1;
Работает ровно до первого события, которое приехало с опозданием.
Событие с event_time за вчера приходит в хранилище сегодня. Условие event_time > processed_until его не поймает — эта граница уже проехала. Событие потеряется навсегда, и вчерашняя цифра останется заниженной.
Первое, что здесь нужно — перестать путать два времени. У каждого события их два: event_time (когда оно произошло у пользователя) и ingested_at (когда оно физически легло к вам). Инкремент отслеживается по ingested_at, а группировка и все бизнес‑расчёты делаются по event_time.
WITH new_rows AS ( SELECT * FROM raw_events WHERE ingested_at > (SELECT max(processed_until) FROM etl_state) ) SELECT date_trunc('day', event_time) AS d, ... FROM new_rows GROUP BY 1;
Теперь опоздавшее событие попадает в обработку (оно новое по ingested_at), но приземляется в правильный день (по event_time). Соответственно, пересчитывать надо не только сегодняшний день, а все дни, которых коснулись новые строки:
SELECT DISTINCT date_trunc('day', event_time) FROM new_rows;
Плюс окно перезаписи. Смотрим на реальность: снимаем гистограмму задержки и решаем, сколько ждём.
SELECT date_diff('hour', event_time, ingested_at) AS lag_hours, count(*) FROM raw_events WHERE ingested_at > now() - interval '30' day GROUP BY 1 ORDER BY 1;
Если 99% событий приезжают в течение шести часов, а хвост тянется до трёх суток, разумно каждый запуск пересчитывать последние трое суток целиком. Данные старше — считаем закрытыми, и это осознанное решение, а не случайность. Три процента потерь, о которых вы знаете, лучше, чем ноль процентов потерь, в которые вы верите.
Повторный запуск, который удваивает выручку
Раз пересчитываем — надо, чтобы пересчёт не ломал уже посчитанное. А INSERT его ломает.
Пайплайн упал на середине, дежурный перезапустил, за перезапущенный интервал данные вставились ещё раз. Выручка за вчера удвоилась. Или ещё веселее: INSERT прошёл частично, потом упал, никто не заметил, и данные в витрине лежат с полуторной выручкой.
Любой шаг пайплайна должен быть идемпотентным: два запуска на одном интервале дают тот же результат, что и один. Проще всего это делается перезаписью партиции, а не добавлением строк.
BEGIN; DELETE FROM events_daily WHERE d BETWEEN :from AND :to; INSERT INTO events_daily SELECT date_trunc('day', event_time), count(*), sum(amount) FROM raw_events WHERE event_time >= :from AND event_time < :to + interval '1' day GROUP BY 1; COMMIT;
Всё в одной транзакции, иначе между DELETE и INSERT кто‑нибудь обязательно посмотрит в дашборд и увидит пустоту. На движках с поддержкой MERGE то же самое пишется одним запросом, в dbt это incremental с unique_key и стратегией delete+insert, в Spark — INSERT OVERWRITE по партиции. Механизм разный, идея одна: результат зависит только от того, что лежит в источнике, и не зависит от того, сколько раз вы нажали кнопку.
Проверять идемпотентность очень просто. Запустите шаг дважды подряд на одном интервале и сравните контрольные суммы. Если совпали — живём. Такой тест на пять строк в CI ловит половину будущих инцидентов.
Дубликаты из очереди
Продолжение предыдущей темы, только теперь дублирует не ваш пайплайн, а источник.
Kafka, RabbitMQ, SQS, вебхуки платёжного шлюза — все они дают доставку «хотя бы один раз». Значит, одно и то же событие рано или поздно приедет дважды. Продьюсер не получил подтверждение и переслал. Консьюмер упал после обработки, но до коммита оффсета. Шлюз ретраил вебхук, потому что ваш ответ шёл дольше таймаута.
В сыром слое это ок и правильно: пусть лежит всё, как приехало. А вот дальше сырого слоя дубликаты пускать нельзя, иначе они попадут в count(*) и в sum(amount).
Дедупликация делается по идентификатору события, а не по «всем полям одинаковые» (два одинаковых клика подряд — это, возможно, два клика).
SELECT * FROM ( SELECT *, row_number() OVER ( PARTITION BY event_id ORDER BY ingested_at ) AS rn FROM raw_events WHERE event_time >= :from - interval '3' day -- окно шире, чем интервал AND event_time < :to ) WHERE rn = 1;
Обратите внимание на окно. Если дедуплицировать строго внутри обрабатываемого интервала, дубликат, приехавший через сутки после оригинала, окажется в другом окне, дедуп его не увидит и пропустит. Окно должно быть шире вашего интервала на типичную задержку доставки.
Если
event_idисточник не присылает — просите. Это самая дешёвая правка на стороне продьюсера и самая дорогая проблема на вашей. Пока его нет, приходится собирать суррогатный ключ из полей и молиться, что он не схлопнет два настоящих события.
Справочник, который переписывает историю
Тут ломается не сумма, а разрезы, и ломается тихо.
Есть таблица заказов и справочник клиентов, в котором лежит регион, тариф и сегмент. Витрина склеивает их в лоб:
SELECT c.region, sum(o.amount) FROM orders o JOIN customers c ON c.id = o.customer_id GROUP BY 1;
Клиент переехал из Екатеринбурга в Москву, менеджер поправил карточку. И вся его история заказов за три года переехала вместе с ним. Итоговая выручка не изменилась, но выручка Урала за прошлый год упала, а Москва внезапно выросла. Отчёт, который вы отправляли в январе, больше не воспроизводится, и в этом никто не виноват: справочник хранит текущее состояние, а история его не хранит вовсе.
Правильно тут развести два вопроса:
«Где клиент сейчас?».
«Где он был в момент заказа?».
Ответ на второй даёт версионный справочник со сроками действия.
CREATE TABLE customers_scd ( customer_id bigint, region text, segment text, valid_from timestamp, valid_to timestamp, -- NULL или '9999-12-31' для текущей версии is_current boolean );
Джойн становится немного длиннее, зато перестаёт врать:
SELECT c.region, sum(o.amount) FROM orders o JOIN customers_scd c ON c.customer_id = o.customer_id AND o.created_at >= c.valid_from AND o.created_at < coalesce(c.valid_to, timestamp '9999-12-31') GROUP BY 1;
Собирается такая таблица из потока изменений (CDC) или снимками: раз в сутки сохраняем состояние справочника целиком, потом схлопываем одинаковые подряд идущие снимки в интервалы. Второй способ грубее, зато не требует ничего от источника.
И заранее договоритесь с бизнесом, какая семантика им нужна. Иногда для отчётности как раз хотят видеть всю историю по текущему региону клиента (потому что бюджет теперь у московского офиса). Это совершенно легитимное требование, просто оно должно быть решением, а не побочным эффектом того, что кто‑то поленился сделать SCD.
Полночь, которой не было
Метрика «за вчера» разъезжается на несколько процентов, и никто не может понять почему.
События приходят с таймстампами в UTC, потому что так правильно. Отчёт смотрит бизнес в Москве, а пользователи сидят по всей стране. Вопрос: что такое «вчера»?
-- сутки по UTC WHERE event_time >= date '2026-03-11' AND event_time < date '2026-03-12' -- сутки по Москве, сдвиг на три часа WHERE event_time >= timestamp '2026-03-10 21:00' AND event_time < timestamp '2026-03-11 21:00'
Это разные множества событий, и разница на вечерней активности заметная. Дальше начинается веселье: один дашборд собрал аналитик, который про это подумал, второй — аналитик, который не подумал, и цифры не сходятся навсегда.
Чинится соглашением, записанным в документации и в коде: все таймстампы хранятся в UTC, все бизнес‑сутки считаются в одном явно названном часовом поясе, конверсия делается один раз в слое витрин.
SELECT date_trunc('day', event_time AT TIME ZONE 'Europe/Moscow') AS business_date, ...
Отдельно про переход на летнее время. В странах, где он есть, одни сутки в году содержат 23 часа, а другие — 25. Все ваши сравнения «неделя к неделе» на этих датах едут, а если вы где‑то в коде прибавляете interval '24 hour' вместо interval '1 day', поедут ещё и границы. В России перевода часов нет, но если у вас пользователи в Европе или Штатах — это ваша проблема тоже.
К тому же есть еще и часовой пояс пользователя. Если продукт про суточные привычки (утренние тренировки, вечерний просмотр), метрика в московском времени для владивостокского пользователя не означает ничего. Тогда нужен третий столбец: локальная дата события, посчитанная по таймзоне пользователя.
Что со всем этим делать
Витрины почти всегда пишут так, будто прошлое лежит и не шевелится. А оно шевелится: события доезжают с опозданием, дубликаты всплывают, справочники правят, границы суток у всех разные.
Значит, пересчёт надо сделать обычной кнопкой.
К тому же скажите людям, какие цифры уже окончательные. Одна строчка на дашборде — «последние трое суток предварительные, дальше заморожено» — снимает половину вопросов. Не потому что данные стали точнее, а потому что «отчёт опять врёт» превращается в «отчёт работает как обещано».
А проверять это всё лучше не глазами. Три запроса в CI: сравнить контрольную сумму витрины после двух прогонов на одном интервале, посчитать долю строк с задержкой больше вашего окна перезаписи, поискать дубликаты event_id за последний месяц. Каждый на пять строк, и каждый однажды поймает то, что вы бы искали два дня.
Заодно можно проверить и собственные знания: бесплатный вступительный тест по ClickHouse покажет, какие темы уже знакомы, а где остались пробелы.
Исторические данные меняются не сами по себе: почти всегда за этим стоят опоздавшие события, повторная обработка, дубликаты, обновившиеся справочники или разные правила работы со временем. В статье разобраны пять типовых причин, из-за которых отчёты перестают быть воспроизводимыми, и способы сделать пересчёт предсказуемым.
Разобраться с очередями, повторной обработкой событий и хранением данных можно на бесплатных занятиях OTUS. Там можно сверить свой подход с практикой экспертов, задать вопросы и посмотреть, как устроено обучение.
3 августа, 20:00. «Использование брокера сообщений Apache Kafka в распределённых очередях». Записаться
4 августа, 20:00. «PostgreSQL как память ИИ-агентов: MVCC, очереди и партиции под нагрузкой». Записаться
Больше бесплатных уроков июля смотрите в дайджесте.
