Ловушки join и взрыв строк
Присоединение двух детей один-ко-многим к одному родителю умножает строки и молча удваивает твои SUM и COUNT — баг fan-out. Фикс — сперва агрегировать каждого ребёнка отдельно. Плюс пропущенные условия, неуникальные ключи и порядок join.
Финансовый дашборд сообщил о $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 Заметить, что запрос соединяет ДВУХ детей один-ко-многим (items, payments) с orders и агрегирует
- 2 Распознать fan-out: строки = items × payments на заказ, поэтому суммы пересчитывают
- 3 Агрегировать order_items до одной строки на order_id (SUM quantity) в подзапросе/CTE
- 4 Агрегировать payments до одной строки на order_id (SUM amount) в отдельном подзапросе/CTE
- 5 Соединить две сводки одна-строка-на-заказ с orders (теперь один-к-одному, без fan-out)
- 6 Проверить, что число строк после join равно числу строк orders
- 01Объясни баг fan-out: почему соединение orders сразу с order_items и payments удваивает выручку?
- 02Каков надёжный фикс и почему SUM(DISTINCT) — не он?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.