Сообщение в личку в 18:40: «Слушай, а почему у тебя в выгрузке 11 903 заказа, а на дашборде 12 480? Завтра в 11 показываем директору».

Дальше начинается то, что я про себя называю паникой первого года. Человек открывает свой запрос и перечитывает его сверху вниз, надеясь, что ошибка как-нибудь сама подсветится - не подсвечивается, тогда он переписывает запрос немного иначе, получает третье число, и к полуночи у него уже три версии правды и ноль понимания 🙃

За пять лет я разбирала такие расхождения десятки раз, и почти всегда работает одно и то же. Расхождение не находится чтением кода, оно находится сужением области поиска и второе, что стоит понять сразу: обычно обе цифры правильные, просто они считают разное, и вопрос тут не в том, кто ошибся, а в том, как у вас в компании определена метрика.

Дальше алгоритм, по которому я сейчас локализую причину почти на любом расхождении. Именно локализую: договориться об определении метрики и починить витрину (таблицу, которая считается заранее и лежит готовой к отчётам) бывает дольше, но это уже спокойная работа, ниже запросы на PostgreSQL, в других базах поменяется только синтаксис, смысл нет.

Если показ уже через два часа

Начну с этого, потому что в горящей ситуации остальное вы всё равно читать не будете 🙂

Заказчику сейчас интересна не причина, ему надо понимать, что делать со встречей, поэтому первым делом отвечаем на три вопроса: меняется ли от расхождения вывод (если обе цифры дают «выросли на 20%», можно выдохнуть и идти на встречу), какой цифрой пользуемся сегодня и почему, когда будет полный разбор. Всё это лучше сказать самому и заранее, потому что если это всплывёт на самом показе, разговор будет уже другой.

И ещё одно, самое дешёвое действие, до которого почему-то доходят в последнюю очередь: попросите запрос, которым считается вторая цифра. Не описание словами, не скриншот, а код. Довольно часто расхождение видно прямо в нём без всякого алгоритма.

Шаг 0. Зафиксировать, что вообще с чем сравниваем

Скучный шаг, его часто пропускают, и из-за него потом полдня сравнивают тёплое с мягким: до того, как открывать SQL, выпишите для обеих цифр пять параметров:

Параметр

Источник А (моя выгрузка)

Источник Б (дашборд)

Что считаем

заказы

заказы

Период

с 01.03 по 31.03

с 01.03 по 31.03

Фильтры

?

?

Разрез

все

все

Когда выгружено

вчера в 19:00

сегодня в 10:00

Иногда всё заканчивается прямо на этой табличке: выясняется, что дашборд считает 31 марта целиком, а у вас в запросе стоит < '2026-03-31', и дальше копать нечего.

Отдельно посмотрите на последнюю строку, если между выгрузками прошли сутки, вы сравниваете два разных состояния данных, и никакая сверка тут в принципе не сойдётся.

Шаг 1. Посмотреть на форму расхождения

Дельта это симптом, и по нему можно поставить предварительный диагноз ещё до первого запроса.

Считаем разницу в штуках и в процентах, а потом смотрим на характер:

Как выглядит расхождение

Что подозревать в первую очередь

Кратное или близкое к кратному (в 2, в 1,5, в 3 раза)

Размножение строк при джойне, дубли по ключу

Маленькое и стабильное (0,5–5%)

Разные фильтры: тесты, отменённые, удалённые, внутренние аккаунты

Нарастает к концу периода

Лаг события: дата заказа против даты оплаты, часть событий ещё не случилась

Ровный сдвиг плюс всплески на первом и последнем дне

Часовой пояс

Пересечение ключей вообще нулевое

Разные типы или форматы ключа (строка с ведущими нулями против числа)

Вчера сходилось, сегодня нет, данные те же

Изменяемые атрибуты: обновился статус, категория или справочник

Только последние 2–3 дня

Данные ещё догружаются, у источников разный лаг

Только в одном срезе

Проблема локальная, дальше копаем только там

Дельта скачет туда-сюда по дням

Дедупликация или пересчёт истории в одном из источников

Около 0,1–1%, и только на уникальных

Приблизительный подсчёт уникальных (uniqapprox_count_distinct)

Цифра на дашборде как будто застыла

Кэш дашборда или витрина не пересчиталась

Эту табличку я собирала по рабочим случаям, своим и чужим, и она экономит больше времени, чем любые ускорения запросов, потому что превращает бесконечный перебор гипотез в две-три проверки.

Шаг 2. Сузить по дням

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

Сразу отвечу на вопрос, который тут возникает: а как выгрузка дашборда оказалась в базе? Обычно её скачивают в CSV и заливают во временную таблицу.
Если строк немного (тридцать дней это немного), можно вообще не заливать, а вклеить прямо в запрос:

with b (dt, cnt_b) as (
    values
        (date '2026-03-01', 402),
        (date '2026-03-02', 388),
        (date '2026-03-03', 415)
        -- и так далее, копипастой из выгрузки
)
select * from b;

Сам запрос сравнения:

with a as (
    select order_date::date as dt, count(*) as cnt_a
    from my_query_result
    group by 1
),
b as (
    select report_date::date as dt, orders as cnt_b
    from dashboard_export
)
select
    coalesce(a.dt, b.dt) as dt,
    a.cnt_a,
    b.cnt_b,
    coalesce(a.cnt_a, 0) - coalesce(b.cnt_b, 0) as diff
from a
full join b on a.dt = b.dt
where coalesce(a.cnt_a, 0) <> coalesce(b.cnt_b, 0)
order by 1;

Смотрим, что получилось:

  • расхождение размазано ровным слоем по всем дням, значит дело в фильтре или в определении метрики;

  • сидит в двух конкретных днях, значит смотреть надо данные за эти дни (сбой загрузки, ручная правка, дубли);

  • вылезает только на первом и последнем дне, значит вы нашли границы периода или часовые пояса.

Если по дням всё ровно, повторяем то же самое по следующему разрезу: категория, канал, платформа, регион, статус, тот же запрос, другое поле в group by.

А если дашборд по дням не выгружается? Бывает сплошь и рядом: доступа к датасету нет, экспорта нет, есть виджет с одним числом и тогда работает поиск руками: ставим в фильтре дашборда первую половину периода, сравниваем с той же половиной у себя, потом делим пополам ту половину, где расхождение осталось.
Пять-шесть кликов, и проблема локализована до одного дня, кода при этом не написано ни строчки.

Шаг 3. Спуститься до конкретных строк

Тут мы перестаём сравнивать суммы и начинаем сравнивать множества ключей, обычно на этом шаге всё и заканчивается.

with a as (select distinct order_id from my_query_result),
     b as (select distinct order_id from dashboard_orders)
select
    case
        when b.order_id is null then 'только в моей выгрузке'
        when a.order_id is null then 'только на дашборде'
    end as side,
    count(*) as cnt
from a
full join b on a.order_id = b.order_id
where a.order_id is null or b.order_id is null
group by 1;

distinct в подзапросах тут не для красоты, если дубли есть хотя бы с одной стороны (а вы их как раз и подозреваете), джойн размножит строки, и счётчик выдаст вам красивую чушь.

Две вещи стоит проверить до того, как поверить результату:

Первое: NULL в ключе не соединяется ни с чем, включая другой NULL, поэтому строки с пустым order_id уедут в категорию «только в моей выгрузке» просто по механике джойна. Посчитайте их отдельно и отложите.

Второе: если запрос показывает, что пересечения нет совсем, дело почти наверняка в типах. В одном источнике ключ лежит строкой '0012345', в другом числом 12345, совпадений ноль, данные при этом одинаковые.
Приводите обе стороны к одному типу явно и теперь самое интересное, посмотрите на десяток выпавших строк глазами:

select a.*
from my_query_result a
left join dashboard_orders b on a.order_id = b.order_id
where b.order_id is null
limit 10;

Чаще всего ответ виден сразу, потому что у всех лишних строк окажется что-то общее: статус cancelled, флаг is_test, нулевая сумма, юрлицо вместо физлица, страна, которой нет в отчёте, или время 2026-03-31 23:40, которое в другом часовом поясе уже апрель.

Кстати, вот вам ответ на вопрос, зачем аналитику вообще смотреть на сырые строки, когда есть агрегаты: агрегат сообщает, что что-то не так, а причину показывает строка.

А если общих ключей нет? Так бывает при сверке с внешней системой: биллинг, 1С, рекламный кабинет, отчёт из бухгалтерии там ваших order_id там нет, сравнивать построчно нечего, шаг 3 отваливается целиком.

Тогда ищем самое дробное общее измерение, какое вообще есть: день, площадка, кампания, юрлицо, тип операции и сверяемся по нему, сжимаем область до одной клетки, а клетку уже разбираем руками, буквально выписывая десяток операций из обеих систем в один файл.
И тут сразу задайте два вопроса, которые в таких сверках закрывают половину расхождений: в какой момент операция попадает в каждую из систем и что каждая из них делает с возвратами.

Шаг 4. Пройтись по списку типовых причин

Если после третьего шага картина всё ещё мутная, идём по списку. Он покрывает почти всё, что я встречала за пять лет.

Границы периода. between включает обе границы, < правую не включает, на месячном отчёте это ровно один день.

Часовые пояса. Один источник в UTC, другой в местном времени, заказы с 21:00 до полуночи уезжают в соседние сутки.

Разная дата события. Дата создания заказа, дата оплаты, дата отгрузки, дата попадания строки в витрину, все четыре законные, все четыре дают разные цифры.

Дубли и размножение строк. Проверяется одним запросом:

select
    count(*)                 as rows,
    count(order_id)          as ids_not_null,
    count(distinct order_id) as ids_unique
from my_query_result;

Три счётчика, а не два, потому что count(distinct) молча пропускает NULL, и без среднего столбца вы не отличите дубли от пустых ключей.

Если rows больше ids_unique, строки размножились. Выручка при этом завышена на сумму продублированных строк, а не на их количество, и это, как правило, совсем другое число. Второй вариант: строки вообще не дубли, а позиции внутри заказа, и тогда чинить надо своё представление о том, что такое одна строка в этой таблице.

Тип джойна. inner join со справочником молча выкидывает всё, чему не нашлось пары, и вот вам минус два процента из ниоткуда, left join честнее, он оставит строку с пустыми полями, но заметите вы это, только если специально посмотрите на пустые значения.

Фильтры по умолчанию в BI. В дашборде почти всегда есть фильтры, о которых вы не знаете, потому что сидят они в датасете, а не в виджете, смотреть надо на запрос под дашбордом, а не на экран.

Поздние данные. Витрина считается раз в сутки, часть событий приезжает с опозданием, вчерашняя цифра завтра станет другой - это нормальное поведение системы, просто про него надо знать.

Определение сущности. У вас пользователь это user_id, а в отчёте маркетинга пользователь это устройство, цифры разъезжаются в полтора раза, при этом в обоих запросах всё написано правильно.

Кэш. Самое обидное из всего списка: оба расчёта верные, дашборд просто показывает результат вчерашнего запроса, ловится принудительным обновлением за минуту, а ищется, если не догадаться, полдня.

Приблизительные функции. uniq в ClickHouse, approx_count_distinct и другие функции на алгоритме HyperLogLog считают уникальных не точно, погрешность зависит от функции и обычно держится в пределах процента, это сознательный размен точности на скорость (точные варианты вроде uniqExact есть, просто они дороже по ресурсам), если у вас стабильный процент расхождения и только на метриках уникальности, скорее всего вы нашли причину и искать дальше нечего.

Семплирование. Веб-аналитика на больших объёмах часто считает по выборке и домножает результат, поэтому с сырыми логами она не сойдётся никогда, вопрос только в том, какое отклонение вы считаете допустимым.

Изменяемые атрибуты. Самая коварная штука, потому что тут вообще ничего не сломано. Заказ переехал в другую категорию, статус обновился, клиента перевели в другой сегмент, справочник перезалили. Отчёт за март, посчитанный сегодня, честно отличается от такого же отчёта, посчитанного вчера, спросите себя, точно ли обе цифры считались на одном состоянии данных, и если у витрины есть история изменений, сверяйтесь на зафиксированный момент.

Метрики-отношения. Когда не сходится конверсия, разбирайте числитель и знаменатель по отдельности, обычно заказы совпадают идеально, а разъезжается количество сессий, и полдня уходит на поиски ошибки не в той половине дроби.

Есть ещё приём для тех, у кого есть доступ ко всему пайплайну: сверяйте не только итоговые цифры, но и слои, считаете одно и то же на сыром слое, на промежуточном и на витрине, находите слой, где число впервые поехало, и дальше живёте только в нём.

Отдельно: чего делать не надо

Подгонять фильтры, пока цифры не совпадут. Соблазн понятный: добавил where status <> 'cancelled', совпало, отправил, пошёл домой. Только причина при этом не найдена, найдена подходящая комбинация, и в следующем месяце всё развалится снова, а имя под отчётом будет стоять ваше.

Отвечать «у меня всё правильно». Даже когда правильно, вас спросили не про вас, вас спросили, какой цифре верить.

Молча починить и никому не сказать. Через месяц кто-то откроет старый отчёт, увидит другое число и придёт с тем же вопросом, а объяснения нигде нет.

Копать бесконечно. Про это редко говорят вслух, но договориться о допустимом расхождении надо заранее (обычно это доли процента при условии, что вывод от него не меняется), попали в коридор, зафиксировали цифры, записали гипотезу, пошли работать дальше. Сутки поисков ради 0,2%, которые ни на что не влияют, на ревью выглядят плохо, и это справедливо.

И момент, который касается скорее команды, чем вас лично: если разбор остался в личке, через месяц он повторится с другим человеком, заведите тикет, напишите там причину и решение, а заодно выясните, кто владелец метрики. Вопрос «какая цифра правильная» решается договорённостью, а у договорённости должен быть хозяин.

Шаг 5. Как оформить результат

Расхождение это находка, и подавать её надо соответственно.
Формат, который у меня прижился:

Разобралась с расхождением по заказам за март.

Обе цифры корректные. В дашборде 12 480, это все заказы, созданные в марте, а в моей выгрузке 11 903, это заказы, по которым была оплата.

Разница 577 заказов это заказы в статусе «ожидает оплаты» и «отменён до оплаты», основная часть приходится на последние три дня месяца, что логично, часть из них оплатится в апреле.

Для нашей задачи (выручка марта) корректна моя цифра, для задачи про спрос корректна цифра дашборда.

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

Предлагаю переименовать метрику на дашборде в «созданные заказы» и добавить рядом «оплаченные», чтобы этот вопрос больше не возникал.

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

Шаг 6. Сделать так, чтобы это не повторилось

Шаг, который пропускают чаще всех остальных: причину нашли, все обрадовались, разошлись.

Минимум, который стоит сделать:

  1. Записать определение метрики словами в одном месте, куда можно кинуть ссылку.

  2. Убедиться, что дашборд и ваши выгрузки берут данные из одного слоя, а не каждый из своего.

  3. Поставить регулярную проверку сходимости, буквально такую:

select
    src.dt,
    src.cnt              as source_cnt,
    mart.cnt             as mart_cnt,
    src.cnt - mart.cnt   as diff_cnt,
    src.amount - mart.amount as diff_amount
from daily_source_stats src
join daily_mart_stats  mart on mart.dt = src.dt
where src.dt >= current_date - 7
  and (
        src.cnt <> mart.cnt
     or abs(src.amount - mart.amount) > 0.01
  );

Допуск в последней строке стоит не из мягкости: суммы в дробных типах почти никогда не совпадают до последнего знака, и без допуска проверка будет краснеть каждый день, а на проверку, которая всегда красная, через неделю перестают смотреть, это универсальный закон.

Если запрос вернул строки, вы узнаете о расхождении сегодня, а не за час до показа директору.

Чек-лист

  1. Зафиксировать метрику, период, фильтры, разрез и момент выгрузки для обеих цифр.

  2. Попросить запрос, которым считается вторая цифра.

  3. Посмотреть на форму дельты и выдвинуть гипотезу по симптому.

  4. Если время горит, сделать триаж: меняется ли вывод и какой цифрой пользуемся сегодня.

  5. Сузить по дням, потом по другим разрезам, а если разбивки нет, поделить период пополам руками.

  6. Сравнить множества ключей с дедупликацией, проверив типы и NULL, и посмотреть на выпавшие строки.

  7. Если общих ключей нет, сжимать область по самому дробному общему измерению.

  8. Пройти список причин: границы, таймзоны, дата события, дубли, тип джойна, фильтры BI, кэш, поздние данные, изменяемые атрибуты, приблизительные функции, семплирование, определение сущности.

  9. Для метрик-отношений разбирать числитель и знаменатель отдельно.

  10. Не подгонять фильтры под нужное число и вовремя останавливаться, если попали в допустимый коридор.

  11. Оформить как находку, ответить про прошлые отчёты, завести тикет, закрыть регулярной проверкой.

Сложного тут ничего нет, все запросы на уровне второго месяца изучения SQL, но на собеседованиях про это не спрашивают, в курсах не рассказывают, а на работе оно прилетает примерно раз в месяц, так что пусть лежит записанным.

Если было полезно, я пишу про аналитику в телеграм-канале Таня и Данные: про свои обучения, рабочие приёмы, разборы задач и истории из практики.