сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · SQL

JOIN в SQL: виды, ON против WHERE и потерянные строки

Зачем это спрашивают

Джойны - самая частая секция живого SQL-кодинга, и на них сразу видно, писал ли человек запросы по работе. Есть два вопроса-детектора: «почему LEFT JOIN потерял строки» и «почему после джойна суммы выросли». Оба ловят тех, кто читал про джойны, но не отлаживал их в три часа ночи.

Типовые формулировки: «собери заказы с именами клиентов», «найди клиентов без заказов», «выручка задвоилась, что случилось?».

// Разберись с двумя вещами - куда писать условие по правой таблице и что такое размножение строк - и восемьдесят процентов джойн-вопросов закроются сами.

Что делает джойн и в каком порядке идёт запрос

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

INNER оставляет только совпавшие пары. LEFT берёт всю левую таблицу, а справа подставляет совпавшее или NULL, если пары не нашлось. FULL делает то же с обеих сторон. CROSS соединяет каждую строку с каждой - обычно он получается не нарочно, из забытого условия соединения.

Запрос выполняется не в том порядке, в каком написан. Сначала FROM и JOIN, потом WHERE, потом GROUP BY, потом HAVING, и только затем SELECT, а в самом конце ORDER BY и LIMIT. Из этого следует бытовая странность, которая всех удивляет: алиас, придуманный в SELECT, не виден в WHERE - к моменту WHERE его ещё не существует.

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

NULL
не ноль и не пустая строка, а «значение неизвестно». Любое сравнение с ним даёт не «ложь», а третий вариант - «неизвестно», и это источник половины сюрпризов в SQL

ON против WHERE: где живёт условие по правой таблице

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

Если условие o.status = 'paid' написать в ON, вернутся две строки: второй юзер на месте, у него просто NULL в статусе. Если то же условие написать в WHERE, вернётся одна строка. Проверено на живой базе, и это не тонкость реализации, а прямое следствие порядка выполнения.

Почему так. JOIN отрабатывает первым и честно оставляет второго юзера с NULL справа. Потом приходит WHERE и проверяет NULL = 'paid'. Результат сравнения - «неизвестно», а WHERE пропускает дальше только честное «истина». Юзер отваливается, и LEFT JOIN незаметно превратился в INNER.

// Правило: условие ОТБОРА пары живёт в ON, условие ФИЛЬТРАЦИИ результата - в WHERE. Одно исключение осознанное: анти-джойн «кто вообще без заказа» пишется как LEFT JOIN плюс WHERE right.id IS NULL, и вот тут отваливание строк - именно то, что нужно.

-- оба юзера на месте: у второго просто NULL
SELECT u.id, o.status FROM users u
LEFT JOIN orders o
  ON o.user_id = u.id AND o.status = 'paid';

-- WHERE o.status = 'paid' -- съел бы юзеров без оплат

Размножение строк: почему выросли суммы

Слева две строки: клиент 1 на 100 рублей и клиент 2 на 200. Сумма 300. Справа - таблица тегов, где у клиента 1 два тега, а у клиента 2 один. Джойним и получаем три строки, а SUM по сумме заказа выдаёт 400. Деньги выросли на сотню из ничего.

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

Лечится пониманием зерна таблицы. Зерно - ответ на вопрос «что означает одна строка»: одна строка на заказ, одна на клиента, одна на клиента и день. Джойн безопасен, когда справа зерно не мельче, чем слева. Если мельче - сначала агрегируй правую таблицу до нужного зерна, потом соединяй.

// Экспресс-проверка до джойна, десять секунд: сравни COUNT(*) и COUNT(DISTINCT ключ) на правой таблице. Разошлись - ключ не уникален, размножение будет. А если нужно всего лишь «у кого есть хоть один», бери EXISTS: он проверяет наличие и строк не плодит, в отличие от JOIN с последующим DISTINCT.

зерно таблицы
что означает одна её строка. Пока не назвал зерно обеих таблиц словами, джойнить рано

Как отвечать: «После джойна суммы стали больше, чем были. Что случилось?»

Классическое размножение строк: ключ справа оказался неуникальным, каждая левая строка склеилась с несколькими правыми, и SUM посчитал одни и те же деньги по нескольку раз. Первым делом смотрю кардинальность правого ключа - COUNT(*) против COUNT(DISTINCT key), это занимает секунду. Дальше лечу по месту: агрегирую правую таблицу до зерна левой или джойню готовый предагрегат. Воткнуть DISTINCT поверх результата - это лечение симптома: суммы всё равно будут кривые, а кривой джойн останется в запросе и выстрелит в следующий раз.

Назван механизм, дана экспресс-диагностика и честное лечение вместо заплатки. По этому вопросу интервьюер отличает практика мгновенно, потому что теоретики предлагают DISTINCT.

На чём валят

  • NOT IN с NULL внутри подзапроса возвращает пусто - всегда, при любых данных. Сравнение с NULL даёт «неизвестно», а WHERE пропускает только «истина». Пиши NOT EXISTS.
  • Суммы после джойна многие-ко-многим задваиваются: агрегируй правую таблицу до джойна, а не после.
  • WHERE по правой таблице после LEFT JOIN молча выкидывает строки без пары - фильтр по правой живёт в ON.
  • Джойнить по «уникальному» ключу, уникальность которого никто не проверял.
  • Забыть условие соединения и получить декартово произведение: миллион на миллион складывается быстро.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 31, остальные разбираются в тренажёре.

  1. #joins1 / 5
    Как найти юзеров, у которых НЕТ ни одного заказа?
    A)INNER JOIN orders, а затем условие WHERE orders.id IS NULL
    B)LEFT JOIN orders ON .. WHERE o.id IS NULL — либо NOT EXISTS
    C)RIGHT JOIN orders по ключу user_id без дополнительных условий
    D)WHERE user_id NOT IN (SELECT id FROM users) в таблице заказов
    показать ответ и разбор
    +B)LEFT JOIN orders ON .. WHERE o.id IS NULL — либо NOT EXISTS

    // разбор: Анти-join двумя идиомами: LEFT JOIN + IS NULL по колонке правой таблицы, или NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id). INNER JOIN строки без пары вообще не отдаёт — фильтровать нечего. NOT EXISTS предпочтителен ещё и NULL-безопасностью (в отличие от NOT IN).

  2. #joins2 / 5
    Чем PRIMARY KEY отличается от FOREIGN KEY?
    A)PK уникально идентифицирует строку; FK ссылается на PK другой таблицы
    B)PRIMARY KEY и FOREIGN KEY — это просто два разных названия одного ограничения
    C)PK допускает дубли и NULL, а FK обязан быть строго уникальным
    D)FOREIGN KEY хранит саму строку, а PRIMARY KEY — её номер
    показать ответ и разбор
    +A)PK уникально идентифицирует строку; FK ссылается на PK другой таблицы

    // разбор: PRIMARY KEY — уникальный, не-NULL идентификатор строки в своей таблице. FOREIGN KEY — колонка, ссылающаяся на PK другой таблицы, чем и связывает таблицы. FK обеспечивает ссылочную целостность: нельзя вставить заказ на несуществующего юзера и (при настройке) удалить юзера с живыми заказами.

  3. #joins3 / 5
    Чем INNER JOIN базово отличается от LEFT JOIN?
    A)INNER JOIN возвращает абсолютно все строки сразу обеих таблиц, а LEFT — только совпавшие
    B)Между INNER JOIN и LEFT JOIN нет никакой разницы в результате
    C)INNER — только совпавшие пары; LEFT добавляет все левые (без пары — NULL)
    D)LEFT JOIN работает только с числовыми ключами, а INNER — с разными
    показать ответ и разбор
    +C)INNER — только совпавшие пары; LEFT добавляет все левые (без пары — NULL)

    // разбор: INNER JOIN оставляет только строки, у которых нашлась пара в обеих таблицах. LEFT JOIN сохраняет все строки левой таблицы, а где пары справа нет — подставляет NULL. Отсюда идиома анти-джойна: LEFT JOIN + WHERE правая.колонка IS NULL находит «левых без пары».

  4. #joins4 / 5
    В employees есть id и manager_id (ссылка на id начальника в этой же таблице). Как вывести пары «сотрудник — имя начальника»?
    A)Обычным GROUP BY по manager_id — агрегат сам подтянет имя начальника к каждому отдельному сотруднику таблицы
    B)Self-join: employees e JOIN employees m ON e.manager_id = m.id — таблицу соединяют саму с собой по ссылке
    C)Такой запрос в SQL не выходит: одну и ту же таблицу не получится использовать в JOIN дважды за один раз
    D)Рекурсивным CTE — без рекурсии имя даже непосредственного начальника получить не получится
    показать ответ и разбор
    +B)Self-join: employees e JOIN employees m ON e.manager_id = m.id — таблицу соединяют саму с собой по ссылке

    // разбор: Иерархия «сотрудник → начальник» лежит в одной таблице (manager_id ссылается на id той же таблицы). Чтобы к строке сотрудника подтянуть данные начальника, таблицу соединяют саму с собой (self-join) с разными алиасами: FROM employees e JOIN employees m ON e.manager_id = m.id. LEFT JOIN нужен, если у кого-то нет начальника. GROUP BY имя не подтягивает; рекурсия нужна лишь для всей цепочки вверх, а не одного уровня.

  5. #joins5 / 5
    Заказ JOIN с товарами заказа И JOIN с платежами по заказу в одном запросе. SUM(amount) платежей внезапно завышена. Почему?
    A)SQL немного случайно завышает суммы при нескольких JOIN сразу — известное ограничение движка
    B)Проблема в порядке JOIN: если поменять таблицы местами, сумма посчитается правильно
    C)Fan-out: два join'а «один-ко-многим» перемножают строки — каждый платёж дублируется по числу товаров
    D)Причина в типе данных колонки amount: нужно всего лишь привести её к DECIMAL, и задвоение суммы сразу исчезнет
    показать ответ и разбор
    +C)Fan-out: два join'а «один-ко-многим» перемножают строки — каждый платёж дублируется по числу товаров

    // разбор: Когда к заказу присоединяют две таблицы «один-ко-многим» (товары и платежи), их строки декартово перемножаются: у заказа с 3 товарами и 2 платежами выйдет 6 строк, и SUM(amount) по платежам задвоится втрое (150 → 450). Это fan-out. Лечат агрегированием каждой ветки ДО join'а (подзапросы/CTE с GROUP BY по order_id, затем join) или COUNT(DISTINCT)/оконными функциями. Порядок join и тип данных ни при чём.

дальше

Теорию прочитали. Навык ставится повторением

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