做后台系统的这几年,我遇到最多也最头疼的SQL,基本都和树形数据有关。组织架构、商品分类、权限菜单、数据字典,表面上是一张张表,拆开看全是层级结构,真正查询起来完全不是普通条件过滤那么简单。早年间我处理这种层级结构查询,要么一次性把全表捞出来丢给程序递归拼树,要么写一个存储过程循环插入临时表,数据量小还能将就,量一大、层级一深,各种性能问题就全冒出来了。后来我把递归SQL系统地过了一遍,用 WITH RECURSIVE 这类写法在数据库内部完成树形数据展开,很多之前绕来绕去的需求突然就清爽了。这篇文章我就用几个贴近真实业务的案例,把递归SQL的语法机制、适用场景,以及我在项目中踩过的坑从头到尾捋一遍。如果你正在做报表、写权限模块、维护分类树,这篇内容大概率能直接帮上忙。
1. 树形数据为什么这么难查:先理解建模方式
1.1 大多数项目用的都是“邻接表”模型
打开任意一套后台管理系统,基本都能看到这种表结构:
sql复制CREATE TABLE dept (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
parent_id INT NULL,
CONSTRAINT fk_dept_parent FOREIGN KEY (parent_id) REFERENCES dept(id)
);
每条记录里只保存一个 parent_id,指向自己的上级节点,这就是经典的邻接表模型。它最大的优点是直观,新增一个部门只需要填一行 parent_id,查询某个节点的直接上级和直接下级也很快,一条普通 SQL 就能完成。但问题在于,一旦你问的不是“直接下级”,而是“某个部门下面到底有所有多少层部门”,邻接表模型本身给不出任何路径信息,SQL 不知道从哪一层结束,这就非常尴尬。
业务需求不会因为你建模简单就手下留情。报表系统需要按下钻看每个区域的所有子门店,权限系统需要判断某个角色有没有某个三级菜单的权限,商品系统需要统计一个顶级分类下的全部商品……这些需求本质上都在问同一个问题:给我这个节点底下所有的子孙节点。而邻接表原生只支持查“一级关系”,要查任意深度,就必须引入递归。
1.2 别再“全表捞出,程序递归”了
我在早期项目里用过一段时间的笨办法:先把整张部门表全部查出来,放到内存里,然后用 Java 或者 PHP 写循环,从根节点一层层往下把子节点挂上去。这种做法在小规模数据下确实能跑,毕竟一张几千行的部门表全量也就几百 KB,程序递归几毫秒就完成。但当树的数据量变大、查询频率变高以后,问题就很明显了。
首先是全表传输成本。我只想查“技术中心”这一支的子部门,却要把整个集团几万条组织架构数据全部拉出来,网络开销、内存占用都不划算。其次是程序里拼树的逻辑很难维护,尤其是节点顺序、路径展示、面包屑这些需求叠上来以后,递归函数越写越复杂,最后很可能只有当初写代码的人敢改。更麻烦的是,这种“先全量再递归”的做法在数据库里完全没法做进一步优化,比如要在递归过程中顺便统计每个部门的商品数量,你只能先把树拼好再重新查一遍,多出来一堆 N+1 查询。
真正让我下定决心改用递归SQL,是有一次一张组织表的数据量到了百万级,程序全量捞 10 次就要把应用服务器的内存打满,接口响应直接超时。那次之后我得到一个非常朴素的结论:能在数据库里完成的下钻,就别把数据搬到应用层做;能用一条 SQL 说清楚的层级结构查询,就别写几十行循环。
1.3 递归SQL到底解决了什么问题
可以把递归SQL理解成数据库自己执行的“局部树遍历”。你只需要告诉它两件事:第一,从哪里开始;第二,每一层怎么由上一层的记录产生下一层的记录。数据库会在内部维护一个临时结果集,反复执行第二步,直到产生不了新行才停下来。这样一来,无论树有多深,一条 SQL 就能返回某个节点下面的完整子树。
我觉得递归SQL真正的价值不只是“查出所有子部门”,而是它能把树展开这个动作和后续的统计、聚合、路径拼接放在同一条 SQL 里完成。比如你需要统计一个顶级分类下所有叶子商品的销售额,用递归CTE先把整棵分类树展开,再 JOIN 商品表和订单表,最后 GROUP BY,整个过程一气呵成,不需要任何中间步骤。这也是为什么树形数据处理里,递归SQL几乎是性价比最高的通用方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 递归CTE的核心机制与数据库写法差异
2.1 锚点成员和递归成员分别是什么
递归SQL在主流关系型数据库里基本都以 CTE(Common Table Expression,公用表表达式)为基础。PostgreSQL 和 MySQL 8.0 的关键字是 WITH RECURSIVE,SQL Server 用的是 WITH + UNION ALL 的形式,Oracle 从 11gR2 开始也支持标准递归 CTE。
一个递归CTE的基本结构是:
sql复制WITH RECURSIVE cte_name AS (
-- 锚点成员:首轮查询,负责产生初始结果
SELECT ...
FROM ...
WHERE 初始条件
UNION ALL
-- 递归成员:引用 cte_name 自身,产生下一层结果
SELECT ...
FROM cte_name
JOIN ... ON 上下层关系
)
SELECT * FROM cte_name;
锚点成员是整个递归的入口,它决定了从哪些行开始展开;递归成员则是“如何根据上一层结果找到下一层结果”的规则。数据库内部大概是这样执行的:先跑锚点成员,得到第一批结果;然后拿着这批结果去执行递归成员,得到第二批结果;再拿着第二批结果去执行递归成员,如此往复,直到某一次执行递归成员后一行都没有产生,整个过程就结束了。最终返回的是所有批次结果的并集。
用一个通俗的比喻
