🗄️ Розділ 1 · Питання #9

Що краще: 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;

Типові помилки

  1. Скалярний підзапит у SELECT на великих даних
    -- ❌ Катастрофа продуктивності
    SELECT u.name, (SELECT COUNT(*) FROM orders WHERE user_id = u.id)
    FROM users u;  -- 1 млн рядків → 1 млн підзапитів!
    
  2. 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);
    
  3. Надлишковий 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

  1. Volatile функції в підзапитах
    -- NOW() стабільний у межах запиту
    SELECT * FROM users WHERE created_at < NOW();
       
    -- RANDOM() обчислюється щоразу!
    SELECT * FROM users WHERE id = (SELECT FLOOR(RANDOM() * 1000000));
    -- Поверне РІЗНІ значення для кожного рядка!
    
  2. Підзапит в 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 (набагато швидше!)
    
  3. Порожній підзапит
    -- Якщо підзапит повернув 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

  1. EXISTS > DISTINCT JOIN для перевірки існування
  2. NOT EXISTS > NOT IN (уникайте NULL пастки)
  3. Уникайте скалярних підзапитів у SELECT на великих даних
  4. LATERAL тільки для малих зовнішніх вибірок (< 10,000 рядків)
  5. Керуйте CTE через MATERIALIZED / NOT MATERIALIZED усвідомлено
  6. UPDATE/DELETE використовуйте FROM/USING замість підзапитів
  7. Memoize (PG 14+) може врятувати LATERAL — перевіряйте Hit rate
  8. Завжди перевіряйте план через 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 завжди допомагає» (потрібні повторювані ключі)

Пов’язані теми: