递归 SQL 这玩意,如果不做后台管理系统,很容易一辈子都用不上。可一旦业务清单里出现"商品分类不限制层级""组织架构多级展示""评论楼中楼回复"这类需求,光靠普通 SELECT 和 JOIN 能把人查到怀疑人生。
我最早被递归 SQL 逼着去研究,是在做商城后台分类的时候。运营说分类要支持任意层级,一级下面挂二级,二级下面挂三级,可能还会出现第五层第六层,页面还要一次性把整棵树加载出来。当时第一个想法是"那我在 Java 里循环查数据库不就行了",结果分类数据一多,循环查询慢到用户直接投诉。后来换成递归 CTE,一条 SQL 返回整棵子树,代码量减少一大半,查询时间也回到毫秒级。
这篇文章我把递归 SQL 从原理到实战完整拆一遍:什么时候用、怎么写向下查子树、怎么写向上查祖先链、怎么控制层级和顺序、以及我踩过的那些坑。内容适合已经会写基础 SQL、但一碰到树形数据就头大的同学,不管你是后端开发、数据分析还是面试前临时抱佛脚,都能直接照着用。
1. 树形数据建模与递归 SQL 的适用场景
1.1 最常见的三种树形存储方案
关系型数据库本身是扁平的二维表,想表达"上级-下级""父节点-子节点"这种结构,业内比较常见的方案有三种。
第一种是邻接表,也是最常见的做法。一张表里加一列 parent_id,指向自己的父记录 ID。比如分类表里"手机壳"的 parent_id 是"手机配件"的 ID,"手机配件"的 parent_id 是"手机数码"的 ID。这种模型理解起来最直观,增删改都方便,产品经理说加一层就加一层,数据库不需要做大改动,所以绝大多数业务表一开始都是这么设计的。
第二种是物化路径,在表里专门存一个 path 字段,比如 001/002/013,这个字段记录从根节点到当前节点的完整路径。查某个节点下所有后代的时候,直接 WHERE path LIKE '001/002/%' 就行,不需要递归。代价是每次移动节点都要同步更新子树内所有 path,维护成本由查询端转移到了写入端。
第三种是嵌套集,用 lft 和 rgt 两个数字表示节点在树里的左右边界,任意节点的后代都在 (lft, rgt) 这个区间里。它查子树非常快,但插入和删除节点需要重算大量节点的左右值,适合树结构基本固定、查询极其频繁的冷数据场景,比如 BOM 物料清单。
这三种方案我在项目里都见过。我的建议是:大部分业务老老实实用邻接表,然后把递归查询能力学会,这才是性价比最高的组合。
1.2 不用递归的话,你只能这样查
假设你现在用邻接表存了一张分类表,产品要求页面展示三级分类:一级分类下面显示二级,二级下面显示三级。刚开始你还能用自连接解决:
sql复制SELECT
l1.name AS level1,
l2.name AS level2,
l3.name AS level3
FROM category l1
LEFT JOIN category l2 ON l2.parent_id = l1.id
LEFT JOIN category l3 ON l3.parent_id = l2.id
WHERE l1.id = 2;
如果有五级、八级呢?你不可能预先把十几次 JOIN 全写进一条 SQL,而且一旦产品改需求说"再加一层",SQL 得重写一遍。更麻烦的是,分类往往不是一颗固定的平衡树,有的分支到第二层就结束了,有的分支能到第八层,静态 JOIN 根本无法应对这种动态层级。
也有人把查询挪到应用层,在 Java、Python 或者 Node 里循环查库:先查所有一级分类,再循环查每个一级分类下的二级分类,再循环查二级下面的三级分类。数据量小的时候这招看起来挺灵活,但数据量一上来,典型的 N+1 问题立刻暴露。比如一次查出 200 个一级分类,每个分类再查一次子分类,至少要打 200 次数据库,每个查询还有网络往返和 SQL 解析开销,整个接口慢到没法用。
1.3 这类问题到底覆盖哪些业务
只要数据具有"自己引用自己"的特征,都可以考虑用递归 SQL:
- 电商商品分类,比如"家用电器"下面有"洗衣机""冰箱",洗衣机下面还有"滚筒洗衣机";
- 组织架构表,集团总部下面有技术中心、销售中心,技术中心下面还有研发部、测试部;
- 权限菜单表,菜单和按钮权限经常做成父子树;
- 评论系统,主评论下面有回复,回复下面还有对回复的回复;
- 各种标准编码表,比如会计科目、行业分类代码这类上下级关系严谨的数据。
这些场景的共同点是层级不固定,而且你需要的数据往往不是某一层,而是某一节点下面所有子孙,或者从某一节点一直追溯到根节点。只要想清楚了这两类需求,递归 SQL 的核心思路就掌握一半了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 递归 CTE 的运行原理与语法差异
2.1 一条递归 CTE 到底是怎么执行的
递归 CTE 的完整名称是 Common Table Expression,公用表表达式。你可以把 CTE 简单理解成一条 SQL 里可以复用的临时结果集,因为它的生命周期只在当前这一条查询里,比临时表更轻量。
递归 CTE 的固定结构是:
sql复制WITH RECURSIVE cte_name AS (
-- 锚点成员:初始的查询结果
SELECT ...
UNION ALL
-- 递归成员:引用 cte_name 自己,不断迭代
SELECT ...
)
SELECT * FROM cte_name;
看起来有点绕,但执行逻辑其实只有三步。
第一步,执行锚点成员,也就是 UNION ALL 上面的那条 SELECT,得到一个初始结果集。这个初始结果集可以理解成递归的第一批"种子"。
第二步,拿这个初始结果集作为输入,去执行递归成员。递归成员里面通常会 JOIN 原始表,子节点的 parent_id 等于上一次结果集里的某个 id,从而查出来下一层的数据。
第三步,把新查出来的结果再作为输入,继续执行递归成员,直到某一次查询出来是空结果集,迭代就终止了。
举一个简单的例子。假如你要查"手机数码"下面所有分类,锚点成员查出"手机数码"这一条记录作为第一批结果。第一轮迭代用手机数码的 id 去匹配,找出它的直接子节点"手机配件""笔记本电脑"。第二轮迭代再用这些子节点的 id 去匹配,找出"手机壳""屏幕膜"。此时如果已经没有新的子节点了,查询就会自动停下,最终返回的是所有轮次结果的并集。
2.2 主流数据库的语法差异速查
很多刚开始学递归 SQL 的人容易卡在一个地方:网上查到的资料有的写 WITH RECURSIVE,有的只写 WITH,有的写的 CONNECT BY,到底哪个是对的?
原因是不同数据库对 SQL 标准的支持程度和实现方式不完全一致。我整理了一个常用的对照表:
| 数据库 | 递归写法 | 默认递归上限 | 备注 |
|---|---|---|---|
| PostgreSQL | WITH RECURSIVE ... |
理论上无硬性上限 | 语法最标准,适合学习 |
| MySQL 8.0+ | WITH RECURSIVE ... |
cte_max_recursion_depth 默认 1000 |
超过会直接报错 |
| SQL Server | WITH ...,不要写 RECURSIVE |
MAXRECURSION 默认 100 |
可以加 OPTION (MAXRECURSION 0) 解除 |
| Oracle | 老项目多用 CONNECT BY PRIOR |
循环检测机制 | 新版也支持标准写法 |
PostgreSQL 和 MySQL 8 的语法几乎一样,都是 WITH RECURSIVE。SQL Server 比较特殊,写成 WITH cte AS (...),但是递归成员和锚点成员之间必须用 UNION ALL,同时可以在整条 SQL 最后用 OPTION (MAXRECURSION N) 控制最大递归层数,N 为 0 表示不限制。
下面全文的示例,我会优先用一种接近 ANSI 标准的写法,并注明在哪类数据库可以直接运行,方便大家举一反三。
2.3 递归终止条件为什么这么重要
递归和死循环之间往往只差一个条件。很多初学者照着模板写完,一执行发现数据库 CPU 直接拉满,久久不返回结果,大概率就是递归没有正确终止。
在树结构没有环的情况下,递归是靠"查不到新的子节点"自然终止的。但如果数据本身有问题,比如某些记录的 parent_id 指向了自己的后代,形成了一个环,那么每次迭代都能查到新记录,永远查不到空集,查询就会无限循环下去。
所以设计递归 CTE 的时候至少要留一个心眼:要么在递归条件里加一个最大深度限制,要么在路径里做重复检测。后面我在实战部分会给出具体写法。
3. 实战一:向下查询整棵子树
3.1 准备一张分类表
我先建一张商品分类表,这是最贴近真实业务的例子。表结构如下:
sql复制CREATE TABLE category (
id INT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INT NULL,
sort_no INT DEFAULT 0
);
INSERT INTO category (id, name, parent_id, sort_no) VALUES
(1, '家用电器', NULL, 10),
(2, '手机数码', NULL, 20),
(3, '手机配件', 2, 21),
(4, '手机壳', 3, 211),
(5, '屏幕膜', 3, 212),
(6, '笔记本电脑', 2, 22),
(7, '电脑配件', 6, 221),
(8, '内存条', 7, 2211),
(9, '洗衣机', 1, 11),
(10, '滚筒洗衣机', 9, 111),
(11, '波轮洗衣机', 9, 112);
这里的层级关系是:
code复制家用电器(1)
洗衣机(9)
滚筒洗衣机(10)
波轮洗衣机(11)
手机数码(2)
手机配件(3)
手机壳(4)
屏幕膜(5)
笔记本电脑(6)
电脑配件(7)
内存条(8)
parent_id 为 NULL 的代表顶级节点。sort_no 是同层排序字段,这个后面会用到。
3.2 一条标准递归 SQL 的逐步拆解
需求是:给定分类 ID = 2,也就是"手机数码",找出它下面所有层级的子分类。
完整的 SQL 可以这么写:
sql复制WITH RECURSIVE category_tree AS (
SELECT
id,
name,
parent_id,
1 AS depth
FROM category
WHERE id = 2
UNION ALL
SELECT
c.id,
c.name,
c.parent_id,
t.depth + 1
FROM category c
INNER JOIN category_tree t ON c.parent_id = t.id
)
SELECT
id,
name,
parent_id,
depth
FROM category_tree
ORDER BY depth, id;
如果你是 SQL Server 用户,把 WITH RECURSIVE 改成 WITH,其他部分不变,最后可加 OPTION (MAXRECURSION 0)。
这条 SQL 的递归终止条件藏在 JOIN 里。我们拿结果集里的每一个 id 去 category 表里找 parent_id 等于这个 id 的记录。第一次递归成员执行完后,得到的是手机配件和笔记本电脑,第二次递归拿这两个 id 继续找,得到手机壳、屏幕膜和电脑配件,第三轮递归从电脑配件能找到内存条,第四轮递归没有任何子节点,查询结束。
执行过程中别忽略两件事。第一件,递归成员里 JOIN 的驱动方向是"用上一次结果集去匹配分类表",所以如果分类表的 parent_id 没有索引,每一轮递归都会做一次全表扫描,数据量大时性能会很差。第二件,depth 字段是我们自己手动加 1 算出来的,它对最终 SELECT 结果没有影响,但在限制递归深度和做面包屑时非常有用。
3.3 输出的树形结果如何更直观
上面的查询结果其实是排好的一批平铺记录,虽然 depth 字段标出了层级,但可读性一般。实际开发里经常要展示成缩进形式,比如"手机数码 > 手机配件 > 手机壳"。
缩进显示可以在递归过程中把路径拼接出来。MySQL 8 可以这样写:
sql复制WITH RECURSIVE category_tree AS (
SELECT
id,
name,
parent_id,
1 AS depth,
CAST(name AS CHAR(500)) AS path
FROM category
WHERE id = 2
UNION ALL
SELECT
c.id,
c.name,
c.parent_id,
t.depth + 1,
CONCAT(t.path, ' > ', c.name)
FROM category c
INNER JOIN category_tree t ON c.parent_id = t.id
)
SELECT
id,
name,
depth,
path,
CONCAT(REPEAT(' ', depth - 1), name) AS tree_name
FROM category_tree
ORDER BY path;
这里的核心思路是维护一个 path 字符串。锚点查询里 path 就是节点自己的 name,递归成员里把上一级的 path 和当前节点的 name 拼接起来。MySQL 语法用 CONCAT,PostgreSQL 可以用 || 运算符。
REPEAT 函数用于生成缩进空格,depth - 1 表示每一层缩进一次,这样在终端或者前端组件里展示树形结构就非常直观。
排序这里我直接用了 ORDER BY path。因为 path 包含父节点名称前缀,子节点会跟在父节点后面,天然形成深度优先遍历的顺序。这个方法简单有效,但要注意如果节点名称里有中文,不同数据库的排序规则可能导致意外结果,生产环境我更推荐在 path 里拼 ID 而不是 name,我会在后面的排序实战里细说。
4. 实战二:向上查询祖先链
4.1 反向递归的写法差异
有时候我们不是从根向下查,而是知道了一个叶子节点,想找到它的所有上级。比如在商品详情页显示面包屑导航,已知当前分类是"内存条",需要展示"手机数码 > 笔记本电脑 > 电脑配件 > 内存条"。
向上递归和向下递归的区别主要在两个地方:锚点成员查的是目标节点本身,递归成员的 JOIN 条件反过来。
sql复制WITH RECURSIVE category_ancestors AS (
SELECT
id,
name,
parent_id,
1 AS depth
FROM category
WHERE id = 8
UNION ALL
SELECT
p.id,
p.name,
p.parent_id,
a.depth + 1
FROM category p
INNER JOIN category_ancestors a ON p.id = a.parent_id
)
SELECT
id,
name,
parent_id,
depth
FROM category_ancestors;
向下递归时,我们是拿着父节点 id 去 category 表里找匹配的子节点,所以条件是 c.parent_id = t.id。向上递归时,我们是拿着当前节点的 parent_id 去 category 表里找对应的父记录,所以条件是 p.id = a.parent_id。
这个 JOIN 方向很多人写着写着就绕晕了。我的记忆技巧是:写 SQL 的时候先想清楚"我要拿哪个字段去查哪张表"。向下查子节点,已知的是当前节点 id,所以去原始表里匹配 parent_id;向上查父节点,已知的是当前节点 parent_id,所以去原始表里匹配主键 id。
注意一点,如果目标节点正好是顶级节点,parent_id 为 NULL,那么递归成员里这个 NULL 永远匹配不到任何父记录,递归会自动停掉,不需要额外处理。
4.2 用路径拼接做出完整面包屑导航
上面查询结果的 depth 越小代表离叶子越近,所以如果我们想要从根节点到当前节点的顺序,需要把结果按照 depth 降序排列,然后拼接各层名称。
MySQL 8 的写法:
sql复制SELECT
GROUP_CONCAT(name ORDER BY depth DESC SEPARATOR ' > ') AS breadcrumb
FROM category_ancestors;
这是在 MySQL 里利用聚合函数把多行合并成一行。PostgreSQL 没有 GROUP_CONCAT,需要改成 STRING_AGG(name, ' > ' ORDER BY depth DESC);SQL Server 则用 STRING_AGG 或者 FOR XML PATH,版本不同写法会有差异。
如果不用聚合函数拼接,更简单的做法是直接 ORDER BY depth DESC 查出来,然后在应用层用循环拼字符串。反正每个分类下的层级通常不会很深,数据量也不大,应用层拼接完全够用。
如果你需要在数据库里同时拿到"当前节点到根的路径"和"树形展示",可以仿照上一节的做法,在向上递归的每一层把 name 往前拼接。递归过程是从叶子往根走的,所以每向上一层,新的节点应该拼在已有 path 的前面:
sql复制WITH RECURSIVE category_ancestors AS (
SELECT
id,
name,
parent_id,
1 AS depth,
CAST(name AS CHAR(500)) AS path
FROM category
WHERE id = 8
UNION ALL
SELECT
p.id,
p.name,
p.parent_id,
a.depth + 1,
CONCAT(p.name, ' > ', a.path)
FROM category p
INNER JOIN category_ancestors a ON p.id = a.parent_id
)
SELECT id, name, depth, path
FROM category_ancestors;
5. 层级控制、排序与安全细节
5.1 最大递归层数是限制展示还是限制计算
产品需求经常是"分类最多展示三级,更深层不展示,但保留数据"。很多人会写出下面的 SQL:
sql复制WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id, 1 AS depth
FROM category
WHERE id = 2
UNION ALL
SELECT c.id, c.name, c.parent_id, t.depth + 1
FROM category c
INNER JOIN category_tree t ON c.parent_id = t.id
)
SELECT id, name, parent_id, depth
FROM category_tree
WHERE depth <= 3;
这个写法有一个隐蔽问题:WHERE depth <= 3 只发生在最终 SELECT 阶段,后面几层数据实际上已经被递归算出来了,只是没展示。也就是说,如果你有一棵深度为 20 的树,数据库实际上已经把 20 层的节点全部递归查完,然后才在结果集里筛掉后面 17 层,性能和资源浪费非常大。
正确的做法是把层级条件写进递归成员里,让它提前断掉:
sql复制WITH RECURSIVE category_tree AS (
SELECT id, name, parent_id, 1 AS depth
FROM category
WHERE id = 2
UNION ALL
SELECT c.id, c.name, c.parent_id, t.depth + 1
FROM category c
INNER JOIN category_tree t ON c.parent_id = t.id
WHERE t.depth < 3
)
SELECT id, name, parent_id, depth
FROM category_tree;
WHERE t.depth < 3 的含义是:如果当前批次已经是第三层,就不再拿它去查子节点。这样递归最多执行三轮,第四轮直接终止。这个差异在处理深层树时非常明显,我专门提出来是因为它属于那种"影响性能但不报错"的经典坑。
MySQL 和 PostgreSQL 都可以在递归成员里加层数限制。如果你担心某些极端情况递归层数失控,还可以在 SQL 尾部限制,比如 MySQL 8 可以通过设置系统变量 cte_max_recursion_depth 来控制整体上限,SQL Server 则使用 OPTION (MAXRECURSION 10) 实现类似效果。
5.2 同层排序与深度优先排序怎么做
很多分类表会带一个 sort_no 字段表示同级之间的展示顺序。直接 ORDER BY depth, sort_no 的问题在于,它只能保证同一层内 sort_no 升序,但无法保证深度优先的树形顺序。举个例子,手机数码(depth=1)下面有手机配件和笔记本电脑(depth=2),手机配件下面有手机壳、屏幕膜(depth=3)。如果按 depth 排序,depth 为 2 的手机配件、笔记本电脑排在前面,第三层全部排在它们后面,结果是:
code复制手机数码
手机配件
笔记本电脑
手机壳
屏幕膜
树形结构已经被割裂了,手机壳没有跟着手机配件走,而是排到了笔记本电脑后面。
解决办法是递归过程中维护一个"排序路径",把每一层的 sort_no 补位成固定长度的字符串后拼接起来。MySQL 8 可以使用 LPAD 补零:
sql复制WITH RECURSIVE category_tree AS (
SELECT
id,
name,
parent_id,
depth,
CAST(LPAD(sort_no, 5, '0') AS CHAR(200)) AS sort_path
FROM category
WHERE id = 2
UNION ALL
SELECT
c.id,
c.name,
c.parent_id,
t.depth + 1,
CONCAT(t.sort_path, '-', LPAD(c.sort_no, 5, '0'))
FROM category c
INNER JOIN category_tree t ON c.parent_id = t.id
)
SELECT id, name, sort_no, sort_path
FROM category_tree
ORDER BY sort_path;
sort_path 类似于 00020-00021-00211。因为每一层 sort_no 都固定占 5 位,字符串排序能正确反映数字大小顺序。为什么不直接拼 sort_no?因为数字排序和字符串排序不一致,10 会排在 2 前面。LPAD 补零后,字符串顺序和数字顺序就完全一致了。
5.3 递归查询同样不能忘记防注入
只要是和业务系统关联的 SQL,必须加上参数化查询。很多老系统习惯把传入的 ID 直接拼到 SQL 字符串里,比如:
java复制String sql = "WITH RECURSIVE ... WHERE id = " + id;
如果这个 id 来自前端接口,且没有做数字类型校验,等于把数据库操作直接暴露给了外部输入。即便你以为"这只是个数字",我也建议一律改成预编译参数或使用 ORM 的占位符。递归 CTE 本身并不特殊,防注入的底线和任何其他 SQL 完全一致,该注意的地方一点都不能省。
6. 递归 SQL 的常见问题排查与性能优化
6.1 高频故障排查速查表
下面这些问题都是我实际开发中遇到过、或者帮别人排查过的,整理成一个速查表,方便以后直接对照。
| 异常现象 | 可能原因 | 解决方案 |
|---|---|---|
| 只查出一层结果,子节点没出来 | 父字段和子字段类型不一致,比如一个是字符串"2",一个是整数 2 | 统一 id 和 parent_id 字段类型,递归时用 CAST 显式转换 |
| 查询长时间不返回,CPU 飙升 | 表里数据成环,比如 A 的 parent 指向 B,B 的 parent 又指向 A | 增加最大深度限制,或者在 path 里做节点重复检测 |
| SQL Server 报"已达到 MAXRECURSION 100" | 默认递归上限是 100 | 在 SQL 末尾加 OPTION (MAXRECURSION 0) |
| MySQL 报"recursion aborted after 1001 iterations" | cte_max_recursion_depth 默认 1000 |
根据实际需求调大该参数,但也要警惕死循环 |
| PostgreSQL 报"recursive reference ... must not appear within subquery" | 递归成员里用了聚合、窗口函数或者子查询 | 把复杂计算放到递归结果外面再做 |
| 递归结果顺序混乱 | 递归 CTE 本身不保证输出顺序 | 明确使用 ORDER BY,按需求用 path 或 sort_path |
6.2 在数据有环的情况下强制终止递归
死循环是递归 SQL 最头疼的问题,因为数据一旦成环,表面上看不出什么异常,但整个数据库会被长时间占住。
我自己维护分类表的时候,确实遇到过运营手工导数据导致 parent_id 指错的情况,两条数据互相指认。后来我在关键查询里做了预防性检测。
以 MySQL 8 为例,可以给递归 CTE 维护一个 path 字段,里面记录从根到当前节点的全部 ID,用逗号分隔。每一轮向下递归之前,先判断当前节点 ID 是否已经在 path 里出现过:
sql复制WITH RECURSIVE category_tree AS (
SELECT
id,
name,
parent_id,
1 AS depth,
CAST(id AS CHAR(100)) AS path
FROM category
WHERE id = 2
UNION ALL
SELECT
c.id,
c.name,
c.parent_id,
t.depth + 1,
CONCAT(t.path, ',', c.id)
FROM category c
INNER JOIN category_tree t ON c.parent_id = t.id
WHERE FIND_IN_SET(c.id, t.path) = 0
)
SELECT id, name, parent_id, depth, path
FROM category_tree;
FIND_IN_SET(c.id, t.path) = 0 的作用是:如果当前节点 ID 已经在路径中出现过,说明这条链路形成了环,就不要再继续往下走了。PostgreSQL 没有 FIND_IN_SET,需要使用字符串位置函数判断,写法类似:
sql复制WHERE position(',' || c.id || ',' IN ',' || t.path || ',') = 0
当然,这种检测会带来额外的字符串开销,所以我只建议在数据来源不可控的场景使用。如果数据源头有严格校验,确保不会成环,那么可以省掉这一步,用深度限制兜底即可。
6.3 性能优化必须优先做索引
递归 SQL 的每一轮迭代,本质上是等值连接。向下递归时每轮要执行类似 WHERE parent_id = 某个值 的操作,向上递归时每轮要执行 WHERE id = 某个值。所以索引策略其实很清楚:
parent_id上必须建索引,支撑向下递归;- 主键如果已经有索引,向上递归通常问题不大;
- 如果表里数据经常变,普通 B+ 树索引就够,不用刻意建复合索引。
建索引的 SQL 很简单:
sql复制CREATE INDEX idx_category_parent_id ON category(parent_id);
不加索引时,树深度只有三层可能还好,一旦深度达到十几层,每层递归都要全表扫描,数据量几十万以上时整个查询会慢到完全不可接受。加了索引之后,每轮递归走的是索引查找,整体耗时会从秒级降到毫秒级,这是性价比最高的一步优化。
另外,递归 CTE 内部尽量不要放 DISTINCT、GROUP BY、窗口函数这类操作。它们不只可能被数据库直接禁止,即使允许,也会破坏或者拖慢迭代过程。正确做法是让递归部分只做纯粹的树形节点关系查询,等结果全集产生后,在外面一层再去做去重、排序、聚合和格式化显示。
6.4 如果深度太深、数据量太大,考虑 plan B
递归 SQL 也不是万能的。我见过一些极端案例,类别结构本身就有二三十层,同时叶子节点数量上百万,这种情况下每轮递归都要传递中间结果集,数据库压力会非常大。
遇到这类场景,我通常有三个替代方向。
第一个方向是物化路径冗余。既然递归慢是因为每次要现算父子关系,那就在写入的时候把 path 字段维护好。比如把分类路径保存成 0001/0002/0013,查询后代直接使用前缀匹配。由于没有递归迭代,一次索引范围扫描就能拿到所有节点,性能很稳定。
第二个方向是用程序一次性查出整张表,然后在内存里组树。某些分类表的全量数据只有几千行,一次性 SELECT 出来加上应用层组装,可能比任何数据库递归都快,代码也好维护。适合菜单、组织架构这类全量加载的业务。
第三个方向是引入专门处理树结构的存储,比如用图数据库、文档数据库,或者在业务层面维护一颗物化树表。关系型数据库的递归能力再强,也不适合当作所有树形问题的万能解,合理规划边界更重要。
我个人在实际操作里比较喜欢的方案是:邻接表作为唯一数据源,同时冗余一份路径字段用于高频查询。写入侧改动父节点时,通过一个后台任务重算受影响子树的所有 path。这样既保留了邻接表的灵活性,又绕开了高频递归的性能损耗。如果你的业务树很大、又需要极致的查询速度,可以往这个方向试试。
