最近技术社群里又开始刷屏式地转发各种连接报错,我在多个数据库交流群里看到不少人在问一个问题:递归CTE和HAVING到底能不能一起用?为什么会“连接不上”?这个问题看起来很奇怪,因为递归CTE是SQL标准里的高级功能,HAVING是聚合过滤的必备子句,两者似乎井水不犯河水。但实际一写才发现,这俩一旦凑到一起,报错、返回错数据、性能翻车的情况比比皆是。
我最初遇到这个问题,是做一个组织架构的层级报表。需求本身很简单:把公司所有部门按树形结构拉出来,然后只保留那些“直接下属部门数量超过3个”的节点。我第一反应就是用递归CTE遍历部门树,然后HAVING过滤。结果跑了半天,要么报语法错误,要么查出结果和预期完全不符。后来花了一整天排查,才把递归CTE和HAVING的执行机制彻底弄明白。
这篇东西没有基础概念讲解,直接讲实战。我会把递归CTE里HAVING为什么会失效、什么时候能生效、什么时候即使能生效也不该用,全部拆开讲清楚。文章涉及的所有SQL,我默认以PostgreSQL 13+为运行环境,MySQL 8、SQL Server、Oracle的差异会在必要时单独标注。
1. 这俩看着像两条平行线,但合在一起写就翻车
1.1 单独看都不陌生,合起来却经常写错
递归CTE(Recursive Common Table Expression)核心功能是遍历有层级关系的数据,比较典型的使用场景是组织架构、商品分类树、BOM物料清单、评论楼中楼。HAVING子句解决的核心问题是“对分组聚合后的结果做过滤”,典型场景是统计每个分类的商品数量后,只保留数量超过指定阈值的分类。
单独用其中一个,绝大多数开发都不会出问题。但把递归CTE和HAVING组合起来写的时候,新手和老手都会踩同一个坑:把HAVING直接写进递归成员(递归部分)里。比如下面这段写法,我相信不少人都试过:
WITH RECURSIVE dept_tree AS ( SELECT id, parent_id, name, 1 AS depth FROM department WHERE parent_id IS NULL UNION ALL SELECT d.id, d.parent_id, d.name, dt.depth + 1 FROM department d JOIN dept_tree dt ON d.parent_id = dt.id HAVING COUNT(d.id) > 3 -- 这行几乎一定会报错 ) SELECT * FROM dept_tree;我在几个数据库上实测过这个写法:PostgreSQL会直接报出语法错误,MySQL 8也会报错,SQL Server会提示“HAVING在该上下文中无效”。这不是某个数据库的实现缺陷,而是SQL标准里固有的规则。要理解为什么,得先搞清楚递归CTE执行时到底发生了什么。
1.2 什么样的业务场景会让“递归 + 聚合过滤”同时出现
先别急着关心语法能不能过,先确认一个更根本的问题:业务上到底存不存在“非要用递归CTE去聚合过滤”的场景。
从我实际做过和见过的项目来看,这类需求比想象中常见得多。典型的两类:
第一类是层级路由过滤。比如商品类目表是树形结构,每个类目下面挂了几百个商品。需求是“找出所有‘挂载商品数大于500’的二、三级类目路径”。这个场景需要在展开整棵类目树的过程中,对每个类目子节点统计商品数,再把这个数量作为决定是否继续深入遍历的条件。
第二类是树形节点指标裁剪。组织架构、权限树、设备拓扑经常会遇到“找出一棵树上所有满足某聚合条件的子树路径”,比如“日均访问量超过一万的二级部门分支”。这里既要递归下钻拿到完整路径,又要在每一层做聚合判断。聚合对象不是全表分组,而是递归过程中每个节点及其子孙节点的集合。
这两类需求,网上能找到的默认答案大多是用“临时表 + 循环”或“程序里递归”去做。在SQL里直接硬写递归CTE配合HAVING的案例确实少。但少不代表不能做,关键是要搞清楚正确的组合顺序。
2. 递归CTE的执行真相:为什么不能把HAVING塞进递归成员里
2.1 递归CTE分四步走,每一步都有自己的规则
很多人的误区在于把递归CTE当成一段“自动循环执行的普通SQL”。实际上,数据库处理递归CTE的方式,和普通子查询完全不同。以PostgreSQL为例,一个标准的递归CTE执行过程是这样的:
第一步,执行锚点成员(ANCHOR MEMBER)。也就是UNION ALL之前,不在FROM里引用CTE自身的那个查询。这个查询产出的行集称为“工作表”(working table),这是递归的第一轮结果。
第二步,执行递归成员(RECURSIVE MEMBER)。这个成员在FROM里引用了CTE自身。数据库会把当前的工作表当作输入,和递归成员里的其他表做JOIN,产出一批新行,这批新行构成“下一轮的工作表”。
第三步,数据库把新产出的行UNION ALL进最终结果集,然后重复第二步。只要每一轮还能产出新行,循环就继续。
第四步,当某次执行递归成员后,没有产出任何新行,递归终止,数据库返回累积的完整结果集。
这四步每一步之间是独立执行的,递归成员里写的东西,每一轮都会被重新执行一次。这里就引出一个关键结论:递归成员里其实是不允许出现聚合函数和分组子句的。在PostgreSQL里,递归CTE的递归成员在语义上被限制成“非聚合查询”,因为它每一轮要处理的是“上一轮的结果集 + 当前表的连接”,这种逐行迭代的处理模型,天然排斥需要全量扫描才能完成的聚合操作。
2.2 在递归成员里写GROUP BY/HAVING会怎么样
从语法层面说,SQL标准对递归成员的要求简单粗暴:递归成员的FROM子句必须且只能引用一次CTE自身,而且这里的引用不允许出现在子查询、聚合函数、GROUP BY或HAVING的内部或作用域里。PostgreSQL文档里专门有一条:
递归查询的递归成员不允许包含聚合函数,不允许使用GROUP BY,ORDER BY也只能出现在最外层。
这条规则的本质原因,是递归成员在每一轮被调用时,工作表中的行数、内容都在变化。如果允许在递归成员里做GROUP BY和聚合,数据库无法确定“聚合的范围”到底是当前这一轮的行,还是历史所有轮次的行。语义上存在根本歧义,各数据库厂商干脆一起禁止。
所以,哪怕你连GROUP BY都不写,只写一个HAVING,同样会报错。因为HAVING本身就是和GROUP BY绑定的聚合过滤子句。
2.3 聚合出现在哪一层,决定你能过滤什么
既然递归成员里不能写聚合,那递归CTE和HAVING到底还能不能在同一条SQL里用?当然能。关键在于把HAVING放在什么位置。
我总结了一个简单粗暴的判断原则:聚合的层级就是过滤的层级。
如果你想过滤的是“递归CTE最终输出结果”的某个分组,那就把聚合和HAVING放在最外层的查询里。比如先递归遍历整个组织架构,然后在外层按某个字段GROUP BY,再用HAVING过滤。这种情况写起来没有任何障碍,因为最外层查询是普通查询,不涉及递归限制。
如果你想过滤的是“每个递归路径内部的子集”,比如遍历到某个节点时要判断它名下所有子节点的数量是否达到阈值,那就要把这个判断改写成“非聚合形式”。常见手法有两个:一个是把聚合判断放到递归成员之外,用EXISTS、NOT EXISTS、JOIN子查询去实现;另一个是先把聚合结果通过窗口函数(比如COUNT(*) OVER())计算出来,再在外层过滤。具体怎么写,下一章展开。
还有个容易忽略的细节:即使最外层可以使用GROUP BY和HAVING,也不建议让递归CTE把所有层级的数据全部展开后再做聚合。因为递归CTE天然会把所有中间行都物化到临时工作表中,行数会随着层级指数级膨胀。最外层再做一次GROUP BY,相当于把膨胀后的数据又扫一遍。这种写法在数据量小的时候没问题,一旦节点数上万、层级超过五六层,性能就会很难看。
3. 实战案例:先递归拉全量再在外层剪枝,怎么写才不踩坑
3.1 数据模型与目标:一个真实的部门树场景
为了把问题讲透,我用一个我实际处理过的部门数据模型来做示例。表结构如下:
CREATE TABLE department ( id INT PRIMARY KEY, parent_id INT REFERENCES department(id), name VARCHAR(100) NOT NULL, team_size INT NOT NULL DEFAULT 0, created_at DATE NOT NULL ); INSERT INTO department (id, parent_id, name, team_size, created_at) VALUES (1, NULL, '总公司', 10, '2020-01-01'), (2, 1, '技术中心', 30, '2020-02-01'), (3, 1, '市场中心', 25, '2020-03-01'), (4, 2, '后端组', 15, '2020-04-01'), (5, 2, '前端组', 12, '2020-05-01'), (6, 4, '支付组', 8, '2020-06-01'), (7, 4, '账号组', 7, '2020-06-15'), (8, 5, '中台组', 9, '2020-07-01'), (9, 3, '品牌组', 20, '2020-08-01');需求是:找出所有“团队总人数超过50人”的顶级部门路径。注意这里的“顶级”不是根节点,而是指能作为一条路径起点的顶层节点。翻译成人话:先把整棵树展开成“根路径字符串 + 每行部门信息”,然后按根路径分组,统计每个根路径下的总人数,过滤出超过50的路径。
3.2 第一版:递归拉全量,最外层直接HAVING
这个版本最直观,结构清晰,也是大多数人第一次写出来的版本:
WITH RECURSIVE dept_tree AS ( SELECT id, parent_id, name, team_size, id::TEXT AS root_path, 1 AS depth FROM department WHERE parent_id IS NULL UNION ALL SELECT d.id, d.parent_id, d.name, d.team_size, dt.root_path || '->' || d.id::TEXT AS root_path, dt.depth + 1 FROM department d JOIN dept_tree dt ON d.parent_id = dt.id ), path_stats AS ( SELECT root_path, SUM(team_size) AS total_team_size FROM dept_tree GROUP BY root_path ) SELECT ps.*, dt.name AS root_dept_name FROM path_stats ps JOIN ( SELECT DISTINCT ON (root_path) root_path, id, name FROM dept_tree ORDER BY root_path, depth ) dt ON ps.root_path = dt.root_path WHERE ps.total_team_size > 50 HAVING COUNT(ps.root_path) > 0; -- 注意这一行,故意留了个问题看到最后那个HAVING没有?这是我故意写的。实际运行这段代码,会直接报错,因为WHERE和HAVING同时出现在同一个SELECT层里,语义冲突。很多人第一次这么写,就是在这里翻车的:HAVING要么不放,要么放在GROUP BY之后,不能和WHERE混在同一层。而且这里GROUP BY的对象是root_path,COUNT(ps.root_path)算的是“每个路径的组内行数”,和“总人数超过50”这个需求没有关系。
这个版本的另一个问题在性能上:递归CTE先展开了一棵全树,然后才做GROUP BY聚合。如果只是组织架构这种几百行的小表,确实不算什么。但换成商品分类树,每个分类几万商品,根节点又特别多时,展开出来的中间结果表会有几十万行,整个查询会变得相当沉重。
3.3 第二版:用JOIN子查询替代递归内的聚合判断
来看正确的标准写法。这个版本的核心思路是:不在递归成员里做聚合,而是用JOIN把一个“预先按路径分组统计好的子查询”挂到最外层,再做HAVING过滤:
WITH RECURSIVE dept_tree AS ( SELECT id, parent_id, name, team_size, id::TEXT AS root_path, 1 AS depth FROM department WHERE parent_id IS NULL UNION ALL SELECT d.id, d.parent_id, d.name, d.team_size, dt.root_path || '->' || d.id::TEXT AS root_path, dt.depth + 1 FROM department d JOIN dept_tree dt ON d.parent_id = dt.id ) SELECT root_path, MIN(name) AS root_dept_name, -- 根节点名称,路径的起点 SUM(team_size) AS total_team_size FROM dept_tree GROUP BY root_path HAVING SUM(team_size) > 50;这个写法就能正确执行。它的核心逻辑:递归CTE负责展开树,产出“每一行 = 一个节点 + 它所属的根路径字符串”;最外层查询按根路径分组,SUM(team_size)汇总该路径下所有节点的团队人数;HAVING把总人数大于50的路径筛掉。
这里有两个操作细节值得说一下:
第一个细节,root_path字段的设计。我用的是“根节点ID + 箭头 + 子节点ID”拼接而成的字符串路径。这种方法的好处是,递归展开时只需要拼接字符串,不需要回表查询路径。坏处是路径字符串会随层级加深变长,字符串拼接开销随深度增加。更规范的做法是用数组或层级物化路径,比如PostgreSQL里直接用ltree类型,或者维护一个int[]类型的ancestors字段。但字符串拼接最适合做示例,因为它不依赖特定扩展,所有数据库都能跑。
第二个细节,MIN(name)的使用。为什么要用MIN(name)而不是直接取dt.name?因为按root_path分组后,组内只有root_path可以确定是一条路径的起点,想要拿到根节点名称,可以用MIN聚合。也可以在最外层JOIN原表,用条件“dept_tree.id = 路径字符串中的第一个ID”来取。MIN写法会比较简洁,代价是语义不够直观。如果路径字符串拼接格式是固定的,我更建议在递归CTE里额外加一列root_name,这样最外层直接GROUP BY root_path, root_name就行,避免MIN这种让人疑惑的写法。
3.4 第三版:递归过程中“预聚合”,减少中间行数的进阶思路
上面的写法能解决问题,但性能还有优化空间。当树特别深、节点特别多时,把整棵树展开再聚合,中间结果集太大,内存和临时文件都会有压力。
这里有一个不容易想到的做法:既然每个节点在递归展开时已经知道了自己的路径字符串,就可以在递归成员里顺便计算“当前路径下所有节点的累计人数”。但问题来了,递归成员里不能写聚合函数。怎么办?
答案是用窗口函数。窗口函数和聚合函数不同,它不算“聚合”,它是“逐行计算但能看到整个分组”,PostgreSQL、Oracle、SQL Server都支持,MySQL 8也支持。在递归成员里写窗口函数,语法上完全合法:
WITH RECURSIVE dept_tree AS ( SELECT id, parent_id, name, team_size, id::TEXT AS root_path, team_size AS path_team_size, 1 AS depth FROM department WHERE parent_id IS NULL UNION ALL SELECT d.id, d.parent_id, d.name, d.team_size, dt.root_path || '->' || d.id::TEXT AS root_path, dt.path_team_size + d.team_size AS path_team_size, dt.depth + 1 FROM department d JOIN dept_tree dt ON d.parent_id = dt.id ) SELECT root_path, MIN(name) AS root_dept_name, MAX(path_team_size) AS total_team_size FROM dept_tree GROUP BY root_path HAVING MAX(path_team_size) > 50;这套写法我把SUM(team_size)换成了路径累计值path_team_size。递归过程中,每往下一层,就把当前节点的team_size往上累加。因为路径字符串root_path本身就是从根到当前节点的一段连续路径,这个累计值等价于“当前路径下的所有节点人数之和”。最外层按root_path分组后,同一路径下的所有行里,路径最深的那一行拥有最大的path_team_size,所以用MAX(path_team_size)就能拿到整条路径的总人数。
这个进阶写法的性能收益很明显:把“全路径展开后再次全量扫描聚合”退化成了“逐行递推累加”,归并的工作量大幅下降。代价是递归中间行里多了path_team_size这一列,每行要多维护一个整数。这在绝大多数场景下是划算的。
不过要注意,这个写法有个前提:team_size是正数,累计值不会出现“叶子节点反而比根节点小”的反常情况。如果业务上可能出现负数指标,MAX取最大值就会失效,这时候必须回到SUM写法。
4. 踩坑实录:递归结果里HAVING“不起作用”的完整排查链路
4.1 现象:明明加了HAVING,为什么结果还是不对
有个朋友在做商品类目树优化时,遇到了一个特别诡异的问题。他的SQL大概长这样:
WITH RECURSIVE category_tree AS ( SELECT id, parent_id, name, product_count, id::TEXT AS path FROM category WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id, c.name, c.product_count, ct.path || '->' || c.id::TEXT FROM category c JOIN category_tree ct ON c.parent_id = ct.id WHERE c.product_count > 100 ) SELECT path, SUM(product_count) AS total_products FROM category_tree GROUP BY path HAVING SUM(product_count) > 500;他反馈的问题是:总有一些产品数明显不足500的路径出现在结果里。单独抽一条路径验证,发现路径下所有节点的product_count加起来根本不超过500,甚至有的只有一两百。
我当时让他把SQL分段执行,一步步排查。
4.2 排除法定位:问题不在HAVING,而在递归成员的WHERE
第一步,去掉最外层的HAVING,直接看递归CTE产出的原始数据:
WITH RECURSIVE category_tree AS (...同样的递归部分...) SELECT path, SUM(product_count) AS total_products FROM category_tree GROUP BY path;结果确实出现了大量product_count总和很小的路径。这个现象本身说明递归过程已经把“不想要的路径”保留下来了。
问题出在哪?仔细看递归成员里的这个条件:
WHERE c.product_count > 100这句话看起来是在过滤“每个节点的产品数大于100才继续展开”,但实际上它干了一件更隐蔽的事:它把不满足条件的节点连同它的所有子节点,一起从递归结果里剪掉了。在这棵树里,如果一个中间节点的product_count不大于100,数据库会直接放弃继续向下遍历,这意味着它下面所有子节点也不会出现在结果集里。
这其实不是HAVING的问题,而是“过滤边界”的问题。如果某个中间节点的产品数本来就很小,但它下面的某个子节点产品数巨大,整个路径的总产品数其实很高,结果因为这个中间节点的product_count不够,下面那一大块子节点全被剪掉了。
这个现象在递归CTE里非常典型:**递归成员里的WHERE条件是“逐行裁剪”,不是“分组裁剪”。**你以为写的是“只要当前节点满足条件就继续往下走”,实际上写的是“只要当前节点不满足条件,这条分支就断掉”。一大批本应保留的分支因此被剪掉,另一批满足节点条件、但聚合后不达标的路径却被保留。
排查到这里,根因已经清楚了。HAVING一直没起作用,不是因为HAVING写错了,而是递归过程早就把“候选集”给改变了。最外层的HAVING只能过滤递归CTE已经产出的行,无法还原已经被剪掉的子节点。
4.3 正确姿势:用EXISTS处理“每个节点的过滤”,把聚合判断留给外层
同类需求如果确实希望在递归过程中“提前剪枝”,必须用EXISTS或IN子查询来改写,让每一层的过滤条件基于“子树的整体情况”,而不是“当前节点单行的字段值”。
比如需求改为“只保留那些‘总产品数大于500’的类目路径”,做法分成两步:
第一步,递归CTE里不做任何WHERE裁剪,先把整棵类目树完整展开。第二步,用窗口函数或GROUP BY + HAVING在外层判断哪些路径总产品数达到500,然后只输出这些路径。
如果担心完整展开的中间结果集太大,可以用EXISTS子查询把“剪枝”提纯成“判断子树是否值得继续展开”:
WITH RECURSIVE category_tree AS ( SELECT id, parent_id, name, product_count, id::TEXT AS path FROM category WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id, c.name, c.product_count, ct.path || '->' || c.id::TEXT FROM category c JOIN category_tree ct ON c.parent_id = ct.id WHERE EXISTS ( SELECT 1 FROM category sub WHERE sub.id = c.id OR sub.parent_id = c.id OR sub.parent_id IN ( SELECT id FROM category WHERE parent_id = c.id ) ) )不过坦白讲,这种EXISTS写法在递归里很容易把子查询写乱,也不够通用。我个人的经验是:如果中间结果集没有大到不可接受,优先选择“完整展开 + 外层聚合过滤”的写法,逻辑清晰,调试方便。只有当树的规模实在太大,展开全量行会内存溢出时,再考虑业务预处理或程序侧递归。
4.4 一个反直觉的实验:同一段SQL在MySQL 8和PostgreSQL里的行为差异
把同一个“错误”的递归CTE(在递归成员里写GROUP BY和HAVING)分别放到MySQL 8和PostgreSQL 14里跑,会有完全不同的反馈:
| 数据库 | 在递归成员里写HAVING的行为 | 结果 |
|---|---|---|
| PostgreSQL | 解析阶段直接报语法错误 | 快速失败,容易定位 |
| MySQL 8 | 部分场景能“容忍”HAVING,但把它当普通过滤条件执行 | 不报错,但语义和预期严重不符 |
| SQL Server | 报错信息提示递归部分不允许聚合 | 快速失败 |
| Oracle | 允许把HAVING放在递归成员里,但要求必须有GROUP BY | 结果大概率不符合预期 |
MySQL 8这个“能容忍”最坑人。它不是真的支持递归成员内聚合,而是把HAVING当作一个可以直接跟着WHERE一起执行的普通过滤条件。比如你在递归成员里写HAVING COUNT(*) > 3,它直接按“当前行的某个字段值大于3”来过滤,而不是先分组再过滤。结果自然就是错的,而且非常难排查。
这个坑我遇到过至少三次。每次都是同事把开发环境从PostgreSQL换成MySQL,或者反过来,然后SQL报错或结果不一致,排查半天才发现是递归成员里写了不该写的聚合语句。所以这里有一个非常实用的检查习惯:在递归CTE中,递归成员里出现的GROUP BY、HAVING、聚合函数,一律视为语法错误去怀疑。即使某个数据库允许它跑过去,也要狠心改写成外层聚合或窗口函数方案。
5. 性能、循环保护与替代方案:别让递归CTE成为慢查询元凶
5.1 递归深度与无限循环:HAVING不能帮你刹住递归
很多人以为给递归CTE加个HAVING就能控制递归轮数,这是误解。HAVING是“过滤结果”的,无法中断“已经发生的递归”。递归CTE真正需要关心的性能指标是“迭代轮数”和“每轮产生的行数”。
数据库对递归CTE的保护机制非常简单粗暴:要么有“最大递归深度”参数,要么有“最大结果集行数”限制。PostgreSQL里有一个专门的参数:
SET recursive_cte_max_depth = 100;MySQL 8也有类似的限制,默认是1000层。SQL Server则是通过选项MAXRECURSION:
OPTION (MAXRECURSION 50);这个限制一旦触顶,数据库直接报错,整条SQL失败。如果你的树本身超过限制,要做的是重新审视这个树的结构是否合理,而不是试图用HAVING来绕过。
我处理过一个最典型的“无限递归”案例:数据里存在循环引用,也就是A的parent_id指向B,B的parent_id又指向A。这种脏数据一旦存在,递归CTE会永远循环下去。如果没有触发深度保护,数据库可能先卡死。后来我们在所有会上递归的查询前面,统一加了一层数据质量检测,专门查循环引用,一旦发现就报警。这个检测SQL本身也可以用递归CTE写,但限制深度为树的最大可能深度,比如100:
WITH RECURSIVE detect_loop AS ( SELECT id, parent_id, 1 AS depth, ARRAY[id] AS visited FROM department WHERE parent_id IS NOT NULL UNION ALL SELECT d.id, d.parent_id, dl.depth + 1, dl.visited || d.id FROM department d JOIN detect_loop dl ON d.parent_id = dl.id WHERE dl.depth < 100 AND NOT (d.id = ANY(dl.visited)) ) SELECT visited FROM detect_loop WHERE id = ANY(visited) -- 如果某个id在visited数组里出现不止一次,说明有环 LIMIT 10;这个检测方案的核心是维护一个“已访问节点ID数组”(visited),每次递归都判断当前节点是否已经在visited里。只要出现重复,就说明存在循环。同样,这个判断必须在递归成员里用NOT (d.id = ANY(dl.visited))完成,不能拖到外层再过滤,否则递归根本停不下来。
5.2 当递归深度和中间行数双双膨胀时,考虑这三条替代路线
递归CTE有一个所有数据库都绕不开的瓶颈:中间结果集必须全部物化到临时存储。只要中间行数一多,不管最终结果多小,整个查询的内存和临时文件消耗都下不来。如果你在递归CTE上再叠加GROUP BY + HAVING做聚合,情况会更糟。
当你发现递归CTE已经明显拖慢数据库时,建议按以下顺序考虑替代方案:
第一个替代路线是“层级物化路径表”。如果树结构相对固定,不经常变更,可以提前给每行维护一个物化路径字段,比如path为“/1/4/6”。查询某个节点的所有子孙,直接LIKE '/1/4/%'就行。这种写法不需要递归,查询速度飞快。代价是写入时要维护路径字段,插入删除时需要对子树路径做批量更新。
第二个替代路线是“程序侧递归”。把树的节点一次性加载到应用内存,然后按level、childrenMap等结构存储,程序里用栈或队列遍历。对几十万节点以内的树,这种方法通常比SQL递归CTE快,逻辑也更灵活。缺点是需要把数据从数据库拉出来,网络I/O和内存占用必须可控。
第三个替代路线是“临时表 + 循环”。在存储过程或脚本里,先建临时表存根节点,然后while循环里反复JOIN原表,把下一层子节点插入临时表,直到不再产生新行为止。这种方式虽然不如递归CTE优雅,但有一个好处:每一轮循环后都可以随时查看中间结果,方便调试;同时对“每轮循环中计算路径累计指标”这类需求来说,怎么写都不会触发“递归成员不能聚合”的限制。
我个人的切分原则是:深度不超过10层、总节点数不超过10万的树,放心用递归CTE + 外层聚合;超过这个量级,优先考虑物化路径表或程序侧递归。不是递归CTE不好,而是数据库的临时表物化机制决定了它在超大数据集上不占优势。
5.3 聚合过滤时的索引与私有变量优化
最后聊一个被很多人忽略的性能细节。当递归CTE最终结果需要做GROUP BY聚合时,有没有索引,差别非常大。
递归CTE中最常见的性能瓶颈不是递归本身,而是JOIN主表和递归结果时的连接操作。上面所有例子里,递归成员都写了这样的JOIN:
JOIN dept_tree dt ON d.parent_id = dt.id这里的驱动顺序是:先用dept_tree递归结果里的一行作为输入,去department表里找parent_id匹配的行。如果department表在parent_id上没有索引,数据库每一次都要做全表扫描。递归每展开一层,就要把department整表扫一遍。啊,这就是递归CTE里面最想吐槽的一种“双重全表扫描”问题。
解决办法就是在parent_id上建索引:
CREATE INDEX idx_department_parent_id ON department(parent_id);这个索引直接影响递归每一轮的连接效率,属于必须建的索引,不是可选项。我见过一个案例,建索引前递归CTE跑12秒,建完瞬间降到200毫秒。差别之大,令人咋舌。在递归CTE之前,先检查你的连接列上有没有索引,这个优先级比任何其他SQL调优手段都高。
至于最外层GROUP BY的字段,比如root_path,如果遇到超大数据集,可以考虑让它走hash聚合(PostgreSQL默认对大结果集自动选择),或者在root_path列上建索引,但这通常只有查询频率特别高时才值得。
6. 个人实践中的一些额外体会
递归CTE和HAVING的缘份就是这样:你可以让它们出现在同一条SQL里,但必须让它们各就各位。递归CTE负责展开树的形状,HAVING负责在形状完全展开之后,对结果做聚合层面的裁剪。把过滤条件放到递归过程里,用“逐行裁剪”替代“分组裁剪”,就会得到和直觉完全相反的结果。
我建议你在自己的库里建一张树形结构的测试表,分别跑一下“完整展开+外层HAVING”和“递归成员提前WHERE剪枝”两种写法,直接对比结果。多跑几次之后,你会形成一种肌肉记忆:以后凡是看到递归CTE里有WHERE条件,第一反应就是去验证这个条件是否会错误剪掉子树的子孙节点。这个直觉比记住任何语法规则都重要。
另外,如果不是特别必要,尽量别在递归CTE上追求“一步到位”的神仙SQL。把递归展开和结果聚合分开写,中间用临时表或WITH子句承接,能调试、能分析、能看每一层产出了什么,比一段炫技式的超长SQL靠谱得多。至少我维护过的几个核心报表系统里,稳定可靠的方案都是这种“看起来不够高端但逻辑清楚”的写法。