Практический SQL для аналитики: как писать запросы, которые не “размножают” данные
Покажем типовые причины ошибок в JOIN-агрегациях, как диагностировать кратности и как строить запросы с предсказуемыми результатами. Будут примеры с CTE, агрегатами и проверками корректности на контрольных наборах данных.
Содержание
Практический SQL для аналитики: как писать запросы, которые не “размножают” данные
Аналитические SQL-запросы чаще ломаются не из‑за синтаксиса, а из‑за логики: когда мы соединяем таблицы, а затем делаем агрегаты, итог может внезапно «размножить» данные. В результате метрики выглядят правдоподобно, но на самом деле считают не то: удвоение выручки, неверные конверсии, «скачки» в дашбордах и расхождения с контрольными отчетами.
Эта статья — практический разбор самых частых причин ошибок в JOIN-агрегациях, способов диагностики кратности (cardinality) и методов построения запросов с предсказуемыми результатами. Будут примеры с CTE, агрегатами и контрольными наборами данных, на которых легко проверить, что запрос считает корректно.
Типовая проблема: агрегат после JOIN и эффект умножения строк
Представьте схему данных для продуктовой аналитики:
orders— заказыorder_items— позиции в заказах (1 заказ → много позиций)refunds— возвраты по заказам (возможны несколько возвратов на один заказ)
Если вы делаете запрос в стиле:
- джойните
ordersсorder_items, - джойните с
refunds, - суммируете
items.priceи/или суммы по заказам,
то любая «многозначность» на одной из сторон JOIN начинает влиять на итог. Особенно опасны случаи, когда в одной связке одна таблица «размножает» строки, а другая еще раз «размножает» — и агрегат увеличивается в несколько раз.
Минимальный пример (логическая ошибка)
Допустим:
- заказ
O1имеет 2 позиции - и 3 возврата (или 2 возврата, неважно — главное, что больше 1)
Если запрос суммирует order_items.price после JOIN с refunds, то каждая позиция будет дублирована на количество возвратов, а итоговая сумма позиций вырастет.
Это не баг СУБД. Это следствие того, что агрегат посчитан на «смешанной» гранулярности.
Как диагностировать кратности JOIN: кто кого размножает
Перед тем как менять запрос, важно понять: какая таблица создает кратность и на каком этапе вы теряете управляемость над гранулярностью.
Шаг 1. Определите «ключ группировки» для метрики
Например, если метрика — «сумма выручки по дням», базовая единица времени — день.
Если метрика — «количество заказов», единица — заказ (уникальный order_id).
Если метрика — «выручка по пользователям», единица — пользователь.
Вопрос: после всех JOIN вы на какой гранулярности остаетесь? Часто ответ: «не знаем» или «на гранулярности самого “размножающего” JOIN».
Шаг 2. Проверьте кратность на ключах
Обычно достаточно двух диагностических запросов:
- сколько строк получается при JOIN на уровне ключей,
- какая кратность на стороне «много».
Универсальный паттерн для проверки кратности
Идея: считать распределение количества совпадений для каждого ключа.
-- Сколько строк "попадает" в result после JOIN с order_items
SELECT
o.order_id,
COUNT(*) AS joined_rows
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id
ORDER BY joined_rows DESC;
Если joined_rows всегда равно 1 — значит, order_items не размножает (скорее всего, у вас ошибка в ожиданиях или связь на самом деле 1:1).
Если же значения больше 1 — это сигнал: order_items переводит вас с гранулярности «заказ» на гранулярность «позиция».
Дальше аналогично — проверка для refunds:
-- Кратность возвратов на один заказ
SELECT
o.order_id,
COUNT(r.refund_id) AS refunds_count
FROM orders o
LEFT JOIN refunds r ON r.order_id = o.order_id
GROUP BY o.order_id
ORDER BY refunds_count DESC;
Шаг 3. Проверьте, не умножаете ли вы агрегат дважды
Если вы делаете два JOIN, и оба переводят гранулярность в «много», итоговая строка будет результатом композиции кратностей.
Например: 2 позиции × 3 возврата = 6 строк на один заказ. Сумма позиций окажется умноженной на 3 (или на 6 — в зависимости от того, что вы суммируете).
Типовые причины ошибок в JOIN-агрегациях
Разберем наиболее частые сценарии.
1) Агрегирование на неверной гранулярности
Вы делаете SUM(order_items.amount) после JOIN, но фактически суммируете на уровне строк, которые уже зависят от возвратов или других дочерних таблиц.
Симптомы:
- сумма выручки «растет» при наличии возвратов,
- агрегаты по заказам отличаются от контрольных значений,
- при фильтрации по параметрам (канал, продукт, период) расхождения усиливаются.
2) JOIN-агрегация «сквозь» нерелевантную таблицу
Иногда запрос содержит JOIN, который нужен только для фильтра (например, убедиться, что заказ из конкретного региона), но затем поля из основной таблицы агрегируются уже после JOIN, и это создает кратность.
Например, вместо того чтобы:
- использовать JOIN для фильтра,
- затем агрегировать отдельно,
вы агрегируете все вместе.
3) Несогласованность ключей агрегации
Частая ловушка: один GROUP BY рассчитан на ключ A, а JOIN добавляет измерение B (которое может быть многозначным), и в итоге фактическая группа становится комбинацией A×B.
4) Неправильный тип соединения (INNER vs LEFT)
INNER JOIN может «съедать» записи, где нет дочерних сущностей.
LEFT JOIN — сохраняет записи, но если потом вы делаете агрегации по дочерним таблицам, нужно корректно обработать NULL и предотвратить «раздувание» при последующих JOIN.
В обоих случаях итоговые метрики «едут», если не контролировать гранулярность.
Контрольный набор данных: чтобы ошибки было видно быстро
Давайте зафиксируем данные, на которых удобно проверить логику. В статье ниже будет несколько запросов — они должны давать ожидаемые результаты.
Таблицы
-- Заказы: 2 заказа
WITH orders AS (
SELECT 1 AS order_id, DATE '2026-08-01' AS order_date, 10.00::numeric AS order_total
UNION ALL
SELECT 2 AS order_id, DATE '2026-08-01' AS order_date, 20.00::numeric AS order_total
),
-- Позиции: заказ 1 имеет 2 позиции, заказ 2 имеет 1 позицию
order_items AS (
SELECT 1 AS order_id, 101 AS item_id, 4.00::numeric AS item_amount
UNION ALL
SELECT 1 AS order_id, 102 AS item_id, 6.00::numeric AS item_amount
UNION ALL
SELECT 2 AS order_id, 201 AS item_id, 20.00::numeric AS item_amount
),
-- Возвраты: заказ 1 имеет 2 возврата, заказ 2 — 0
refunds AS (
SELECT 1001 AS refund_id, 1 AS order_id, 2.00::numeric AS refund_amount
UNION ALL
SELECT 1002 AS refund_id, 1 AS order_id, 1.00::numeric AS refund_amount
)
-- Ниже запросы будут использовать эти CTE
SELECT 1;
Ожидаемая логика:
- Сумма позиций по заказам:
10 + 20 = 30 - Сумма возвратов:
2 + 1 = 3 - Если суммировать позиций после JOIN с возвратами, то заказ 1 будет иметь кратность 2 возврата, а значит суммы позиций заказ 1 (10) умножатся до 20.
Плохой запрос и правильная альтернатива
Ошибка: сумма позиций после JOIN с возвратами
Допустим, мы хотим посчитать по дню:
gross_items_amount— сумму позицийrefund_amount— сумму возвратов
Ниже — распространенный неправильный вариант: вы джойните и суммируете одновременно.
WITH orders AS (
SELECT 1 AS order_id, DATE '2026-08-01' AS order_date, 10.00::numeric AS order_total
UNION ALL
SELECT 2 AS order_id, DATE '2026-08-01' AS order_date, 20.00::numeric AS order_total
),
order_items AS (
SELECT 1 AS order_id, 101 AS item_id, 4.00::numeric AS item_amount
UNION ALL
SELECT 1 AS order_id, 102 AS item_id, 6.00::numeric AS item_amount
UNION ALL
SELECT 2 AS order_id, 201 AS item_id, 20.00::numeric AS item_amount
),
refunds AS (
SELECT 1001 AS refund_id, 1 AS order_id, 2.00::numeric AS refund_amount
UNION ALL
SELECT 1002 AS refund_id, 1 AS order_id, 1.00::numeric AS refund_amount
)
SELECT
o.order_date,
SUM(oi.item_amount) AS gross_items_amount,
COALESCE(SUM(r.refund_amount), 0) AS refund_amount
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
LEFT JOIN refunds r ON r.order_id = o.order_id
GROUP BY o.order_date;
Что пойдет не так:
- Для
order_id=1у нас 2 позиции и 2 возврата. JOIN даст 4 строки. SUM(oi.item_amount)по этим 4 строкам превратится в:(4+6) * 2 = 20.order_id=2— 1 позиция и 0 возвратов ⇒ останется одна строка (в LEFT JOIN), сумма позиций = 20.- Итого
gross_items_amountстанет40, хотя корректно должно быть30.
Это и есть «размножение» данных.
Ремонт: сначала агрегируйте на нужной гранулярности, потом джойните
Классический и наиболее надежный подход: свести дочерние таблицы к ключу родителя до того, как вы объедините их между собой.
В нашем примере:
- сначала агрегируем
order_itemsдо уровняorder_id, - затем агрегируем
refundsдо уровняorder_id, - потом делаем join этих агрегатов с
orders.
Исправленный запрос (через CTE)
WITH orders AS (
SELECT 1 AS order_id, DATE '2026-08-01' AS order_date, 10.00::numeric AS order_total
UNION ALL
SELECT 2 AS order_id, DATE '2026-08-01' AS order_date, 20.00::numeric AS order_total
),
items_by_order AS (
SELECT
oi.order_id,
SUM(oi.item_amount) AS gross_items_amount
FROM order_items oi
GROUP BY oi.order_id
),
refunds_by_order AS (
SELECT
r.order_id,
SUM(r.refund_amount) AS refund_amount
FROM refunds r
GROUP BY r.order_id
)
SELECT
o.order_date,
SUM(COALESCE(i.gross_items_amount, 0)) AS gross_items_amount,
SUM(COALESCE(r.refund_amount, 0)) AS refund_amount
FROM orders o
LEFT JOIN items_by_order i ON i.order_id = o.order_id
LEFT JOIN refunds_by_order r ON r.order_id = o.order_id
GROUP BY o.order_date;
Теперь гранулярность управляется:
items_by_order— ровно 1 строка наorder_id,refunds_by_order— ровно 1 строка наorder_id,- итоговая сумма позиций не зависит от количества возвратов.
Контроль корректности: проверка на контрольных наборах
Чтобы уверенно править запросы, добавляйте проверяемость прямо в SQL (или хотя бы в промежуточных CTE).
Проверка инвариантов
Для нашего набора данных полезен инвариант:
- сумма позиций по всем заказам должна равняться
30 - сумма возвратов —
3
Можно добавить отдельный запрос:
WITH orders AS (
SELECT 1 AS order_id, DATE '2026-08-01' AS order_date, 10.00::numeric AS order_total
UNION ALL
SELECT 2 AS order_id, DATE '2026-08-01' AS order_date, 20.00::numeric AS order_total
),
order_items AS (
SELECT 1 AS order_id, 101 AS item_id, 4.00::numeric AS item_amount
UNION ALL
SELECT 1 AS order_id, 102 AS item_id, 6.00::numeric AS item_amount
UNION ALL
SELECT 2 AS order_id, 201 AS item_id, 20.00::numeric AS item_amount
),
refunds AS (
SELECT 1001 AS refund_id, 1 AS order_id, 2.00::numeric AS refund_amount
UNION ALL
SELECT 1002 AS refund_id, 1 AS order_id, 1.00::numeric AS refund_amount
),
items_by_order AS (
SELECT oi.order_id, SUM(oi.item_amount) AS gross_items_amount
FROM order_items oi
GROUP BY oi.order_id
),
refunds_by_order AS (
SELECT r.order_id, SUM(r.refund_amount) AS refund_amount
FROM refunds r
GROUP BY r.order_id
),
final AS (
SELECT
o.order_id,
o.order_date,
COALESCE(i.gross_items_amount, 0) AS gross_items_amount,
COALESCE(r.refund_amount, 0) AS refund_amount
FROM orders o
LEFT JOIN items_by_order i ON i.order_id = o.order_id
LEFT JOIN refunds_by_order r ON r.order_id = o.order_id
)
SELECT
SUM(gross_items_amount) AS gross_items_total,
SUM(refund_amount) AS refund_total
FROM final;
Если вы видите, что gross_items_total стал 40 — значит вы где-то снова «размножили» данные.
Автоматическая проверка кратности (удобный диагностический CTE)
Иногда полезно проверить, что агрегаты действительно 1:1:
WITH items_by_order AS (
SELECT
oi.order_id,
SUM(oi.item_amount) AS gross_items_amount
FROM order_items oi
GROUP BY oi.order_id
),
refunds_by_order AS (
SELECT
r.order_id,
SUM(r.refund_amount) AS refund_amount
FROM refunds r
GROUP BY r.order_id
)
SELECT
(SELECT COUNT(*) FROM items_by_order) AS items_rows,
(SELECT COUNT(DISTINCT order_id) FROM items_by_order) AS items_distinct_order_id,
(SELECT COUNT(*) FROM refunds_by_order) AS refunds_rows,
(SELECT COUNT(DISTINCT order_id) FROM refunds_by_order) AS refunds_distinct_order_id;
Если COUNT(*) отличается от COUNT(DISTINCT order_id) — значит где-то ваша «агрегация к ключу» не сработала (например, вы группировали по лишним колонкам).
Практический паттерн: «агрегируй до JOIN» и «джойни после контроля гранулярности»
В реальных проектах правила проще запомнить как два принципа:
- Если нужно суммировать — суммируйте на “правильной” гранулярности до того, как появятся многозначные JOIN.
- Любой JOIN, который может сделать больше строк, должен быть “ограничен” агрегатами или дедупликацией.
На практике это выглядит так:
- Дочерние таблицы (
*_items,*_events,*_tags) сначала приводим к уровню родителя (entity_idилиdate+entity_id). - Затем джойним агрегаты с основной таблицей (
orders,users,sessions) и делаем итоговую агрегацию по измерениям.
Сложнее: когда нужно несколько разных метрик из разных дочерних таблиц
Частая ситуация: вы хотите одновременно посчитать, например:
- количество событий
eventsна заказ - сумму
paymentsпо заказу - возвраты
refundsпо заказу - при этом все это свернуть по дням
Если сворачивать «все вместе» после JOIN — риск кратности максимален.
Правильная схема: три независимых агрегата
WITH orders AS (
SELECT 1 AS order_id, DATE '2026-08-01' AS order_date
UNION ALL
SELECT 2 AS order_id, DATE '2026-08-01' AS order_date
),
events_by_order AS (
SELECT order_id, COUNT(*) AS events_cnt
FROM order_events
GROUP BY order_id
),
payments_by_order AS (
SELECT order_id, SUM(amount) AS payments_amount
FROM payments
GROUP BY order_id
),
refunds_by_order AS (
SELECT order_id, SUM(refund_amount) AS refunds_amount
FROM refunds
GROUP BY order_id
)
SELECT
o.order_date,
SUM(COALESCE(e.events_cnt, 0)) AS events_cnt,
SUM(COALESCE(p.payments_amount, 0)) AS payments_amount,
SUM(COALESCE(r.refunds_amount, 0)) AS refunds_amount
FROM orders o
LEFT JOIN events_by_order e ON e.order_id = o.order_id
LEFT JOIN payments_by_order p ON p.order_id = o.order_id
LEFT JOIN refunds_by_order r ON r.order_id = o.order_id
GROUP BY o.order_date;
Заметьте: все агрегаты приходят одним числом на ключ. Поэтому итог по дням определяется только orders.
Типичные ошибки при “ремонте”: как сделать правильно, но снова сломать
Даже после перехода к CTE и агрегатам легко допустить другие логические промахи.
Ошибка A: агрегировали до order_id, но не учли фильтры
Например, вы фильтруете refunds по статусу «accepted» в основной части запроса, но если этот фильтр применен уже после JOIN, он может снова изменить набор строк.
Решение: фильтры, влияющие на набор дочерних данных, должны быть применены внутри соответствующего *_by_order CTE.
Ошибка B: дедупликация вместо агрегата
Иногда кажется, что достаточно SELECT DISTINCT перед JOIN. Но если в таблице есть разные суммы/события, DISTINCT может скрыть реальные значения.
Принцип: если вам нужна сумма/количество — делайте агрегат. Если вам нужна конкретная «последняя запись» — используйте оконные функции и выбирайте одну запись строго по правилу.
Ошибка C: выбор “последнего статуса” без окна
Например, статус заказа хранится как история. Если вы JOIN-ите историю статусов напрямую и потом считаете метрику, опять получите кратность.
Решение: привести историю к 1 строке на заказ:
- через
ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY status_date DESC).
Дедупликация: когда нужно взять ровно одну запись на ключ
Рассмотрим историю статусов заказа: order_status_history содержит несколько статусов. Допустим, вы хотите считать метрики только по текущим статусам.
Неправильно: джойнить всю историю статусов и фильтровать по status='delivered' — строк может быть много.
Правильно: выбрать текущий статус:
WITH orders AS (
SELECT 1 AS order_id, DATE '2026-08-01' AS order_date
UNION ALL
SELECT 2 AS order_id, DATE '2026-08-01' AS order_date
),
current_status AS (
SELECT
osh.order_id,
osh.status,
ROW_NUMBER() OVER (
PARTITION BY osh.order_id
ORDER BY osh.status_date DESC
) AS rn
FROM order_status_history osh
)
SELECT
o.order_date,
COUNT(*) AS orders_delivered
FROM orders o
JOIN current_status cs
ON cs.order_id = o.order_id
AND cs.rn = 1
WHERE cs.status = 'delivered'
GROUP BY o.order_date;
Теперь у вас 1 строка статуса на order_id, и дальше агрегация не раздувается.
Когда “агрегируй до JOIN” не хватает: агрегаты по разным измерениям
Иногда разные метрики должны считаться на разных временных горизонтах или с разной гранулярностью.
Например:
- выручка по дате заказа
- возвраты по дате возврата
- конверсия по дате события
Если вы все сведете к order_date, часть метрик будет концептуально неверной.
Но и здесь можно сохранить управляемость: уводить агрегацию в отдельные CTE так, чтобы каждая метрика жила на своем измерении, а потом объединять их на уровне целевого отчета (обычно через FULL JOIN или через «календарь»/список дат).
Как построить запрос с предсказуемыми результатами: практический чек-лист
Ниже — чек-лист, который реально помогает при отладке.
1) Явно зафиксируйте гранулярность каждого шага
orders— 1 строка наorder_iditems_by_order— 1 строка наorder_idrefunds_by_order— 1 строка наorder_id- финальный
GROUP BY— по нужному измерению (дата, канал, сегмент)
Если в CTE не гарантирована кратность 1:1 на ключ — вы не закончили ремонт.
2) Агрегируйте дочерние таблицы раньше, чем начинаете «склеивать» их друг с другом
Это основной антидот от размножения.
3) Проверяйте ожидаемые суммы на контрольных наборах
Добавляйте временные запросы, которые сравнивают «идеальные» итоги (например, суммы по ключам) с тем, что получилось после всех JOIN.
4) Смотрите на распределение ключей (кратности) перед расчетами
Один COUNT(*) после JOIN на каждом ключе часто экономит часы.
5) Сначала делайте логически корректно — потом оптимизируйте
Переход на оптимизацию раньше исправления логики приводит к сложному и неверному запросу.
Итог: меньше магии, больше контроля над гранулярностью
Размножение данных в SQL почти всегда сводится к одному: агрегат считается на строках, гранулярность которых неожиданно поменялась из‑за JOIN. Диагностика кратности (cardinality) и подход «агрегируй до JOIN» дают предсказуемые результаты и делают запросы устойчивыми к расширению модели данных (добавили новую таблицу — и вы не получили внезапное умножение метрик).
Если вы только начинаете и хотите системно разобраться с тем, как думать о запросах, JOIN и агрегатах, можно начать с материала уровня «SQL — для начинающих!» — как минимум, он поможет выстроить правильные базовые представления о том, что именно и на каком этапе агрегируется (и почему это важно). В дальнейшем полезно возвращаться к практикам из этой статьи и применять их к вашим реальным схемам — например, отрабатывать паттерн с CTE и контрольными проверками.
Подобный путь обычно лучше всего работает: вы осваиваете не набор операторов, а мышление, которое предотвращает самые дорогие ошибки в аналитике. Если хочется продолжить, хороший следующий шаг — посмотреть практику по SQL для начинающих.
Комментарии
Пока нет комментариев