PostgreSQL 实战:一文掌握如何优雅的进行递归查询?

文章目录

    • [一、递归查询基础: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:在递归部分使用聚合函数)

在实际开发中,我们经常会遇到树形结构图结构的数据需求,比如:

  • 组织架构(部门 → 子部门)
  • 商品分类(一级类目 → 二级类目 → ...)
  • 评论回复(评论 → 回复 → 回复的回复)
  • 权限继承(角色 → 子角色)
  • 路径查找(最短路径、依赖关系)

这些场景的核心问题是:如何高效查询具有层级/递归关系的数据?

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 ...

✅ 解决:先递归,再在外层聚合

相关推荐
陌上丨2 小时前
MySQL8.0高可用集群架构实战
数据库·mysql·架构
重生之绝世牛码2 小时前
Linux软件安装 —— ClickHouse单节点安装(rpm安装、tar安装两种安装方式)
大数据·linux·运维·数据库·clickhouse·软件安装·clickhouse单节点
一只自律的鸡2 小时前
【MySQL】第十一章 存储过程和存储函数
数据库·mysql
翔云1234562 小时前
MySQL 中的 utf8 vs utf8mb4 区别
数据库·mysql
AIFQuant2 小时前
如何通过股票数据 API 计算 RSI、MACD 与移动平均线MA
大数据·后端·python·金融·restful
数据知道2 小时前
PostgreSQL 实战:索引的设计原则详解
数据库·postgresql
MasonYyp3 小时前
DSPy优化提示词
大数据·人工智能
happyboy19862113 小时前
2026 大专大数据技术专业零基础能考的证书有哪些?
大数据
大公产经晚间消息3 小时前
天九企服董事长戈峻出席欧洲经贸峰会“大进步日”
大数据·人工智能·物联网