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

CTE и рекурсивные запросы в PostgreSQL

Урок 6 из 10 в курсе PostgreSQL для backend-разработчика

CTE и рекурсивные запросы в PostgreSQL

CTE (Common Table Expressions) с оператором WITH — это PostgreSQL-способ писать сложные запросы как последовательность именованных шагов. Вместо глубоко вложенных подзапросов: WITH step1 AS (...), step2 AS (...) SELECT FROM step2. Рекурсивный CTE (WITH RECURSIVE) добавляет возможность обхода иерархических структур и графов прямо в SQL: деревья категорий, org-структуры, цепочки зависимостей. Data-modifying CTE позволяет выполнять DELETE + INSERT атомарно в одном запросе. PostgreSQL 14 добавил встроенную защиту от циклов: CYCLE clause. Освоив CTE, ты пишешь сложные многоуровневые запросы так же читаемо, как обычный код — каждый блок делает одну понятную вещь.

Почему это важно: До CTE сложные аналитические запросы в PostgreSQL превращались в нечитаемые матрёшки из подзапросов — понять такой код через месяц практически невозможно. CTE решают это архитектурно: каждый этап трансформации данных получает имя и становится отдельным читаемым блоком. Рекурсивный CTE решает ещё более серьёзную проблему: без него обход деревьев (категории → подкатегории → подподкатегории неограниченной глубины) требует либо многократных запросов из приложения с накоплением результата, либо хранимой процедуры, либо загрузки всего дерева и обхода в памяти. Рекурсивный CTE делает это одним запросом, который выполняется внутри PostgreSQL — без сетевых round-trips, без памяти приложения.

Главная идея

CTE синтаксис: WITH имя AS (SELECT ...) SELECT ... FROM имя. Несколько CTE: WITH a AS (...), b AS (...) SELECT FROM a JOIN b. CTE доступны только внутри запроса и не создают объектов в БД. Рекурсивный CTE: WITH RECURSIVE имя AS (базовый_случай UNION ALL рекурсивная_часть). Базовый случай выполняется один раз и возвращает начальные строки. Рекурсивная часть использует предыдущий результат как входные данные, генерирует новые строки, повторяется пока есть новые строки. CYCLE id SET is_cycle (PostgreSQL 14+) автоматически обнаруживает циклы в графе. Data-modifying CTE: WITH moved AS (DELETE FROM staging RETURNING *) INSERT INTO production SELECT * FROM moved. Изменения атомарны. Materialization: WITH cte AS MATERIALIZED (...) / NOT MATERIALIZED (...) — явное управление тем, вычисляется ли CTE один раз или встраивается в план.

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

  1. Задача: построить страницу профиля клиента с суммой заказов, количеством, средним чеком и последней покупкой. Первое решение: 4 отдельных запроса в Ruby. Понимаешь что это неэффективно — переносишь в CTE.
  2. Пишешь WITH order_stats AS (SELECT COUNT(*), SUM(amount), AVG(amount), MAX(created_at) FROM orders WHERE user_id = $1 GROUP BY user_id). Один запрос, один round-trip, результат совпадает.
  3. Появляется требование: показать дерево категорий для сайдбара магазина. Категории вложены на 4-5 уровней. Первое решение в коде: рекурсивная функция, которая делает SELECT для каждого узла — 200+ запросов при 200 категориях.
  4. Узнаёшь про рекурсивный CTE. Пишешь WITH RECURSIVE cats AS (корень UNION ALL дочерние). Один запрос возвращает всё дерево — 200 узлов за 3ms вместо 500ms и 200 round-trips.
  5. Появляется задача перенести обработанные заказы из staging-таблицы в основную и одновременно залогировать операцию. Нужна атомарность. Data-modifying CTE: WITH deleted AS (DELETE FROM staging RETURNING *) INSERT INTO orders SELECT * FROM deleted, INSERT INTO log SELECT id, NOW() FROM deleted.
  6. На code review замечаешь что коллега написал запрос с 5 уровнями вложенных подзапросов. Показываешь как переписать в CTE — код уменьшается вдвое и становится читаемым.
  7. Нужно найти всех подчинённых сотрудника в org-структуре (произвольная глубина). Рекурсивный CTE с накоплением пути: ancestors || employee_id позволяет построить полную иерархию.
  8. Обнаруживаешь медленный запрос: CTE используется дважды в запросе, но PostgreSQL вычисляет его дважды (NOT MATERIALIZED в Postgres 12+). Добавляешь MATERIALIZED — CTE вычисляется один раз, производительность растёт в 2 раза.
  9. Задача: для каждого товара найти все родительские категории (breadcrumbs). Рекурсивный CTE снизу вверх: начинаешь с category_id товара, идёшь по parent_id до корня. Массив ancestors || id накапливает путь.
  10. Интеграция с внешней системой: массовый upsert пользователей из CSV. Data-modifying CTE с ON CONFLICT DO UPDATE + RETURNING (xmax = 0) позволяет в одном запросе узнать сколько создано и сколько обновлено.
  11. Генерация серии дат для отчёта: WITH RECURSIVE dates AS (SELECT '2024-01-01'::DATE AS d UNION ALL SELECT d + 1 FROM dates WHERE d < '2024-01-31') SELECT d FROM dates — все даты января без generate_series.
  12. На собеседовании просят написать запрос для поиска всех возможных путей в графе между двумя узлами. Рекурсивный CTE с накоплением visited nodes и проверкой на циклы через CYCLE clause.

Примеры кода

CTE-цепочка: разбивка сложной логики на шаги

-- Отчёт: топ клиенты за квартал с их сегментом и регионом
WITH
-- Шаг 1: заказы за текущий квартал
quarterly_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 >= DATE_TRUNC('quarter', NOW())
    AND status = 'completed'
  GROUP BY user_id
),
-- Шаг 2: определяем сегмент клиента
segmented AS (
  SELECT
    user_id,
    order_count,
    total_spent,
    last_order_at,
    CASE
      WHEN total_spent >= 100000 THEN 'VIP'
      WHEN total_spent >= 30000  THEN 'Premium'
      WHEN total_spent >= 10000  THEN 'Regular'
      ELSE 'New'
    END AS segment
  FROM quarterly_orders
),
-- Шаг 3: ранжируем внутри сегмента
ranked AS (
  SELECT
    s.*,
    u.email,
    u.name,
    u.country,
    ROW_NUMBER() OVER (PARTITION BY s.segment ORDER BY s.total_spent DESC) AS rank_in_segment
  FROM segmented s
  JOIN users u ON u.id = s.user_id
  WHERE u.is_active = true
)
-- Финальный результат: топ-3 в каждом сегменте
SELECT *
FROM ranked
WHERE rank_in_segment <= 3
ORDER BY segment, rank_in_segment;

Каждый CTE — один чёткий шаг: фильтрация → сегментация → ранжирование. Читается как спецификация задачи. PostgreSQL 12+ может встроить (inline) простые CTE в основной план для оптимизации. Этот запрос заменяет 3-4 запроса в Ruby с merge в хэш.

Рекурсивный CTE: обход дерева категорий

-- Таблица: categories(id, name, slug, parent_id)
-- Задача: получить полное поддерево для категории 'electronics'

WITH RECURSIVE category_tree AS (
  -- Базовый случай: корневая категория
  SELECT
    id,
    name,
    slug,
    parent_id,
    0           AS depth,
    name::TEXT  AS full_path,
    ARRAY[id]   AS ancestors
  FROM categories
  WHERE slug = 'electronics'

  UNION ALL

  -- Рекурсивная часть: дочерние категории текущего уровня
  SELECT
    c.id,
    c.name,
    c.slug,
    c.parent_id,
    ct.depth + 1,
    ct.full_path || ' > ' || c.name,
    ct.ancestors || c.id
  FROM categories c
  JOIN category_tree ct ON c.parent_id = ct.id
  WHERE ct.depth < 8  -- ограничение глубины
)
SELECT
  id,
  name,
  depth,
  full_path,
  -- Можно подсчитать товары в каждой ветке
  (SELECT COUNT(*) FROM products p WHERE p.category_id = category_tree.id AND p.is_active) AS product_count
FROM category_tree
ORDER BY full_path;

-- PostgreSQL 14+: встроенная защита от циклов (для графов с циклами)
WITH RECURSIVE graph_traversal AS (
  SELECT id, parent_id, name, 0 AS depth
  FROM nodes WHERE id = 1

  UNION ALL

  SELECT n.id, n.parent_id, n.name, gt.depth + 1
  FROM nodes n
  JOIN graph_traversal gt ON n.parent_id = gt.id
  WHERE gt.depth < 20
)
CYCLE id SET is_cycle TO true DEFAULT false USING path
SELECT id, name, depth, is_cycle, path
FROM graph_traversal
WHERE NOT is_cycle;

Рекурсивный CTE выполняется итеративно: базовый случай → рекурсия до исчерпания. ARRAY[id] || c.id накапливает путь от корня — полезно для breadcrumbs. Условие depth < 8 — защита от бесконечного цикла при цикличных данных. PostgreSQL 14 добавил CYCLE clause для автоматического обнаружения циклов в графах.

Data-modifying CTE и materialization

-- Атомарный перенос данных: из staging в production + логирование
WITH
moved_orders AS (
  DELETE FROM orders_staging
  WHERE processed_at IS NULL
    AND created_at < NOW() - INTERVAL '1 hour'
  RETURNING *
),
inserted AS (
  INSERT INTO orders (user_id, amount, status, created_at)
  SELECT user_id, amount, 'pending', created_at
  FROM moved_orders
  RETURNING id, amount
)
INSERT INTO order_processing_log (order_id, amount, processed_at, source)
SELECT id, amount, NOW(), 'staging_transfer'
FROM inserted;

-- Upsert с возвратом результата в CTE для дальнейшей обработки
WITH upserted_users AS (
  INSERT INTO users (email, name, source)
  VALUES
    ('alice@example.com', 'Alice', 'import'),
    ('bob@example.com', 'Bob', 'import')
  ON CONFLICT (email) DO UPDATE
    SET name = EXCLUDED.name,
        updated_at = NOW()
  RETURNING id, email, (xmax = 0) AS is_new
)
SELECT
  email,
  CASE WHEN is_new THEN 'created' ELSE 'updated' END AS action
FROM upserted_users;

-- Управление материализацией CTE
-- NOT MATERIALIZED: CTE встраивается в план, оптимизатор может использовать индексы
WITH expensive_filter AS NOT MATERIALIZED (
  SELECT * FROM orders WHERE user_id = $1 AND status = 'completed'
)
SELECT * FROM expensive_filter WHERE amount > 1000;

-- MATERIALIZED: CTE вычисляется один раз, результат кешируется
-- Полезно когда CTE используется несколько раз в запросе
WITH order_stats AS MATERIALIZED (
  SELECT user_id, SUM(amount) AS total, COUNT(*) AS cnt
  FROM orders GROUP BY user_id
)
SELECT u.name, os.total, os.cnt
FROM users u
JOIN order_stats os ON u.id = os.user_id
WHERE os.total > 50000
UNION ALL
SELECT 'TOTAL', SUM(os.total), SUM(os.cnt)
FROM order_stats os;

Data-modifying CTE делает DELETE+INSERT атомарным — либо оба успевают, либо ни один. Трюк (xmax = 0) AS is_new в RETURNING позволяет отличить INSERT от UPDATE при ON CONFLICT. MATERIALIZED гарантирует однократное вычисление при нескольких ссылках на CTE — без него планировщик может выполнить CTE дважды.

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

  • PostgreSQL 12+ изменил поведение CTE: по умолчанию CTE теперь NOT MATERIALIZED если он используется один раз и не рекурсивный. Это означает что планировщик может встроить CTE в основной план и использовать индексы, как для обычного подзапроса.
  • Явная материализация: WITH cte AS MATERIALIZED (...) — результат сохраняется во временной структуре данных (analogous to temp table). Полезно когда CTE дорогой и используется несколько раз — без MATERIALIZED планировщик может выполнить его несколько раз.
  • Рекурсивный CTE всегда MATERIALIZED — планировщик не может встроить его в основной план. Каждая итерация использует WorkTable, которая обновляется на каждом шаге рекурсии.
  • EXPLAIN ANALYZE для CTE: MATERIALIZED CTE виден как CTE Scan в дереве плана. NOT MATERIALIZED CTE встроен и не виден как отдельный узел. Это полезно для диагностики производительности.
  • Data-modifying CTE и snapshot isolation: все части запроса видят снимок данных на момент начала запроса. Если CTE-A удаляет строки, а CTE-B читает ту же таблицу — CTE-B видит данные до удаления CTE-A. Изменения становятся видимы только после завершения всего запроса.
  • CYCLE detection (PostgreSQL 14+): CYCLE id SET is_cycle TO true DEFAULT false USING path. PostgreSQL автоматически отслеживает посещённые id и устанавливает is_cycle = true при повторном посещении. path — массив пройденных узлов. До PostgreSQL 14: ручная защита через WHERE depth < N или ARRAY проверку.
  • Рекурсивный CTE vs ltree extension: ltree хранит иерархические пути как '1.5.12.8' и поддерживает мощные операторы запросов. Для статичных деревьев с частыми запросами — ltree быстрее. Для динамичных деревьев с частыми изменениями структуры — рекурсивный CTE гибче.
  • Производительность рекурсии: каждая итерация = Hash Join или Nested Loop с WorkTable. Для дерева глубиной 10 и branching factor 5 — 5^10 потенциальных строк. Всегда ограничивай WHERE depth < N.

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

  • Ошибка: CTE всегда быстрее подзапроса потому что 'оптимизирован'. До PostgreSQL 12 CTE был optimization fence — всегда материализовался и блокировал push-down предикатов. С PostgreSQL 12+ поведение изменилось. Всегда проверяй EXPLAIN ANALYZE.
  • Ошибка: рекурсивный CTE безопасен по умолчанию. Без защиты (depth < N или CYCLE clause) рекурсивный CTE на данных с циклами (A → B → A) уйдёт в бесконечный цикл и исчерпает память. Всегда добавляй защиту.
  • Ошибка: data-modifying CTE видит свои же изменения в следующем CTE. Snapshot isolation: все CTE в запросе видят данные на момент начала запроса. CTE-A удалил строки — CTE-B по-прежнему их видит.
  • Ошибка: CTE на который есть несколько ссылок вычисляется один раз автоматически. Без MATERIALIZED планировщик может вычислить CTE для каждой ссылки отдельно (если решит что inlining выгоднее). Явный MATERIALIZED гарантирует однократное вычисление.
  • Ошибка: UNION ALL в рекурсивном CTE можно заменить UNION для дедупликации. UNION не поддерживается в рекурсивном CTE — только UNION ALL. Дедупликацию делай в финальном SELECT или через CYCLE.
  • Ошибка: рекурсивный CTE хорош для любой иерархии любого размера. При дереве в миллионы узлов рекурсивный CTE может быть медленным. Альтернативы: ltree extension, materialized path, nested sets — для статичных деревьев с частым чтением.

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

  • CTE = именованные шаги запроса, как переменные в коде — читаемость и переиспользование.
  • Рекурсивный CTE: базовый случай UNION ALL рекурсивная часть — итерация до отсутствия новых строк.
  • Всегда защищай рекурсию: WHERE depth < N или CYCLE clause (PostgreSQL 14+).
  • Data-modifying CTE: DELETE/INSERT/UPDATE внутри WITH — атомарные сложные операции.
  • MATERIALIZED / NOT MATERIALIZED — явное управление кешированием CTE при нескольких ссылках.

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

CTE (Common Table Expression): именованный временный результат в блоке WITH, доступный внутри одного запроса.
RECURSIVE CTE: CTE с самоссылкой через UNION ALL для итеративного обхода иерархий.
Working table: временная таблица результата каждой итерации рекурсивного CTE.
MATERIALIZED: подсказка планировщику вычислить CTE один раз и сохранить результат.
NOT MATERIALIZED: подсказка встроить CTE в основной план (inline), как подзапрос.
Data-modifying CTE: CTE с INSERT/UPDATE/DELETE операцией; изменения атомарны с основным запросом.
CYCLE clause: встроенная защита от бесконечной рекурсии в графах с циклами (PostgreSQL 14+).
Optimization fence: барьер оптимизации — результат CTE до PostgreSQL 12 всегда материализовался.

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

Три главных применения CTE в backend: читаемые многошаговые аналитические запросы (вместо nested subqueries), обход иерархических структур (категории, org-tree, зависимости), атомарные data-modifying операции (DELETE old + INSERT new). Рекурсивный CTE — стандартное решение для дерева категорий: один запрос вместо рекурсивной функции с N round-trips к БД.

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

E-commerce: страница категории показывает breadcrumbs (путь от корня), все подкатегории с количеством товаров, и рекомендуемые товары из всего поддерева. Первая реализация: 3 отдельных запроса в контроллере, рекурсивная функция в Ruby для breadcrumbs (по одному SELECT на уровень = 5 запросов), 800ms суммарно. Переписано: два рекурсивных CTE — один строит путь снизу вверх (breadcrumbs), другой строит поддерево сверху вниз (подкатегории). Финальный SELECT JOIN-ит товары из поддерева. Три запроса превратились в один — 35ms.

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

  • Рекурсивный CTE = базовый случай UNION ALL рекурсивная часть. Всегда WHERE depth < N.
  • MATERIALIZED гарантирует однократное вычисление при нескольких ссылках на CTE.
  • Data-modifying CTE видит только снимок данных ДО начала запроса — snapshot isolation.

Итог

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