open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 05 · 03

Материализация CTE: сдвинувшийся барьер

Postgres 12 превратил старый безусловный оптимизационный барьер CTE в умное встраивание — и дал MATERIALIZED / NOT MATERIALIZED, чтобы форсировать любое поведение, когда планировщик ошибается.

SQL Senior ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Ты обновляешь базу с Postgres 11 на 14, и отчёт, занимавший восемь секунд, теперь занимает сорок миллисекунд — ты не менял ничего, кроме версии. Или наоборот: годами быстрый запрос внезапно регрессирует после обновления. У обоих один корень. Postgres 12 тихо переопределил, что CTE делает с твоим планом. Старая гарантия — «CTE всегда вычисляется один раз, отдельно» — была несущим допущением в куче продакшн-SQL, и когда она исчезла, планы сдвинулись. Знать правило и два ключевых слова, что его переопределяют, — это базовый минимум сеньора.

Барьер и день, когда он сдвинулся

Когда ты пишешь CTE, Postgres реально отделяет его от внешнего запроса или сворачивает всё вместе, давая планировщику оптимизировать свободно? Ответ изменился на одной версионной границе — и ошибиться здесь стоит секунд на каждом запросе. До Postgres 12 каждый CTE был безусловным оптимизационным барьером. Планировщик вычислял каждый CTE ровно один раз, сохранял его полный результат в памяти (материализовал) и затем выполнял внешний запрос против этого сохранённого результата. Он никогда не проталкивал фильтры внешнего запроса вниз в CTE. Это делало CTE предсказуемой, но часто дорогой границей: удобной, когда хочешь вычислить что-то один раз и переиспользовать, губительной, когда внешнему запросу нужен лишь кусочек огромного CTE.

Postgres 12 сменил дефолт на встраивание (inlining): CTE, на который ссылаются один раз, который без побочных эффектов и не рекурсивен, сворачивается во внешний запрос ровно так, будто ты написал его подзапросом — так что предикаты проталкиваются вниз, а индексы задействуются. Если хоть одно условие нарушено — на CTE ссылаются больше одного раза, или он содержит модифицирующий данные оператор (INSERT/UPDATE/DELETE ... RETURNING), или он рекурсивен — Postgres всё ещё материализует его.

-- PG12+: ссылка один раз, без побочных эффектов → встроен.
-- tenant_id = 7 проталкивается в CTE и попадает в индекс.
WITH e AS (SELECT * FROM events)
SELECT * FROM e WHERE tenant_id = 7;

-- Ссылка дважды → материализован (вычислен один раз, переиспользован).
WITH e AS (SELECT * FROM events WHERE created_at > now() - interval '1 day')
SELECT (SELECT count(*) FROM e) AS total,
       (SELECT count(*) FROM e WHERE tenant_id = 7) AS for_seven;

Два ключевых слова, переопределяющие планировщик

Ты не во власти планировщика. Два явных ключевых слова форсируют поведение:

-- Форсировать материализацию: вычислить раз, хотя ссылка один раз.
WITH e AS MATERIALIZED (SELECT * FROM big_expensive_function_call())
SELECT * FROM e WHERE id = 42;

-- Форсировать встраивание: свернуть внутрь, хотя ссылка дважды (соглашаешься на пересчёт).
WITH e AS NOT MATERIALIZED (SELECT * FROM events WHERE tenant_id = 7)
SELECT ... ;

Когда форсировать MATERIALIZED: CTE действительно дорог в вычислении (дорогая функция, тяжёлый агрегат) и ссылается один раз, но встраивание заставит эту дорогую работу выполняться на каждую внешнюю строку или повторяться; или у CTE есть побочные эффекты, которые нужно выполнить ровно один раз; или — реальное спасение — встраивание дало худший план, и ты хочешь вернуть старое поведение барьера, чтобы сломать плохой порядок join. Когда форсировать NOT MATERIALIZED: на CTE ссылаются несколько раз, поэтому Postgres его материализовал, но каждой ссылке нужен лишь отфильтрованный срез, и ты предпочёл бы проталкивание предиката материализации всего.

Ловушка режет в обе стороны. MATERIALIZED возвращает барьер, так что селективный WHERE во внешнем запросе не может протолкнуться в CTE — ты можешь просканировать всю базовую таблицу для построения CTE, а потом отфильтровать. Это ровно тот до-12 «выстрел в ногу». Используй MATERIALIZED осознанно, а не рефлекторно.

Как разглядеть это в EXPLAIN

EXPLAIN говорит, какой путь выбрал планировщик. Материализованный CTE показывает узел CTE Scan, читающий из отдельно вычисленного подплана CTE <name>; встроенный CTE не показывает CTE Scan вовсе — таблицы CTE появляются прямо в основном дереве плана, и ты увидишь внешний предикат, применённый как Index Cond глубоко внутри. Так что диагностика проста: ищи в плане CTE Scan. Есть — материализован; нет — встроен. Глубокий навык чтения этих планов — числа стоимости, оценка-против-факта по строкам, узлы join — относится к разделу 08 и к главе execution-plans трека databases; здесь нужно лишь распознать развилку.

Частая ошибка

Классическая регрессия после обновления: команда полагалась на до-12 барьер как на ручную оптимизацию. Они написали CTE именно чтобы остановить планировщик от плохого выбора — материализация форсировала вменяемый порядок join. После обновления на 12+ тот же CTE встроился, планировщик вернулся к плохому выбору, и запрос регрессировал в 20×. Фикс — одно слово: пометь CTE AS MATERIALIZED, чтобы вернуть старое поведение. Урок в том, что барьер был несущим там, где никто это не документировал — поэтому при обновлении через границу 12 именно те CTE, что тихо работали как подсказки планировщику, и стоит перепроверить через EXPLAIN.

Викторина

На Postgres 14 когда CTE встраивается во внешний запрос по умолчанию?

Викторина

CTE оборачивает всю таблицу, а внешний запрос фильтрует одного арендатора, но EXPLAIN показывает CTE Scan по миллионам строк. Какая однословная правка, вероятно, чинит это на PG12+?

Закончи аналогию

Заполни пропуск: форсирование CTE AS MATERIALIZED возвращает оптимизационный _______, так что предикаты внешнего запроса больше не могут протолкнуться в CTE.

Вспомните перед уходом
  1. 01
    Каким было поведение CTE до 12 и что изменил Postgres 12?
  2. 02
    Когда форсировать MATERIALIZED и какова цена?
  3. 03
    Как по EXPLAIN понять, был CTE встроен или материализован?
Итог

Самый важный факт о CTE и производительности — правило изменилось в Postgres 12. До того каждый CTE был безусловным оптимизационным барьером: вычислен один раз, материализован целиком, без проталкивания предиката — предсказуемо, иногда полезная ручная подсказка, часто без нужды дорого. Postgres 12 перевернул дефолт на встраивание для общего случая: CTE, на который ссылаются ровно один раз, без побочных эффектов и не рекурсивный, сворачивается во внешний запрос как подзапрос, так что фильтры проталкиваются вниз, а индексы задействуются; всё прочее (несколько ссылок, модифицирующие данные CTE, рекурсия) всё ещё материализуется. Планировщик переопределяют два ключевых слова: MATERIALIZED — форсировать барьер «вычислить-один-раз» (для действительно дорогих или дающих побочные эффекты CTE, или чтобы вернуть хороший план, сломанный встраиванием), и NOT MATERIALIZED — форсировать встраивание, чтобы фильтр протолкнулся сквозь то, что иначе было бы барьером. Выстрел в ногу: MATERIALIZED блокирует проталкивание — ровно та до-12 цена — поэтому применяй его осознанно. Читай это в EXPLAIN, глядя на узел CTE Scan: есть — материализован, нет — встроен. Более глубокое ремесло чтения планов живёт в разделе 08 и главе execution-plans трека databases; здесь ты владеешь развилкой и двумя её ключевыми словами. Теперь, когда в выводе EXPLAIN ты видишь CTE Scan по миллионам строк, ты точно знаешь, за каким ключевым словом тянуться — и почему планировщик вообще поставил барьер.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.