В обычном SQL UNION и UNION ALL отличаются одним: первый убирает дубли, второй — нет. В рекурсивном CTE этот выбор приобретает дополнительный вес — он влияет не только на результат, но и на то, закончится ли рекурсия вообще.
В этом уроке: что меняет UNION в семантике recursive CTE, когда его стоит брать, и почему UNION ALL — практический дефолт.
Напоминание: семантическая разница
UNION ALL— bag-семантика: склейка результатов «как есть», без удаления дубликатов.UNION— set-семантика: после склейки удаляются дубликаты строк. Это дополнительный шаг (sort/hash).
В рекурсивном CTE этот «дополнительный шаг» применяется на каждой итерации — после того как recursive term отработал, PostgreSQL сравнивает новые кортежи с result table и оставляет только уникальные.
После каждого шага recursive term промежуточный результат сравнивается с уже накопленным.
Эта де-дупликация — не косметика. Она меняет, какие кортежи попадают в working table, а значит, какие порождаются на следующем шаге.
Где UNION становится спасением
Помнишь наивный обход графа из урока 4? Без cycle detection он зависал, потому что Москва порождала Дубай, Дубай Москву, и так далее. Если заменить UNION ALL на UNION, дубли убираются — и working table в итоге пустеет.
Тот же граф, но через UNION — он останавливается без cycle detection:
Запрос завершается. Логика: на каждом шаге recursive term возвращает города-соседей, UNION отсеивает уже виденные, working table содержит только новые города. Когда новых нет — фикс-точка достигнута.
Это элегантно, но за это платишь:
- Стоимость де-дупликации на каждом шаге — hash table или сортировка. На больших промежуточных результатах это значительно.
- Теряешь информацию о путях. У тебя есть только «достижимо или нет», но не «какими путями». Если важен путь —
UNIONне подходит. - Семантика «уникальный кортеж» — она про все колонки. Если ты тащишь
(city, depth), дубликат — это совпадение по обоим полям. Сdepthдубликатов почти не будет, иUNIONне спасёт от циклов. С одним толькоcity— спасёт.
Сравни: с depth дубликаты по (city, depth) почти не возникают, и UNION ведёт себя как UNION ALL:
Видишь? (Москва, 0), (Дубай, 1), (Москва, 2), (Дубай, 3), … — все эти кортежи уникальны, потому что depth отличается. UNION от циклов не защитил. Это типичная ошибка: думаешь, что UNION — это «убрать дубли», а на деле он убирает только полные дубли.
Когда UNION ALL — практический дефолт
В большинстве реальных задач recursive CTE используется в одном из двух режимов:
- Обход дерева — циклов в данных нет (или они баг), нужно собрать всё поддерево. Здесь
UNION ALLкорректен и быстрее. - Обход графа с явной cycle detection через
pathмассив — циклов нет, потому что recursive term их сам отсекает.UNION ALLкорректен и быстрее.
В обоих случаях ты знаешь, что дубликатов не будет, и хочешь сохранить производительность.
В нашем дереве сотрудников, например, каждый сотрудник имеет ровно одного менеджера. Recursive term по дереву никогда не выдаст один кортеж дважды — топология не позволяет. Значит, UNION ничего не убрал бы, а потратил бы CPU на проверку.
В иерархии сотрудников UNION ALL семантически идентичен UNION — но дешевле:
Меняй UNION ALL на UNION — результат тот же 16. Но EXPLAIN покажет, что во втором случае добавляется HashAggregate или Unique шаг.
Цена де-дупликации в больших данных
Представим, что мы обходим не граф из 5 городов, а реальную транспортную сеть с тысячами рёбер. Recursive term на каждой итерации может породить десятки тысяч промежуточных кортежей. UNION обязан их все сравнить с уже накопленным result table.
Это даёт квадратичный рост стоимости: с шагом число строк в result растёт, и каждый шаг сверяется со всем накопленным. На больших графах это разница между «работает за 2 секунды» и «работает за 2 часа».
UNION ALL плюс явное <> ALL(path) в recursive term — это та же дедупликация, но локальная: проверяем только в пределах одного пути, не сверяясь со всем result. Линейная стоимость вместо квадратичной.
UNION делает глобальную дедупликацию через весь result. Локальный 'NOT ANY(path)' — только в пределах текущего пути.
Когда UNION всё-таки нужен
UNION оправдан, когда:
- Цель — достижимость, без путей и без счётчиков шагов. Просто «какие узлы достижимы из A» — и тебе всё равно, какими путями.
UNIONрешает задачу одной строкой, безpathмассива. - Граф маленький. На 100 узлов разница в производительности незаметна, а код короче.
- Ты пишешь учебный пример и хочешь показать студентам семантику.
Во всех остальных случаях — UNION ALL плюс явный контроль над тем, какие кортежи попадают в working table.
Практическая рекомендация
- По умолчанию в любом recursive CTE —
UNION ALL. Это договорённость, как «писатьORDER BY idесли важен порядок». - Если есть риск дубликатов или циклов — добавляй
pathмассив и<> ALL(path). UNIONбери только если задача — «множество достижимых узлов» и больше ничего.- На уровне PostgreSQL 14+ есть встроенный
CYCLEсинтаксис — он делает ровно то же, что ручнойpath, но красивее. О нём в уроке 6.
Чек-лист урока
UNION ALLв recursive CTE — дефолт. Bag-семантика, без дополнительной де-дупликации.UNION— set-семантика, де-дупликация на каждой итерации. Может остановить рекурсию в цикличном графе, если кортежи в самом деле повторяются.UNIONне спасает от циклов, если в кортеже есть колонки, делающие записи уникальными (например,depth).- Стоимость
UNIONв рекурсии — квадратичная по числу шагов. На больших данных это убивает производительность. - Правильный паттерн для графов:
UNION ALL+pathмассив +dst <> ALL(path).