Learning Platform
Урок 16.03 · 24 мин
Продвинутый
RefactoringEXISTSJOINLATERALMaterialized View

В предыдущих уроках мы разбирали antipatterns и индексы. Этот урок — про то, как переписать существующий запрос, оставив логику той же самой, но сделав его в 10-100 раз быстрее. Это не про индексы, это про форму SQL: одну и ту же задачу можно сформулировать пятью разными способами, и planner на каждом построит разный план.

Эта дисциплина в индустрии называется query refactoring. Канонических паттернов немного — 4 покроют ~90% случаев. Все остальные — вариации.

Pattern 1: EXISTS вместо IN с подзапросом

Классика. Два запроса, которые делают одно и то же:

-- Вариант A
SELECT id FROM orders WHERE customer_id IN (
  SELECT id FROM customers WHERE country = 'RU'
);

-- Вариант B
SELECT o.id FROM orders o WHERE EXISTS (
  SELECT 1 FROM customers c WHERE c.id = o.customer_id AND c.country = 'RU'
);

В большинстве современных версий PostgreSQL planner умеет конвертировать IN (SELECT ...) в semi-join — и оба плана получаются идентичными. Но не всегда:

  • Если подзапрос возвращает NULL’ы, IN имеет другую семантику (NULL входит в подзапрос — внешний предикат становится UNKNOWN), и planner обязан проявить осторожность с трансформацией.
  • Если подзапрос большой и не возвращает уникальные значения, IN требует материализации distinct-set, а EXISTS останавливается на первом совпадении.

Правило: для проверки «существует ли хотя бы один…» используй EXISTS. Оно явно говорит planner’у: «короткое замыкание после первого match’а». Для случая, когда нужны конкретные id для дальнейшей фильтрации — JOIN ещё лучше (см. следующий pattern).

Semi-join
— это именно то, что делает EXISTS. И именно поэтому EXISTS обычно даёт correct и быстрый план.

IN vs EXISTS на одной задаче — найти id заказов клиентов из RU. На современном Postgres оба плана почти идентичны (Hash Semi Join), но EXISTS более явный.

PostgreSQL

Pattern 2: JOIN вместо коррелированного подзапроса

Коррелированный подзапрос — это подзапрос, который ссылается на внешнюю таблицу. Пример:

-- Bad: коррелированный подзапрос — выполняется для КАЖДОЙ orders строки
SELECT o.id,
       (SELECT email FROM customers c WHERE c.id = o.customer_id) AS email
FROM orders o
WHERE o.placed_at >= '2024-01-01';

Если planner не справится с трансформацией — это buktrue O(N×M) или nested loop с миллионом подзапросов. Каждый раз идём в customers, ищем по id, возвращаем email. Index Scan ускоряет, но мы всё равно делаем N отдельных запросов.

Fix через JOIN:

SELECT o.id, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.placed_at >= '2024-01-01';

Planner свободен выбрать Hash Join (для крупных наборов) или Merge Join (если оба отсортированы), которые работают в O(N+M). На больших таблицах — порядок-ускорение.

Correlated subquery vs JOIN

Correlated subquery планируется как Nested Loop с N lookup-ами. JOIN даёт planner свободу выбрать Hash Join.

Correlated subquery: SELECT (SELECT ... WHERE c.id = o.customer_id)План: Nested Loop
Для каждой orders строки — отдельный lookup в customersO(N × log M)
N = 100K → 100K Index Scan'ов
JOIN: FROM orders o JOIN customers c ON c.id = o.customer_idПлан: Hash Join
Один scan customers → hash table; один scan orders → probeO(N + M)
N = 100K, M = 10K → 110K операций

Когда коррелированный подзапрос всё-таки уместен: если ты выбираешь одно скалярное значение, и подзапрос предельно простой (например, (SELECT MAX(version) FROM v WHERE v.entity_id = e.id)). Planner часто умеет переписать такое в LATERAL или Hash Join. Но как правило — явный JOIN всегда лучше.

Pattern 3: LATERAL для top-N-per-group

Задача: «для каждого customer — три последних заказа». Наивный путь:

-- Bad: window function — корректно, но требует ANALYZE всей orders + сортировка
SELECT * FROM (
  SELECT o.*, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY placed_at DESC) AS rn
  FROM orders o
) sub
WHERE rn <= 3;

Window function работает на всём orders, считает row_number для каждой строки, а затем фильтрует. Если у тебя 100M orders и 1M customers — это 100M операций для возврата 3M строк.

Fix через LATERAL:

SELECT c.id, c.email, last.id AS order_id, last.placed_at
FROM customers c
JOIN LATERAL (
  SELECT id, placed_at FROM orders o
  WHERE o.customer_id = c.id
  ORDER BY placed_at DESC
  LIMIT 3
) last ON true;

LATERAL = «для каждой строки левой таблицы выполни этот подзапрос». Здесь — для каждого customer один LIMIT 3 поиск. Если есть индекс (customer_id, placed_at DESC) — каждый подзапрос делает 3 index lookup’а. Итого 3M операций вместо 100M.

Когда применять: всегда, когда нужны top-N-per-group и N маленькое (1-10). LATERAL даёт O(N_groups × log M) против O(M) window function.

LATERAL — top-3 заказа для каждого customer. Создаём индекс (customer_id, placed_at DESC), затем LATERAL JOIN. Сравни план: должен быть Nested Loop с маленьким количеством обращений к индексу.

PostgreSQL

Pattern 4: Materialized view как кеш дорогих aggregations

SELECT COUNT(*), SUM(total), AVG(total) FROM orders GROUP BY country на таблице 1B строк — это секунды (минимум). Если такой запрос нужен в дашборде каждые 5 секунд — production задохнётся.

Materialized view
— это «кеш», который хранится физически:

CREATE MATERIALIZED VIEW country_stats AS
SELECT
  country,
  COUNT(*) AS orders_total,
  SUM(total_cents) AS revenue_cents,
  AVG(total_cents) AS avg_order_cents
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY country;

CREATE UNIQUE INDEX ON country_stats (country);

Теперь дашборд читает SELECT * FROM country_stats за миллисекунды. Раз в час/день запускаем REFRESH MATERIALIZED VIEW [CONCURRENTLY] country_stats и данные обновляются.

CONCURRENTLY — отдельная история. Обычный REFRESH берёт AccessExclusiveLock на view (запросы к нему блокируются). CONCURRENTLY пишет в shadow-таблицу и атомарно подменяет — старые запросы продолжают читать старую версию. Требует UNIQUE-индекс на view.

Когда использовать:

  • Aggregations, которые нужно отдавать быстро, и которые приемлемо устаревшие на минуты-часы.
  • Дорогие JOIN’ы для отчётов.
  • Pre-computed дашбордовые метрики.

Когда НЕ использовать:

  • Когда нужны real-time данные (используй обычный VIEW или прямой запрос).
  • Когда REFRESH дороже, чем сам запрос (например, refresh = 10 секунд, а запрос — 1 секунда и нужен раз в день).
  • Когда данные часто меняются и stale-window недопустим.

Альтернатива — incremental update. PostgreSQL пока не имеет native incremental materialized views (есть планы), но можно реализовать триггерами или через extension типа pg_ivm. Для read-heavy дашбордов с rare-write — отличная штука.

Когда materialized view выигрывает

Ось X — частота refresh, ось Y — частота чтения. View выигрывает в правом-верхнем квадранте.

Чтение часто, refresh редкоMV win
Дашборд: чтение 100/sec, refresh 1/час
Чтение часто, refresh частоMV приемлемо если refresh < самого запроса
Streaming dashboard: refresh каждые 5 мин
Чтение редко, refresh редкоMV не нужен
Ad-hoc анализ — обычный запрос
Чтение редко, refresh частоMV вреден
Постоянный refresh, нечастые чтения — пустая работа

Bonus pattern: CTE materialization

В PostgreSQL до 12-й версии любой WITH cte AS (...) материализовался — выполнялся отдельно, результат складывался в memory, и затем использовался. С 12-й версии planner может inline-ить CTE (если он используется один раз и не имеет side effects).

Иногда хочется обратного — заставить материализацию: WITH cte AS MATERIALIZED (...). Это полезно, когда:

  • CTE сложный, и planner inline-ит его в плохой план.
  • CTE используется несколько раз, и пересчитывать его дорого.
  • Хочется явно отбросить статистику корреляции для следующих этапов запроса (planner после материализации видит CTE как «таблицу с известным cardinality»).

Обратное — AS NOT MATERIALIZED — заставит planner inline даже там, где он бы не стал.

Сравнительная таблица

Refactoring patterns: применимость

Когда какой паттерн применять.

EXISTSПроверка существования; не нужны колонки правой таблицы
JOINНужны колонки обеих таблиц; рассматривай Hash/Merge Join
LATERALTop-N-per-group; параметризованный подзапрос
MATERIALIZED VIEWRead-heavy aggregations; refresh < read frequency
CTE MATERIALIZEDСложный CTE, plan-stability важна
Window functionКогда top-N большой (N>>10) или нужны ranking-метрики
Проверка знанийKnowledge check
Запрос: SELECT u.id, (SELECT MAX(o.placed_at) FROM orders o WHERE o.customer_id = u.id) AS last_order FROM customers u; — выполняется 30 секунд на 50K customers. Как переписать через JOIN+GROUP BY и через LATERAL? Какой вариант предпочтительнее?
ОтветAnswer
Вариант 1 — JOIN + GROUP BY (рекомендуется для большинства случаев): SELECT u.id, MAX(o.placed_at) AS last_order FROM customers u LEFT JOIN orders o ON o.customer_id = u.id GROUP BY u.id; Hash Aggregate по customer_id — один scan customers, один scan orders, агрегация. Если есть индекс (customer_id, placed_at DESC) — Merge Join + GROUP BY ещё лучше. Вариант 2 — LATERAL (когда нужно много колонок из последнего заказа): SELECT u.id, last.placed_at, last.total_cents, last.status FROM customers u LEFT JOIN LATERAL ( SELECT placed_at, total_cents, status FROM orders o WHERE o.customer_id = u.id ORDER BY placed_at DESC LIMIT 1 ) last ON true; LATERAL с LIMIT 1 даёт O(N × log M) — для каждого из 50K customers один index lookup. Хорошо при индексе (customer_id, placed_at DESC). Какой лучше: если нужно только MAX(placed_at) — GROUP BY быстрее (Hash Aggregate ~линейный). Если нужны другие колонки последнего заказа — LATERAL единственно правильный вариант (GROUP BY не возвращает «другие колонки строки с max»).

Чек-лист

  • EXISTS для проверки существования — явная семантика semi-join, короткое замыкание.
  • JOIN вместо коррелированного подзапроса — даёт planner свободу выбрать Hash/Merge Join (O(N+M) вместо O(N×log M)).
  • LATERAL для top-N-per-group — параметризованный подзапрос для каждой строки левой таблицы; идеален с N=1-10 и подходящим индексом.
  • MATERIALIZED VIEW для read-heavy aggregations — кеш с REFRESH (CONCURRENTLY для production).
  • CTE с AS MATERIALIZED / NOT MATERIALIZED — явный контроль над inline vs materialization (PostgreSQL 12+).
  • Универсальная проверка: EXPLAIN до и после refactoring; сравни actual time и план целиком.
CTE vs подзапрос vs temp table: что когда Коррелированные подзапросы и EXISTS

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Запрос: SELECT o.id, (SELECT email FROM customers c WHERE c.id = o.customer_id) AS email FROM orders o WHERE o.placed_at >= '2024-01-01' — работает 20 секунд на 1M orders. Почему медленно и как переписать?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5