作为开发者,我们经常需要处理具有层级关系的数据:组织结构、菜单树、分类目录、评论的嵌套回复等等。在SQL的世界里,这类数据通常表现为树形结构(每个节点有一个父节点,根节点无父节点)。传统的SQL查询在处理这种“无限层级”或“深度未知”的关系时往往力不从心,需要编写复杂的自连接或依赖应用程序层进行多次查询。而SQL标准中的递归公用表表达式(Recursive Common Table Expression, Recursive CTE),通过 WITH RECURSIVE
语法,为我们提供了一种在单条SQL语句内优雅遍历整个树形结构的强大方法。
WITH AS 基础回顾
WITH AS
子句(也称为CTE)的主要作用是为一个复杂的查询定义临时的命名结果集,这个结果集可以在后续的 SELECT
, INSERT
, UPDATE
, DELETE
或 CREATE VIEW
语句中被多次引用。它提高了查询的可读性和可维护性。
WITH cte_name (column1, column2, …) AS (
-- 定义子查询
SELECT …
FROM …
WHERE …
)
SELECT *
FROM cte_name
WHERE …;
WITH
:引入 CTE 的关键字。cte_name
:自定义的临时结果集名称,可在后续主查询或其他 CTE 中引用。- 可选列名列表:当子查询列不便或需要重命名时使用。
WITH AS
递归 CTE 语法结构
递归 CTE 是 WITH AS
的一种特殊形式,它允许CTE在其定义中引用自身。这使得我们能够基于初始结果集(锚点成员)逐步迭代(递归成员),构建出层级关系或序列。其基本结构如下:
WITH RECURSIVE cte_name (column_list) AS (
-- 锚点成员 (Anchor Member): 初始查询,通常是根节点
SELECT ... FROM ... WHERE ... -- 初始化条件
UNION [ALL] -- 使用 UNION ALL 保留所有行(包括重复行,递归通常需要)
-- 递归成员 (Recursive Member): 引用 cte_name 自身,连接到下一层级
SELECT ... FROM some_table
JOIN cte_name ON ... -- 关键!连接条件定义父子关系
WHERE ... -- 可选的递归终止条件
)
-- 主查询:使用最终构建好的递归CTE结果
SELECT * FROM cte_name [OPTIONS];
- 锚点(Anchor Member) :提供初始行,相当于递归的“种子”。
- 递归成员(Recursive Member) :基于上一次结果进行自我引用查询,直至满足终止条件。
示例:组织机构数据模型
假设我们有一个 organization
表,结构如下:
字段名 | 类型 | 描述 |
---|---|---|
id | INT | 组织/部门唯一ID |
name | VARCHAR(50) | 组织/部门名称 |
p_id | INT | 父级组织/部门ID (根节点的 p_id 通常为 NULL 或 0) |
示例数据 (organization
):
INSERT INTO organization (id, name, p_id) VALUES
(1, '总公司', NULL),
(2, '技术部', 1),
(3, '市场部', 1),
(4, '人力资源部', 1),
(5, '后端组', 2),
(6, '前端组', 2),
(7, '运维组', 2),
(8, '华东大区', 3),
(9, '华南大区', 3),
(10, '招聘组', 4),
(11, '培训组', 4),
(12, 'Java小组', 5),
(13, 'Go小组', 5),
(14, '上海分公司', 8),
(15, '杭州分公司', 8);
结构示意:
总公司 (id:1)
├── 技术部 (id:2)
│ ├── 后端组 (id:5)
│ │ ├── Java小组 (id:12)
│ │ └── Go小组 (id:13)
│ ├── 前端组 (id:6)
│ └── 运维组 (id:7)
├── 市场部 (id:3)
│ ├── 华东大区 (id:8)
│ │ ├── 上海分公司 (id:14)
│ │ └── 杭州分公司 (id:15)
│ └── 华南大区 (id:9)
└── 人力资源部 (id:4)
├── 招聘组 (id:10)
└── 培训组 (id:11)
递归查询实战:获取技术部及其所有子孙部门
技术部id=2
,获取该部门自身以及它下面所有层级的子部门。
递归CTE查询:
WITH RECURSIVE TechTree AS (
-- 锚点成员:选取起始点(技术部自身)
SELECT id, name, p_id, 1 AS level -- level 表示层级深度
FROM organization
WHERE id = 2 -- 指定技术部为起点
UNION ALL
-- 递归成员:查找当前结果集中所有节点的直接子节点
SELECT child.id, child.name, child.p_id, parent.level + 1
FROM organization AS child
INNER JOIN TechTree AS parent -- 关键!连接递归CTE自身
ON child.p_id = parent.id -- 连接条件:子节点的 p_id = 父节点的 id
)
SELECT id, name, p_id, level
FROM TechTree
ORDER BY level, id; -- 按层级和ID排序,便于查看
关键点:
WITH RECURSIVE TechTree ...
: 声明一个名为TechTree
的递归CTE。- 锚点成员 (
SELECT ... WHERE id = 2
):- 查询起始点(技术部,
id=2
)。 - 同时初始化一个
level
字段,值为1,表示这是树的第一层(根节点通常是0层,这里批总公司
)。
- 查询起始点(技术部,
UNION ALL
: 将递归成员的结果追加到锚点成员的结果之后。ALL
很重要,因为不同层级上的子节点是不同的行。- 递归成员 (
SELECT child.id ... JOIN TechTree AS parent ON child.p_id = parent.id
):FROM organization AS child
: 从原始表中选择潜在的子节点。INNER JOIN TechTree AS parent
: 这是递归的核心。TechTree
此时包含了之前(锚点或上一次递归)查询到的节点(作为父节点)。ON child.p_id = parent.id
: 连接条件。找到所有p_id
等于TechTree
中某个父节点id
的记录。这些记录就是父节点的直接子节点。parent.level + 1
: 子节点的层级是父节点层级加1。
- 递归终止:
- 隐式终止:当递归成员 (
SELECT child.id ...
) 再也找不到满足child.p_id = parent.id
的新行时,递归过程自然停止。在我们的例子中,当查询到叶子节点(如Java小组
,Go小组
等)后,它们没有子节点,递归即结束。
- 隐式终止:当递归成员 (
- 主查询 (
SELECT * FROM TechTree ORDER BY level, id
):- 最终从构建好的
TechTree
CTE 中选择所有列。 - 按
level
(层级)和id
排序,使结果呈现清晰的树形结构。
- 最终从构建好的
查询结果:
id | name | p_id | level |
---|---|---|---|
2 | 技术部 | 1 | 1 |
5 | 后端组 | 2 | 2 |
6 | 前端组 | 2 | 2 |
7 | 运维组 | 2 | 2 |
12 | Java小组 | 5 | 3 |
13 | Go小组 | 5 | 3 |
如何生成完整树路径
如果需要知道从根节点到当前节点的完整路径。利用递归CTE中的字符串拼接也可以轻松实现:
WITH RECURSIVE FullOrgPath AS (
-- 锚点成员:根节点(p_id IS NULL)
SELECT id, name, p_id, 1 AS level, CAST(name AS VARCHAR(255)) AS path
FROM organization
WHERE p_id IS NULL -- 通常根节点的父ID为空
UNION ALL
-- 递归成员
SELECT child.id, child.name, child.p_id, parent.level + 1,
CONCAT(parent.path, ' -> ', child.name) -- 拼接路径
FROM organization AS child
INNER JOIN FullOrgPath AS parent
ON child.p_id = parent.id
)
SELECT id, name, p_id, level, path
FROM FullOrgPath
ORDER BY path; -- 按路径排序,树形结构一目了然
结果示例:
id | name | p_id | level | path |
---|---|---|---|---|
1 | 总公司 | NULL | 1 | 总公司 |
4 | 人力资源部 | 1 | 2 | 总公司 -> 人力资源部 |
10 | 招聘组 | 4 | 3 | 总公司 -> 人力资源部 -> 招聘组 |
11 | 培训组 | 4 | 3 | 总公司 -> 人力资源部 -> 培训组 |
3 | 市场部 | 1 | 2 | 总公司 -> 市场部 |
8 | 华东大区 | 3 | 3 | 总公司 -> 市场部 -> 华东大区 |
14 | 上海分公司 | 8 | 4 | 总公司 -> 市场部 -> 华东大区 -> 上海分公司 |
... | ... | ... | ... | ... |
注意事项
RECURSIVE
关键字:在支持递归CTE的数据库(如 PostgreSQL, MySQL 8.0+, SQL Server, Oracle, SQLite 3.8.3+)中,定义递归CTE 必须 使用WITH RECURSIVE
(部分数据库如 SQL Server 允许省略RECURSIVE
,但显式写出是良好实践)。UNION ALL
vsUNION
:递归成员几乎总是使用UNION ALL
,因为我们希望保留所有层级的节点(即使名字相同)。使用UNION
会去重,可能导致层级信息丢失或递归提前终止。- 递归终止条件:
- 显式终止:可以在递归成员的
WHERE
子句中添加额外的终止条件(例如,限制最大深度AND parent.level < 5
)。 - 隐式终止:当递归成员不再产生新行时,递归自动终止(这是最常见的情况)。
- 显式终止:可以在递归成员的
- 避免无限循环:如果数据中存在循环引用(例如 A 的父是 B,B 的父又是 A),递归将陷入死循环。大多数数据库有递归深度限制(如
MAXRECURSION
提示,默认通常是 100)来防止这种情况。设计数据模型时应避免循环。
掌握递归CTE,可以轻松应对各种层级关系挑战,如组织架构、产品分类、权限继承关系等场景。下次遇到“无限级分类”或“树形数据查询”的需求时,不妨试试 WITH RECURSIVE
这把瑞士军刀!