Российские технологии и цифровая экономика
ПоискRSS

Ошибки в Sql-агрегациях: как не получить лгущий дашборд

7 минут чтения

Как не получить лгущий дашборд: 7 ошибок в SQL-агрегациях

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

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

Ниже разобраны семь типичных проблем, из-за которых ломаются SQL аналитика данных и разработка BI дашбордов. Первые три связаны с `NULL`, следующие - с неожиданным изменением количества строк, а последняя касается временных границ. Примеры воспроизводятся в DuckDB 1.5.5 и SQLite; одинаковое поведение объясняется стандартной трёхзначной логикой SQL и характерно также для PostgreSQL и MySQL. Дополнительные примеры и пояснения по теме собраны в материале о том, как находить ошибки в SQL агрегациях.

1. `COUNT(*)`, `COUNT(column)` и `COUNT(DISTINCT column)` - разные показатели

Предположим, в таблице заказов часть полей заполнена не полностью. У клиента без промокода `promo_code` равен `NULL`, а сумма заказа может отсутствовать, если её ещё не рассчитала платёжная система.

В такой ситуации четыре визуально похожих счётчика способны вернуть разные результаты:

- `COUNT(*)` считает все строки;
- `COUNT(column)` учитывает только строки, где указанная колонка не равна `NULL`;
- `COUNT(DISTINCT column)` считает уникальные непустые значения;
- `COUNT(DISTINCT CASE WHEN ... END)` дополнительно зависит от условий фильтрации.

Каждый результат может быть правильным - вопрос лишь в том, что именно требуется измерить. Если "количество заказов" считают через `COUNT(amount)`, все заказы без рассчитанной суммы исчезнут из статистики. Аналогичная проблема возникает при сравнении двух отчётов: один использует `COUNT(*)`, другой - `COUNT(id)`. Пока идентификатор заполнен всегда, различий не видно. Но после `LEFT JOIN` появляются строки с пустыми полями, и показатели расходятся.

2. `AVG` автоматически исключает пропуски

Среднее значение ведёт себя аналогично. Функция `AVG(amount)` не воспринимает `NULL` как ноль - она исключает такие строки из расчёта.

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

Оба подхода могут быть оправданными. Если отсутствие суммы означает "заказ ещё не обработан", `AVG` работает корректно. Если же бизнес-правило трактует отсутствие значения как нулевую выручку, потребуется явное преобразование:

```sql
AVG(COALESCE(amount, 0))
```

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

3. Сравнение с `NULL` не даёт ни истинного, ни ложного результата

Рассмотрим клиентов, у одного из которых не указан регион. Запросы вроде этих выглядят естественно:

```sql
WHERE region = 'Север'
WHERE region <> 'Север'
```

Однако клиент с `NULL` не попадёт ни в одну выборку. В SQL выражение `NULL = 'Север'` возвращает не `FALSE`, а `UNKNOWN`. Аналогично, `NULL <> 'Север'` также даёт неопределённый результат. А оператор `WHERE` оставляет только строки, для которых условие истинно.

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

Чтобы поведение было явным, пропуски можно выделить в отдельную категорию:

```sql
COALESCE(region, 'Не указан')
```

Другой вариант - запретить `NULL` на уровне схемы и обязать систему заполнять регион при создании записи.

4. `NOT IN` ломается из-за одного `NULL`

Особенно опасна конструкция:

```sql
WHERE customer_id NOT IN (11, NULL)
```

На первый взгляд она должна выбрать всех клиентов, кроме клиента с идентификатором 11. Но SQL интерпретирует условие как:

```sql
customer_id <> 11
AND customer_id <> NULL
```

Вторая часть возвращает `UNKNOWN`, поэтому всё выражение не становится истинным ни для одной строки. В итоге запрос возвращает пустой результат.

Надёжнее использовать `NOT EXISTS`:

```sql
WHERE NOT EXISTS (
SELECT 1
FROM blocked_clients b
WHERE b.customer_id = c.customer_id
)
```

Либо заранее исключать `NULL` из подзапроса. При этом `IN` обычно ведёт себя менее неожиданно: если найдено хотя бы одно истинное совпадение, наличие `NULL` в списке не мешает вернуть строку. Тем не менее полагаться на неявное поведение не стоит - фильтры лучше формулировать явно.

5. `JOIN` может размножить строки

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

Если после соединения выполнить:

```sql
SUM(order.amount)
```

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

Исправить проблему можно несколькими способами:

- сначала агрегировать позиции до уровня заказа;
- суммировать стоимость позиций вместо суммы заказа;
- использовать `SUM(DISTINCT ...)` только при гарантированной уникальности значения;
- проверять количество строк до и после каждого `JOIN`.

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

6. `LEFT JOIN` легко превратить в `INNER JOIN`

`LEFT JOIN` сохраняет строки из левой таблицы, даже если соответствия справа нет. Но фильтр по правой таблице в секции `WHERE` меняет это поведение:

```sql
FROM orders o
LEFT JOIN payments p ON p.order_id = o.id
WHERE p.status = 'paid'
```

Заказы без платежа получают `NULL` в полях `p`, а условие `p.status = 'paid'` их отбрасывает. Фактически запрос начинает работать как `INNER JOIN`.

Если нужно сохранить все заказы и ограничить только присоединяемые платежи, условие следует перенести в `ON`:

```sql
FROM orders o
LEFT JOIN payments p
ON p.order_id = o.id
AND p.status = 'paid'
```

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

7. Среднее по группам не равно общему среднему

Допустим, у одного канала 100 посетителей и конверсия 10%, а у другого - 10 000 посетителей и конверсия 2%. Простое среднее двух процентов даст 6%, хотя реальная конверсия по всей аудитории будет близка к 2,08%.

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

```sql
AVG(conversion_rate)
```

Корректнее считать общие числитель и знаменатель:

```sql
SUM(converted_users) * 1.0 / SUM(total_users)
```

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

8. `BETWEEN` может потерять события последнего дня

Для дат без времени конструкция `BETWEEN '2025-01-01' AND '2025-01-31'` часто выглядит безопасно. Но если поле хранит timestamp, верхняя граница обычно интерпретируется как начало 31 января - `2025-01-31 00:00:00`. Все события, произошедшие позже, будут исключены.

Надёжный вариант - использовать полуоткрытый интервал:

```sql
WHERE created_at >= '2025-01-01'
AND created_at < '2025-02-01' ``` Так в выборку попадает весь последний день, включая 23:59:59 и доли секунды. Это особенно важно для ежедневных отчётов, финансовых периодов и расчёта KPI.

Как проверять агрегаты до публикации

Перед выпуском отчёта полезно тестировать не только синтаксис SQL, но и инварианты данных: сумма по группам должна совпадать с общим итогом, количество строк после `JOIN` должно быть объяснимым, а доля `NULL` - контролируемой. Для ключевых показателей стоит подготовить небольшой набор эталонных данных, где заранее известен правильный результат.

Не менее важна фиксация гранулярности. В документации к метрике нужно указывать, что считается единицей измерения: заказ, пользователь, сессия или позиция. Без этого даже корректный запрос легко использовать не по назначению.

Наконец, полезно регулярно сравнивать показатели между независимыми системами и запускать автоматические проверки на выбросы. Если выручка внезапно выросла на 30%, а количество заказов осталось прежним, это повод проверить `JOIN`, `NULL`, временные фильтры и уровень агрегации ещё до того, как цифра попадёт руководству. Практические приёмы для оптимизации SQL запросов и контроля агрегатов помогают сделать такие проверки частью обычного процесса разработки.

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

Прокрутить вверх