做后端开发这些年,树形结构大概是最容易让人又爱又恨的数据模型。尤其是维护企业内部系统或者电商平台的时候,组织架构、商品分类、评论回复、权限菜单,几乎全都长成一颗带 parent_id 的树。以前在 Java 里查部门树,很多人的第一反应是循环查库:先查出根节点,再对每个节点执行一次 “查子节点” 的 SQL。逻辑倒是不难写,但每次加载一棵稍微大点的树,数据库交互次数直接几十次起步,接口那个慢真能把人逼疯。后来切到 PostgreSQL,用 WITH RECURSIVE 一句 SQL 把整棵树拉回来,性能和代码量完全是两个世界。这篇文章就围绕 PostgreSQL 递归查询,把核心语法、实战场景、常见坑和性能优化一次讲透。
1. 树形结构为什么必须在数据库里解决
1.1 应用层递归的 N+1 痛苦
先还原一下绝大多数人最早写过的部门树代码。伪代码大致是这样:
java复制List<Dept> allDept = new ArrayList<>();
void loadChildren(int parentId, int depth) {
List<Dept> children = deptMapper.selectByParentId(parentId);
for (Dept dept : children) {
dept.setDepth(depth);
allDept.add(dept);
loadChildren(dept.getId(), depth + 1); // 递归查子树
}
}
这段代码跑起来没有任何问题,甚至看着还挺清晰。但只要你数一下执行了几条 SQL,就明白什么叫 N+1 查询:根节点 1 条、第一层 N 条、第二层又各 N 条……一棵 5 层的树,轻轻松松几十条 SQL。网络往返占了大头,数据库压力反而成了次要问题。
更别扭的是,这种递归逻辑通常还要复制到多个查询场景里。想查某个部门下所有子孙部门,写一套;想根据当前部门反向找父级链路,又得换套写法。如果用的是 MyBatis-Plus 这类 ORM,不同条件还要对应不同的 XML SQL,维护成本直接翻倍。而且递归发生在应用层,意味着你没法在 SQL 层面做全局优化,也无法利用数据库索引去加速“层级遍历”这个动作。
1.2 哪些场景真正需要递归查询
并不是所有层级数据都需要上递归查询,下面这几类才是典型的适用场景:
| 场景 | 典型需求 | 说明 |
|---|---|---|
| 组织架构 / 菜单权限 | 查某个节点下全部子孙 | 层级不固定,需要深度优先或广度优先展开 |
| 商品分类 / 内容类目 | 向上找父级链路、找兄弟节点 | 最常见的“找祖宗”需求 |
| BOM 物料清单 | 多层数量汇总 | 父子关系可能多对多,数量需要跨层累乘 |
| 邀请关系 / 关注链 | 查出完整关系链条 | 需要判断是否存在环 |
| 连续数据补全 | 补齐缺失日期、序号 | 本质是“由已知推导未知”的迭代 |
这些场景的共同点是:层级数不是写死的,可能 3 层,也可能 30 层。如果你事先知道最多只有两层,那用一次自连接 JOIN 就够了,完全没必要动用递归。递归 CTE 真正发力的,就是层级不确定、需要一直往下钻到叶子节点为止的查询。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. WITH RECURSIVE 的核心语法与执行流程
2.1 一条递归 CTE 如何拆成三段
PostgreSQL 的递归查询语法核心是 WITH RECURSIVE,它由两部分拼接而成:锚点成员(anchor member)和递归成员(recursive member),两者用 UNION ALL 或 UNION 连接。先看一个最经典的例子:查一张 dept 表里所有部门和层级深度。
sql复制WITH RECURSIVE dept_tree AS (
-- 锚点成员:先查出根节点
SELECT id, name, parent_id, 0 AS depth
FROM dept
WHERE parent_id IS NULL
UNION ALL
-- 递归成员:根据上一轮结果继续向下找子节点
SELECT d.id, d.name, d.parent_id, dt.depth + 1
FROM dept_tree dt
JOIN dept d ON d.parent_id = dt.id
)
SELECT * FROM dept_tree;
锚点成员负责“启动”整个递归,一般取树的根节点,或者你想开始遍历的任意起点。递归成员负责“迭代”,它的关键动作就是 JOIN:用上一轮查询结果里的 id,去匹配子表的 parent_id,找到下一层节点。这里最容易被忽略的点是:dept_tree 这个 CTE 在递归成员里被引用时,并不是代表“已经查出的所有数据”,而是只代表上一轮迭代新产出的那批数据。初始执行时,它等于锚点成员的结果;之后每一轮迭代,它都被替换成上一轮的新结果。
2.2 数据库是怎么一步步迭代的
我用一张表格把执行过程拆开,理解了这个过程,很多坑就能提前绕开:
| 迭代轮次 | 输入(本轮引用的 cte 内容) | 本轮输出 | 累计结果集 |
|---|---|---|---|
| 第 0 轮 | 无 | 锚点返回根节点 A | A |
| 第 1 轮 | A | 以 A 的 id 匹配 parent_id,返回子节点 B、C | A、B、C |
| 第 2 轮 | B、C | 以 B、C 的 id 继续匹配,返回 D、E、F | A、B、C、D、E、F |
| 第 3 轮 | D、E、F | 若没有子节点则返回空,递归终止 | 最终结果 |
所以 WITH RECURSIVE 并不会把已经查出来的所有行再次全表扫描,每次都只处理上一轮“新长出来”的节点。这个设计保证了递归的每一轮开销是可控的,也意味着如果你真想控制遍历范围,应该想办法控制每一轮输入的行数,而不是指望数据库自动“记忆”以前的结果。
2.3 递归成员的几个“不能”
PostgreSQL 对递归成员内部能写什么有明确限制,这也是新手最容易踩雷的地方:
- 递归成员里不能直接使用聚合函数(
SUM、COUNT等)、GROUP BY、窗口函数。 - 递归成员里不能使用
ORDER BY可以出现在子查询里,但不能直接编排在递归集合上。 - 递归成员里的子查询不能引用 CTE 自身,比如
WHERE id IN (SELECT id FROM cte)这种写法不允许。 - 同一个 CTE 在递归成员中通常只能出现一次。
这些限制的本质原因是:递归查询的语义要求每一轮迭代只基于上一轮数据做一次“单调”的集合变换。如果你能在每一轮里做聚合,那结果集的大小随时可能变化,递归规则就会变得不可预测。这就像写循环时禁止你随意改变循环变量的递增规则一样,看着是约束,其实是为了保证程序能够稳定终止。
如果确实需要聚合,常见做法是把递归得到的结果当成临时表,在外层继续 GROUP BY。例如查所有部门数量时可以这样:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, parent_id, 0 AS depth
FROM dept
WHERE parent_id IS NULL
UNION ALL
SELECT d.id, d.parent_id, dt.depth + 1
FROM dept_tree dt
JOIN dept d ON d.parent_id = dt.id
)
SELECT depth, COUNT(*)
FROM dept_tree
GROUP BY depth;
把“换层”和“汇总”分两步做,反而逻辑更清晰。
3. 四个高频实战场景,从查询到汇总
3.1 向下展开:组织架构树
这是最标准的场景:给定任意一个部门,查出它下面所有子部门,并带上层级深度和路径。我通常会在递归 CTE 里额外维护 depth 和 path 两个字段,前者用来知道节点在第几层,后者用来排序,保证输出顺序符合“父节点在前、子节点在后”的直觉。
先造一点测试数据:
sql复制CREATE TABLE dept (
id INT PRIMARY KEY,
name TEXT,
parent_id INT REFERENCES dept(id)
);
INSERT INTO dept VALUES
(1, '总公司', NULL),
(2, '技术部', 1),
(3, '产品部', 1),
(4, '后端组', 2),
(5, '前端组', 2),
(6, '平台组', 4);
递归查询:
sql复制WITH RECURSIVE dept_tree AS (
SELECT
id,
name,
parent_id,
0 AS depth,
ARRAY[id] AS path
FROM dept
WHERE id = 1 -- 从总公司开始向下展开
UNION ALL
SELECT
d.id,
d.name,
d.parent_id,
dt.depth + 1,
dt.path || d.id
FROM dept_tree dt
JOIN dept d ON d.parent_id = dt.id
)
SELECT id, name, parent_id, depth
FROM dept_tree
ORDER BY path;
path 字段我习惯用数组拼接,每一轮迭代都把当前节点的 id 加到上一层路径的末尾。这样外部 ORDER BY path 能保证结果顺序就是树的深度优先遍历顺序。如果你不需要排序,depth 字段已经足够展示层级。
这个查询最大的优势是:只需要改锚点成员里的 WHERE id = 1,就能从任意节点开始向下展开,连 SQL 结构都不用动。
3.2 向上回溯:找父级链路
很多时候需求反过来:已知一个子部门的 id,要查它到根节点的完整链路。比如我刚入职时经常要回答“我在哪个一级部门下面”这种问题。这种查询的关键是把 JOIN 的方向反过来。
sql复制WITH RECURSIVE ancestors AS (
SELECT id, name, parent_id, 0 AS up_depth
FROM dept
WHERE id = 4 -- 从后端组开始向上找
UNION ALL
SELECT d.id, d.name, d.parent_id, a.up_depth + 1
FROM dept d
JOIN ancestors a ON d.id = a.parent_id -- 注意方向:找父节点
)
SELECT id, name, parent_id, up_depth
FROM ancestors
ORDER BY up_depth DESC;
向下展开用的是“父表的 id = 子的 parent_id”,向上回溯则变成“子的 parent_id = 父表的 id”。这个方向很容易写反,我见过不少同事在这上面卡了半天。
结果会从当前部门一直追溯到根节点,up_depth 越大代表越顶层。如果想直接输出一条“从总到子”的链路,可以在外层把顺序反过来:ORDER BY up_depth DESC 后变成总公司、技术部、后端组。
3.3 多级汇总:BOM 数量累乘
树形结构还有一个高频需求是汇总计算。以 BOM(物料清单)为例:一个成品由多个部件组成,每个部件又由更小的零件组成,每个组合关系都有用量。我要算“生产一个成品,到底需要多少个底层零件”。
sql复制CREATE TABLE bom (
component_id INT, -- 零件 id
parent_id INT, -- 上一级装配件 id,NULL 表示顶层成品
qty NUMERIC -- 装配一个 parent 需要多少个 component
);
INSERT INTO bom VALUES
(100, NULL, 1), -- 成品 A
(200, 100, 2), -- A 由 2 个 B 组成
(300, 200, 3), -- B 由 3 个 C 组成
(400, 100, 4); -- A 由 4 个 D 组成
递归查询并逐层累乘用量:
sql复制WITH RECURSIVE bom_tree AS (
SELECT
component_id,
parent_id,
qty,
qty AS total_qty
FROM bom
WHERE parent_id IS NULL
UNION ALL
SELECT
b.component_id,
b.parent_id,
b.qty,
bt.total_qty * b.qty
FROM bom_tree bt
JOIN bom b ON b.parent_id = bt.component_id
)
SELECT component_id, SUM(total_qty) AS required_qty
FROM bom_tree
GROUP BY component_id
ORDER BY component_id;
这里的核心是 total_qty 字段:每一轮迭代都拿上一层的累计用量乘以当前层级的单件用量。最终 GROUP BY component_id 汇总后,就能得到每个零件在整个产品下的总需求。
要注意的是,BOM 可能有多对多关系(一个零件被多个父件使用),这时递归结果会出现重复,必须在外层用 SUM 汇总。这也是为什么这类查询通常要配合 GROUP BY,而不是简单 SELECT。
3.4 非树形场景:生成日期序列
递归查询不仅能对付树,还能用来做数据补全。有一类经典需求是:数据库里只有某些日期的数据,但报表需要把中间缺失的日期也列出来,缺失部分填 0。用递归 CTE 生成连续日期序列特别直观:
sql复制WITH RECURSIVE dates AS (
SELECT DATE '2025-01-01' AS d
UNION ALL
SELECT d + 1
FROM dates
WHERE d < DATE '2025-01-10'
)
SELECT d
FROM dates;
这个例子的运行逻辑和树形结构完全一样:以第一天为锚点,每次 d + 1 生成新的一行,直到超出截止日期。虽然后续想生成连续日期我会直接推荐 generate_series,但理解这个例子,能帮你真正建立“递归 = 初始行 + 迭代变换”的心智模型。以后再遇到“序列类”的生成任务,比如生成连续编号、斐波那契数列这类,就能举一反三。
4. 最常见的坑和排查链路
4.1 循环引用导致永不终止
递归查询最可怕的坑不是慢,而是根本不终止。PostgreSQL 的 WITH RECURSIVE 不像 SQL Server 那样有默认递归次数上限(SQL Server 默认 100,可以用 MAXRECURSION 指定),也没有 Oracle CONNECT BY 那种自动检测环形引用的处理。如果你的表里有环,比如 A 的父节点是 B,B 的父节点是 A,那递归会无限循环下去,直到资源耗尽。
现实里环是怎么出现的?很常见:手工导入数据时 parent_id 写错;系统迁移时父子关系乱掉;某些业务允许把节点挂到自己的子节点下形成闭环。组织架构这种“天然有环会出大问题”的数据,我们一般会在应用层禁止,但不能保证所有历史数据都干净。
我自己的习惯是:写递归查询时默认带上路径检测,用数组记录已经走过的节点,遇到重复就停止:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 0 AS depth, ARRAY[id] AS path
FROM dept
WHERE id = 1
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.depth + 1, dt.path || d.id
FROM dept_tree dt
JOIN dept d ON d.parent_id = dt.id
WHERE NOT (d.id = ANY(dt.path)) -- 如果已经在路径里,不再扩展
)
SELECT * FROM dept_tree;
这个 WHERE NOT (d.id = ANY(dt.path)) 相当于给递归加了安全阀,有环也能正常结束。虽然多了一点数组判断的开销,但安全性和稳定性远远大于那点性能损失。特别是数据是从老系统迁移过来的,我强烈建议所有核心树查询都加上这层保护。
4.2 深度暴涨导致查询卡死
就算没有环,递归查询也可能因为层级过深而卡死。最常见的原因是数据导入异常,比如某个节点的 parent_id 被错误地指到了自己的后辈,导致链路被串得特别长。组织架构正常十几层就到头了,一旦出现几百上千层的“无限套娃”,每一轮迭代都要参与 JOIN 计算,查询很容易陷入长时间执行。
遇到这种情况,先别急着优化 SQL,要先确认数据本身是不是已经脏了。一个非常实用的排查语句是:查询是否存在“某节点沿着父指针向上走,能走回自己”的环。可以把路径检测逻辑套上去跑一遍,看 path 数组的长度是否异常。
如果生产环境不允许你直接写检测语句,也可以在递归成员里加一个深度限制作为兜底:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 0 AS depth, ARRAY[id] AS path
FROM dept
WHERE id = 1
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.depth + 1, dt.path || d.id
FROM dept_tree dt
JOIN dept d ON d.parent_id = dt.id
WHERE dt.depth < 30 -- 强制最多展开 30 层
)
SELECT * FROM dept_tree;
这样即使数据出问题,查询最多跑 30 层就会停下,不会无限循环下去,接口也能及时报错而不是拖死数据库。
4.3 结果重复的根源:UNION 与 UNION ALL
递归查询里 UNION ALL 和 UNION 的区别经常被忽略,但在某些场景下会造成完全不同的结果。如果你在树形结构中,每个节点只有一个父节点(严格树),那 UNION 和 UNION ALL 结果一致,因为不会出现同一节点通过两条路径到达的情况。但如果是 BOM、知识图谱、关注关系这类多父节点场景,同一节点很可能通过不同路径被多次查到。
这时用 UNION ALL 会返回重复行,外层如果没有聚合,就会看到一堆重复记录。用 UNION 会自动去重,但代价是多了一次排序/哈希操作,数据量大时性能差距明显。
我的选择标准很简单:能确定数据不会重复时,一律用 UNION ALL;不确定时,先想清楚业务上是否需要去重,而不是盲目换成 UNION。实际上在树形结构里“重复到达”本身就是业务信息(比如 BOM 里多路径用同一零件),这时你反而需要保留重复值再做汇总。
4.4 慢递归的排查思路
遇到递归查询慢,我一般按这个顺序排查:
- 先看执行计划:
EXPLAIN ANALYZE出来,看看递归成员内部是不是在扫描全表。 - 检查 JOIN 字段索引:递归成员的
ON d.parent_id = dt.id,两侧字段有没有索引。尤其是parent_id,这是树结构的“血管”,没索引几乎必然慢。 - 看每一轮迭代的行数:如果某轮输入行数呈指数增长,说明查询从某个节点开始疯狂扩展,可能是环,也可能真是数据量巨大。
- 尝试缩小问题范围:把锚点条件换成具体某个根节点,看是否依然慢。如果个别节点特别慢,多半是局部数据有问题。
说句实话,大多数递归查询慢,原因都不是 PostgreSQL 不行,而是 parent_id 上压根没建索引,或者递归成员里 SELECT * 把大字段全查出来了。先把这两个问题解决,性能马上会有质的提升。
5. 性能优化与更好用的新特性
5.1 索引、UNION ALL 和一些执行细节
递归查询的性能优化,优先级最高的是在建表阶段就做好铺垫:
parent_id上一定要建索引,最好是普通 B-tree 索引:CREATE INDEX idx_dept_parent_id ON dept(parent_id);- 递归 CTE 里只查需要的列,别用
SELECT *,尤其是当表里还有description这种大字段时,每轮迭代都把这些大字段带上,内存和排序开销会成倍增长。 - 能确认不重复就坚持用
UNION ALL,省掉一次去重排序。
还有一个容易被忽略的细节:PostgreSQL 默认会把递归 CTE 的结果物化,也就是在递归结束后把整个结果集暂存。这对外部多次引用 CTE 的场景是好事,但如果只是简单查询一次,用 NOT MATERIALIZED 可以让优化器把递归结果直接“内联”到外层,省一次物化开销。
sql复制WITH RECURSIVE dept_tree AS NOT MATERIALIZED (
SELECT id, name, parent_id, 0 AS depth
FROM dept
WHERE id = 1
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.depth + 1
FROM dept_tree dt
JOIN dept d ON d.parent_id = dt.id
)
SELECT * FROM dept_tree;
不过这个优化不是玄学,建议实际 EXPLAIN ANALYZE 对比后再决定。有些场景 MATERIALIZED 反而更快,因为它避免了重复计算。
5.2 PostgreSQL 14 新增的 SEARCH 和 CYCLE 子句
PostgreSQL 14 之后,递归查询引入了两个非常实用的语法糖:SEARCH 和 CYCLE。其中 CYCLE 直接解决了我前面提的“环检测”痛点,不用再手动拼数组了。
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id
FROM dept
WHERE parent_id IS NULL
UNION ALL
SELECT d.id, d.name, d.parent_id
FROM dept_tree dt
JOIN dept d ON d.parent_id = dt.id
)
CYCLE id SET is_cycle USING path
SELECT id, name, parent_id, is_cycle, path
FROM dept_tree;
CYCLE id SET is_cycle USING path 的意思是:用 id 字段检测循环,自动生成一个布尔字段 is_cycle 标记当前行是否已经出现在路径中,同时生成 path 数组用来记录路径。这样你既拿到了路径信息,又自动得到了循环保护,代码也能简洁不少。
SEARCH 子句则用来指定遍历顺序:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id
FROM dept
WHERE parent_id IS NULL
UNION ALL
SELECT d.id, d.name, d.parent_id
FROM dept_tree dt
JOIN dept d ON d.parent_id = dt.id
)
SEARCH DEPTH FIRST BY name SET order_seq
SELECT * FROM dept_tree ORDER BY order_seq;
SEARCH DEPTH FIRST BY name SET order_seq 表示按深度优先遍历,并按照 name 字段排序来生成 order_seq 排序号。如果你需要广度优先,把 DEPTH FIRST 换成 BREADTH FIRST 即可。这个语法省去了手工维护 depth 和 path 的繁琐操作,代码可读性提升非常明显,建议升级到 PostgreSQL 14 以上的同学直接用它替代手写方案。
5.3 递归 CTE 和物化路径、闭包表的取舍
递归 CTE 虽然好用,但不是唯一的树查询方案,也不是所有场景的最优解。常见的替代方案有三类:
| 方案 | 优势 | 劣势 |
|---|---|---|
| 递归 CTE | 直观、灵活、不改表结构 | 层级很深或数据量极大时性能受限 |
| 物化路径(path 字段) | 查询子树只需 LIKE,分页简单 |
更新父节点时需要批量改 path 前缀 |
| 闭包表(Closure Table) | 任意层级查询简单,支持多父节点 | 存储量大,写入/删除维护复杂 |
以我的实际经验看,绝大多数业务场景的数据量都在百万以下,层级也就几十层,递归 CTE 完全够用,它的可读性和维护成本远胜其他方案。只有当树的深度经常超过 50 层,或者单表数据达到千万级,且查询频率极高时,才值得考虑物化路径或闭包表。这属于数据库模型设计的进阶问题,建议真有需求时再研究,作为常规武器,递归 CTE 已经非常能打。
6. 我在实际项目里的选择习惯
最后分享几个基于经验总结的习惯。第一,建表的时候一定会给 parent_id 建索引,哪怕当时业务只有一层。因为你根本不知道产品哪一天会突然要一个层级树,届时再补索引,可能已经产生了大量性能问题。第二,写任何递归查询,默认带上 depth、path 两个字段,哪怕是临时排查用。depth 能让结果层次一目了然,path 既能排序又能做环检测,属于“一旦需要就非常救命”的字段。第三,递归成员内不加 ORDER BY,所有排序都放到最外层。原因很简单:排序放在每一轮迭代里,等于每层都要额外做一次排序操作,白白浪费 CPU;而最外层统一排序,一次搞定。
还有一个容易被忽视的小技巧:如果递归结果要被前端做树组装,我建议直接在 SQL 里用 ORDER BY path 保证父子顺序,这样后端循环一次就能构建出树结构,不需要再做二次排序。如果你用 CYCLE 子句自动生成了 path,那更是顺手的事。
递归查询这种能力,本质上就是“用声明式语法表达迭代逻辑”。它不复杂,但细节不少:方向、去重、环检测、索引,每一个点都可能成为线上事故的源头。希望这篇整理能让你少踩几个我踩过的坑,真正把 PostgreSQL 递归查询用得又优雅又稳。
