PostgreSQL
CTE и WITH RECURSIVE
Таблица categories с parent_id. Запрос: найти все подкатегории категории «Электроника» на любой глубине. Без рекурсивного CTE - это либо N запросов в приложении, либо хранимая процедура с циклом, либо денормализация. Один рекурсивный CTE решает это в стандартном SQL. А для графов с циклами - тот же инструмент с небольшим дополнением.
- **Org chart (HR системы):** кто подчиняется менеджеру X, на всех уровнях иерархии - WITH RECURSIVE за один запрос
- **Маршрутизация (логистика):** найти все возможные пути доставки из A в B - обход графа через CTE
- **Финансы (счета):** транзитивное замыкание зависимостей счетов для проверки circular references
CTE: синтаксис и когда применять
**CTE (Common Table Expression)** - именованный подзапрос, определённый в начале запроса через `WITH`. Результат CTE можно использовать несколько раз в основном запросе, как если бы это была временная таблица. Главная ценность - читаемость: сложный запрос разбивается на именованные шаги.
**CTE vs подзапрос в FROM:** синтаксически они взаимозаменяемы для простых случаев. CTE выигрывает когда: (1) один и тот же подзапрос нужен несколько раз, (2) запрос нужно читать сверху вниз как алгоритм, (3) нужна рекурсия.
Главное преимущество CTE перед эквивалентным подзапросом в FROM:
Рекурсивные CTE
С ключевым словом `RECURSIVE` CTE может ссылаться сам на себя. Это единственный способ в стандартном SQL делать итеративные/рекурсивные вычисления без процедурного кода. Структура всегда одинакова: base case (anchor) + рекурсивный шаг, объединённые через `UNION ALL`.
**Предотвращение бесконечной рекурсии:** всегда добавляйте ограничение в WHERE или используйте `LIMIT`. Для графов с циклами - отслеживайте путь через массив посещённых вершин. PostgreSQL не имеет встроенной защиты от infinite loop в рекурсивных CTE.
В рекурсивном CTE какую роль играет anchor (первая часть до UNION ALL)?
Иерархические данные с RECURSIVE
Организационная иерархия, категории с подкатегориями, дерево комментариев - все эти структуры хранятся как таблица с `parent_id`. Обход такого дерева без RECURSIVE CTE требовал N запросов или денормализации. RECURSIVE даёт это в одном запросе.
**Альтернативы для больших иерархий:** Ltree extension (PostgreSQL) - хранит путь как `'1.5.23.104'`, поддерживает индексированные запросы на поддеревья. Для readonly иерархий Nested Set (left/right числа) даёт O(1) запросы на поддерево без рекурсии.
В рекурсивном CTE для обхода иерархии мы добавляем массив `path || current_id`. Зачем?
Обход графа через CTE
Граф (в отличие от дерева) может содержать циклы: A -> B -> C -> A. Без защиты рекурсивный CTE уйдёт в бесконечный цикл. Стандартный приём - отслеживать массив посещённых вершин и фильтровать уже посещённые.
**UNION vs UNION ALL в RECURSIVE CTE:** `UNION ALL` - быстрее, добавляет все строки. `UNION` - удаляет дубликаты, может использоваться для защиты от циклов вместо массива visited, но медленнее на больших графах.
Рекурсивный CTE обходит граф без защиты от циклов. Что произойдёт при наличии цикла A->B->C->A?
MATERIALIZED vs не-MATERIALIZED
До PostgreSQL 12 все CTE были **optimization fences**: планировщик не мог «заглянуть внутрь» CTE и применить внешние условия WHERE для оптимизации. CTE выполнялся полностью и кешировался. С PostgreSQL 12 поведение изменилось - CTE по умолчанию не материализуется, если используется ровно один раз.
Практическое правило: если CTE с `VOLATILE` функцией (`random()`, `now()`, `clock_timestamp()`) используется несколько раз - обязательно `MATERIALIZED`. Иначе каждое использование вызовет функцию заново, давая разные результаты.
**EXPLAIN проверка:** `EXPLAIN (ANALYZE)` покажет `CTE Scan` (материализованный CTE) или инлайненный подзапрос. `CTE Scan` всегда значит материализацию. Если видите `CTE Scan` для CTE, который используется один раз - добавьте `NOT MATERIALIZED` или перепишите в подзапрос.
CTE - это всегда optimization fence: планировщик не может их оптимизировать
До PG12 - да. С PG12+ CTE по умолчанию инлайнятся (NOT MATERIALIZED), если используются один раз. Keyword MATERIALIZED явно включает старое поведение.
Многие советы «использовать CTE как optimization fence» были написаны до PG12. В современном PostgreSQL нужно явно писать MATERIALIZED если нужно кеширование или изоляция от планировщика.
CTE содержит `random()` и используется в двух местах запроса. Без `MATERIALIZED` что произойдёт в PostgreSQL 12+?
CTE и WITH RECURSIVE
- CTE: именованный подзапрос в WITH - улучшает читаемость, позволяет использовать результат несколько раз
- RECURSIVE CTE: anchor UNION ALL recursive step - единственный способ итерации в стандартном SQL
- Иерархии: parent_id таблица + RECURSIVE = обход дерева на любую глубину за один запрос
- Граф с циклами: массив visited + WHERE id != ALL(visited) - обязательная защита от бесконечной рекурсии
- MATERIALIZED: нужен для VOLATILE функций и когда CTE используется 2+ раз; PG12+ инлайнит по умолчанию
Связанные темы
CTE строятся поверх подзапросов и часто заменяются оконными функциями.
- Подзапросы: EXISTS, IN, ANY, ALL — CTE - эволюция подзапросов: именованные, повторно используемые, рекурсивные
- Оконные функции — Многие задачи, решаемые рекурсивным CTE (rank, running total), элегантнее через оконные функции
- EXPLAIN и планировщик запросов — CTE Scan в EXPLAIN показывает материализацию - ключ к пониманию производительности CTE
Вопросы для размышления
- Рекурсивный CTE выполняет итерацию в BFS-порядке. Можно ли реализовать DFS через стандартный RECURSIVE CTE в PostgreSQL?
- MATERIALIZED CTE в PG12+ нужно указывать явно. В каких production сценариях забыть MATERIALIZED может привести к некорректным данным, а не просто к замедлению?
- Ltree extension vs RECURSIVE CTE для иерархий: при каком размере дерева и частоте запросов стоит переходить на Ltree?
Связанные уроки
- pg-09-subqueries — CTEs are named subqueries; subquery mechanics must be understood first
- pg-11-window — CTEs and window functions often appear together in analytics queries
- pg-08-aggregation — Recursive CTEs for hierarchical aggregation combine both concepts
- pg-07-joins — Inline views (subqueries in FROM) vs CTEs: same power, different readability tradeoffs
- db-06-sql-advanced