CTE и материализация
На ретро спринта отчётов команда собирается у доски: что за две недели тормозило, что переписывали дважды, где отчёт молчит вместо честной ошибки.
Ты открываешь. Три SELECT, вложенных один в другой: внутренний считает сумму заказа, средний сворачивает по клиенту, внешний подставляет имя. Скобки закрываются где-то в самом низу.
Именованные шаги — это CTE, секция WITH; лист с граблями — чек-лист в конце юнита. Заодно разберём материализацию: считает ли Postgres CTE отдельно или встраивает в основной запрос. Начнём с CTE.
CTE: именованные шаги-кирпичи
WITH имя AS (запрос) объявляет временный результат, на который дальше ссылаются по имени, как на таблицу. CTE можно цепочкой: второй ссылается на первый, основной запрос — на оба.
Сначала посмотри, как те же три шага выглядят без WITH — подзапрос в FROM другого подзапроса. Прочитать его можно только изнутри наружу, держа в голове, что считает каждый вложенный уровень:
SELECT c.name, p.orders, p.spent
FROM (
SELECT customer_id, count(*) AS orders, sum(cents)::bigint AS spent
FROM (
SELECT o.id, o.customer_id,
sum(oi.quantity * oi.unit_price)::bigint AS cents
FROM orders o JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.customer_id
) AS order_totals
GROUP BY customer_id
) AS p JOIN customers c ON c.id::text = p.customer_id
ORDER BY p.spent DESC;Смысл тот же, но скобки вложены, имена шагов спрятаны в AS-псевдонимах в самом низу, а читать приходится от внутреннего SELECT к внешнему. CTE переворачивает это в линейный конвейер сверху вниз:
WITH order_totals AS ( -- шаг 1: сумма каждого заказа из позиций
SELECT o.id AS order_id, o.customer_id,
sum(oi.quantity * oi.unit_price)::bigint AS cents
FROM orders o JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.customer_id
),
per_customer AS ( -- шаг 2: свернуть заказы по клиенту
SELECT customer_id, count(*) AS orders, sum(cents)::bigint AS spent
FROM order_totals GROUP BY customer_id
)
SELECT c.name, p.orders, p.spent -- шаг 3: подставить имя
FROM per_customer p JOIN customers c ON c.id::text = p.customer_id
ORDER BY p.spent DESC;Каждый шаг — отдельный именованный кирпич, и результат течёт сверху вниз в следующий:
WITH order_totals AS (...) шаг 1 · сумма каждого заказа из позиций
│
▼
per_customer AS (...) шаг 2 · свернуть заказы по клиенту
│
▼
SELECT … JOIN customers шаг 3 · подставить имя клиентаГлавная ценность CTE для приложения — читаемость и переиспользование промежуточного результата, а не «ускорение». CTE сам по себе запрос не ускоряет.
За кофе Ботыр возвращается к теме:
Материализация: фенс против встраивания
Тут начинается тонкость. Postgres может обойтись с CTE двумя способами:
- Встроить (inline) — подставить тело
CTEв основной запрос, как обычный подзапрос. Тогда планировщик видит запрос целиком и может, например, протолкнуть фильтр внутрьCTE. - Материализовать (фенс) — вычислить
CTEотдельно, один раз, сложить результат во временный буфер и дальше читать из него. Оптимизатор за этот «забор» не заглядывает.
Начиная с PG12 правило по умолчанию такое: если на CTE ссылаются один раз — он встраивается; если больше одного раза (или внутри есть запись/VOLATILE-функция) — материализуется (логично: считать один раз и переиспользовать дешевле, чем дважды). VOLATILE-функция — та, чей результат может меняться от вызова к вызову при тех же аргументах (например, random() или clock_timestamp()); встраивать её копии и звать по нескольку раз было бы небезопасно, поэтому Postgres ставит забор (подробно про волатильность — модуль 09). Эти умолчания можно переопределить ключевыми словами: AS MATERIALIZED форсирует забор, AS NOT MATERIALIZED форсирует встраивание.
| встраивание (inline) | материализация (фенс) | |
|---|---|---|
| что делает | тело CTE подставляется в основной запрос | CTE считается отдельно, один раз, в буфер |
| оптимизатор | видит запрос целиком, проталкивает фильтры внутрь | за «забор» не заглядывает |
| умолчание (PG12+) | на CTE ссылаются один раз | ссылок больше одной (или запись/VOLATILE) |
| форс | AS NOT MATERIALIZED | AS MATERIALIZED |
Наш второй запрос ссылается на order_totals дважды — в FROM и в scalar-подзапросе с общим итогом, — поэтому он материализуется (мы написали AS MATERIALIZED явно, но и без ключевого слова умолчание было бы тем же).
Что показывает наш код
Два запроса в query.sql. Первый — конвейер из двух CTE (CustomerSpend, см. выше). Второй — CTE, использованный дважды:
-- name: OrderShareOfTotal :many
WITH order_totals AS MATERIALIZED (
SELECT o.id AS order_id, sum(oi.quantity * oi.unit_price)::bigint AS cents
FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id
)
SELECT order_id, cents,
round(100.0 * cents / (SELECT sum(cents) FROM order_totals), 1)::text AS pct
FROM order_totals ORDER BY order_id;order_totals читается и в FROM, и в (SELECT sum(cents) FROM order_totals) — отсюда доля каждого заказа в общем итоге.
Запуск
Подними песочницу (из корня репозитория) и накати схему Brew:
docker compose up -d
make lecture L=04-querying-across-tables/04-06-ctes-and-materialization T=db-reset
make lecture L=04-querying-across-tables/04-06-ctes-and-materialization(T=run — значение по умолчанию. Изнутри каталога юнита это make db-reset, make run.)
Вывод:
1) Траты клиента — CTE-конвейер (order_totals → per_customer → имя):
клиент заказов потрачено
Алиса Иванова 2 19.30
Борис Петров 1 3.00
→ суммы посчитаны из позиций order_items; Карины нет — у неё заказов нет.
2) Доля заказа от общего — CTE order_totals использован дважды (FROM + scalar-подзапрос):
заказ сумма доля,%
#1 9.70 43.5
#2 3.00 13.5
#3 9.60 43.0
→ ссылок на CTE две → Postgres материализует его (считает один раз, переиспользует).Первый запрос прочитался как три шага и свернул траты по клиенту (суммы — из позиций order_items, поэтому Алиса = 9.70 + 9.60 = 19.30). Второй переиспользовал один CTE дважды и посчитал долю каждого заказа в общем итоге (9.70 + 3.00 + 9.60 = 22.30 → 43.5% + 13.5% + 43.0%).
Заборчик
Что мы упростили.
- Разница между встраиванием и материализацией видна не в результате (он одинаков), а в плане: материализованный
CTEставит «забор», за который оптимизатор не проталкивает фильтры, и на больших данных лишнийAS MATERIALIZEDиногда мешает (сервер считает весьCTE, хотя снаружи нужна пара строк). Увидеть это можно только черезEXPLAIN— это модуль 06, поэтому здесь мы лишь называем рычаги, не меряя их. CTE— про читаемость, а не про скорость. Вера «перепишу подзапрос вWITH— станет быстрее» — миф (до PG12 материализация была всегда и иногда даже вредила).- Рекурсивный
CTE(WITH RECURSIVE) — отдельная большая тема: обход деревьев и графов; ему посвящён юнит 08-04.
Типичные ошибки модуля 04
Это тот самый лист, который на ретро договорились повесить над ревью, — грабли, на которые модуль наступал по очереди: Карина, дважды выпавшая из отчёта (04-01 и 04-05), и «+40% выручки», которых не было (fan-out, 04-03). Все они общие: запрос не падает и не выдаёт ошибку — он молча отдаёт неверное число или не ту строку. Повесь её у себя над код-ревью.
| ловушка | юнит | как правильно |
|---|---|---|
count(*) поверх LEFT JOIN считает строки результата, а не сущности: клиент без заказов даёт строку с NULL и попадает в счёт как 1 | 04-03 | считай не-NULL ключ дочерней таблицы — count(o.id); так клиент без пары даёт 0 |
NOT IN (подзапрос) по nullable-колонке: один NULL в наборе — и условие сворачивается в NULL, результат пуст для всех | 04-05 | для «нет среди» бери NOT EXISTS (или гарантируй, что в подзапросе нет NULL) |
DISTINCT ON без tie-break в хвосте ORDER BY: при равных ключах сортировки «первая» строка не определена, отчёт «плавает» между прогонами | 04-04 | дотягивай ORDER BY уникальной колонкой (id DESC), чтобы выбор строки был детерминирован |
условие по правой таблице LEFT JOIN в WHERE (а не в ON): строки с NULL справа отсекаются, и LEFT тихо вырождается в INNER | 04-01 | фильтр по правой таблице держи в ON; в WHERE по правой стороне осмысленна только проверка IS NULL (анти-join) |
sum/count поверх fan-out: JOIN родителя с дочерней размножает строки родителя, и агрегат по родителю задваивается | 04-02 | агрегируй до JOIN (свернуть детей в CTE/подзапросе, потом соединить) либо считай count(DISTINCT …) |
Что забрать с собой
CTE(WITH имя AS (...)) даёт промежуточному результату имя; запрос из «матрёшки подзапросов» превращается в линейный конвейер шагов.CTEможно цепочкой: следующий ссылается на предыдущий — это и есть главная ценность для читаемости.CTEсам по себе не ускоряет запрос; он про структуру кода, а не про производительность.- С PG12: ссылка одна →
CTEвстраивается; ссылок больше одной (или есть запись) → материализуется. Рычаги —AS MATERIALIZED/AS NOT MATERIALIZED. - Разница встраивание/материализация проявляется в плане (
EXPLAIN, модуль 06), а не в результате.
На этом модуль 04 «Запросы по таблицам» завершён: ты умеешь связывать таблицы (JOIN/self-join), сворачивать строки в сводки (GROUP BY/HAVING, DISTINCT ON), задавать вопросы вопросами (подзапросы, EXISTS vs IN) и собирать читаемые конвейеры (CTE). Дальше — модуль 05 «Транзакции, MVCC и конкурентность»: что происходит, когда к этим данным одновременно тянутся несколько сессий.
На том же ретро, уже закрывая ноутбук, Ботыр кивает на лист с граблями:
С этого вопроса модуль 05 и начнётся.