open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 02 · 06

Ловушки join и взрыв строк

Присоединение двух детей один-ко-многим к одному родителю умножает строки и молча удваивает твои SUM и COUNT — баг fan-out. Фикс — сперва агрегировать каждого ребёнка отдельно. Плюс пропущенные условия, неуникальные ключи и порядок join.

SQL Middle ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Финансовый дашборд сообщил о $2,4M месячной выручки. Бухгалтерская система сказала $1,2M. Ровно вдвое. Баг был в одном запросе, который соединял orders сразу с order_items и payments в одном выражении и суммировал total заказа. У каждого заказа было ~2 платежа, поэтому каждая строка заказа дублировалась, и SUM(o.total) считал сумму каждого заказа по разу на строку платежа. Ни ошибки, ни предупреждения — join молча удвоил число, которому доверяла вся компания. Это и есть fan-out, и это самый дорогой баг join, потому что запрос работает нормально.

Fan-out: два ребёнка, умноженные строки

Вспомни урок 1: join — это произведение, и число строк умножается. Fan-out — это то самое умножение, бьющее там, где ты не ждал. Возьми одного родителя (orders) с двумя детьми один-ко-многим: order_items (позиции) и payments (платежи частями). Соедини все три в одном запросе:

-- НЕВЕРНО: выручка удвоена (или хуже)
SELECT SUM(o.total) AS revenue,
       SUM(oi.quantity) AS units
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN payments    p  ON p.order_id  = o.id;

Если у заказа 3 позиции и 2 платежа, join производит 3 × 2 = 6 строк для этого единственного заказа — каждая позиция спарена с каждым платежом, потому что оба ребёнка джойнятся по order_id независимо. Теперь SUM(o.total) складывает total этого заказа 6 раз, а SUM(oi.quantity) считает каждую позицию 2 раза (по разу на платёж). Сумма заказа не изменилась; изменилось число строк, её несущих. Два ребёнка размножились друг против друга — декартово произведение внутри группы каждого заказа.

Фикс: предагрегируй каждого ребёнка перед join

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

-- ВЕРНО: каждый ребёнок агрегирован до одной строки на заказ, затем join
SELECT o.id,
       o.total,
       items.units,
       pays.paid
FROM orders o
LEFT JOIN (
  SELECT order_id, SUM(quantity) AS units
  FROM order_items GROUP BY order_id
) items ON items.order_id = o.id
LEFT JOIN (
  SELECT order_id, SUM(amount) AS paid
  FROM payments GROUP BY order_id
) pays ON pays.order_id = o.id;

Каждый подзапрос возвращает не более одной строки на order_id, поэтому ни один join не умножает. SUM(o.total) по этому теперь верен. Паттерн обобщается на любое число детей: агрегируй каждого в своём CTE/подзапросе, затем соедини сводки одна-строка-на-родителя. (LATERAL из прошлого урока — другой способ вычислить эти агрегаты на родителя инлайн.)

Для быстрого подсчёта иногда можно залатать fan-out через COUNT(DISTINCT oi.id) вместо COUNT(*) — distinct-подсчёт игнорирует дублированные строки. Но SUM(DISTINCT o.total) — ловушка: два разных заказа могут законно иметь одинаковый total, и DISTINCT их схлопнет. COUNT(DISTINCT) спасает подсчёты; только предагрегация надёжно спасает суммы.

Другие ловушки: пропущенные условия, неуникальные ключи, порядок join

Fan-out — тонкая, но три более грубые ловушки делят раздел. Когда ревьюишь запрос с несколькими join, спроси себя: ограничивает ли каждое условие join реальное отношение, уникален ли ключ хотя бы с одной стороны, и не присоединяются ли несколько независимых детей к одному родителю?

  • Пропущенное условие join → случайный cross join из урока 1. Забытый ON или ON, который реально не связывает таблицы, даёт |A| × |B| строк — мгновенный взрыв, а не тихое удвоение.
  • Неуникальные ключи join → даже «невинный» join дублирует строки, если ключ неуникален на стороне, которую ты считал уникальной. Соединение orders с users по email вместо id сделает fan-out, если два пользователя делят email. Всегда джойнь по ключу, уникальному хотя бы на одной стороне, или точно знай, почему он неуникален.
  • Порядок join и планировщик → ты пишешь join слева направо, но планировщик свободно их переупорядочивает ради минимизации стоимости; результат идентичен независимо от написанного порядка (для inner join). Так что не подкручивай порядок join вручную ради корректности или скорости — чини предикаты и статистику и дай планировщику выбрать. (Outer join ограничивают переупорядочивание; глава execution-plans трека databases разбирает когда.)
Частая ошибка

Причина, по которой fan-out так опасен, в том, что он невидим на малых данных. В деве у заказа одна позиция и один платёж, так что 1 × 1 = 1 — запрос выглядит верным и уезжает. В проде у заказов много позиций и платежей частями, умножение включается, и дашборд молча завышен в 2× или 5×. Защита — привычка, а не инструмент: всякий раз, когда запрос соединяет больше одной детской таблицы с родителем и затем агрегирует, остановись и предагрегируй каждого ребёнка. Проверяй здравость, сравнивая число строк после join с числом строк родителя — если join добавил строки, любой агрегат на родителя теперь неверен.

Викторина

У заказа 3 позиции и 2 платежа. Ты делаешь SELECT SUM(o.total), соединяя orders с ОБОИМИ order_items и payments. Истинный total $100. Что сообщит запрос?

Викторина

Каков надёжный фикс fan-out при суммировании значения родителя по нескольким детским таблицам?

Расставь шаги по порядку

Расставь шаги для безопасного отчёта о выручке и единицах по orders, items и payments:

  1. 1 Заметить, что запрос соединяет ДВУХ детей один-ко-многим (items, payments) с orders и агрегирует
  2. 2 Распознать fan-out: строки = items × payments на заказ, поэтому суммы пересчитывают
  3. 3 Агрегировать order_items до одной строки на order_id (SUM quantity) в подзапросе/CTE
  4. 4 Агрегировать payments до одной строки на order_id (SUM amount) в отдельном подзапросе/CTE
  5. 5 Соединить две сводки одна-строка-на-заказ с orders (теперь один-к-одному, без fan-out)
  6. 6 Проверить, что число строк после join равно числу строк orders
Вспомните перед уходом
  1. 01
    Объясни баг fan-out: почему соединение orders сразу с order_items и payments удваивает выручку?
  2. 02
    Каков надёжный фикс и почему SUM(DISTINCT) — не он?
  3. 03
    Стоит ли вручную упорядочивать join ради производительности и что вызывает дубли строк из «обычного» join?
Итог

Fan-out — самый дорогой баг раздела, потому что запрос успешен, а число неверно. Соединение двух независимых детей один-ко-многим с одним родителем образует декартово произведение внутри группы каждого родителя: i позиций × p платежей = i × p строк на заказ, поэтому SUM(o.total) складывает сумму заказа i × p раз, и финансовый дашборд молча удваивается. Надёжный фикс — предагрегация (pre-aggregation): схлопни каждого ребёнка до одной строки на родителя в своём подзапросе или CTE, затем соедини эти сводки один-к-одному (или вычисли их через LATERAL); COUNT(DISTINCT id) может залатать подсчёт, но SUM(DISTINCT) не латает сумму, ведь равные значения схлопываются. Другие ловушки раздела грубее: пропущенный или слишком слабый ON — это случайный cross join из урока 1; неуникальный ключ join дублирует строки тем же образом (джойнь по ключу, уникальному хотя бы на одной стороне); а порядок join — работа планировщика, не твоя — чини предикаты и статистику, а не написанную последовательность. Завершающая привычка: всякий раз, когда запрос соединяет больше одного ребёнка с родителем и агрегирует, сравни число строк после join с числом строк родителя — если join добавил строки, каждый агрегат на родителя уже неверен. Теперь, когда видишь запрос с несколькими дочерними join и суммой, сразу сравниваешь число строк результата с числом строк родителя — потому что тихое удвоение опаснее любой ошибки, которая хотя бы шумит.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки
опирается на

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.