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

Обход графов в SQL

Рекурсивные CTE обходят деревья, DAG'и и графы — оргструктуры, спецификации изделий, достижимость — накапливая массив пути; но взрыв путей и join на каждом хопе делают SQL неправильным инструментом за пределами нескольких миллионов рёбер.

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

Продуктовая команда выкатывает граф фич: фичи зависят от фич, и им нужно «всё, что сломается, если задепрекейтить фичу X». Это запрос достижимости над edges(src, dst), и рекурсивный CTE решает его в пятнадцать строк — пока граф не дорастает до нескольких миллионов рёбер, и один запрос не начинает занимать девяносто секунд и 8 ГБ work_mem. SQL не был неправ; он упёрся в ту стену, в которую рано или поздно упирается любой граф-на-реляционной-базе. Знать, где эта стена — и как её отодвинуть, и когда перестать толкать — это сеньорский навык, о котором этот урок.

Три формы: дерево, DAG, граф

Прежде чем писать рекурсию, спроси себя: данные образуют дерево, DAG или общий граф? Ответ определяет, безопасен ли запрос по природе, нужен ли шаг дедупликации или он зависнет на реальных данных без явного тормоза. Один и тот же рекурсивный двигатель справляется с тремя структурами нарастающей опасности, все над edges(src, dst) (или manager_id, parent_id):

  • дерево — у каждого узла один родитель (оргструктура, categories). Обход всегда завершается; риска циклов нет.
  • DAG — направленный, без циклов, но у узла может быть много родителей (спецификация изделия: болт, используемый десятью сборками). Завершается, но один и тот же узел достигается многими путями, так что можешь посетить его многократно.
  • общий граф — направленный, циклы возможны (графы зависимостей, подписки в соцсетях). Циклы значат, что ты обязан нести защиту от циклов, иначе обход не кончится никогда, ровно как в уроке 04.

Рабочая лошадка для всех трёх — накопление пути: неси массив посещённых узлов, и для обнаружения циклов, и для отчёта о реальном маршруте. Здесь «все детали внутри сборки 1, с путём, ведущим к каждой» над спецификацией изделия:

WITH RECURSIVE bom AS (
  -- якорь: прямые потомки сборки 1
  SELECT dst AS part, ARRAY[1, dst] AS path, 1 AS depth
  FROM edges
  WHERE src = 1

  UNION ALL

  -- рекурсивный терм: спускаемся, дописывая каждый узел в путь
  SELECT e.dst, b.path || e.dst, b.depth + 1
  FROM edges e
  JOIN bom b ON e.src = b.part
  WHERE NOT e.dst = ANY(b.path)        -- защита от цикла/повтора
    AND b.depth < 30                   -- ремень безопасности по глубине
)
SELECT part, path, depth FROM bom ORDER BY depth, part;

Достижимость, почти-кратчайшие пути и что SQL может выразить

Этот паттерн чисто отвечает на типичные продакшн-вопросы. Достижимость — «достижим ли B из A?» — это EXISTS по обходу. Транзитивное замыкание — «все узлы, достижимые из A» — это SELECT DISTINCT по обходу. Можно даже приблизить кратчайший путь, неся depth (число хопов) и беря минимум на пункт назначения, ведь цикл по рабочей таблице расширяется в ширину по уровням. Чего SQL-рекурсия не даёт дёшево — это настоящий взвешенный кратчайший путь (Дейкстра/A*): выразить его можно, но у движка нет очереди с приоритетом, так что он исследует куда больше путей, чем специализированный алгоритм, и цена жестока на реальных графах.

-- Расстояние в минимум хопов от узла 1 до каждого достижимого узла
WITH RECURSIVE walk AS (
  SELECT dst, 1 AS hops, ARRAY[1, dst] AS path FROM edges WHERE src = 1
  UNION ALL
  SELECT e.dst, w.hops + 1, w.path || e.dst
  FROM edges e JOIN walk w ON e.src = w.dst
  WHERE NOT e.dst = ANY(w.path)
)
SELECT dst, min(hops) AS shortest_hops
FROM walk GROUP BY dst ORDER BY dst;

Стена: взрыв путей и join на каждом хопе

Теперь сеньорская часть — почему это тормозит и какие числа. Две стоимости складываются. Первая: каждая итерация — это join всей рабочей таблицы против edges; без индекса на edges(src) каждый хоп — это sequential scan, и 6-хоповый обход по таблице на 10M рёбер — это шесть полных сканов. Всегда индексируй столбец join (src, или manager_id/parent_id). Вторая, и хуже, — взрыв путей: в плотном графе число различных путей растёт комбинаторно с глубиной, так что рабочая таблица может раздуться до куда большего числа строк, чем есть узлов — ты перечисляешь маршруты, а не узлы. Граф с парой тысяч узлов, но высоким ветвлением может породить миллионы промежуточных строк-путей, пробить work_mem (по умолчанию 4 МБ), слить на диск и превратить концептуально маленький обход в многогигабайтную сортировку.

Меры, по порядку: индексируй столбец join; агрессивно ограничивай глубину (большинству реальных вопросов нужно 3–6 хопов, не безграничность); если нужны лишь достижимые узлы, дедуплицируй рано через UNION (дедуп за проход) или перестрой так, чтобы отслеживать посещённые узлы, а не полные пути, чтобы рабочая таблица оставалась ограничена числом узлов, а не путей. С этим рекурсивные CTE комфортно тянут оргструктуры, деревья категорий и спецификации изделий примерно до нескольких миллионов рёбер при ограниченной глубине. Вместе эти три меры переносят узкое место с памяти и диска обратно на сетевую задержку — туда, где оно и должно быть для хорошо проиндексированного иерархического запроса. Пропусти хоть одну из них на плотном графе, и стена вернётся очень быстро.

За этим — миллионы рёбер с глубоким или безграничным обходом, частые запросы кратчайшего пути или центральности, или граф-форма как основная нагрузка — ты перерос инструмент. Реляционный движок соединяет множества; у него нет нативного индекса смежности, нет граф-осведомлённых операторов. Тянись к графовой базе (Neo4j и подобные) или к расширениям Postgres pgRouting/Apache AGE, добавляющим настоящие графовые алгоритмы. Продакшн-правило: рекурсивные CTE идеальны для эпизодических запросов иерархии и достижимости над умеренными графами, уже живущими в твоей реляционной схеме; они — неправильный дефолт для граф-центричного продукта.

Почему это работает

Почему реляционный движок мучается с графами, когда данные прекрасно ложатся в таблицу? Потому что стоимостная модель построена для операций над множествами, а не для гонки по указателям. Пройти одно ребро — это join: Postgres должен найти совпадающие строки, что дёшево однократно, но случается на каждом хопе для каждой строки фронтира, и планировщик не видит сквозь итерации, чтобы оптимизировать обход как целое. Нативный графовый движок хранит смежность как прямой указатель от каждого узла к соседям, так что хоп — это O(1) разыменование, а не реляционный поиск, и алгоритмы обхода работают в движке, а не симулируются самосоединяющимся циклом. То, что данные хранимы как строки, не делает паттерн доступа реляционным.

Викторина

В CTE обхода графа накопленный массив пути служит двум целям разом. Каким двум?

Викторина

Рекурсивный обход плотного графа взрывается до миллионов строк рабочей таблицы, хотя у графа лишь тысячи узлов. Что происходит и первая мера?

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

Заполни пропуск: нативный графовый движок проходит ребро как O(1) разыменование указателя, тогда как рекурсивный CTE проходит каждое ребро как _______ на каждом хопе, поэтому глубокие обходы больших графов медленны в SQL.

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

Рекурсивный двигатель из урока 04 превращает Postgres в достойный — в пределах ограничений — инструмент обхода графов над edges(src, dst) (или manager_id/parent_id). Он справляется с тремя формами: деревьями (один родитель, всегда завершаются), DAG’ами вроде спецификаций изделий (без циклов, но много путей к узлу) и общими графами (циклы возможны, так что защита от циклов обязательна). Каноничный паттерн — накопление пути: неси массив посещённых узлов, который одновременно защищает от циклов (WHERE NOT node = ANY(path)) и записывает маршрут к каждому узлу. С ним отвечаешь на достижимость (EXISTS), транзитивное замыкание (SELECT DISTINCT) и расстояние в минимум хопов (min(hops)), ведь цикл расширяется в ширину по уровням; настоящий взвешенный кратчайший путь выразим, но дорог, потому что нет очереди с приоритетом. Стена реальна и имеет числа: каждый хоп — это join (так что индексируй столбец join или каждый хоп — полный скан), а взрыв путей в плотных графах растит строки рабочей таблицы комбинаторно, пробивая дефолтные 4 МБ work_mem и сливая на диск. Меры — индексация, ограничение глубины (реальным вопросам нужно 3–6 хопов) и отслеживание посещённых узлов вместо полных путей, чтобы множество оставалось ограничено числом узлов. Рекурсивные CTE комфортно служат эпизодическим запросам иерархии и достижимости над умеренными графами, уже живущими реляционно; для миллионов рёбер, частой работы с кратчайшим путём или граф-центричного продукта переходи на графовую базу или Postgres pgRouting/Apache AGE. Это суждение — отодвинуть стену, а затем знать, когда перестать толкать — сеньорская отдача раздела, и оно замыкает дугу от именования подзапроса до обхода графа в чистом SQL. Теперь, когда графовый запрос взорвёт work_mem, ты узнаешь взрыв путей раньше, чем потянешься за инстанцией побольше — и точно будешь знать, когда пора остановить SQL и передать нагрузку графовому движку.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.