当我们处理组织架构、分类层级或物料清单这类具有自引用关系的数据时,递归查询几乎是唯一高效的手段。SQL标准中定义了递归公共表表达式的语法,即WITH RECURSIVE,但不同数据库在实现上存在细微却影响使用的差别,例如关键字是否强制、递归终止条件的行为、以及默认的递归深度限制。如果不了解这些差异,同一段递归SQL在迁移数据库时可能直接报错或者出现结果异常。本文结合PostgreSQL、MySQL和SQL Server三个主流数据库的实际表现,梳理递归CTE的语法差异与限制,并给出可执行的示例。

递归CTE的基本语法与标准要求
递归公共表表达式遵循SQL标准中的WITH RECURSIVE语法,其基本结构分为两部分:锚定成员(anchor member)和递归成员(recursive member)。锚定成员是递归的起点,通常是一个简单的SELECT语句;递归成员则引用CTE自身,并基于上一次递归产生的结果继续运算。两部分之间用UNION ALL连接,最终查询可以引用这个CTE获取完整结果集。
标准要求中,RECURSIVE关键字紧跟在WITH之后,用于声明该CTE是递归的。列名可以在CTE名称后面的括号中显式声明,如果省略,则由第一个SELECT语句的列名决定。递归终止的条件并非显式写出,而是隐含在递归成员不再产生新行这一事实上。也就是说,当某一次迭代的SELECT返回空结果集时,递归自动停止。这种隐式终止机制要求递归成员必须能够逐步缩减数据集,否则会导致无限递归。
以下是一个标准SQL递归CTE的示例,生成从1到10的数字序列:
WITH RECURSIVE numbers(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT * FROM numbers;
注意上面的递归成员中使用了WHERE n < 10来限制递增长度,当n达到10时不再产生新行,递归停止。这个例子看似简单,但涵盖了递归CTE的核心要素:锚定成员、递归成员、引用自身以及终止条件。如果写成WHERE n <= 10,当n=10时n+1=11,下一次递归尝试会得到11,但条件判断时已经返回空,所以仍然会多生成一次递归尝试,但结果不包含11,实际是否多一次迭代取决于数据库优化,但逻辑上正确。
不同数据库的语法差异
PostgreSQL:完全遵循标准
PostgreSQL对递归CTE的支持非常完整,严格遵循SQL标准。在语法上必须写出WITH RECURSIVE,如果省略RECURSIVE,PostgreSQL会将其视为普通CTE,递归成员中引用自身会导致错误。PostgreSQL还允许在递归成员中使用一些高级特性,比如在递归部分使用UNION(而不是UNION ALL)来去重,但官方文档警告这可能导致无限循环,因为去重会破坏递归的单调性。此外,PostgreSQL的递归项不能包含GROUP BY、HAVING、聚合函数、窗口函数或DISTINCT,这些限制与SQL标准一致。
一个典型的PostgreSQL递归查询示例,展示员工上下级关系:
WITH RECURSIVE employee_tree(id, name, manager_id, level) AS (
SELECT id, name, manager_id, 1
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, et.level + 1
FROM employees e
INNER JOIN employee_tree et ON e.manager_id = et.id
)
SELECT * FROM employee_tree ORDER BY level, id;
PostgreSQL默认没有递归深度限制,但可以通过参数max_recursive_iterations(从14版本开始引入)或使用语句级参数SET LOCAL max_recursive_iterations = N来限制递归次数。在旧版本中,只能依赖操作系统资源或由用户自行在递归成员中加入计数器来中止。这一点在迁移到其他数据库时需要特别留意。
MySQL:从8.0开始支持,但存在默认深度限制
MySQL从8.0版本开始支持递归CTE,语法上与PostgreSQL几乎一致,同样使用WITH RECURSIVE。但是MySQL对递归CTE有一项重要的内置限制:默认最大递归深度为1000,由系统变量cte_max_recursion_depth控制。当递归迭代次数超过1000时,MySQL会抛出错误Recursive query aborted after 1001 iterations。这个限制是为了防止失控的递归耗尽资源,但在处理深层数据(如超过1000层的组织架构)时可能造成困扰,用户可以通过SET SESSION cte_max_recursion_depth = 10000;来提高限制。
MySQL的递归成员同样不允许使用聚合函数、GROUP BY、ORDER BY、DISTINCT、窗口函数,而且递归成员中只能引用CTE自身一次。一个容易被忽略的差异是:MySQL要求递归CTE的锚定成员和递归成员的列数和数据类型必须兼容。如果锚定成员返回VARCHAR,递归成员返回INT,可能导致隐式类型转换或错误。因此建议在CTE名称后的括号中显式声明列名和类型(但MySQL不支持动态类型声明,只能依靠推断),这一点与PostgreSQL类似,但验证机制略有不同。
下面是一个MySQL递归查询示例,用于展开嵌套的评论:
SET SESSION cte_max_recursion_depth = 5000;
WITH RECURSIVE comment_tree(id, parent_id, content, depth) AS (
SELECT id, parent_id, content, 0
FROM comments
WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.parent_id, c.content, ct.depth + 1
FROM comments c
JOIN comment_tree ct ON c.parent_id = ct.id
)
SELECT * FROM comment_tree ORDER BY depth, id;
注意在MySQL中查询大量层级时,需要先调整cte_max_recursion_depth,否则可能半途报错。这一行为与PostgreSQL的无限递归默认值完全不同,是迁移时的一个常见坑。
SQL Server:没有RECURSIVE关键字,强制使用UNION ALL
SQL Server的递归CTE语法与标准SQL存在显著差异:它不使用RECURSIVE关键字,而是直接使用WITH cte_name AS (...),内部通过UNION ALL连接锚定成员和递归成员来识别递归。如果使用UNION而不是UNION ALL,SQL Server会报错,因为递归CTE必须使用UNION ALL。这一设计使得从SQL Server迁移到PostgreSQL或MySQL时,需要手动添加RECURSIVE关键字,反之则需要删除。
SQL Server对递归CTE的限制更加严格:递归成员中不能使用LEFT JOIN、RIGHT JOIN、FULL JOIN,不能包含GROUP BY、HAVING、DISTINCT、TOP、聚合函数、子查询、EXCEPT和INTERSECT。此外,递归成员中只能引用CTE自身一次。这些限制在其他数据库中有些也存在,但SQL Server的文档明确列出并执行。
SQL Server还提供了查询提示OPTION (MAXRECURSION n)来控制递归深度,其中n可以是0到32767之间的整数,0表示不限制(但实际受服务器资源限制)。默认递归深度为100。如果没有指定MAXRECURSION且递归超过100层,查询会报错。这一点与MySQL的1000默认值不同,需要开发者根据数据深度调整。下面是一个生成斐波那契数列的递归CTE示例(仅作演示,实际可能效率不高):
WITH fib(n, fib_n, fib_n_minus_1) AS (
SELECT 1, 1, 0
UNION ALL
SELECT n + 1, fib_n + fib_n_minus_1, fib_n
FROM fib
WHERE n < 20
)
SELECT n, fib_n FROM fib OPTION (MAXRECURSION 30);
注意SQL Server中OPTION子句要紧跟查询,表示最大递归次数为30。如果递归深度超过30但仍小于100,也会因为OPTION的限制而停止并报错,这是一个显式的保护机制。
递归CTE的关键限制与常见问题
无论使用哪种数据库,递归CTE都存在一些共通的限制,理解这些限制有助于编写健壮的查询。第一,递归成员中不允许使用聚合函数和DISTINCT,这是因为聚合会破坏递归的迭代语义,可能导致结果集无法收敛。如果需要基于聚合结果递归,通常需要改用存储过程或基于窗口函数的替代方案。第二,递归成员必须能够逐步减少数据集,否则会陷入无限循环。例如常见的错误是在递归成员中没有正确连接父子关系,导致每一行都匹配到所有子行,递归永远不会终止。
另一个常见问题是循环数据。如果表中存在环,例如员工A的经理是B,B的经理又是A,递归查询将无限迭代,直到触发数据库的深度限制或资源耗尽。为了避免这种情况,可以在递归成员中加入路径追踪字段,例如使用ARRAY(PostgreSQL)或字符串拼接(MySQL、SQL Server)记录已经访问过的ID,并在WHERE子句中排除这些ID。下面是一个PostgreSQL防止循环的示例:
WITH RECURSIVE tree(id, parent_id, path) AS (
SELECT id, parent_id, ARRAY[id] FROM nodes WHERE parent_id IS NULL
UNION ALL
SELECT n.id, n.parent_id, t.path || n.id
FROM nodes n
JOIN tree t ON n.parent_id = t.id
WHERE NOT n.id = ANY(t.path)
)
SELECT * FROM tree;
需要注意的是,不同数据库的数组或路径表示方法不同,MySQL可以使用CONCAT(t.path, ',', n.id)配合FIND_IN_SET,SQL Server则需要用CAST(n.id AS VARCHAR(MAX))拼接,但是这些方案都会导致递归成员变得复杂且性能下降,因此在设计表结构时应尽量避免产生循环数据。
性能方面,递归CTE通常通过迭代执行,每次迭代都是一次独立的表访问,因此不适合处理超大层级或宽表。如果递归深度很大,建议评估是否可以使用其他方法,例如邻接表配合闭包表、或者使用图数据库。在必须使用递归CTE时,确保递归成员中的连接列上有索引,否则每层迭代都可能进行全表扫描,导致性能急剧下降。
总结
递归CTE是处理层级数据的强大工具,但不同数据库的语法和限制差异可能给跨平台开发带来麻烦。PostgreSQL最贴近标准且默认无深度限制,MySQL需要警惕cte_max_recursion_depth默认值1000,SQL Server则完全省略RECURSIVE关键字并依赖MAXRECURSION提示。递归成员中的操作限制大体相似,但SQL Server更为严格。编写递归查询时,应仔细考虑终止条件、循环数据防护以及深度控制,同时关注性能影响。理解这些差异,能够帮助你在数据库选型和迁移时做出更合理的决策。
SQL CTE递归查询WITH RECURSIVE修改时间:2026-09-28 08:54:05