CTE (WITH) vs подзапросы vs временные таблицы — разница?
Одна задача — три способа
Задача: клиенты, чья сумма заказов за месяц выше средней.
SELECT c.name, t.total
FROM customers c
JOIN (
SELECT customer_id, SUM(total) AS total
FROM orders
WHERE created_at >= '2025-09-01'
GROUP BY customer_id
) t ON t.customer_id = c.id
WHERE t.total > (
SELECT AVG(s.total) FROM (
SELECT SUM(total) AS total FROM orders WHERE created_at >= '2025-09-01' GROUP BY customer_id
) s
);WITH monthly AS (
SELECT customer_id, SUM(total) AS total
FROM orders
WHERE created_at >= '2025-09-01'
GROUP BY customer_id
),
avg_total AS (
SELECT AVG(total) AS value FROM monthly -- CTE ссылается на предыдущий CTE
)
SELECT c.name, m.total
FROM monthly m
JOIN customers c ON c.id = m.customer_id
CROSS JOIN avg_total a
WHERE m.total > a.value;CREATE TEMP TABLE monthly AS
SELECT customer_id, SUM(total) AS total
FROM orders WHERE created_at >= '2025-09-01'
GROUP BY customer_id;
CREATE INDEX ON monthly (customer_id);
ANALYZE monthly; -- статистика для оптимизатора
SELECT c.name, m.total FROM monthly m JOIN customers c ON c.id = m.customer_id
WHERE m.total > (SELECT AVG(total) FROM monthly);
UPDATE customers SET segment = 'vip'
WHERE id IN (SELECT customer_id FROM monthly WHERE total > 1000000);Сравнение
| Подзапрос | CTE | Временная таблица | |
|---|---|---|---|
| Область видимости | Место, где написан | Один оператор | Сессия / транзакция |
| Читаемость | Падает с вложенностью | Высокая: шаги по порядку | Высокая, но многословно |
| Повторное использование | Нужно копировать | В пределах оператора | В нескольких операторах |
| Рекурсия | Нет | Да | Через цикл |
| Индексы и статистика | — | — | Да |
| Оптимизация | Встраивается | Встраивается (PG 12+, MSSQL) или материализуется | Отдельные шаги, план каждого запроса отдельно |
| Стоимость | Нет накладных расходов | Нет (если встраивается) | Запись в tempdb / temp-файлы |
Рекурсивный CTE
-- Дерево категорий: все потомки категории 5 с уровнем и путём
WITH RECURSIVE tree AS (
-- якорь: стартовая строка
SELECT id, parent_id, name, 1 AS depth, name::text AS path
FROM categories
WHERE id = 5
UNION ALL
-- рекурсивная часть: дети уже найденных
SELECT c.id, c.parent_id, c.name, t.depth + 1, t.path || ' > ' || c.name
FROM categories c
JOIN tree t ON c.parent_id = t.id
WHERE t.depth < 10 -- защита от бесконечной рекурсии
)
SELECT * FROM tree ORDER BY path;Другие применения: цепочка руководителей сотрудника, развёртка BOM (спецификация изделия), поиск путей в графе, генерация последовательностей дат (хотя для этого есть generate_series).
Коррелированный подзапрос
-- Для каждого клиента — дата последнего заказа (выполняется «для каждой строки» внешнего запроса)
SELECT c.name,
(SELECT MAX(o.created_at) FROM orders o WHERE o.customer_id = c.id) AS last_order_at
FROM customers c;Оптимизатор часто превращает такой подзапрос в JOIN с агрегатом, но не всегда. При проблемах — переписать через LEFT JOIN (SELECT customer_id, MAX(...) ... GROUP BY) или LATERAL.
Материализация CTE в PostgreSQL
-- Принудительно вычислить один раз (например, дорогая функция используется несколько раз)
WITH expensive AS MATERIALIZED (
SELECT id, heavy_calculation(data) AS score FROM items
)
SELECT * FROM expensive WHERE score > 10
UNION ALL
SELECT * FROM expensive WHERE score < -10;
-- Наоборот, разрешить встраивание, даже если CTE используется дважды
WITH cheap AS NOT MATERIALIZED (...)Модифицирующие CTE (WITH deleted AS (DELETE ... RETURNING *) INSERT INTO archive SELECT * FROM deleted) выполняются всегда ровно один раз — удобный способ атомарно переместить данные.
CTE и EF Core
LINQ-запросы EF Core генерируют подзапросы, а не CTE. Для рекурсивных запросов и сложной аналитики используют FromSql / SqlQuery<T>:
var descendants = await db.Categories.FromSql($"""
WITH RECURSIVE tree AS (
SELECT * FROM categories WHERE id = {rootId}
UNION ALL
SELECT c.* FROM categories c JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree
""").AsNoTracking().ToListAsync(ct);Альтернатива для иерархий — хранить путь: ltree в PostgreSQL, hierarchyid в SQL Server, materialized path (/1/5/12/), closure table.
Вопросы на засыпку
Материализуется ли CTE в SQL Server?
Нет, SQL Server всегда встраивает нерекурсивный CTE в запрос; если CTE используется дважды — он вычисляется дважды. Для однократного вычисления — временная таблица.
Чем UNION отличается от UNION ALL в рекурсивном CTE?
UNION ALL добавляет все строки итерации. UNION убирает дубликаты — это может остановить зацикливание в графах, но дороже (нужно сравнивать с уже полученными строками).
Временная таблица или табличная переменная в SQL Server?
#temp — есть статистика, индексы, можно ALTER; лучше для больших наборов. @table — без статистики (оптимизатор считает, что там 1 строка — до SQL Server 2019 с deferred compilation), меньше перекомпиляций; для небольших наборов.
Видна ли временная таблица другим сессиям?
Локальная временная таблица (#t в SQL Server, TEMP в PostgreSQL) видна только своей сессии и удаляется при её завершении. С пулом соединений это важно: соединение возвращается в пул, и таблица может «пережить» логическую операцию — удаляйте явно или используйте ON COMMIT DROP.
Итог
Подзапросы — для простых вложенных условий, CTE — для читаемых многошаговых запросов и рекурсии, временные таблицы — для больших промежуточных результатов, которым нужны индексы, статистика и повторное использование. В современных СУБД CTE обычно не влияет на производительность, а в PostgreSQL материализацией можно управлять явно.