递归 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 主流数据库的语法差异速查
很多刚开始学递归 S
