Что лучше: JOIN или подзапрос?
PostgreSQL умеет Unnesting (расплющивание) — превращает подзапросы в JOIN на уровне плана выполнения. Это значит, что написанный вами подзапрос физически выполняется как соедине...
🟢 Junior Level
Однозначного ответа нет — зависит от ситуации. В большинстве современных баз данных (включая PostgreSQL) оптимизатор часто превращает подзапросы в JOIN, поэтому производительность может быть одинаковой.
Простое правило:
- JOIN — когда нужны данные из обеих таблиц
- Подзапрос (EXISTS/IN) — когда нужно только проверить существование
Пример:
-- JOIN: получить пользователей И их заказы
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id;
-- EXISTS: проверить, есть ли заказы у пользователя
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
Когда что использовать:
- Нужны колонки из обеих таблиц → JOIN
- Нужна только фильтрация → EXISTS/IN
- Сложная логика с агрегацией → подзапрос
🟡 Middle Level
Как оптимизатор обрабатывает подзапросы
PostgreSQL умеет Unnesting (расплющивание) — превращает подзапросы в JOIN на уровне плана выполнения. Это значит, что написанный вами подзапрос физически выполняется как соединение таблиц, что обычно быстрее.
При этом часто используется Semi-Join — особый тип соединения: для каждой строки левой таблицы проверяет, есть ли хотя бы одно совпадение в правой, и возвращает строку левой таблицы ОДИН РАЗ (без дублей). В SQL нет прямого синтаксиса для Semi-Join — он возникает внутри планировщика.
-- Подзапрос в WHERE
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 100);
-- Оптимизатор превращает в:
SELECT * FROM users
INNER JOIN (SELECT user_id FROM orders WHERE amount > 100) o
ON users.id = o.user_id;
-- Plan: Hash Semi-Join (очень эффективно)
Semi-Join vs JOIN
Проблема JOIN: Join Amplification
-- ❌ Если у пользователя 10 заказов, он появится 10 раз!
SELECT DISTINCT u.* FROM users u
JOIN orders o ON u.id = o.user_id;
-- Потом DISTINCT убирает дубли (дополнительная сортировка/хэширование)
-- ✅ EXISTS использует Semi-Join (нет дублей!)
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id
);
-- Останавливается после первого совпадения → быстрее
-- EXISTS использует Semi-Join: как только найдено ПЕРВОЕ совпадение для строки
-- левой таблицы, сканирование правой таблицы для этой строки прекращается
-- (short-circuit evaluation). Не нужно строить полный результат соединения
-- и затем делать DISTINCT для удаления дублей.
Оптимизационные барьеры (Optimization Fences)
Некоторые конструкции мешают оптимизатору расплющить подзапрос:
| Барьер | Что происходит |
|---|---|
LIMIT / OFFSET |
Подзапрос выполняется отдельно |
DISTINCT |
Не может быть вытянут в JOIN |
GROUP BY / Агрегаты |
Подзапрос материализуется |
UNION / INTERSECT |
Отдельный узел в плане |
-- ❌ LIMIT создаёт барьер
SELECT * FROM users u
WHERE id IN (SELECT user_id FROM orders ORDER BY amount DESC LIMIT 10);
-- Plan: SubPlan (выполняется для каждой строки!)
-- ✅ Замените на JOIN LATERAL
SELECT u.*, o.amount
FROM users u
LEFT JOIN LATERAL (
SELECT amount FROM orders
WHERE o.user_id = u.id
ORDER BY amount DESC
LIMIT 10
) o ON true;
Скалярные подзапросы в SELECT
-- ❌ ПЛОХО: подзапрос выполняется для каждой строки
SELECT u.name,
(SELECT SUM(amount) FROM orders o WHERE o.user_id = u.id) as total
FROM users u;
-- Plan: SubPlan (1,000,000 подзапросов для 1,000,000 пользователей!)
-- ✅ ХОРОШО: JOIN + GROUP BY
SELECT u.name, SUM(o.amount) as total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.name;
-- Plan: Hash Join + HashAggregate (намного быстрее!)
CTE (WITH) и материализация
PostgreSQL 12+: CTE по умолчанию встраиваются (Inlined):
До PG 12 CTE ВСЕГДА материализовались — это был оптимизационный барьер. Подзапрос внутри CTE вычислялся один раз, результат сохранялся во временную структуру. Это могло быть плюсом (избежание повторных вычислений), так и минусом (оптимизатор не мог «заглянуть» внутрь CTE и применить фильтры из внешнего запроса).
-- PG 12+: этот CTE будет встроен в основной запрос
WITH active_users AS (
SELECT * FROM users WHERE status = 'ACTIVE'
)
SELECT * FROM active_users u
JOIN orders o ON u.id = o.user_id;
-- Plan: как если бы написали JOIN напрямую
Управление материализацией:
-- Принудительная материализация (вычислить CTE один раз)
WITH c AS MATERIALIZED (
SELECT user_id, SUM(amount) as total FROM orders GROUP BY user_id
)
SELECT * FROM users u JOIN c ON u.id = c.user_id;
-- Принудительное встраивание
WITH c AS NOT MATERIALIZED (
SELECT * FROM orders WHERE amount > 100
)
SELECT * FROM c JOIN users u ON c.user_id = u.id;
Типичные ошибки
- Скалярный подзапрос в SELECT на больших данных
-- ❌ Катастрофа производительности SELECT u.name, (SELECT COUNT(*) FROM orders WHERE user_id = u.id) FROM users u; -- 1 млн строк → 1 млн подзапросов! - NOT IN с NULL
-- ❌ Вернёт пустой результат, если есть NULL! SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders); -- ✅ Используйте NOT EXISTS SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id); - Избыточный DISTINCT с JOIN
-- ❌ DISTINCT после JOIN — признак Join Amplification SELECT DISTINCT u.* FROM users u JOIN orders o ON u.id = o.user_id; -- ✅ EXISTS эффективнее SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
Практическое сравнение
| Ситуация | Лучший инструмент | Почему |
|---|---|---|
| Данные из обеих таблиц | JOIN | Максимум стратегий выполнения |
| Проверка существования | EXISTS | Hash Semi-Join, нет дублей |
| Зависимая выборка (топ-N) | LATERAL | Использует индексы внутри |
| Агрегация для каждой строки | JOIN + GROUP BY | Один проход вместо N подзапросов |
| Сложная логика с LIMIT | LATERAL | Планировщик видит индексы |
🔴 Senior Level
Unnesting и Decorrelation
Как оптимизатор “расплющивает” подзапросы:
-- Исходный запрос
SELECT * FROM users u
WHERE u.id IN (
SELECT o.user_id FROM orders o
WHERE o.amount > (SELECT AVG(amount) FROM orders)
);
-- Шаги трансформации:
-- 1. Скалярный подзапрос AVG → InitPlan (вычисляется один раз)
-- 2. IN подзапрос → Hash Semi-Join
-- 3. Финальный план:
-- InitPlan: AVG(amount)
-- → Hash Semi-Join (users ↔ orders)
Декорреляция:
-- Коррелированный подзапрос (зависит от внешней строки)
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 100
);
-- После декорреляции:
-- Hash Semi-Join:
-- 1. Строим хэш из orders WHERE amount > 100
-- 2. Сканируем users, проверяем наличие в хэше
-- Сложность: O(N + M) вместо O(N × M)!
SubPlan vs InitPlan vs Param
SubPlan: Выполняется для каждой строки внешнего запроса
EXPLAIN SELECT u.name,
(SELECT SUM(amount) FROM orders o WHERE o.user_id = u.id)
FROM users u;
-- Plan:
-- Seq Scan on users
-- SubPlan 1
-- -> Aggregate
-- -> Index Scan on orders (user_id = u.id)
-- ⚠️ Выполняется 1,000,000 раз!
InitPlan: Выполняется один раз перед основным запросом
EXPLAIN SELECT * FROM users
WHERE amount > (SELECT AVG(amount) FROM orders);
-- Plan:
-- InitPlan 1
-- -> Aggregate (AVG)
-- -> Seq Scan on orders
-- → Seq Scan on users (с фильтром по InitPlan результату)
-- ✅ Выполняется 1 раз!
Memoize Node (PostgreSQL 14+)
Memoize — узел в плане выполнения, который кэширует результаты подзапроса. Если подзапрос вызывается с теми же параметрами повторно, результат берётся из кэша вместо повторного выполнения. Кэш ограничен памятью (параметр memoize_cache_size, по умолчанию 10MB на узел Memoize).
Революционная оптимизация для коррелированных подзапросов:
-- Запрос с LATERAL (вынужденный Nested Loop)
SELECT u.name, o.amount
FROM users u
LEFT JOIN LATERAL (
SELECT amount FROM orders o
WHERE o.user_id = u.id
ORDER BY created_at DESC
LIMIT 1
) o ON true;
-- PostgreSQL 14+: может вставить Memoize
-- Plan:
-- Nested Loop
-- → Seq Scan on users
-- → Memoize
-- → Limit
-- → Index Scan Backward on orders
-- Memoize кэширует результаты для каждого user_id
-- Если user_id повторяется → берёт из кэша!
-- 🚀 Ускорение в 10-100 раз на данных с повторами
Мониторинг Memoize:
EXPLAIN (ANALYZE, BUFFERS)
SELECT ... LATERAL ...;
-- Ищите в плане:
-- Memoize (cost=... rows=... actual rows=...)
-- Hits: 500000 ← взято из кэша
-- Misses: 10000 ← вычислено заново
-- Evictions: 500 ← вытеснено из кэша
LATERAL JOIN: Когда и почему
Lateral выбирает Nested Loop:
LATERAL почти всегда → Nested Loop
→ Отлично, если внешняя таблица маленькая (< 10,000 строк)
→ Катастрофа, если внешняя таблица большая (> 1,000,000 строк)
Альтернативы для больших данных:
-- ❌ LATERAL на 1 млн пользователей → медленно
SELECT u.name, last_order.amount
FROM users u
LEFT JOIN LATERAL (
SELECT amount FROM orders o
WHERE o.user_id = u.id
ORDER BY created_at DESC
LIMIT 1
) last_order ON true;
-- ✅ Window Function → Hash Join
SELECT name, amount
FROM (
SELECT u.name, o.amount,
ROW_NUMBER() OVER(PARTITION BY u.id ORDER BY o.created_at DESC) as rn
FROM users u
INNER JOIN orders o ON u.id = o.user_id
) sub
WHERE rn = 1;
-- ✅ DISTINCT ON → Sort + Unique
SELECT DISTINCT ON (u.id) u.name, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
ORDER BY u.id, o.created_at DESC;
CTE: MATERIALIZED vs NOT MATERIALIZED
Когда использовать MATERIALIZED:
-- CTE используется несколько раз → материализация выгодна
WITH expensive_calc AS MATERIALIZED (
SELECT user_id, SUM(amount) * 1.2 as total
FROM orders
GROUP BY user_id
)
SELECT * FROM users u JOIN expensive_calc ec ON u.id = ec.user_id
UNION ALL
SELECT * FROM archived_users au JOIN expensive_calc ec ON au.id = ec.user_id;
-- CTE вычисляется 1 раз, используется 2 раза
Когда использовать NOT MATERIALIZED:
-- CTE используется 1 раз + имеет фильтрацию → встраивание выгоднее
WITH filtered_orders AS NOT MATERIALIZED (
SELECT * FROM orders WHERE amount > 1000 AND status = 'COMPLETED'
)
SELECT * FROM users u
JOIN filtered_orders fo ON u.id = fo.user_id
WHERE u.created_at > '2024-01-01';
-- Позволяет оптимизатору "протолкнуть" фильтр u.created_at внутрь CTE
Edge Cases
- Volatile функции в подзапросах
-- NOW() стабилен в рамках запроса SELECT * FROM users WHERE created_at < NOW(); -- RANDOM() вычисляется каждый раз! SELECT * FROM users WHERE id = (SELECT FLOOR(RANDOM() * 1000000)); -- Вернёт РАЗНЫЕ значения для каждой строки! - Подзапрос в UPDATE/DELETE
-- ❌ Коррелированный подзапрос в UPDATE → медленно UPDATE orders o SET status = 'VIP' WHERE user_id IN (SELECT id FROM users WHERE vip = true); -- ✅ FROM синтаксис → JOIN UPDATE orders o SET status = 'VIP' FROM users u WHERE o.user_id = u.id AND u.vip = true; -- Plan: Hash Join (гораздо быстрее!) - Пустой подзапрос
-- Если подзапрос вернул 0 строк: -- IN () → FALSE -- NOT IN () → TRUE (осторожно с NULL!) -- EXISTS () → FALSE -- NOT EXISTS () → TRUE
Performance Comparison
| Подход | Сложность | Когда использовать |
|---|---|---|
| JOIN | O(N + M) с Hash Join | Данные из обеих таблиц |
| EXISTS/IN | O(N + M) с Semi-Join | Проверка существования |
| Scalar Subquery | O(N × M) | Избегайте на больших данных |
| LATERAL | O(N × log M) | Зависимые выборки, малые внешние |
| CTE MATERIALIZED | O(M) + O(N) | CTE используется > 1 раза |
| CTE NOT MATERIALIZED | Как JOIN | CTE используется 1 раз |
Production Experience
Реальный сценарий #1: SubPlan убивает API
- REST API: для каждого из 10,000 пользователей считаем баланс
- Запрос: скалярный подзапрос в SELECT
- Время: 45 секунд (10,000 подзапросов!)
- Решение:
-- Заменили на JOIN + GROUP BY SELECT u.*, COALESCE(SUM(o.amount), 0) as balance FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id; - Результат: 200ms (ускорение в 225 раз)
Реальный сценарий #2: Memoize спасает от LATERAL
- Отчёт: топ-5 заказов для каждого клиента (100,000 клиентов)
- LATERAL без Memoize: > 5 минут
- После PG 14 с Memoize: 3 секунды
- Hits/Misses ratio: 95%/5% (многие клиенты имеют одинаковые заказы в кэше)
Реальный сценарий #3: CTE материализация
- Аналитика: CTE с агрегацией используется в 4 UNION ALL
- Без MATERIALIZED: CTE вычисляется 4 раза → 2 минуты
- С MATERIALIZED: вычисляется 1 раз → 30 секунд
- Но: если CTE маленький, MATERIALIZED может замедлить (накладные расходы)
Monitoring
-- 1. Проверка SubPlan в планах
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, (SELECT COUNT(*) FROM orders WHERE user_id = u.id)
FROM users u;
-- Ищите "SubPlan" → потенциальная проблема
-- 2. Проверка Memoize эффективности
EXPLAIN (ANALYZE)
SELECT ... LATERAL ...;
-- Memoize (Hits: X, Misses: Y)
-- Hit rate = Hits / (Hits + Misses)
-- > 80% → отлично, < 50% → Memoize не помогает
-- 3. CTE материализация
EXPLAIN (ANALYZE)
WITH c AS MATERIALIZED (...)
SELECT ...;
-- CTE Scan on c → материализовано
-- Ищите "CTE c" в подпланах → встроено
-- 4. Поиск скалярных подзапросов в pg_stat_statements
SELECT query, mean_exec_time
FROM pg_stat_statements
WHERE query ~ 'SELECT.*\(SELECT.*FROM.*\).*FROM'
ORDER BY mean_exec_time DESC;
Best Practices
- EXISTS > DISTINCT JOIN для проверки существования
- NOT EXISTS > NOT IN (избегайте NULL ловушки)
- Избегайте скалярных подзапросов в SELECT на больших данных
- LATERAL только для малых внешних выборок (< 10,000 строк)
- Управляйте CTE через MATERIALIZED / NOT MATERIALIZED осознанно
- UPDATE/DELETE используйте FROM/USING вместо подзапросов
- Memoize (PG 14+) может спасти LATERAP — проверяйте Hit rate
- Всегда проверяйте план через
EXPLAIN (ANALYZE)
Резюме для Senior
- Оптимизатор умеет Unnesting и Decorrelation подзапросов
- SubPlan на каждую строку → катастрофа; заменяйте на JOIN
- Memoize (PG 14+) кэширует результаты LATERAL → проверяйте Hits/Misses
- CTE в PG 12+ по умолчанию встраивается; управляйте через MATERIALIZED
- EXISTS = Semi-Join (нет Join Amplification)
- LATERAL ≈ Nested Loop → только для малых внешних выборок
- UPDATE/DELETE через FROM/USING вместо коррелированных подзапросов
- Всегда проверяйте план: ищите SubPlan, Batches, CTE Scan
🎯 Шпаргалка для интервью
Обязательно знать:
- PostgreSQL умеет Unnesting — превращает подзапросы в JOIN (Hash Semi-Join)
- SubPlan = выполняется для каждой строки → O(N × M) → КАТАСТРОФА
- InitPlan = выполняется один раз → O(M) + O(N) → отлично
- EXISTS = Semi-Join: нет Join Amplification (дубли от JOIN), останавливается после 1-го совпадения
- NOT EXISTS > NOT IN: NOT IN с NULL вернёт пустой результат
- Memoize (PG 14+): кэширует результаты LATERAL → Hit rate > 80% = отлично
- CTE в PG 12+: по умолчанию встраивается (NOT MATERIALIZED), управляйте явно
- Скалярный подзапрос в SELECT = N подзапросов → JOIN + GROUP BY лучше
- UPDATE FROM / DELETE USING быстрее коррелированных подзапросов
Частые уточняющие вопросы:
- «Почему EXISTS лучше DISTINCT JOIN?» → Semi-Join не создаёт дубли, нет сортировки для DISTINCT
- «Когда Memoize не помогает?» → Все ключи уникальны → 0% Hits
- «CTE MATERIALIZED или NOT MATERIALIZED?» → MATERIALIZED если используется > 1 раза
- «Почему скалярный подзапрос в SELECT плох?» → N подзапросов для N строк
Красные флаги (НЕ говорить):
- ❌ «Подзапрос всегда медленнее JOIN» (декоррелированный EXISTS может быть быстрее)
- ❌ «NOT IN безопасен» (NULL вернёт пустой результат!)
- ❌ «CTE всегда материализуется» (PG 12+ встраивает по умолчанию)
- ❌ «Memoize всегда помогает» (нужны повторяющиеся ключи)
Связанные темы:
- [[Какие типы JOIN существуют]] → Semi-Join, Anti-Join
- [[Что такое коррелированный подзапрос]] → SubPlan vs InitPlan, Memoize
- [[В чём разница между INNER JOIN и LEFT JOIN]] → JOIN Amplification