Средний ~55 мин чтения

Подзапросы и CTE: сложные запросы без боли

Урок 9 из 10 в курсе SQL с нуля: полный курс

Подзапросы и CTE: сложные запросы без боли

Подзапросы и CTE (Common Table Expressions) — это инструменты для построения сложной логики прямо внутри SQL-запроса. Вместо того чтобы загружать данные в приложение, обрабатывать их в цикле и делать новые запросы, ты выражаешь всю логику в одном запросе. CTE с оператором WITH дают возможность разбить сложный запрос на читаемые именованные блоки — как функции, но внутри одного SELECT. Это ключевой навык для написания профессиональных SQL-запросов: аналитика, отчёты, иерархические данные, дедупликация — всё это решается подзапросами и CTE без единой строки кода на стороне приложения.

Почему это важно: Без подзапросов и CTE разработчики часто пишут несколько последовательных запросов в коде: первый запрос получает список ID, второй запрос с этими ID получает связанные данные, третий что-то считает. Это N+1 проблема в disguise — множество round-trips к базе, сериализация/десериализация данных, логика размазана между SQL и кодом. CTE позволяют перенести всю логику в базу данных, где она выполняется эффективно: один round-trip, один план выполнения, данные не покидают БД до финального результата. Особенно критично для отчётных запросов, дашбордов и аналитики, где нужно агрегировать данные из нескольких таблиц по сложным условиям.

Главная идея

Подзапрос — это SELECT внутри другого SELECT. Он может появляться в WHERE (фильтрация по результату другого запроса), в FROM (как временная таблица), в SELECT (скалярное значение для каждой строки). CTE с оператором WITH — это способ дать имя подзапросу и переиспользовать его несколько раз. Синтаксис: WITH имя AS (SELECT ...) SELECT * FROM имя. Несколько CTE: WITH a AS (...), b AS (...) SELECT ... FROM a JOIN b. Рекурсивный CTE: WITH RECURSIVE — специальная форма для обхода иерархий: деревья категорий, org-структуры, графы зависимостей. Ключевое понимание: подзапросы и CTE — это не отдельные запросы, это части одного плана выполнения, который оптимизирует PostgreSQL.

Как это выглядит на практике

  1. Ты строишь страницу профиля пользователя, где нужно показать его 5 последних заказов с суммой и статусом. Начинаешь с простого подзапроса в WHERE: SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 5.
  2. Появляется требование: показать только пользователей, которые сделали хотя бы один заказ на сумму больше 5000 руб. Пишешь EXISTS: WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 5000).
  3. Нужно найти пользователей без ни одного заказа. Пишешь NOT IN (SELECT user_id FROM orders). Тест падает — в orders есть строки с user_id = NULL из-за гостевых заказов. NOT IN возвращает пустой результат. Переписываешь на NOT EXISTS — работает корректно.
  4. Задача усложняется: нужен отчёт по топ-клиентам с суммой заказов, количеством и датой последнего заказа. Первая попытка — три отдельных запроса в Ruby с объединением в хэш. Понимаешь что это медленно — переносишь в один CTE.
  5. Пишешь WITH order_stats AS (SELECT user_id, COUNT(*) AS cnt, SUM(amount) AS total, MAX(created_at) AS last_at FROM orders GROUP BY user_id) SELECT u.*, os.* FROM users u JOIN order_stats os ON u.id = os.user_id ORDER BY os.total DESC LIMIT 20. Три round-trips превращаются в один.
  6. Запрос становится сложнее: нужно добавить фильтрацию по периоду, исключить заблокированных пользователей, добавить их регион из другой таблицы. Разбиваешь на несколько CTE: filtered_orders, user_regions, order_stats — каждый делает одно дело.
  7. Аналитик просит воронку: сколько пользователей зарегистрировалось → добавило в корзину → купило за последние 30 дней. Три события из разных таблиц. Пишешь три CTE и джойнишь их по дате. Один запрос, один round-trip.
  8. Новое требование: категории товаров хранятся в виде дерева (parent_id). Нужно получить все подкатегории для раздела 'Одежда' включая вложенные. Обычный JOIN не работает для произвольной глубины. Узнаёшь о рекурсивном CTE.
  9. Пишешь WITH RECURSIVE cat_tree AS (SELECT * FROM categories WHERE slug = 'clothing' UNION ALL SELECT c.* FROM categories c JOIN cat_tree ct ON c.parent_id = ct.id WHERE ct.depth < 8) SELECT * FROM cat_tree. Получаешь все уровни дерева одним запросом.
  10. Производительность: замечаешь что запрос с несколькими CTE работает медленнее ожидаемого. Запускаешь EXPLAIN ANALYZE и видишь что один CTE материализуется (PostgreSQL 11) и не использует индекс. Добавляешь NOT MATERIALIZED — производительность восстанавливается.
  11. Рефакторинг: коллега показывает запрос с 4 вложенными подзапросами в WHERE — нечитаемый. Вы вместе переписываете в CTE: каждый уровень вложенности становится именованным шагом. Код ревью проходит с первого раза.
  12. На собеседовании спрашивают разницу между CTE и временной таблицей. Объясняешь: CTE живёт только в рамках одного запроса, не создаёт объект в БД, может быть встроен в план оптимизатором. Временная таблица (CREATE TEMP TABLE) материализуется на диске/памяти и доступна в нескольких запросах сессии.

Примеры кода

Подзапросы: IN, EXISTS, скалярный и в FROM

-- Подзапрос в WHERE с IN: пользователи из РФ с заказами
SELECT id, email, name
FROM users
WHERE country = 'RU'
  AND id IN (
    SELECT DISTINCT user_id
    FROM orders
    WHERE created_at >= NOW() - INTERVAL '30 days'
  );

-- EXISTS: быстрее IN для больших наборов (short-circuit evaluation)
SELECT u.id, u.email
FROM users u
WHERE EXISTS (
  SELECT 1
  FROM orders o
  WHERE o.user_id = u.id
    AND o.status = 'completed'
    AND o.amount > 10000
);

-- NOT EXISTS безопасен с NULL (NOT IN опасен!)
-- Найти пользователей БЕЗ ни одного заказа
SELECT u.id, u.email
FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- Подзапрос в FROM (derived table / встроенная таблица)
SELECT
  category_id,
  avg_price,
  CASE WHEN avg_price > 5000 THEN 'premium' ELSE 'standard' END AS segment
FROM (
  SELECT category_id, AVG(price) AS avg_price
  FROM products
  WHERE is_active = true
  GROUP BY category_id
) AS category_stats
ORDER BY avg_price DESC;

IN удобен для небольших списков. EXISTS эффективнее для больших наборов — он останавливается при первом совпадении. NOT IN с NULL в подзапросе вернёт пустой результат из-за трёхзначной логики SQL — всегда используй NOT EXISTS вместо NOT IN. Скалярный подзапрос в SELECT выполняется для каждой строки — заменяй его на JOIN или CTE.

CTE: читаемые многоуровневые запросы

-- Простой CTE: топ-10 клиентов за последние 90 дней
WITH recent_orders AS (
  SELECT
    user_id,
    COUNT(*)          AS order_count,
    SUM(amount)       AS total_spent,
    MAX(created_at)   AS last_order_at
  FROM orders
  WHERE created_at >= NOW() - INTERVAL '90 days'
    AND status = 'completed'
  GROUP BY user_id
),
ranked_customers AS (
  SELECT
    u.id,
    u.email,
    u.name,
    ro.order_count,
    ro.total_spent,
    ro.last_order_at,
    RANK() OVER (ORDER BY ro.total_spent DESC) AS spending_rank
  FROM users u
  JOIN recent_orders ro ON u.id = ro.user_id
  WHERE u.is_active = true
)
SELECT *
FROM ranked_customers
WHERE spending_rank <= 10
ORDER BY spending_rank;

-- CTE с несколькими шагами: отчёт по конверсии воронки
WITH
visits AS (
  SELECT DATE(created_at) AS day, COUNT(*) AS visit_count
  FROM page_views
  WHERE page = '/product' AND created_at >= NOW() - INTERVAL '7 days'
  GROUP BY DATE(created_at)
),
cart_adds AS (
  SELECT DATE(created_at) AS day, COUNT(*) AS cart_count
  FROM cart_events
  WHERE event = 'add' AND created_at >= NOW() - INTERVAL '7 days'
  GROUP BY DATE(created_at)
),
purchases AS (
  SELECT DATE(created_at) AS day, COUNT(*) AS purchase_count
  FROM orders
  WHERE created_at >= NOW() - INTERVAL '7 days'
  GROUP BY DATE(created_at)
)
SELECT
  v.day,
  v.visit_count,
  ca.cart_count,
  p.purchase_count,
  ROUND(ca.cart_count::numeric / NULLIF(v.visit_count, 0) * 100, 2) AS visit_to_cart_pct,
  ROUND(p.purchase_count::numeric / NULLIF(ca.cart_count, 0) * 100, 2) AS cart_to_purchase_pct
FROM visits v
LEFT JOIN cart_adds ca ON v.day = ca.day
LEFT JOIN purchases p  ON v.day = p.day
ORDER BY v.day;

CTE с WITH делают сложный запрос читаемым как последовательность шагов. Каждый CTE — именованный блок с понятным назначением. Несколько CTE через запятую выполняются как единый план. Этот воронки-отчёт на чистом SQL заменяет 3 отдельных запроса плюс обработку в Ruby/Python.

Рекурсивный CTE для иерархий

-- Обход дерева: все потомки категории 'Электроника'
WITH RECURSIVE category_tree AS (
  -- Базовый случай: корневая категория
  SELECT
    id,
    name,
    parent_id,
    slug,
    0          AS depth,
    name::text AS path
  FROM categories
  WHERE slug = 'electronics'

  UNION ALL

  -- Рекурсивная часть: дочерние категории
  SELECT
    c.id,
    c.name,
    c.parent_id,
    c.slug,
    ct.depth + 1,
    ct.path || ' > ' || c.name
  FROM categories c
  JOIN category_tree ct ON c.parent_id = ct.id
  WHERE ct.depth < 10  -- защита от бесконечного цикла
)
SELECT id, name, depth, path
FROM category_tree
ORDER BY path;

-- Рекурсивный CTE с подсчётом товаров в ветке
WITH RECURSIVE category_tree AS (
  SELECT id, name, parent_id, 0 AS depth
  FROM categories WHERE parent_id IS NULL

  UNION ALL

  SELECT c.id, c.name, c.parent_id, ct.depth + 1
  FROM categories c
  JOIN category_tree ct ON c.parent_id = ct.id
  WHERE ct.depth < 5
)
SELECT
  ct.id,
  ct.name,
  ct.depth,
  COUNT(p.id) AS product_count
FROM category_tree ct
LEFT JOIN products p ON p.category_id = ct.id AND p.is_active = true
GROUP BY ct.id, ct.name, ct.depth
ORDER BY ct.depth, ct.name;

Рекурсивный CTE состоит из базового случая (корень дерева) и рекурсивной части (каждый следующий уровень), соединённых UNION ALL. Условие WHERE ct.depth < 10 — защита от бесконечного цикла при цикличных данных. Это заменяет несколько запросов в цикле или сложную логику на стороне приложения.

Что происходит под капотом

  • Подзапрос в WHERE с IN: PostgreSQL строит HashSet из результата подзапроса, затем проверяет каждую строку внешнего запроса за O(1). При больших наборах (100k+ строк) лучше JOIN или EXISTS.
  • EXISTS использует semi-join стратегию: для каждой строки внешнего запроса выполняет подзапрос и останавливается при первом совпадении (short-circuit). Это быстрее IN когда совпадений много в начале набора.
  • NOT IN с NULL: SQL использует трёхзначную логику (TRUE/FALSE/UNKNOWN). Если подзапрос содержит NULL, то NOT IN всегда возвращает UNKNOWN — пустой результат. NOT EXISTS корректно обрабатывает NULL через отсутствие строк.
  • Коррелированный подзапрос (ссылается на столбцы внешнего запроса) выполняется для каждой строки внешнего запроса. При 10k строк внешнего — 10k выполнений подзапроса. PostgreSQL часто оптимизирует их в JOIN автоматически.
  • CTE в PostgreSQL до версии 12 всегда материализовался — выполнялся отдельно и результат сохранялся в памяти. С версии 12+ оптимизатор может встроить (inline) CTE в основной план если это выгодно.
  • Явное управление материализацией: WITH cte AS MATERIALIZED (...) — принудительная материализация (защита от многократного выполнения при нескольких ссылках на CTE). WITH cte AS NOT MATERIALIZED (...) — принудительный inline.
  • Рекурсивный CTE использует структуру данных WorkTable: базовый случай заполняет таблицу, рекурсивная часть читает из WorkTable и добавляет новые строки пока не останется новых строк или не сработает защитное условие.
  • EXPLAIN ANALYZE показывает CTE Scan для материализованных CTE как отдельный узел в дереве плана. Не-материализованный CTE встраивается в план и его узел не виден отдельно — это хорошо для производительности.
  • Window functions и CTE: оконные функции (RANK, ROW_NUMBER) нельзя использовать в WHERE, только в SELECT. Стандартный паттерн: WITH ranked AS (SELECT *, RANK() OVER (...) AS r FROM t) SELECT * FROM ranked WHERE r <= 10.
  • Derived table (подзапрос в FROM) и CTE с одной ссылкой генерируют идентичный план выполнения в большинстве случаев начиная с PostgreSQL 12. Выбирай тот вариант, который читаемее.

Типичные ошибки и заблуждения

  • Ошибка: IN и EXISTS всегда дают одинаковый результат. NOT IN с NULL в подзапросе возвращает пустой результат из-за трёхзначной логики. Всегда проверяй может ли подзапрос вернуть NULL, и используй NOT EXISTS как безопасную альтернативу.
  • Ошибка: CTE всегда быстрее подзапроса потому что 'кешируется'. Это зависит от версии PostgreSQL и конфигурации. CTE может материализоваться (медленнее) или инлайниться (как подзапрос). Измеряй с EXPLAIN ANALYZE.
  • Ошибка: подзапрос в FROM всегда медленнее явного JOIN. Планировщик часто оптимизирует derived table и JOIN одинаково — генерируется один и тот же план выполнения. Предпочитай тот вариант, который читаемее.
  • Ошибка: рекурсивный CTE безопасен по умолчанию. При цикличных данных (A → B → A) рекурсивный CTE уйдёт в бесконечный цикл. Всегда добавляй условие глубины (WHERE depth < N) или проверку на посещённые узлы.
  • Ошибка: скалярный подзапрос в SELECT удобен и быстр. Он выполняется для каждой строки результата — при 10k строк это 10k выполнений. Используй LEFT JOIN или CTE + JOIN вместо скалярного подзапроса в SELECT.
  • Ошибка: CTE на который есть несколько ссылок выполняется один раз. Не-материализованный CTE может выполняться несколько раз при нескольких ссылках. Используй явный MATERIALIZED если нужно гарантировать однократное выполнение.
  • Ошибка: подзапросы делают код модульным как функции. CTE — да, они именованные и переиспользуемые в рамках запроса. Вложенные подзапросы в WHERE — нет, они нечитаемы при глубокой вложенности. Предпочитай CTE для сложной логики.

Ключевые выводы

  • CTE (WITH) — разбей сложный запрос на именованные читаемые шаги, как переменные в коде.
  • EXISTS эффективнее IN для больших наборов; NOT EXISTS безопасен с NULL в отличие от NOT IN.
  • Рекурсивный CTE — единственный способ обойти иерархии (деревья, графы) прямо в SQL без нескольких запросов.
  • Скалярный подзапрос в SELECT выполняется для каждой строки — заменяй на JOIN или CTE для производительности.
  • EXPLAIN ANALYZE покажет материализован ли CTE (CTE Scan) или встроен в план (нет отдельного узла).

Термины урока

Subquery (подзапрос): SELECT внутри другого SQL-выражения — в WHERE, FROM или SELECT.
CTE (Common Table Expression): именованный подзапрос в блоке WITH ... AS (...), доступный ниже по запросу.
EXISTS: оператор проверки наличия хотя бы одной строки в подзапросе; останавливается при первом совпадении.
Correlated subquery: подзапрос, ссылающийся на столбцы внешнего запроса; выполняется для каждой строки внешнего запроса.
Recursive CTE: CTE с UNION ALL и самоссылкой для обхода иерархических структур (деревья, графы).
Materialization: физическое сохранение результата CTE во временной структуре данных; управляется MATERIALIZED / NOT MATERIALIZED.
Semi-join: план выполнения для EXISTS — проверяет наличие совпадения без возврата всех совпавших строк.
Scalar subquery: подзапрос в SELECT, возвращающий одно значение для каждой строки результата.

Связь с работой backend-разработчика

Золотое правило: если твой код делает первый запрос, обрабатывает результат в цикле и делает второй запрос — это можно и нужно переписать в один CTE. CTE — это SQL-пайплайн: каждый шаг берёт результат предыдущего. Вся логика выполняется в базе данных, которая оптимизирована для работы с данными. Результат — один round-trip вместо N, меньше памяти в приложении, понятный и тестируемый SQL.

Мини-разбор реальной ситуации

Задача: страница аналитики для менеджера — топ-20 клиентов за квартал с суммой заказов, количеством, средним чеком, регионом и статусом (активный/VIP/churned). Первая реализация: 4 запроса в Ruby, merge в хэш, сортировка в памяти — 800ms. Переписано в один запрос: WITH quarterly_orders AS (фильтрация по дате и статусу), order_stats AS (агрегация по user_id), user_segments AS (определение сегмента через CASE), customer_regions AS (JOIN с регионами). Финальный SELECT из user_segments JOIN всем необходимым с ORDER BY и LIMIT 20. Результат: 45ms, код в одном месте, тестируется одним запросом в psql.

Что запомнить

  • NOT EXISTS вместо NOT IN — всегда, если подзапрос может содержать NULL.
  • CTE = именованные шаги = читаемый SQL. Разбивай сложную логику на маленькие именованные блоки.
  • Рекурсия → защита от бесконечного цикла: WHERE depth < N или проверка посещённых узлов.
  • Скалярный подзапрос в SELECT = O(n) запросов. Заменяй на LEFT JOIN + COALESCE.

Итог

Подзапросы и CTE — это переход от 'загружаю данные и обрабатываю в коде' к 'формулирую логику в SQL'. CTE делают сложные запросы читаемыми — каждый именованный блок делает одну вещь, как функция. Рекурсивный CTE открывает целый класс задач (иерархии, графы), которые иначе требуют нескольких запросов или рекурсии в коде. Освоив этот инструментарий, ты начинаешь видеть SQL как мощный язык трансформации данных, а не просто способ 'достать строки из таблицы'.