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

Semi и anti join

EXISTS/IN возвращают левые строки, у которых есть совпадение, без размножения (semi-join); NOT EXISTS/NOT IN возвращают левые строки без совпадения (anti-join). NOT IN молча ломается, когда подзапрос выдаёт NULL — предпочитай NOT EXISTS.

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

Запрос NOT IN, питавший промо «клиенты без неудачных платежей», уехал в прод, выглядел верным на ревью и вернул пустое множество в проде — так что предложение не получил никто. У подзапроса, против которого он проверялся, была ровно одна строка с NULL в payment_id, погребённая среди миллионов. Этот единственный NULL перевернул весь NOT IN в «ноль строк», и трёхзначная логика сделала это молча, без ошибки. Сеньор, который это отладил, не читал данные; он распознал форму. NOT IN + nullable колонка подзапроса — это известная мина.

Semi-join: «есть ли совпадение?» — да/нет, без размножения

Часто тебе не нужны совпавшие строки из другой таблицы; ты хочешь лишь знать, существует ли совпадение. «Пользователи, сделавшие хотя бы один заказ». Обычный JOIN отвечает плохо: если у пользователя 50 заказов, join выдаёт этого пользователя 50 раз, и приходится привинчивать DISTINCT, чтобы отменить ущерб. Это fan-out, а за ним уборка.

Semi-join — правильный инструмент: он возвращает каждую подходящую левую строку ровно один раз, независимо от того, сколько у неё совпадений справа. SQL пишет это как EXISTS или IN:

-- Пользователи с хотя бы одним заказом — каждый один раз, без дублей
SELECT u.id, u.name
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- Эквивалентный semi-join через IN
SELECT u.id, u.name
FROM users u
WHERE u.id IN (SELECT user_id FROM orders);

EXISTS прекращает сканировать внутреннюю сторону, как только находит первую совпадающую строку — это короткозамкнутый булев, а не подсчёт. Поэтому semi-join обычно дешевле, чем JOIN ... DISTINCT: он никогда не строит размноженный промежуточный результат лишь для того, чтобы снова его схлопнуть. Планировщик даже показывает это как отдельный узел Semi Join в EXPLAIN (урок join-algorithms трека databases разбирает, как он исполняется).

Anti-join: «нет совпадения» — дополнение

Зеркало semi-join — это anti-join: вернуть левые строки, у которых нет совпадения. «Пользователи, которые никогда не заказывали». Пишется как NOT EXISTS или NOT IN:

-- Пользователи, никогда не заказывавшие (anti-join через NOT EXISTS)
SELECT u.id, u.name
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

Это то же множество, что и паттерн LEFT JOIN ... WHERE o.id IS NULL из прошлого урока, и планировщик часто исполняет их одинаково (узел Anti Join). NOT EXISTS — самый ясный способ это выразить и иммунен к NULL-ловушке ниже.

Ловушка NOT IN + NULL — причина существования этого урока

NOT IN выглядит естественным anti-join, и он работает нормально — пока подзапрос не вернёт хотя бы один NULL. Тогда он молча возвращает ноль строк, ровно баг прода из хука.

-- ОПАСНО: если у любого заказа user_id равен NULL, это вернёт НИЧЕГО
SELECT u.id FROM users u
WHERE u.id NOT IN (SELECT user_id FROM orders);

Причина — трёхзначная логика. x NOT IN (a, b, NULL) разворачивается в x <> a AND x <> b AND x <> NULL. Последнее сравнение x <> NULL это UNKNOWN, никогда не истинно, поэтому весь AND не может стать истинным — в лучшем случае это UNKNOWN, что WHERE отбрасывает. Один NULL в списке делает предикат невыполнимым для каждой строки. Ошибки нет; результат просто схлопывается.

У NOT EXISTS этого изъяна нет: он спрашивает «есть ли коррелированная совпадающая строка?» на каждую левую строку, и NULL справа просто не совпадает, что и есть корректное поведение anti-join. Производственное правило: никогда не пиши NOT IN против подзапроса, чья колонка может быть NULL — используй NOT EXISTS. (Обычный IN безопасен; ловушка специфична для NOT IN.) Если NOT IN обязателен, защити его через WHERE user_id IS NOT NULL внутри подзапроса — но NOT EXISTS проще и быстрее.

Граничные случаи

Почему у IN нет той же проблемы? Потому что x IN (a, b, NULL) это x = a OR x = b OR x = NULL; член NULL это UNKNOWN, но OR истинен, как только совпадает любой реальный член, поэтому настоящее совпадение всё равно всплывает. Асимметрия — в AND внутри NOT IN: один UNKNOWN отравляет конъюнкцию (true AND UNKNOWN = UNKNOWN), но для дизъюнкции достаточно лишь одного истинного члена. Это та же логика трёхзначности из урока про NULL в разделе 01 — semi/anti join это просто место, где она кусает сильнее всего на практике.

Викторина

Тебе нужен каждый пользователь, сделавший заказ, перечисленный один раз. У пользователя 40 заказов. Какой подход перечислит его ровно один раз без DISTINCT?

Викторина

u.id NOT IN (SELECT user_id FROM orders) возвращает ноль строк, хотя многие пользователи явно никогда не заказывали. Наиболее вероятная причина?

Закончи аналогию

Заполни пропуск: semi-join возвращает каждую подходящую левую строку ровно _______ неважно, сколько у неё совпадений справа — в отличие от обычного JOIN, который дублирует левую строку на каждое совпадение.

Вспомните перед уходом
  1. 01
    Что такое semi-join и почему он лучше JOIN + DISTINCT для «пользователи, сделавшие заказ»?
  2. 02
    Объясни точно, почему NOT IN возвращает ноль строк, когда подзапрос содержит NULL.
  3. 03
    Почему NOT EXISTS иммунен к NULL-ловушке и каково правило большого пальца?
Итог

Semi- и anti-join отвечают на вопросы существования, не комбинируя строки. Semi-join (EXISTS / IN) возвращает каждую левую строку, у которой есть хотя бы одно совпадение, ровно один раз — это проверка членства, без fan-out — поэтому он бьёт JOIN ... DISTINCT (нет широкого промежуточного результата, который надо схлопывать, и EXISTS короткозамыкается на первом совпадении). Anti-join (NOT EXISTS) возвращает дополнение: левые строки без совпадения, эквивалентно LEFT JOIN ... WHERE o.id IS NULL. Ловушка, делающая этот урок необходимым, — это NOT IN против nullable подзапроса: единственный NULL превращает неявное x <> a AND x <> b AND x <> NULL в никогда-истинный предикат (трёхзначная логика, three-valued logic), и запрос молча возвращает ноль строк. Обычный IN безопасен, потому что его OR нуждается лишь в одном истинном члене; AND внутри NOT IN отравляется одним UNKNOWN. Производственное правило прямолинейно: пиши anti-join как NOT EXISTS. В EXPLAIN они появляются как узлы Semi Join и Anti Join — собственное подтверждение планировщика, что ты выразил существование, а не комбинацию. Теперь, когда встретишь NOT IN с подзапросом, сначала проверишь nullable ли колонка — потому что один тихий NULL обнуляет весь результат.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.