Чем корреляция отличается от не-корреляции
В прошлом уроке все наши подзапросы были некоррелированными: они вычислялись один раз, без зависимости от внешней строки. Это позволяло планировщику материализовать результат и подставить его как константу.
Слева — независимый от внешнего запроса. Справа — связанный через ссылку на внешнюю колонку.
Запомни характерный признак корреляции — алиас внешней таблицы появляется внутри подзапроса. Если бы во внутреннем SELECT не было ссылки на c.id, подзапрос был бы независимым и вычислялся один раз.
EXISTS — проверка «есть ли хотя бы одна строка»
EXISTS (subquery) возвращает TRUE, если подзапрос вернул хотя бы одну строку, и FALSE — если ни одной. Что именно лежит в этих строках — неважно. Поэтому в подзапросе под EXISTS обычно пишут SELECT 1 — все остальные значения проигнорируются.
Клиенты, у которых был хотя бы один заказ
Семантика читается как «оставь клиента c, если для него существует хотя бы один заказ». Это в чистом виде semi-join из реляционной алгебры — операция, которая фильтрует левую таблицу по факту наличия соответствия в правой, не размножая строки.
EXISTS vs JOIN + DISTINCT
Эту же задачу можно решить через JOIN:
Тот же результат — но через JOIN. Зачем здесь DISTINCT?
JOIN размножает строки клиента по числу его заказов. Чтобы получить «уникальных клиентов с заказами», приходится накладывать DISTINCT. Это две дополнительные проблемы:
DISTINCTстоит дорого на больших наборах — это сортировка или hash, который СУБД должна построить отдельно.DISTINCTтеряет смысл запроса. Из-заDISTINCTдругому разработчику трудно понять, ты убираешь дубликаты данных или дубликаты, порождённые JOIN’ом.
EXISTS решает обе проблемы:
- Останавливается на первой найденной строке внутреннего запроса (короткое замыкание). Не нужно сканировать всё.
- Семантика «существует» прозрачна — никаких лишних DISTINCT.
Планировщик PostgreSQL обычно переписывает оба варианта в semi-join, и их планы могут совпасть. Но писать EXISTS — это явно сказать планировщику «мне нужен semi-join». Это всегда выигрышнее по читаемости и часто — по плану.
NOT EXISTS — anti-join, безопасный к NULL
NOT EXISTS (subquery) возвращает TRUE, если подзапрос вернул ноль строк. Это anti-join — оставить строки левой таблицы, у которых нет соответствия в правой.
Клиенты без заказов — anti-join через NOT EXISTS
Главное преимущество перед NOT IN: NOT EXISTS корректно работает с NULL. Внутри подзапроса используется equi-join по o.customer_id = c.id, который отсекает NULL стандартным образом (NULL = anything это NULL, не TRUE). Никаких сюрпризов вроде «весь результат пустой».
Это — главная причина, по которой в production-коде NOT EXISTS обычно заменяет NOT IN. Сравни поведение на NULL-чувствительных колонках:
Сравни два способа найти клиентов с уникальным birth_year, какого нет у IL-клиентов
NOT EXISTS смотрит на сам факт совпадения и не страдает от трёхзначной логики. Ты можешь даже не вычищать NULL из подзапроса — equi-join сделает это сам.
Коррелированный подзапрос в SELECT-списке
Корреляция работает не только под EXISTS. Её можно использовать прямо в SELECT, чтобы посчитать что-то «для текущей строки».
Для каждого клиента — количество его заказов одной коррелированной формулой
Это альтернатива LEFT JOIN + GROUP BY + COUNT. Читаемость — на твой вкус, оба варианта планировщик умеет переписывать в один и тот же план. Коррелированная форма часто короче, когда нужен один маленький агрегат «для каждой строки».
Почему «коррелированный = медленный» — это миф
Старая байка: коррелированный подзапрос обязательно медленный, потому что он выполняется N раз. На самом деле PostgreSQL почти никогда не выполняет его N раз буквально. Он переписывает запрос в semi-join (для EXISTS), anti-join (для NOT EXISTS), или lateral nested loop (для коррелированного SELECT).
Проверь сам через EXPLAIN:
Посмотри на план — слово 'Semi Join' должно появиться вместо 'Nested Loop'
В выводе ты увидишь Hash Semi Join или Nested Loop Semi Join (зависит от размеров). СУБД сама поняла семантику и развернула её в правильную алгебраическую операцию.
Когда форма всё-таки бьёт по производительности — это случаи, когда планировщик не может переписать. Например, коррелированный подзапрос с агрегатом в SELECT-списке, который ссылается на внешнюю колонку в произвольной арифметике. Тогда — действительно nested loop по строкам. Поправить можно через LATERAL или переписать руками в GROUP BY.
Чек-лист
- Коррелированный подзапрос ссылается на алиас внешней таблицы. Это его единственный отличительный признак.
EXISTS (...)— это semi-join: «оставь строку, если в правой таблице есть хотя бы одно совпадение». Подзапрос обычно пишут какSELECT 1.EXISTSобычно лучшеJOIN + DISTINCT— нет накладных расходов на дедупликацию, прозрачнее семантика.NOT EXISTS— anti-join, безопасный к NULL. В production-коде он почти всегда лучшеNOT IN.- Планировщик переписывает
EXISTS/NOT EXISTSв Semi/Anti Join. Коррелированный подзапрос — это не автоматически медленно.