Learning Platform
Урок 10.05 · 16 мин
Средний
UNION ALLUNIONDeduplicationRecursive semanticsPerformance

В обычном SQL UNION и UNION ALL отличаются одним: первый убирает дубли, второй — нет. В рекурсивном CTE этот выбор приобретает дополнительный вес — он влияет не только на результат, но и на то, закончится ли рекурсия вообще.

В этом уроке: что меняет UNION в семантике recursive CTE, когда его стоит брать, и почему UNION ALL — практический дефолт.

Напоминание: семантическая разница

  • UNION ALL — bag-семантика: склейка результатов «как есть», без удаления дубликатов.
  • UNION — set-семантика: после склейки удаляются дубликаты строк. Это дополнительный шаг (sort/hash).

В рекурсивном CTE этот «дополнительный шаг» применяется на каждой итерации — после того как recursive term отработал, PostgreSQL сравнивает новые кортежи с result table и оставляет только уникальные.

Цикл итераций: где работает UNION vs UNION ALL

После каждого шага recursive term промежуточный результат сравнивается с уже накопленным.

recursive term порождаетN кортежей
UNION ALLвсе N идут в result и в working
recursive term порождаетN кортежей
UNIONфильтр: только те, которых нет в resultЭто дополнительная стоимость — hash или sort на каждой итерации. Зато working table не растёт от дубликатов.

Эта де-дупликация — не косметика. Она меняет, какие кортежи попадают в working table, а значит, какие порождаются на следующем шаге.

Где UNION становится спасением

Помнишь наивный обход графа из урока 4? Без cycle detection он зависал, потому что Москва порождала Дубай, Дубай Москву, и так далее. Если заменить UNION ALL на UNION, дубли убираются — и working table в итоге пустеет.

Тот же граф, но через UNION — он останавливается без cycle detection:

PostgreSQL

Запрос завершается. Логика: на каждом шаге recursive term возвращает города-соседей, UNION отсеивает уже виденные, working table содержит только новые города. Когда новых нет — фикс-точка достигнута.

Это элегантно, но за это платишь:

  1. Стоимость де-дупликации на каждом шаге — hash table или сортировка. На больших промежуточных результатах это значительно.
  2. Теряешь информацию о путях. У тебя есть только «достижимо или нет», но не «какими путями». Если важен путь — UNION не подходит.
  3. Семантика «уникальный кортеж» — она про все колонки. Если ты тащишь (city, depth), дубликат — это совпадение по обоим полям. С depth дубликатов почти не будет, и UNION не спасёт от циклов. С одним только city — спасёт.

Сравни: с depth дубликаты по (city, depth) почти не возникают, и UNION ведёт себя как UNION ALL:

PostgreSQL

Видишь? (Москва, 0), (Дубай, 1), (Москва, 2), (Дубай, 3), … — все эти кортежи уникальны, потому что depth отличается. UNION от циклов не защитил. Это типичная ошибка: думаешь, что UNION — это «убрать дубли», а на деле он убирает только полные дубли.

Когда UNION ALL — практический дефолт

В большинстве реальных задач recursive CTE используется в одном из двух режимов:

  1. Обход дерева — циклов в данных нет (или они баг), нужно собрать всё поддерево. Здесь UNION ALL корректен и быстрее.
  2. Обход графа с явной cycle detection через path массив — циклов нет, потому что recursive term их сам отсекает. UNION ALL корректен и быстрее.

В обоих случаях ты знаешь, что дубликатов не будет, и хочешь сохранить производительность.

В нашем дереве сотрудников, например, каждый сотрудник имеет ровно одного менеджера. Recursive term по дереву никогда не выдаст один кортеж дважды — топология не позволяет. Значит, UNION ничего не убрал бы, а потратил бы CPU на проверку.

В иерархии сотрудников UNION ALL семантически идентичен UNION — но дешевле:

PostgreSQL

Меняй UNION ALL на UNION — результат тот же 16. Но EXPLAIN покажет, что во втором случае добавляется HashAggregate или Unique шаг.

Цена де-дупликации в больших данных

Представим, что мы обходим не граф из 5 городов, а реальную транспортную сеть с тысячами рёбер. Recursive term на каждой итерации может породить десятки тысяч промежуточных кортежей. UNION обязан их все сравнить с уже накопленным result table.

Это даёт квадратичный рост стоимости: с шагом число строк в result растёт, и каждый шаг сверяется со всем накопленным. На больших графах это разница между «работает за 2 секунды» и «работает за 2 часа».

UNION ALL плюс явное <> ALL(path) в recursive term — это та же дедупликация, но локальная: проверяем только в пределах одного пути, не сверяясь со всем result. Линейная стоимость вместо квадратичной.

Стоимость UNION vs локальная защита от циклов

UNION делает глобальную дедупликацию через весь result. Локальный 'NOT ANY(path)' — только в пределах текущего пути.

UNIONдедуп против всего result
стоимость шагаO(новые × накопленные)
на N шагахO(N²) в худшем случае
UNION ALL + path-checkлокальная проверка пути
стоимость шагаO(новые × длина_пути)
на N шагахO(N × средняя_длина_пути)

Когда UNION всё-таки нужен

UNION оправдан, когда:

  1. Цель — достижимость, без путей и без счётчиков шагов. Просто «какие узлы достижимы из A» — и тебе всё равно, какими путями. UNION решает задачу одной строкой, без path массива.
  2. Граф маленький. На 100 узлов разница в производительности незаметна, а код короче.
  3. Ты пишешь учебный пример и хочешь показать студентам семантику.

Во всех остальных случаях — UNION ALL плюс явный контроль над тем, какие кортежи попадают в working table.

Практическая рекомендация

  • По умолчанию в любом recursive CTE — UNION ALL. Это договорённость, как «писать ORDER BY id если важен порядок».
  • Если есть риск дубликатов или циклов — добавляй path массив и <> ALL(path).
  • UNION бери только если задача — «множество достижимых узлов» и больше ничего.
  • На уровне PostgreSQL 14+ есть встроенный CYCLE синтаксис — он делает ровно то же, что ручной path, но красивее. О нём в уроке 6.
Проверка знанийKnowledge check
Ты обходишь дерево комментариев на форуме (каждый комментарий имеет parent_id). Глубина до 8 уровней. Что лучше использовать — UNION или UNION ALL — и почему?
ОтветAnswer
UNION ALL. Дерево комментариев — это дерево (без циклов): у каждого комментария ровно один parent_id, повторный комментарий в обходе не появится. UNION потратит CPU на бесполезную проверку. Исключение: если в таблице может быть некорректный self-loop (parent_id указывает на сам комментарий) или общий цикл — но это баг данных, который надо чинить ограничением CHECK или триггером, а не заплаткой через UNION. Правильный путь: UNION ALL для производительности, и опционально WHERE depth < 100 как страховка от испорченных данных.
DuckDB и UNION ALL в рекурсивных CTE

Чек-лист урока

  • UNION ALL в recursive CTE — дефолт. Bag-семантика, без дополнительной де-дупликации.
  • UNION — set-семантика, де-дупликация на каждой итерации. Может остановить рекурсию в цикличном графе, если кортежи в самом деле повторяются.
  • UNION не спасает от циклов, если в кортеже есть колонки, делающие записи уникальными (например, depth).
  • Стоимость UNION в рекурсии — квадратичная по числу шагов. На больших данных это убивает производительность.
  • Правильный паттерн для графов: UNION ALL + path массив + dst <> ALL(path).

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Почему UNION ALL — практический дефолт в рекурсивных CTE при обходе деревьев?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6