PostgreSQL

关注公众号 jb51net

关闭
首页 > 数据库 > PostgreSQL > PostgreSQL进行递归查询

PostgreSQL优雅的进行递归查询的实战指南

作者:数据知道

在实际开发中,我们经常会遇到树形结构或图结构的数据需求,这些场景的核心问题是:如何高效查询具有层级/递归关系的数据?所以本文将从基础到实战,手把手教你掌握递归查询的精髓,需要的朋友可以参考下

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

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

PostgreSQL 提供了强大的 WITH RECURSIVE(公共表表达式递归) 功能,是处理此类问题的标准 SQL 解决方案。本文将从基础到实战,手把手教你掌握递归查询的精髓。

一、递归查询基础:CTE 与WITH RECURSIVE

1.1 什么是 CTE(Common Table Expression)?

CTE 是一种临时结果集,可被主查询引用,语法如下:

WITH cte_name AS (
    -- 查询语句
)
SELECT * FROM cte_name;

优点:提升 SQL 可读性、避免重复子查询、支持递归

1.2 递归 CTE 的基本结构

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

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 查询“技术部”及其所有子部门(向下递归)

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 查询“后端组”的完整上级路径(向上递归)

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 限制递归深度(防止无限循环)

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),递归会无限进行。解决方案:记录访问路径

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 构建树

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:

{
  "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 场景:电商商品分类(多级类目)

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 查询“电子产品”下所有叶子类目(带完整路径)

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 中使用递归查询:

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 必建索引

-- 对 parent_id 建索引(递归连接的关键)
CREATE INDEX idx_departments_parent_id ON departments(parent_id);

-- 如果常按 name 查询根节点
CREATE INDEX idx_departments_name ON departments(name);

5.2 查看执行计划

EXPLAIN (ANALYZE, BUFFERS)
WITH RECURSIVE ... ;  -- 你的递归查询

关键观察点:

5.3 大数据量优化建议

问题解决方案
递归太深(>100层)限制 depth < N,业务上通常不需要过深层级
数据量大(百万级)分页查询(先查ID再关联)、物化路径(见下文)
频繁查询使用 物化路径(Materialized Path)闭包表(Closure Table)

六、替代方案对比:何时不用递归?

虽然 WITH RECURSIVE 很强大,但在某些场景下,其他模型更高效

6.1 物化路径(Materialized Path)

在每条记录中存储完整路径:

ALTER TABLE categories ADD COLUMN path TEXT; -- 如 "/1/2/3/"

-- 查询“手机”下所有子类目
SELECT * FROM categories 
WHERE path LIKE '/1/2/%';

✅ 优点:查询极快(走索引)
❌ 缺点:移动节点时需更新大量 path

6.2 闭包表(Closure Table)

额外建一张表存储所有祖先-后代关系:

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条件导致无限循环

-- 错误:缺少终止条件
SELECT ... FROM table, cte WHERE table.parent_id = cte.id
-- 如果存在循环引用,永远停不下来!

✅ 解决:始终加上 depth < N 或路径检测

陷阱 2:使用UNION而非UNION ALL

✅ 解决:除非明确需要去重,否则用 UNION ALL

陷阱 3:在递归部分使用聚合函数

-- 错误:递归成员不能包含聚合
SELECT ..., COUNT(*) FROM ... JOIN cte ...

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

以上就是PostgreSQL优雅的进行递归查询的实战指南的详细内容,更多关于PostgreSQL进行递归查询的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文