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

Рекурсивные CTE: якорь, итерация, стоп

WITH RECURSIVE — это якорный терм UNION рекурсивный терм, перечитывающий CTE; Postgres гоняет это итеративным циклом по рабочей таблице, пока рекурсивный терм не выдаст ничего, так что завершение и защита от циклов на тебе.

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

Кто-то просит: «дай всех под 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 по ритуалу и его отладкой под нагрузкой:

  1. Вычислить якорный терм. Положить его строки в результат и в рабочую таблицу.
  2. Повторять: вычислить рекурсивный терм, но каждая самоссылка на CTE читает только рабочую таблицу (строки предыдущей итерации, не весь накопленный результат). Дописать новые строки в результат; они становятся новой рабочей таблицей.
  3. Стоп, когда итерация даёт ноль новых строк.

Вместе эти три шага означают: каждый проход видит только свежие строки-фронтир, никогда — весь накапливающийся результат. Именно поэтому любое нужное состояние (глубину, путь) надо нести в самой строке. Без условия завершения из шага 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 < 50

UNION (не 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. 1 Вычислить якорный терм; положить его строки в результат и рабочую таблицу
  2. 2 Вычислить рекурсивный терм только по текущей рабочей таблице
  3. 3 Дописать новые строки в результат; они становятся новой рабочей таблицей
  4. 4 Повторить рекурсивный терм по обновлённой рабочей таблице
  5. 5 Остановиться, когда итерация даёт ноль новых строк
Вспомните перед уходом
  1. 01
    Каковы две части рекурсивного CTE и как они объединяются?
  2. 02
    Опиши модель выполнения через рабочую таблицу и правило завершения.
  3. 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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.