递归 cte 必须使用 union all,因语法强制且去重会导致收敛失效、性能下降和语义丢失;去重应在最终查询处理,如 distinct 或 row_number();防环应通过路径记录或 cycle 语法实现。

在 SQL 递归 CTE(Common Table Expression)中,必须使用 UNION ALL,不能用 UNION。这不是性能取舍问题,而是语法强制要求——绝大多数主流数据库(如 PostgreSQL、SQL Server、Oracle、SQLite、MySQL 8.0+)明确禁止在递归 CTE 的递归成员中使用 UNION(即带去重的 UNION),只允许 UNION ALL。
为什么递归 CTE 要求 UNION ALL?
递归 CTE 的执行逻辑是“迭代展开”:先算锚点(anchor),再用上一轮结果驱动下一轮递归,直到无新行产生。如果中间强行去重(UNION),会导致:
- 无法判断递归是否收敛:去重可能意外合并不同层级或不同路径的相同值,使终止条件失效,引发无限循环或错误结果;
- 严重性能损耗:每次迭代都需全局排序 + 去重,时间复杂度从 O(n) 升至 O(n log n),且无法利用索引加速;
- 语义不明确:CTE 递归本意是按层级逐层生成数据,重复值常有业务含义(例如同一节点被多路径访问),盲目去重会丢失上下文。
如果确实需要去重,该怎么做?
去重不是在递归过程中做,而应在最终查询结果上处理。常见安全做法有:
- 在外层 SELECT 中用 DISTINCT 或 GROUP BY:适用于去重维度明确(如只要唯一 ID);
- 用 ROW_NUMBER() 窗口函数对每组逻辑去重(如按 id 分组取 level 最小的一条);
- 若需层级内去重,可在递归 CTE 内部加 WHERE 子句限制路径(如排除已访问过的 parent_id),但这属于业务逻辑控制,不是靠 UNION 实现。
UNION ALL 在递归中真的一点不“去重”吗?
它不做自动去重,但你可以主动控制重复。例如树形结构中子节点重复出现,往往说明存在环或冗余路径。这时应:
- 在锚点或递归部分加入路径记录(如 ARRAY 或字符串拼接 path),配合 CONTAINS 或 @> 判断是否成环;
- 利用 MAXRECURSION(SQL Server)或 SEARCH / CYCLE(PostgreSQL)等语法显式检测并截断循环;
- 把“去重”转化为“防环”和“剪枝”,比事后去重更高效、更可靠。
不复杂但容易忽略:递归 CTE 的设计重点从来不是选 UNION 还是 UNION ALL,而是如何定义锚点、如何安全扩展、如何识别终止与环路。把去重逻辑塞进递归体,既违法又危险。











