Рекурсивные CTE: якорь, итерация, стоп
WITH RECURSIVE — это якорный терм UNION рекурсивный терм, перечитывающий CTE; Postgres гоняет это итеративным циклом по рабочей таблице, пока рекурсивный терм не выдаст ничего, так что завершение и защита от циклов на тебе.
Кто-то просит: «дай всех под Ada в оргструктуре, на любой глубине». Через join это не напишешь — глубина неизвестна, а число JOIN в SQL фиксируется на стадии parse. Честными вариантами раньше были рекурсивная функция в приложении или хранимая процедура с циклом. Тут ты вспоминаешь WITH RECURSIVE, пишешь двенадцать строк, и Postgres обходит всё поддерево за тебя. Но в первый же раз на таблице с циклом он не возвращается — крутится, пока не забьёт диск. Рекурсия в SQL — это настоящий цикл, а циклам нужен тормоз.
Анатомия: якорь UNION рекурсивный терм
У рекурсивного CTE фиксированная форма. Ключевое слово — WITH RECURSIVE, а тело CTE — два запроса, объединённых через UNION или UNION ALL:
- якорный терм — нерекурсивный запрос, дающий стартовые строки (базовый случай);
- рекурсивный терм — запрос, ссылающийся на CTE по его собственному имени, выдающий следующие строки из предыдущих.
Вот «все подчинённые под Ada, с глубиной» над employees(id, manager_id, name):
WITH RECURSIVE subtree AS (
-- якорь: старт с Ada
SELECT id, manager_id, name, 1 AS depth
FROM employees
WHERE name = 'Ada'
UNION ALL
-- рекурсивный терм: на уровень вниз за проход
SELECT e.id, e.manager_id, e.name, s.depth + 1
FROM employees e
JOIN subtree s ON e.manager_id = s.id
)
SELECT id, name, depth FROM subtree ORDER BY depth, id;Рекурсивный терм соединяет employees с subtree — CTE, ссылающимся сам на себя — это та самая единственная самоссылка, про которую урок 01 говорил, что она легальна только здесь.
Модель выполнения через рабочую таблицу
Postgres вычисляет это не математической рекурсией и не вызовами функций. Он гоняет итеративный цикл по рабочей таблице, и понимание этой модели — разница между написанием рекурсивного SQL по ритуалу и его отладкой под нагрузкой:
- Вычислить якорный терм. Положить его строки в результат и в рабочую таблицу.
- Повторять: вычислить рекурсивный терм, но каждая самоссылка на CTE читает только рабочую таблицу (строки предыдущей итерации, не весь накопленный результат). Дописать новые строки в результат; они становятся новой рабочей таблицей.
- Стоп, когда итерация даёт ноль новых строк.
Вместе эти три шага означают: каждый проход видит только свежие строки-фронтир, никогда — весь накапливающийся результат. Именно поэтому любое нужное состояние (глубину, путь) надо нести в самой строке. Без условия завершения из шага 3 бесконечный источник новых строк превращается в бесконечный цикл.
Это правило завершения — вся игра. Цикл кончается, когда рекурсивный терм не выдаёт ничего — так что если он всегда что-то выдаёт, цикл не кончается никогда.
Завершение, циклы и тормоз
В чистом дереве (у каждого узла один родитель, петель нет) рекурсия завершается естественно: рано или поздно дойдёшь до листьев, и рекурсивный терм не найдёт детей. Опасность — цикл: строка, чья цепочка родителей в итоге указывает на саму себя, обычное дело в графовых данных edges(src, dst) или при испорченном manager_id. С UNION ALL и циклом цикл бесконечно перепосещает одни и те же строки, и запрос не возвращается; он крутится, пока не исчерпает work_mem, не сольёт на диск и в итоге не упадёт или не зависнет. Нужен явный тормоз. Три варианта:
-- Вариант 1 (PG14+): клауза CYCLE делает это декларативно.
WITH RECURSIVE walk AS (
SELECT src, dst FROM edges WHERE src = 1
UNION ALL
SELECT e.src, e.dst FROM edges e JOIN walk w ON e.src = w.dst
)
CYCLE dst SET is_cycle USING path -- стоп перепосещению dst, уже бывшего на пути
SELECT * FROM walk WHERE NOT is_cycle;
-- Вариант 2 (любая версия): неси массив посещённых и исключай уже бывшие на пути узлы.
WITH RECURSIVE walk AS (
SELECT src, dst, ARRAY[src] AS path FROM edges WHERE src = 1
UNION ALL
SELECT e.src, e.dst, w.path || e.src
FROM edges e JOIN walk w ON e.src = w.dst
WHERE NOT e.src = ANY(w.path) -- не входи повторно в посещённый узел
)
SELECT * FROM walk;
-- Вариант 3 (жёсткий предел): неси глубину и ограничь её.
... WHERE s.depth < 50UNION (не UNION ALL) также дедуплицирует каждый проход против накопленного результата, что останавливает простые повторы — но он медленнее и ловит не все графовые циклы, поэтому на реальных графовых данных предпочитай явную клаузу CYCLE (Postgres 14+) или массив посещённых. Предел глубины — это ремень безопасности, который добавляешь в любом случае: даже с защитой от циклов неограниченный обход огромного графа может вернуть миллионы строк, так что ограничь глубину тем, что реально нужно твоему сценарию.
▸Граничные случаи
Тонкий подвох: рекурсивный терм видит только строки предыдущей итерации, не весь результат пока что. Поэтому нельзя ссылаться на агрегат по всему накапливающемуся CTE внутри рекурсивного терма, и поэтому нарастающие итоги сквозь рекурсию нужно нести вперёд построчно (напр. s.depth + 1 или w.path || e.src), а не вычислять, оглядываясь на всё. Если ловишь себя на желании «увидеть все собранные пока строки» посреди рекурсии — ты упёрся в границу модели рабочей таблицы; перестрой так, чтобы нести нужное состояние в самой строке.
В WITH RECURSIVE из чего на самом деле читает каждый проход рекурсивного терма, когда ссылается на CTE по имени?
Рекурсивный CTE над edges(src, dst) с UNION ALL зависает и не возвращается на реальных графовых данных. Наиболее вероятная причина и фикс?
Расставь, как Postgres выполняет рекурсивный CTE:
- 1 Вычислить якорный терм; положить его строки в результат и рабочую таблицу
- 2 Вычислить рекурсивный терм только по текущей рабочей таблице
- 3 Дописать новые строки в результат; они становятся новой рабочей таблицей
- 4 Повторить рекурсивный терм по обновлённой рабочей таблице
- 5 Остановиться, когда итерация даёт ноль новых строк
- 01Каковы две части рекурсивного CTE и как они объединяются?
- 02Опиши модель выполнения через рабочую таблицу и правило завершения.
- 03Почему рекурсивный CTE может зависнуть и какие способы это предотвратить?
WITH RECURSIVE наконец позволяет чистому SQL обходить структуры неизвестной глубины — оргструктуры, деревья категорий, графы — которые фиксированные JOIN выразить не могут. Его анатомия — два запроса, объединённых через UNION/UNION ALL: якорный терм, засевающий базовые строки, и рекурсивный терм, ссылающийся на CTE по его имени, чтобы вывести следующие строки из предыдущих. Postgres выполняет это не математической рекурсией, а итеративным циклом по рабочей таблице: вычислить якорь в результат и рабочую таблицу, затем повторять рекурсивный терм — каждый проход читает только строки предыдущей итерации — дописывая и заменяя рабочую таблицу, пока проход не даст ноль новых строк, что и есть неявное условие завершения. Это правило же и опасность: на цикле рекурсивный терм не перестаёт выдавать строки, и запрос крутится, пока не исчерпает память. Тормоза — явная клауза CYCLE (Postgres 14+), несомый массив посещённых с WHERE NOT node = ANY(path), UNION для дедупликации простых повторов и предел глубины как ремень безопасности, который добавляешь в любом случае. Помни границу модели: рекурсивный терм видит только прошлый проход, никогда — весь накапливающийся результат, так что неси вперёд нужное состояние в строке. Дальше урок 05 направляет этот двигатель на реальные графы — пути, DAG’и и где SQL-рекурсия перестаёт быть правильным инструментом. Теперь, когда под нагрузкой зависнет рекурсивный CTE, ты первым делом проверишь модель рабочей таблицы: есть ли в данных цикл и стоит ли тормоз?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.