文章目录
-
- [一、递归查询基础:CTE 与 `WITH RECURSIVE`](#一、递归查询基础:CTE 与
WITH RECURSIVE) -
- [1.1 什么是 CTE(Common Table Expression)?](#1.1 什么是 CTE(Common Table Expression)?)
- [1.2 递归 CTE 的基本结构](#1.2 递归 CTE 的基本结构)
- [1.3 递归查询的建议](#1.3 递归查询的建议)
- 二、经典场景实战:组织架构查询
-
- [2.1 查询"技术部"及其所有子部门(向下递归)](#2.1 查询“技术部”及其所有子部门(向下递归))
- [2.2 查询"后端组"的完整上级路径(向上递归)](#2.2 查询“后端组”的完整上级路径(向上递归))
- 三、高级技巧:控制递归深度与防环
-
- [3.1 限制递归深度(防止无限循环)](#3.1 限制递归深度(防止无限循环))
- [3.2 检测并避免循环引用(图结构必备)](#3.2 检测并避免循环引用(图结构必备))
- [3.3 反向应用:扁平数据转树形 JSON](#3.3 反向应用:扁平数据转树形 JSON)
- 四、实战案例:商品分类树
-
- [4.1 场景:电商商品分类(多级类目)](#4.1 场景:电商商品分类(多级类目))
- [4.2 查询"电子产品"下所有叶子类目(带完整路径)](#4.2 查询“电子产品”下所有叶子类目(带完整路径))
- [4.3 Python + SQLAlchemy 实战](#4.3 Python + SQLAlchemy 实战)
- 五、性能优化:索引与执行计划
-
- [5.1 必建索引](#5.1 必建索引)
- [5.2 查看执行计划](#5.2 查看执行计划)
- [5.3 大数据量优化建议](#5.3 大数据量优化建议)
- 六、替代方案对比:何时不用递归?
-
- [6.1 物化路径(Materialized Path)](#6.1 物化路径(Materialized Path))
- [6.2 闭包表(Closure Table)](#6.2 闭包表(Closure Table))
- 七、常见陷阱与避坑指南
-
- [陷阱 1:忘记 `WHERE` 条件导致无限循环](#陷阱 1:忘记
WHERE条件导致无限循环) - [陷阱 2:使用 `UNION` 而非 `UNION ALL`](#陷阱 2:使用
UNION而非UNION ALL) - [陷阱 3:在递归部分使用聚合函数](#陷阱 3:在递归部分使用聚合函数)
- [陷阱 1:忘记 `WHERE` 条件导致无限循环](#陷阱 1:忘记
- [一、递归查询基础:CTE 与 `WITH RECURSIVE`](#一、递归查询基础:CTE 与
在实际开发中,我们经常会遇到树形结构 或图结构的数据需求,比如:
- 组织架构(部门 → 子部门)
- 商品分类(一级类目 → 二级类目 → ...)
- 评论回复(评论 → 回复 → 回复的回复)
- 权限继承(角色 → 子角色)
- 路径查找(最短路径、依赖关系)
这些场景的核心问题是:如何高效查询具有层级/递归关系的数据?
PostgreSQL 提供了强大的 WITH RECURSIVE(公共表表达式递归) 功能,是处理此类问题的标准 SQL 解决方案。本文将从基础到实战,手把手教你掌握递归查询的精髓。
一、递归查询基础:CTE 与 WITH RECURSIVE
1.1 什么是 CTE(Common Table Expression)?
CTE 是一种临时结果集,可被主查询引用,语法如下:
sql
WITH cte_name AS (
-- 查询语句
)
SELECT * FROM cte_name;
优点:提升 SQL 可读性、避免重复子查询、支持递归
1.2 递归 CTE 的基本结构
sql
WITH RECURSIVE cte_name AS (
-- 1. 初始查询(锚点成员 Anchor Member)
SELECT ... FROM table WHERE ...
UNION [ALL]
-- 2. 递归查询(递归成员 Recursive Member)
SELECT ... FROM table, cte_name WHERE ...
)
SELECT * FROM cte_name;
核心三要素:
| 部分 | 作用 | 注意事项 |
|---|---|---|
| 初始查询 | 定义递归起点(如根节点) | 必须能终止递归 |
| UNION [ALL] | 合并结果集 | UNION 去重,UNION ALL 保留重复(性能更高) |
| 递归查询 | 引用自身 CTE,向下/向上遍历 | 必须有连接条件,避免无限循环 |
1.3 递归查询的建议
| 场景 | 推荐方案 |
|---|---|
| 标准树形查询(上下级) | WITH RECURSIVE + UNION ALL |
| 防循环 | 记录访问路径 ARRAY[id] + != ALL(path) |
| 限制深度 | 添加 depth 字段 + WHERE depth < N |
| 高性能读 | 物化路径 / 闭包表(写少读多) |
| 返回树形 JSON | 自底向上聚合 + jsonb_build_object |
| Python 集成 | 直接执行原生 SQL(SQLAlchemy 支持 CTE) |
💡 终极建议 :
"90% 的树形查询,一个精心设计的WITH RECURSIVE就够了。"只有在性能成为瓶颈时,才考虑物化路径等复杂模型。
二、经典场景实战:组织架构查询
假设有一张部门表 departments:
sql
CREATE TABLE departments (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INTEGER REFERENCES departments(id)
);
-- 插入示例数据
INSERT INTO departments (name, parent_id) VALUES
('总公司', NULL),
('技术部', 1),
('产品部', 1),
('前端组', 2),
('后端组', 2),
('iOS组', 2),
('设计组', 3);
2.1 查询"技术部"及其所有子部门(向下递归)
sql
WITH RECURSIVE dept_tree AS (
-- 锚点:找到"技术部"
SELECT id, name, parent_id, 0 AS level
FROM departments
WHERE name = '技术部'
UNION ALL
-- 递归:找子部门
SELECT d.id, d.name, d.parent_id, dt.level + 1
FROM departments d
INNER JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT
LPAD('', level * 4, ' ') || name AS hierarchy, -- 缩进显示层级
id, parent_id, level
FROM dept_tree
ORDER BY level;
输出结果:
hierarchy | id | parent_id | level
-----------------|----|-----------|------
技术部 | 2 | 1 | 0
前端组 | 4 | 2 | 1
后端组 | 5 | 2 | 1
iOS组 | 6 | 2 | 1
技巧:
LPAD('', level * 4, ' ')生成缩进,直观展示树形结构
2.2 查询"后端组"的完整上级路径(向上递归)
sql
WITH RECURSIVE dept_path AS (
-- 锚点:从"后端组"开始
SELECT id, name, parent_id, 0 AS level
FROM departments
WHERE name = '后端组'
UNION ALL
-- 递归:找父部门
SELECT d.id, d.name, d.parent_id, dp.level + 1
FROM departments d
INNER JOIN dept_path dp ON d.id = dp.parent_id
WHERE dp.parent_id IS NOT NULL -- 避免 NULL 连接
)
SELECT
REPEAT(' → ', level) || name AS path_from_root
FROM dept_path
ORDER BY level DESC; -- 从根到当前节点
输出结果:
path_from_root
---------------------------
总公司 → 技术部 → 后端组
三、高级技巧:控制递归深度与防环
3.1 限制递归深度(防止无限循环)
sql
WITH RECURSIVE dept_limited AS (
SELECT id, name, parent_id, 1 AS depth
FROM departments
WHERE parent_id IS NULL -- 从根开始
UNION ALL
SELECT d.id, d.name, d.parent_id, dl.depth + 1
FROM departments d
INNER JOIN dept_limited dl ON d.parent_id = dl.id
WHERE dl.depth < 3 -- 最多查3层
)
SELECT * FROM dept_limited;
3.2 检测并避免循环引用(图结构必备)
如果数据存在循环(如 A→B→C→A),递归会无限进行。解决方案:记录访问路径。
sql
WITH RECURSIVE graph_traversal AS (
-- 锚点
SELECT
id,
name,
parent_id,
ARRAY[id] AS path, -- 记录已访问节点
1 AS depth
FROM departments
WHERE name = '技术部'
UNION ALL
-- 递归
SELECT
d.id,
d.name,
d.parent_id,
gt.path || d.id, -- 追加当前节点
gt.depth + 1
FROM departments d
INNER JOIN graph_traversal gt ON d.parent_id = gt.id
WHERE
d.id != ALL(gt.path) -- 关键:当前节点不在已访问路径中
AND gt.depth < 10 -- 安全兜底
)
SELECT * FROM graph_traversal;
d.id != ALL(gt.path)确保不重复访问节点,彻底解决循环问题
3.3 反向应用:扁平数据转树形 JSON
PostgreSQL 支持将递归结果直接转为 嵌套 JSON ,适合 API 返回。如使用 jsonb_build_object 构建树
sql
WITH RECURSIVE tree AS (
-- 叶子节点(无子节点)
SELECT
id,
name,
parent_id,
jsonb_build_object('id', id, 'name', name, 'children', '[]'::jsonb) AS node
FROM categories c1
WHERE NOT EXISTS (
SELECT 1 FROM categories c2 WHERE c2.parent_id = c1.id
)
UNION ALL
-- 非叶子节点(聚合子节点)
SELECT
p.id,
p.name,
p.parent_id,
jsonb_build_object(
'id', p.id,
'name', p.name,
'children', jsonb_agg(t.node)
) AS node
FROM categories p
INNER JOIN tree t ON t.parent_id = p.id
GROUP BY p.id, p.name, p.parent_id
)
SELECT node
FROM tree
WHERE parent_id IS NULL; -- 返回根节点
输出 JSON:
json
{
"id": 1,
"name": "电子产品",
"children": [
{
"id": 2,
"name": "手机",
"children": [
{"id": 3, "name": "iPhone", "children": []},
{"id": 4, "name": "华为", "children": []}
]
},
{
"id": 5,
"name": "电脑",
"children": [
{"id": 6, "name": "笔记本", "children": []}
]
}
]
}
💡 此方法利用 自底向上聚合 ,天然避免循环,但要求数据为严格树形(无环)
四、实战案例:商品分类树
4.1 场景:电商商品分类(多级类目)
sql
CREATE TABLE categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INTEGER REFERENCES categories(id),
is_leaf BOOLEAN DEFAULT false -- 是否叶子节点
);
-- 插入数据
INSERT INTO categories (name, parent_id, is_leaf) VALUES
('电子产品', NULL, false),
('手机', 1, false),
('iPhone', 2, true),
('华为', 2, true),
('电脑', 1, false),
('笔记本', 5, true);
4.2 查询"电子产品"下所有叶子类目(带完整路径)
sql
WITH RECURSIVE category_tree AS (
-- 锚点:根类目
SELECT
id,
name,
parent_id,
name::TEXT AS full_path, -- 路径字符串
1 AS level
FROM categories
WHERE name = '电子产品'
UNION ALL
-- 递归:拼接路径
SELECT
c.id,
c.name,
c.parent_id,
ct.full_path || ' > ' || c.name, -- 路径拼接
ct.level + 1
FROM categories c
INNER JOIN category_tree ct ON c.parent_id = ct.id
)
SELECT
full_path,
id,
level
FROM category_tree
WHERE is_leaf = true; -- 只查叶子节点
输出:
full_path | id | level
----------------------------|----|------
电子产品 > 手机 > iPhone | 3 | 3
电子产品 > 手机 > 华为 | 4 | 3
电子产品 > 电脑 > 笔记本 | 6 | 3
4.3 Python + SQLAlchemy 实战
在 Python 中使用递归查询:
python
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
def get_dept_tree(session, root_name):
query = text("""
WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 0 AS level
FROM departments
WHERE name = :root_name
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.level + 1
FROM departments d
INNER JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT * FROM dept_tree ORDER BY level;
""")
result = session.execute(query, {"root_name": root_name})
return result.fetchall()
# 使用
with Session() as session:
tree = get_dept_tree(session, "技术部")
for row in tree:
print(f"{' ' * row.level}{row.name}")
五、性能优化:索引与执行计划
5.1 必建索引
sql
-- 对 parent_id 建索引(递归连接的关键)
CREATE INDEX idx_departments_parent_id ON departments(parent_id);
-- 如果常按 name 查询根节点
CREATE INDEX idx_departments_name ON departments(name);
5.2 查看执行计划
sql
EXPLAIN (ANALYZE, BUFFERS)
WITH RECURSIVE ... ; -- 你的递归查询
关键观察点:
- 是否使用了
Index Scan(而非Seq Scan) - 递归深度是否合理
- 内存使用(
Buffers)
5.3 大数据量优化建议
| 问题 | 解决方案 |
|---|---|
| 递归太深(>100层) | 限制 depth < N,业务上通常不需要过深层级 |
| 数据量大(百万级) | 分页查询(先查ID再关联)、物化路径(见下文) |
| 频繁查询 | 使用 物化路径(Materialized Path) 或 闭包表(Closure Table) |
六、替代方案对比:何时不用递归?
虽然 WITH RECURSIVE 很强大,但在某些场景下,其他模型更高效:
6.1 物化路径(Materialized Path)
在每条记录中存储完整路径:
sql
ALTER TABLE categories ADD COLUMN path TEXT; -- 如 "/1/2/3/"
-- 查询"手机"下所有子类目
SELECT * FROM categories
WHERE path LIKE '/1/2/%';
✅ 优点:查询极快(走索引)
❌ 缺点:移动节点时需更新大量 path
6.2 闭包表(Closure Table)
额外建一张表存储所有祖先-后代关系:
sql
CREATE TABLE category_closure (
ancestor_id INT,
descendant_id INT,
depth INT
);
-- 查询"手机"(id=2)的所有后代
SELECT c.*
FROM categories c
JOIN category_closure cl ON c.id = cl.descendant_id
WHERE cl.ancestor_id = 2;
✅ 优点:查询快,支持任意深度
❌ 缺点:写操作复杂,存储空间大
📌 选择建议:
- 读多写少 + 深度固定 → 物化路径
- 频繁查询全路径 → 闭包表
- 通用场景 + 中小数据量 →
WITH RECURSIVE
七、常见陷阱与避坑指南
陷阱 1:忘记 WHERE 条件导致无限循环
sql
-- 错误:缺少终止条件
SELECT ... FROM table, cte WHERE table.parent_id = cte.id
-- 如果存在循环引用,永远停不下来!
✅ 解决:始终加上 depth < N 或路径检测
陷阱 2:使用 UNION 而非 UNION ALL
UNION会去重,但递归中通常不需要(父子ID唯一)- 性能损失高达 30%+
✅ 解决:除非明确需要去重,否则用 UNION ALL
陷阱 3:在递归部分使用聚合函数
sql
-- 错误:递归成员不能包含聚合
SELECT ..., COUNT(*) FROM ... JOIN cte ...
✅ 解决:先递归,再在外层聚合