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, остальные разбираются в тренажёре.
- Как найти юзеров, у которых НЕТ ни одного заказа?A)INNER JOIN orders, а затем условие WHERE orders.id IS NULLB)LEFT JOIN orders ON .. WHERE o.id IS NULL — либо NOT EXISTSC)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).
- Чем 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 обеспечивает ссылочную целостность: нельзя вставить заказ на несуществующего юзера и (при настройке) удалить юзера с живыми заказами.
- Чем 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 находит «левых без пары».
- В 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 имя не подтягивает; рекурсия нужна лишь для всей цепочки вверх, а не одного уровня.
- Заказ 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 и тип данных ни при чём.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.