Привет, Хабр!

Есть особый жанр сообщений в рабочем чате: «А почему в отчёте за март другая выручка, чем на прошлой неделе?». Отвечать на это неприятно, потому что ответ звучит как «Не знаю, сейчас разберусь», а разбираться приходится по всему пайплайну.

Почти всегда виновата модель данных, построенная на молчаливом допущении, что данные приезжают вовремя, приезжают один раз и больше не меняются. Ни одно из трёх допущений в проде не выполняется.

Разберём пять мест, где цифры уезжают. Код в основном 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;

Клиент переехал из Екатеринбурга в Москву, менеджер поправил карточку. И вся его история заказов за три года переехала вместе с ним. Итоговая выручка не изменилась, но выручка Урала за прошлый год упала, а Москва внезапно выросла. Отчёт, который вы отправляли в январе, больше не воспроизводится, и в этом никто не виноват: справочник хранит текущее состояние, а история его не хранит вовсе.

Правильно тут развести два вопроса:

  1. «Где клиент сейчас?».

  2. «Где он был в момент заказа?».

Ответ на второй даёт версионный справочник со сроками действия.

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, очереди и партиции под нагрузкой». Записаться

Больше бесплатных уроков июля смотрите в дайджесте.