Skip to content

树形结构(递归 CTE) ​

树形数据无处不在:组织架构、商品类目、评论区盖楼、文件目录、菜单权限树。但在关系数据库里,「父子关系」的一维表格与「树」的二维直觉之间有一道鸿沟——如何在 SQL 里回答「某个节点的全部子孙」「从根到叶的完整路径」「某个节点的祖先链」?

PostgreSQL 给这道鸿沟准备了不止一座桥:WITH RECURSIVE 递归 CTE 能在查询时实时遍历任意深度的树;ltree 扩展用专门的路径类型与 GiST 索引把「路径包含」查询优化到毫秒级;物化路径与闭包表则用空间换时间的思路把常见查询变成普通索引扫描。嵌套集(nested set)这类曾经流行的技巧,在递归 CTE 普及后反而成了负资产。

本页把这几座桥逐一走一遍:先从最朴素的邻接表讲起,再用递归 CTE 解决实时遍历,然后引入 ltree 与物化路径优化高频查询,最后给出闭包表与嵌套集的对比和完整选型表。

前置知识

建议先阅读:多租户设计 与 递归 CTE。

基础概念 ​

概念英文 / 记法说明
邻接表adjacency list每行存 parent_id 指向父节点,最朴素的树存储
递归 CTErecursive common table expressionWITH RECURSIVE 在 SQL 内做图/树遍历
物化路径materialized path每行冗余存一条从根到自己的路径(如 1.4.9)
ltree—PostgreSQL 内建扩展,专管「标签路径」类型与操作符
闭包表closure table单独一张表存「所有祖先-后代对」,含深度
嵌套集nested set每行存左右界(lft/rgt),删除插入代价高昂,已不推荐
深度depth节点到根的距离
子树subtree某节点及其全部后代
兄弟排序sibling ordering同层同父节点的展示顺序

一棵示例树 ​

后续所有示例都基于这棵「部门树」:

text
公司 (id=1)
├── 研发部 (id=2)
│   ├── 后端组 (id=4)
│   │   ├── 平台小组 (id=7)
│   │   └── 业务小组 (id=8)
│   └── 前端组 (id=5)
├── 销售部 (id=3)
│   └── 华东区 (id=6)
└── 人事部 (id=9)

邻接表:树的默认形态 ​

sql
CREATE TABLE departments (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    parent_id bigint REFERENCES departments(id),
    name      text NOT NULL,
    sort_order int  NOT NULL DEFAULT 0
);

INSERT INTO departments (id, parent_id, name, sort_order) VALUES
    (1, NULL, '公司',    0),
    (2, 1,    '研发部',  1),
    (3, 1,    '销售部',  2),
    (4, 2,    '后端组',  1),
    (5, 2,    '前端组',  2),
    (6, 3,    '华东区',  1),
    (7, 4,    '平台小组', 1),
    (8, 4,    '业务小组', 2),
    (9, 1,    '人事部',  3);

邻接表的优点是写操作极其简单:插入节点就是一条 INSERT(带 parent_id),移动子树就是一条 UPDATE(改 parent_id),不破坏任何其他行。它的缺点是读操作依赖递归——「研发部下有多少人」「平台小组的全部上级」这些查询在 SQL 标准没有递归 CTE 的年代几乎无解(只能存储过程循环或应用层多次查询)。有了递归 CTE,这个缺点已经不复存在,这正是邻接表 + 递归 CTE 成为默认方案的原因。

语法与用法 ​

递归 CTE 查询整棵子树 ​

「查询 id=2(研发部)及其全部子孙」:

sql
WITH RECURSIVE subtree AS (
    -- 非递归部分(锚点):起点
    SELECT id, parent_id, name, 0 AS depth
    FROM departments
    WHERE id = 2

    UNION ALL

    -- 递归部分:从上一层的子节点继续
    SELECT d.id, d.parent_id, d.name, s.depth + 1
    FROM departments d
    JOIN subtree s ON d.parent_id = s.id
)
SELECT * FROM subtree ORDER BY depth, id;
text
 id | parent_id |   name   | depth
----+-----------+----------+-------
  2 |         1 | 研发部   |     0
  4 |         2 | 后端组   |     1
  5 |         2 | 前端组   |     1
  7 |         4 | 平台小组 |     2
  8 |         4 | 业务小组 |     2
(5 rows)

递归 CTE 的结构是「锚点(anchor)UNION ALL 递归部分」,PostgreSQL 反复执行递归部分直到返回 0 行。更多递归 CTE 的语法细节与限制见递归 CTE。

递归 CTE 查询祖先链 ​

「平台小组(id=7)的全部上级,从近到远」:

sql
WITH RECURSIVE ancestors AS (
    SELECT id, parent_id, name, 0 AS depth
    FROM departments
    WHERE id = 7

    UNION ALL

    SELECT d.id, d.parent_id, d.name, a.depth + 1
    FROM departments d
    JOIN ancestors a ON d.id = a.parent_id
)
SELECT id, name, depth FROM ancestors ORDER BY depth;
text
 id |   name   | depth
----+----------+-------
  7 | 平台小组 |     0
  4 | 后端组   |     1
  2 | 研发部   |     2
  1 | 公司     |     3
(4 rows)

与查子树的区别只在递归部分的连接方向:d.id = a.parent_id 是「向上走」。

ltree 扩展:路径类型与索引 ​

ltree 是 PostgreSQL 自带的 contrib 扩展,把「标签路径」做成一等类型:

sql
-- 安装扩展(contrib 包自带)
CREATE EXTENSION IF NOT EXISTS ltree;

-- 物化路径列用 ltree 类型
CREATE TABLE dept_tree (
    id       bigint PRIMARY KEY,
    path     ltree NOT NULL,          -- 如 公司.研发部.后端组,或用 id:1.2.4
    name     text  NOT NULL
);

CREATE INDEX dept_tree_path_gist ON dept_tree USING GIST (path gist_ltree_ops);

ltree 的常用操作符:

sql
-- path 里是否存在子路径(包含语义)
SELECT * FROM dept_tree WHERE path @> '1.2';        -- 1.2 及其所有子孙(path 以 1.2 开头)
SELECT * FROM dept_tree WHERE path <@ '1.2.4';      -- 1.2.4 的祖先(path 是其前缀)

-- 匹配模式(* 任意标签、? 单字符)
SELECT * FROM dept_tree WHERE path ~ '1.2.*{1}';    -- 1.2 的直接子节点

-- 取路径片段
SELECT subltree(path, 0, 2) FROM dept_tree;         -- 取前两级
SELECT nlevel(path) FROM dept_tree;                 -- 路径层级数

@> 表示「包含」(左边的路径覆盖右边的),<@ 表示「被包含」。gist_ltree_ops 是 ltree 专用的 GiST 索引操作符类,让 @> / <@ 查询不用全表扫描——这是 ltree 相比自存 text 路径列的核心优势。

详细说明 ​

递归 CTE 的执行模型与性能 ​

递归 CTE 在 PostgreSQL 里的执行方式是逐层迭代:执行锚点得到第 0 层,把第 0 层代入递归部分得到第 1 层,如此往复。性能特征:

  1. 每层是独立的扫描。「查整棵子树」的代价 ≈ 树深 × 子树节点数相关的索引查找。子树 1 万个节点、深度 10 层,就是约 1 万次索引探测——毫秒级,完全够用。
  2. parent_id 上必须有索引。递归部分的 JOIN subtree s ON d.parent_id = s.id 靠它走索引,没有索引会退化为每层全表扫。
  3. 宽树快、深树慢。同样节点数下,深度大的树迭代轮次多、单层行数少,恒定开销占比高。超深的树(几十上百层)考虑物化路径。
  4. UNION(去重)vs UNION ALL。树没有环时用 UNION ALL(更快);存在环风险(数据损坏、图结构)时用 UNION 去重防止死循环,或用 CYCLE 子句(PG14+)。

防环:递归 CTE 的安全阀 ​

正常树结构没有环,但脏数据(比如「自己是自己父节点」)会让递归 CTE 永不终止。两种防护:

sql
-- 方法一:深度上限(简单粗暴)
WITH RECURSIVE subtree AS (
    SELECT id, parent_id, name, 0 AS depth
    FROM departments WHERE id = 2
    UNION ALL
    SELECT d.id, d.parent_id, d.name, s.depth + 1
    FROM departments d
    JOIN subtree s ON d.parent_id = s.id
    WHERE s.depth < 50            -- 层数硬上限
)
SELECT * FROM subtree;

-- 方法二:UNION 去重(遇到已访问节点自动停止)
WITH RECURSIVE subtree AS (
    SELECT id, parent_id, name FROM departments WHERE id = 2
    UNION                          -- 注意是 UNION,不是 UNION ALL
    SELECT d.id, d.parent_id, d.name
    FROM departments d
    JOIN subtree s ON d.parent_id = s.id
)
SELECT * FROM subtree;

数据写入层再加一道触发器校验(新 parent_id 不能是自己的后代),双保险。

物化路径:用冗余换查询效率 ​

邻接表查「子树」要递归,物化路径把答案直接写进行里:

sql
ALTER TABLE departments ADD COLUMN path text;   -- 也可用 ltree

-- 维护 path:插入时取父节点 path || '.' || id
UPDATE departments SET path = '1'         WHERE id = 1;
UPDATE departments SET path = '1.2'       WHERE id = 2;
UPDATE departments SET path = '1.2.4'     WHERE id = 4;
UPDATE departments SET path = '1.2.4.7'   WHERE id = 7;

查询变成普通的字符串前缀匹配:

sql
-- 研发部(1.2)的全部子孙:一次普通扫描/索引查询,无递归
SELECT * FROM departments WHERE path LIKE '1.2.%';

-- 平台小组(1.2.4.7)的全部祖先
SELECT * FROM departments WHERE '1.2.4.7' LIKE path || '.%';

-- 深度
SELECT length(path) - length(replace(path, '.', '')) + 1 AS depth FROM departments;

代价是写复杂化:移动子树时,子树内所有节点的 path 都要重写(一条 UPDATE 配合递归 CTE 可以做到,但不再是一行代码);path 列也要占存储并进索引。适用判断:读多写少、子树查询是高频操作的树(类目、组织架构)适合物化路径;写频繁的树留在邻接表 + 递归 CTE。

ltree 与 GiST 索引:前缀查询的极致优化 ​

物化路径用 text + LIKE '1.2.%' 时,B-tree 索引帮不上「后缀任意」的忙(% 在尾部时 B-tree 才可用——前缀 LIKE 其实可以用 B-tree!但祖先查询 path || '.%' 方向的匹配用不了)。ltree 的 GiST 索引同时优化两个方向:

sql
CREATE EXTENSION IF NOT EXISTS ltree;

ALTER TABLE departments ADD COLUMN lpath ltree;
UPDATE departments
SET lpath = translate(path, '.', '.')::ltree;   -- '1.2.4' -> ltree '1.2.4'

CREATE INDEX departments_lpath_gist ON departments USING GIST (lpath gist_ltree_ops);

-- 子孙查询:GiST 索引直取
SELECT * FROM departments WHERE lpath <@ '1.2';    -- 1.2 的全部后代
-- 祖先查询:同样走 GiST
SELECT * FROM departments WHERE lpath @> '1.2.4.7';

选 ltree 还是自存 text?

维度text path + LIKEltree + GiST
标签内含 . / 中文要转义、易错标签语法原生支持(中文标签合法)
子孙查询B-tree 可用(前缀 LIKE)GiST,快且两个方向都行
祖先查询需反向 LIKE,无索引GiST 直接支持
层级操作(第 n 级、层级数)字符串函数拼接内建 subltree / nlevel
依赖无需 CREATE EXTENSION

结论:路径结构复杂、两个方向的路径查询都要、层级操作多 → ltree;简单场景 text 足够。

闭包表:空间换时间的完全体 ​

闭包表把「祖先-后代」的所有对(含自身、含深度)存成独立关系表:

sql
CREATE TABLE dept_closure (
    ancestor_id   bigint NOT NULL REFERENCES departments(id),
    descendant_id bigint NOT NULL REFERENCES departments(id),
    depth         int    NOT NULL,
    PRIMARY KEY (ancestor_id, descendant_id)
);

CREATE INDEX ON dept_closure (descendant_id, depth);

对示例树填充后(约定 depth=0 为自身):

text
 ancestor_id | descendant_id | depth
-------------+---------------+-------
           1 |             1 |     0
           1 |             2 |     1
           1 |             3 |     1
           1 |             4 |     2
           1 |             5 |     2
           1 |             6 |     2
           1 |             7 |     3
           1 |             8 |     3
           1 |             9 |     1
           2 |             2 |     0
           2 |             4 |     1
           2 |             5 |     1
           2 |             7 |     2
           2 |             8 |     2
           ...

查询全是普通连接,零递归:

sql
-- 研发部的全部子孙(不含自身)
SELECT d.* FROM departments d
JOIN dept_closure c ON c.descendant_id = d.id
WHERE c.ancestor_id = 2 AND c.depth > 0;

-- 平台小组的全部祖先(按层级排序)
SELECT d.*, c.depth FROM departments d
JOIN dept_closure c ON c.ancestor_id = d.id
WHERE c.descendant_id = 7 AND c.depth > 0
ORDER BY c.depth;

-- 某深度的后代:直接按 depth 过滤
SELECT d.* FROM departments d
JOIN dept_closure c ON c.descendant_id = d.id
WHERE c.ancestor_id = 2 AND c.depth = 1;    -- 直接子部门

-- 统计每个部门(含子部门)的节点数:一次 GROUP BY
SELECT c.ancestor_id, count(*) AS subtree_size
FROM dept_closure c
GROUP BY c.ancestor_id;

代价是存储 O(n×深度) 与写操作复杂:插入节点要为它和每个祖先各写一行闭包;移动子树要删旧路径闭包、重建新路径闭包(推荐用递归 CTE 生成):

sql
-- 移动子树(把 6 号从 3 号下移到 4 号下)的闭包重建
BEGIN;
-- 删除旧闭包(6 及其后代与旧祖先的关联)
DELETE FROM dept_closure c
USING dept_closure a, dept_closure d
WHERE a.ancestor_id = c.ancestor_id
  AND d.descendant_id = c.descendant_id
  AND d.descendant_id IN (SELECT id FROM dept_closure WHERE ancestor_id = 6)
  AND a.ancestor_id IN (SELECT ancestor_id FROM dept_closure WHERE descendant_id = 3 AND depth > 0);

-- 更新 parent_id
UPDATE departments SET parent_id = 4 WHERE id = 6;

-- 重建闭包:新祖先 × 新子树
INSERT INTO dept_closure (ancestor_id, descendant_id, depth)
SELECT a.ancestor_id, d.descendant_id, a.depth + d.depth + 1
FROM dept_closure a
JOIN dept_closure d ON d.ancestor_id = 6
WHERE a.descendant_id = 4;
COMMIT;

闭包表适合:子树统计、跨层级聚合频繁(权限系统、评论楼中楼 + 统计)的树;配合触发器维护闭包可把写复杂度收进数据库。

嵌套集:为什么不再推荐 ​

嵌套集给每个节点分配左右界(lft/rgt),子树的左右界完全落在父节点界内,子树查询变成一个范围条件,看起来很美。但它有两个致命伤:

  1. 插入/删除要重编号一半的树。在树中间插一个节点,右侧所有节点的 lft/rgt 全部 +2——大表的写放大不可接受。
  2. 并发写几乎不可行。重编号要锁整棵树。

在 WITH RECURSIVE 成为标准能力后,嵌套集的唯一优势(不递归查子树)已被物化路径与闭包表以更低的写代价取代。新项目没有理由再选嵌套集;遇到存量嵌套集系统,迁移目标是邻接表 + 递归 CTE 或物化路径。

层级展示与兄弟排序 ​

树形 UI 需要的是「带缩进的有序列表」,递归 CTE 直接输出:

sql
WITH RECURSIVE tree AS (
    SELECT id, parent_id, name, sort_order,
           0 AS depth,
           array[name]::text[] AS breadcrumb,
           lpad(sort_order::text, 4, '0') AS tree_path
    FROM departments
    WHERE parent_id IS NULL

    UNION ALL

    SELECT d.id, d.parent_id, d.name, d.sort_order,
           t.depth + 1,
           t.breadcrumb || d.name,
           t.tree_path || '.' || lpad(d.sort_order::text, 4, '0')
    FROM departments d
    JOIN tree t ON d.parent_id = t.id
)
SELECT repeat('  ', depth) || name AS indented_name,
       depth,
       breadcrumb
FROM tree
ORDER BY tree_path;    -- 按「父路径 + 兄弟序号」全局排序,兄弟顺序由 sort_order 保证

要点:

  • 兄弟顺序由 sort_order 参与递归路径拼接(lpad 补零保证字典序正确),最终 ORDER BY tree_path 一次得到整棵树的展示顺序。
  • 面包屑(breadcrumb 数组)直接可用于前端展示「研发部 / 后端组 / 平台小组」。
  • 树的展开接口通常按深度逐层懒加载:首次只返回 depth = 0 的节点,点开节点再按 parent_id 查一层——递归 CTE 服务端一次性算好的整树适合菜单、小类目;十万节点的树必须懒加载。

删除子树 ​

邻接表删子树的正确姿势是递归 CTE 定位 + 一条 DELETE:

sql
WITH RECURSIVE subtree AS (
    SELECT id FROM departments WHERE id = 4        -- 要删的子树根
    UNION ALL
    SELECT d.id FROM departments d JOIN subtree s ON d.parent_id = s.id
)
DELETE FROM departments
WHERE id IN (SELECT id FROM subtree);

外键 parent_id REFERENCES departments(id) 不拦这条 DELETE——因为删除顺序由 PostgreSQL 在语句内处理(子行与父行同批删除)。若外键定义为 ON DELETE RESTRICT 且担心并发插入干扰,把 DELETE 放进事务并先 LOCK TABLE。

实战示例 ​

示例一:递归 CTE 性能验证 ​

用 100 万节点的树验证递归查询的实际代价:

sql
-- 造一棵 10 层、约 111 万节点的随机树(每节点 10 个孩子)
CREATE TABLE big_tree (id bigint PRIMARY KEY, parent_id bigint, name text);
INSERT INTO big_tree
WITH RECURSIVE gen AS (
    SELECT 1::bigint AS id, NULL::bigint AS parent_id, 0 AS depth
    UNION ALL
    SELECT (id * 10 + i), id, depth + 1
    FROM gen, generate_series(0, 9) i
    WHERE depth < 6
)
SELECT id, parent_id, 'node ' || id FROM gen;

CREATE INDEX ON big_tree (parent_id);
ANALYZE big_tree;

-- 查 id=1 的整棵子树(约 111 万节点,7 层)
EXPLAIN ANALYZE
WITH RECURSIVE subtree AS (
    SELECT id FROM big_tree WHERE id = 1
    UNION ALL
    SELECT b.id FROM big_tree b JOIN subtree s ON b.parent_id = s.id
)
SELECT count(*) FROM subtree;
text
 Aggregate (actual time=1354.224..1354.225 rows=1 loops=1)
  CTE subtree
    ->  Recursive Union (actual time=0.037..918.455 rows=1111112 loops=1)
          ...
Execution Time: 1354.738 ms

111 万节点的全子树聚合约 1.3 秒——递归遍历本身是线性的。这提示了使用边界:整树聚合类操作要么控制子树规模,要么把结果物化(闭包表计数、定期刷新的统计表)。

示例二:组织架构树接口(PHP) ​

php
<?php
// Tree/OrgTree.php —— 一次查询构建整棵树(PDO + PostgreSQL)
class OrgTree
{
    public function __construct(private PDO $pdo)
    {
        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }

    /** 返回指定根节点下的完整树(含子孙),兄弟按 sort_order 排列 */
    public function subtree(int $rootId): array
    {
        $stmt = $this->pdo->prepare(<<<'SQL'
            WITH RECURSIVE subtree AS (
                SELECT id, parent_id, name, sort_order, 0 AS depth
                FROM departments
                WHERE id = :root
                UNION ALL
                SELECT d.id, d.parent_id, d.name, d.sort_order, s.depth + 1
                FROM departments d
                JOIN subtree s ON d.parent_id = s.id
                WHERE s.depth < 20            -- 防环安全阀
            )
            SELECT id, parent_id, name, sort_order, depth
            FROM subtree
            ORDER BY depth, sort_order, id
        SQL);
        $stmt->execute([':root' => $rootId]);

        // depth 顺序保证父节点先于子节点出现,一次遍历即可组树
        $nodes = [];
        $byId = [];
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $row['children'] = [];
            $byId[$row['id']] = $row;
            if ($row['parent_id'] !== null && isset($byId[$row['parent_id']])) {
                $byId[$row['parent_id']]['children'][] = &$byId[$row['id']];
            } else {
                $nodes[] = &$byId[$row['id']];
            }
            unset($byId[$row['id']]);           // 引用清理,避免循环引用
        }
        return $nodes;
    }

    /** 移动子树:校验环 + 更新 parent_id */
    public function move(int $id, int $newParentId): void
    {
        // newParentId 不能是 id 自己的后代
        $stmt = $this->pdo->prepare(<<<'SQL'
            WITH RECURSIVE subtree AS (
                SELECT id FROM departments WHERE id = :id
                UNION
                SELECT d.id FROM departments d JOIN subtree s ON d.parent_id = s.id
            )
            SELECT 1 FROM subtree WHERE id = :new_parent
        SQL);
        $stmt->execute([':id' => $id, ':new_parent' => $newParentId]);
        if ($stmt->fetchColumn()) {
            throw new DomainException('cannot move a node under its own subtree');
        }

        $upd = $this->pdo->prepare(
            'UPDATE departments SET parent_id = :p WHERE id = :id'
        );
        $upd->execute([':p' => $newParentId, ':id' => $id]);
    }
}

$tree = new OrgTree($pdo);
header('Content-Type: application/json');
echo json_encode($tree->subtree(1), JSON_UNESCAPED_UNICODE);

示例三:ltree 类目路径查询 ​

sql
CREATE EXTENSION IF NOT EXISTS ltree;

CREATE TABLE categories (
    id    bigint PRIMARY KEY,
    lpath  ltree NOT NULL,
    title text  NOT NULL
);

INSERT INTO categories VALUES
    (1, '1',           '电器'),
    (2, '1.2',         '电器.大家电'),
    (3, '1.2.3',       '电器.大家电.冰箱'),
    (4, '1.2.4',       '电器.大家电.洗衣机'),
    (5, '1.5',         '电器.小家电');

CREATE INDEX ON categories USING GIST (lpath gist_ltree_ops);

-- 「大家电」分类下(含子孙)的全部类目
SELECT title FROM categories WHERE lpath <@ '1.2';
text
      title
------------------
 电器.大家电
 电器.大家电.冰箱
 电器.大家电.洗衣机
(3 rows)
sql
-- 面包屑:冰箱的完整路径(直接是 lpath,无需查询)
SELECT lpath::text AS breadcrumb FROM categories WHERE id = 3;
text
   breadcrumb
-----------------
 1.2.3
(1 row)

类目表把路径作为业务标识(1.2.3 即冰箱的完整类目路径),GiST 索引让「某分类下所有商品类目」这类查询保持毫秒级。

注意事项 ​

递归 CTE 的递归部分禁止使用聚合、DISTINCT(配合去重语义时除外)等会破坏逐层语义的操作,也引用不了外层 CTE 多次——复杂树算法(拓扑排序、最短路径)要在递归结构上小心设计,别把普通 CTE 的用法直接照搬。

parent_id 必须有索引。 递归查询每层都按它找孩子,缺索引的树查询是每层全表扫,深度大时灾难。

环是真实风险。 应用 bug、手工修数据都可能造出环,递归 CTE 无防护时直接挂死连接。写入端触发器校验 + 查询端深度上限/UNION 去重,至少做一层。

物化路径与闭包表的冗余必须与主数据同事务维护。 散落在应用代码里的「记得更新 path」必然失守——用触发器或统一的写入口保证一致性。

ltree 标签语法有约束。 标签不含 . 且区分大小写;用 id 做标签最稳妥,展示名另存列。

嵌套集不要用于新项目。 写放大的问题无解,递归 CTE 时代它没有任何不可替代的优势。

层级展示要在应用层限制单次返回规模。 一棵 10 万节点的树一次查完,JSON 都要几百 MB;菜单、类目这类树要么懒加载,要么整体缓存。

复合业务里树查询要叠加租户过滤。 多租户系统的树(部门、类目)每层递归都应带 tenant_id 条件(或依赖 RLS 下推),否则跨租户越权风险。

depth 的计数要一致。 锚点从 0 开始、递归 +1 是惯例;锚点从 1 开始会让「直接子节点」过滤条件(depth = 1)含义漂移,团队内统一约定。

最佳实践 ​

  1. 默认选邻接表 + 递归 CTE。 结构最简单、写操作最自然,读性能在几十万节点规模内完全够用;先证明不够用再考虑物化路径/闭包表。
  2. 递归查询永远带深度上限或 UNION 去重。 数据损坏导致的环不应表现为数据库挂死;深度上限取业务树深的合理倍数(如理论最大 10 层,上限写 30)。
  3. 移动子树前先校验目标不是自己的后代。 应用层一次祖先查询 + 数据库触发器校验双保险,环从源头杜绝。
  4. 读多写少的高频子树查询升级为物化路径(或 ltree)。 类目、组织架构这类树,路径列把递归变成索引扫描;路径维护用触发器统一实现。
  5. 需要两个方向的路径查询或层级运算时直接用 ltree。 gist_ltree_ops + @>/<@ 覆盖子孙与祖先双向查询,比 text LIKE 的表达能力与索引支持都强。
  6. 重统计的树用闭包表。 权限继承、评论统计这类「任意节点的全部后代计数/聚合」,闭包表把递归变成一次 GROUP BY。
  7. 写路径/闭包的一致性交给触发器或统一 Repository。 冗余数据结构最怕「有入口绕过」,让一致性成为数据库行为而不是代码纪律。
  8. 层级展示用「路径拼接排序」一次取整树。 tree_path || lpad(sort_order) 的排序让兄弟顺序、层级缩进在一次查询里全部就绪,应用层组树只需一次线性遍历。
  9. 树接口默认懒加载,整树返回要有规模护栏。 按深度逐层展开是前端友好且后端可控的模式;确需整树的场景(菜单)加缓存与节点数上限。
  10. 存量嵌套集系统安排向邻接表/路径迁移。 迁移脚本用递归 CTE 从 lft/rgt 推导 parent 关系,一次搬迁,长期受益。
  11. 多租户树查询叠加租户条件或依赖 RLS。 树的每一层递归都是一次越权机会,隔离条件要在递归 CTE 的锚点与递归部分都成立。
  12. 为树操作写回归测试:环、孤儿、深链。 「把节点移到自己后代下」「删除中间节点后孩子归属」「百层深度查询」这类用例是树结构系统的地基,CR 时要求伴随变更更新。

下一节

继续学习:时序数据设计

参考链接 ​