在我们日常的数据库开发工作中,处理具有层级结构数据的场景无处不在。从复杂的组织架构图、无限级分类的电商目录,到社交媒体的关注关系网络,甚至是 2026 年大热的 AI 知识图谱中的实体关系,这些“树状”或“图状”数据曾是我们开发者的噩梦。如果我们仅仅使用传统的 SQL 连接(JOIN)或者子查询,往往会写出极其复杂且性能低下的查询语句,甚至需要在应用层进行多次往返数据库才能获得结果。
作为一名追求高效的数据库开发者,我们需要找到一种更优雅的解决方案。今天,我们将深入探讨 PostgreSQL 中的一项核心强大功能——递归公用表表达式(Recursive CTEs)。通过这篇文章,你将学会如何利用 WITH RECURSIVE 语句,以极其简洁的逻辑处理复杂的层级数据。我们不仅会剖析其底层工作原理,还会通过丰富的实战案例,带你一步步掌握这项“黑科技”,并分享我们在实际开发中总结的性能优化经验。
目录
什么是递归公用表表达式(CTE)?
在 PostgreSQL 中,WITH 语句为我们提供了一种定义临时结果集的方式,我们通常称之为公用表表达式。它就像是为复杂的查询逻辑创建了一个临时的“命名视图”,极大地提升了 SQL 代码的可读性和复用性。
而递归 CTE 则是其中的“进阶版”。简单来说,递归 CTE 是一个能够引用自身的查询。这就好比编程语言中的递归函数:它包含一个初始的“基础情况”,然后不断地调用自己处理下一层数据,直到满足终止条件。在处理树形结构遍历(如查找所有子节点)或图结构路径搜索时,递归 CTE 是最理想的工具,它能让我们用几行代码完成以往需要数百行逻辑才能实现的任务。
递归查询的语法剖析
让我们通过拆解标准的语法结构,来理解递归 CTE 是如何运作的。虽然代码看起来不复杂,但每一部分都至关重要。
WITH RECURSIVE cte_name (
-- 这里可以定义列名(可选)
) AS (
-- 1. 非递归项(初始成员 / 锚点)
CTE_query_definition
UNION [ALL] -- 连接运算符
-- 2. 递归项(递归成员)
CTE_query_definition -- 这里会引用 cte_name
)
-- 3. 最终查询
SELECT * FROM cte_name;
让我们详细分析这三个关键组成部分:
- 非递归项(初始成员/锚点):这是递归的起点。查询执行时,PostgreSQL 首先执行这部分,生成初始的结果集。例如,在查找组织架构时,这就是最高层的“老板”记录。
- 递归项(递归成员):这是递归的核心。在这个查询中,我们必须引用
cte_name自身。它的逻辑通常是“拿着上一步的结果集,去原始表中查找相关的数据(比如查找下属)”。 - 终止条件:这一点非常重要。递归不会无限进行下去,当递归项执行后不再产生任何新行时,PostgreSQL 会自动停止递归。此外,使用 INLINECODE9d414029(去重)代替 INLINECODEf03f5735(不去重)也是控制结果集的一种方式。
环境准备:构建员工组织架构表
为了演示递归查询的威力,我们需要一个具有典型层级关系的数据集。让我们创建一张 INLINECODEe7023662 表,模拟一个公司的组织结构。在这个场景中,每个员工都有 INLINECODEed7cfbf2 和 INLINECODEb4413e3b,通过 INLINECODE21f735b8 指向其上级,从而形成一个树状结构。
-- 创建员工表,包含 ID、姓名和上级经理 ID
CREATE TABLE employees (
employee_id SERIAL PRIMARY KEY,
full_name VARCHAR NOT NULL,
manager_id INT
);
-- 插入演示数据
-- M.S Dhoni 是最高层经理 (manager_id 为 NULL)
-- 其他人分别向不同的经理汇报
INSERT INTO employees (employee_id, full_name, manager_id)
VALUES
(1, ‘M.S Dhoni‘, NULL), -- CEO/总经理
(2, ‘Sachin Tendulkar‘, 1), -- 经理级
(3, ‘R. Sharma‘, 1), -- 经理级
(4, ‘S. Raina‘, 1), -- 经理级
(5, ‘B. Kumar‘, 1),
(6, ‘Y. Singh‘, 2), -- 向 Sachin 汇报
(7, ‘Virender Sehwag‘, 2), -- 向 Sachin 汇报
(8, ‘Ajinkya Rahane‘, 2), -- 向 Sachin 汇报
(9, ‘Shikhar Dhawan‘, 2),
(10, ‘Mohammed Shami‘, 3), -- 向 R. Sharma 汇报
(11, ‘Shreyas Iyer‘, 3),
(12, ‘Mayank Agarwal‘, 3),
(13, ‘K. L. Rahul‘, 3),
(14, ‘Hardik Pandya‘, 4), -- 向 S. Raina 汇报
(15, ‘Dinesh Karthik‘, 4),
(16, ‘Jasprit Bumrah‘, 7), -- 向 Virender Sehwag 汇报
(17, ‘Kuldeep Yadav‘, 7),
(18, ‘Yuzvendra Chahal‘, 8), -- 向 Ajinkya Rahane 汇报
(19, ‘Rishabh Pant‘, 8),
(20, ‘Sanju Samson‘, 8);
现在我们的数据已经准备好了。让我们开始探索如何使用递归查询从中提取有价值的信息。
实战案例 1:查找指定经理的所有下属
假设我们需要找到 ID 为 3 的经理(R. Sharma)手下所有的员工,不仅包括直接汇报的,还包括间接汇报的(即下属的下属)。如果不使用递归,这简直是一场噩梦。但有了递归 CTE,我们可以轻松实现。
查询逻辑
- 起点:先找到
employee_id = 3的员工。 - 递归:拿着这个 ID,去 INLINECODEad17836a 表中找所有 INLINECODE1bd7ffc9 的人。
- 循环:把刚找到的人加入结果集,再拿着他们的 ID,去表里找以此 ID 为
manager_id的下一层员工。 - 结束:直到找不到新的下属为止。
WITH RECURSIVE subordinates AS (
-- --- 步骤 1: 非递归项(锚点) ---
-- 首先选中我们要查找的起点:ID 为 3 的经理
SELECT
employee_id,
manager_id,
full_name
FROM
employees
WHERE
employee_id = 3 -- 这里是起始点:R. Sharma
UNION ALL
-- --- 步骤 2: 递归项 ---
-- 将子查询的结果与主表连接,查找下一层级
SELECT
e.employee_id,
e.manager_id,
e.full_name
FROM
employees e
INNER JOIN
subordinates s ON s.employee_id = e.manager_id
-- 关键点:这里的 ‘subordinates‘ 引用了 CTE 自身
-- s.employee_id 代表上一次迭代找到的人,e 是他的下属
)
-- --- 步骤 3: 最终查询 ---
-- 从生成的临时结果集中提取所有数据
SELECT * FROM subordinates;
代码深度解析
你可能会注意到我们在递归部分使用了 INLINECODE23b98a9b。这是因为我们需要保留所有层级的数据,不去重。如果使用 INLINECODEe75a5d53,数据库会尝试消除重复行,这在处理层级结构时不仅消耗性能,还可能导致逻辑错误(如果两个不同层级的员工恰好名字相同)。在这个查询中,INLINECODEaccfb2e3 的条件 INLINECODE336aba97 是递归的引擎:它利用上一轮迭代产生的 employee_id 来筛选出下一轮的“子节点”。
实战案例 2:计算层级深度与路径追踪
仅仅列出名单有时是不够的。在数据分析或生成报表时,我们往往需要知道每个员工处于第几层级(例如,Level 1 是经理,Level 2 是主管,Level 3 是员工)。此外,在构建导航菜单或面包屑时,我们还需要完整的路径信息。我们可以通过在 CTE 中添加计数器字段和字符串拼接来实现这一点。
WITH RECURSIVE organization_hierarchy AS (
-- --- 非递归项:起始点 ---
SELECT
employee_id,
full_name,
manager_id,
1 AS level, -- 初始化层级为 1
full_name AS path -- 初始化路径
FROM
employees
WHERE
employee_id = 2 -- 这里我们查找 Sachin Tendulkar 的部门树
UNION ALL
-- --- 递归项:层级递增与路径拼接 ---
SELECT
e.employee_id,
e.full_name,
e.manager_id,
oh.level + 1, -- 核心:每递归一次,层级加 1
oh.path || ‘ > ‘ || e.full_name -- 拼接路径字符串
FROM
employees e
INNER JOIN
organization_hierarchy oh ON oh.employee_id = e.manager_id
)
SELECT
full_name,
level,
path,
-- 使用缩进字符直观展示树形结构(在客户端查看时效果更佳)
REPEAT(‘ ‘, level - 1) || full_name AS tree_view
FROM
organization_hierarchy;
2026 开发视角:数据可视化与 AI 辅助
在我们最近的一个项目中,我们需要将这种层级结构直接转化为前端的 D3.js 可视化图表。通过在 SQL 层面直接计算出 INLINECODEa118c287 和 INLINECODE4c485431,我们极大地简化了后端 API 的逻辑。配合现在的 AI 辅助工具,我们可以直接把这段 SQL 拖入像 Cursor 这样的编辑器中,让 AI 生成对应的 TypeScript 接口定义。这种“数据库即逻辑中心”的模式,正在成为现代全栈开发的新趋势。
深入探讨:处理图结构与环检测
在 2026 年,随着图数据和知识图谱的普及,我们遇到的数据结构不再仅仅是完美的树,而是复杂的“图”。在图中,可能会存在环路,即 A 引用 B,B 引用 C,C 又引用 A。如果不加处理,标准的递归 CTE 会陷入无限循环,直到耗尽服务器资源。
让我们思考一下这个场景:假设我们要在社交网络中查找“朋友的朋友”,或者在一个复杂的权限系统中查找“间接授权”。环检测变得至关重要。
生产级解决方案:路径数组去重
PostgreSQL 提供了强大的数组类型,我们可以利用它来记录已访问节点的路径,从而在递归过程中切断环路。
-- 假设我们有一张模拟图结构的表 edges (source_id, target_id)
WITH RECURSIVE graph_traversal AS (
-- 锚点:从节点 1 开始
SELECT
target_id,
ARRAY[target_id] AS visited_path, -- 记录路径数组
1 AS depth
FROM edges
WHERE source_id = 1
UNION ALL
-- 递归项:沿着边向下走
SELECT
e.target_id,
gt.visited_path || e.target_id, -- 将新节点加入路径数组
gt.depth + 1
FROM
edges e
INNER JOIN
graph_traversal gt ON e.source_id = gt.target_id
WHERE
-- 核心防环逻辑:如果目标节点不在已访问路径中,才继续递归
NOT (e.target_id = ANY(gt.visited_path))
-- 另外,为了性能,一定要限制最大深度
AND gt.depth < 10
)
SELECT * FROM graph_traversal;
在这段代码中,NOT (e.target_id = ANY(gt.visited_path)) 是防止无限递归的关键。它确保了我们不会重复访问同一个节点。这种技术在处理复杂的 BOM(物料清单)爆单计算或网络拓扑分析时是非常成熟且必要的。
工程化深度:性能优化与替代方案对比
虽然递归 CTE 很强大,但如果在大规模数据集上使用不当,可能会导致严重的性能问题。以下是我们总结的一些关键优化建议。
1. 索引是关键(必须项)
递归查询通常涉及大量的查找操作,例如 WHERE manager_id = ?。为了确保这些操作迅速完成,你必须在递归连接的外键列上建立索引。
-- 确保在 manager_id 上创建索引
CREATE INDEX idx_employees_manager_id ON employees(manager_id);
如果没有这个索引,数据库引擎将被迫对每一行递归结果都进行全表扫描,查询速度会呈指数级下降。在我们的生产环境中,建立索引前后通常会有 100 倍以上的性能差异。
2. 谨慎使用 UNION 与 UNION ALL
- UNION ALL:速度更快,因为它不需要检查重复项。在处理树形结构(父子关系唯一)时,应优先使用
UNION ALL。 - UNION:会进行去重排序操作。如果你的数据结构是“图”(即一个节点可能有多个父节点,或者存在环路),去重是必须的,否则递归可能永远不会结束。但在纯层级结构中,它通常是不必要的开销。
3. 2026 视角下的技术选型:何时不用递归 CTE?
递归 CTE 虽然通用,但在某些极端性能场景下并非最佳选择。让我们看看在 2026 年的技术栈中,我们如何做决策。
- 场景 A:静态或低频更新的树(如菜单、地区表)。
* 建议:不要每次都查。使用“物化路径”模式,即维护一个额外的字段 INLINECODE1faa7d65 (如 INLINECODE38f676a3),查询时直接使用 INLINECODE240bb559 或 INLINECODE28eeb091 (PostgreSQL ltree 扩展)。这可以利用 B-Tree 或 GiST 索引,实现微秒级查询。
- 场景 B:复杂的图遍历(如社交网络、知识图谱)。
* 建议:PostgreSQL 的递归 CTE 在处理超大规模图(千万级节点)时会显得吃力。这时建议引入专用的图数据库(如 Neo4j)或使用 Apache AGE 扩展。但如果你不想引入新的技术栈,可以尝试使用 pg_graph 等正在发展的插件。
在我们的技术债管理会议中,我们通常遵循一个原则:能用简单 JSONB 字段存就不递归,能用物化路径就不动态递归,只有必须动态计算且层级可控时才使用 Recursive CTE。
总结
通过这篇文章,我们深入探讨了 PostgreSQL 的递归查询功能。从基本的语法结构,到查找下属、计算层级深度、逆向追溯管理链路,再到至关重要的性能优化建议,我们完整地体验了这一工具的强大之处。
掌握 WITH RECURSIVE 不仅仅意味着学会了一种语法,更意味着你能够以集合论的思维方式去解决层级遍历问题。相比于编写复杂的存储过程或在应用层进行多次数据库查询,递归 CTE 提供了原生的、高性能的解决方案。结合现代的索引策略和防环逻辑,它完全有能力胜任 2026 年企业级应用的数据处理需求。我们鼓励你在下一次遇到组织架构图、评论回复树或物料清单(BOM)查询时,尝试使用这一技术。相信只要稍加练习,它将成为你数据库工具箱中一把不可或缺的利器。