SQL 里的递归听起来很怪,看过例子就明白了
大多数查询都是从已有的数据中返回行。递归 CTE 不一样:它把自己的输出作为输入再送回去,一步一步地生成行,直到没有新内容可加为止。遍历一棵深度未知的树,或者在没有数字表的情况下生成 1 到 100 的数字,靠的都是它。
它的结构永远是这样:
WITH RECURSIVE name(columns) AS (
-- anchor: the starting rows
SELECT ...
UNION ALL
-- recursive: rows derived from the previous step
SELECT ... FROM name WHERE ...
)
SELECT * FROM name;
上面是锚点查询,中间是 UNION ALL,下面是递归查询。SQLite 先执行一次锚点查询,然后不断执行递归部分,每次都使用上一轮产生的行,直到它不再返回新行。然后就停下来。
从 1 数到 10
最简单的递归 CTE 用来生成序列,连表都不需要:
一步步看它是怎么执行的:
- 锚点查询产生一行:
n = 1。 - 递归步骤拿到这一行,计算
n + 1 = 2,2 < 10为真,所以保留这一行。 - 下一轮拿到
n = 2,产生n = 3,依此类推。 - 当
n到达10时,10 < 10为假,递归步骤不再返回任何行,SQLite 停止。
WHERE n < 10 就是终止条件。没有它,查询会永远运行下去。
生成日期序列
思路相同,在实际报表中很有用:把某个范围内的每一天都补齐,哪怕那天什么都没发生:
通常你会把它和事件表做 LEFT JOIN,才能正确统计没有事件的日子。普通的 GROUP BY date 会直接跳过空的日子;而日期序列无论如何都会给每一天一行。
遍历父子树
这是最经典的用法。下面是一张员工表,每一行都指向自己的经理:
锚点查询选出根节点(没有经理的那个人)。递归步骤把员工表和 CTE 连接起来,找出所有 manager_id 与 CTE 中已有 id 匹配的人。每一轮都往下深入一层。depth 只是我们加上的计数器,用来缩进输出。
这对任意深度的树都有效。两层也好,十层也好,查询都不用改。
查找某一行的所有祖先
换个方向。不是从根往下走,而是从某个员工往上走,找出他的整条上级链:
锚点是起始员工。每一步递归都跳到上一级。到达根节点时 SQLite 就会停止:根节点的 manager_id IS NULL,连接找不到任何行。
这个模式适用于面包屑导航、楼层式评论、分类路径,以及任何需要“一路往上走到顶”的场景。
终止条件与无限循环
最常见的 bug 是忘了写终止条件,或者写的条件永远不会生效。比较一下:
-- Runs forever:
WITH RECURSIVE bad(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM bad
)
SELECT n FROM bad;
这里没有任何 WHERE 子句能让它返回零行。SQLite 会乐此不疲地一直数下去。
两个防御性习惯:
- 递归部分一定要有限制增长的
WHERE子句。 - 开发时给外层
SELECT加上LIMIT作为保险:就算你的终止条件写错了,查询也会结束。
CTE 本身没有上限,但 LIMIT 5 让外层查询提前结束。SQLite 足够聪明,不会递归到超出 LIMIT 所需的范围。这在探索数据时很有用,但不能代替生产代码中真正的终止条件。
图中的环
树不会有环,一般的图却可能有。如果数据中有环,简单的递归 CTE 会永远循环下去。解决方法是记录已经走过的路径,拒绝重复访问节点:
path 是一个用逗号分隔的字符串,记录已经访问过的节点。在加入新节点之前,WHERE 子句会检查它是否已经在里面。没有这个保护,1 → 2 → 3 → 1 这个环就会无限循环。
SQL 没有内置的“已访问集合”,你需要自己构建一个,通常用字符串,或者和目前为止的 CTE 做连接。
递归 CTE 与自连接
如果只需要一两层,自连接更简单,也更快:
这能回答“每个人的直属经理是谁”。但如果你需要“所有向上汇报到 Ada 的人,不管隔了多少层”,深度未知,那就只有递归 CTE 能干净地处理。根据需要的深度选择工具:
- 深度固定且很浅:用自连接,也许两三个。
- 深度未知或任意:用
WITH RECURSIVE。
心智模型
递归 CTE 就是一个用声明式写法表达的循环:
- 锚点查询是循环的初始值。
- 递归查询是循环体:它根据当前这批行产生下一批行。
- 终止条件是循环的退出判断:返回零行时,循环结束。
UNION ALL把所有结果累积到最终的结果集中。
一旦脑子里建立了这个对应关系,语法就不再显得奇怪了。你其实是在用 SQL 写一个 for 循环。
下一步:索引
递归 CTE 会遍历很多行,而递归步骤中的连接每一轮都要执行。如果连接列没有索引,性能会迅速断崖式下跌。下一章讲索引,而 manager_id 正是能从索引中受益的那种列。
常见问题
SQLite 中的递归 CTE 是什么?
递归 CTE 是一种 WITH RECURSIVE 查询,它通过反复引用自身来构建结果集。它由两部分组成,用 UNION ALL 连接:锚点查询产生起始行,递归查询根据上一步的结果产生更多行。SQLite 会一直执行递归部分,直到它不再返回新行为止。
什么时候应该在 SQLite 中使用 WITH RECURSIVE?
当你需要遍历树或图(员工和经理、分类和子分类、楼层式评论),或者生成一个序列(某个范围内的每一天、1 到 100 的数字)时,就该用它。普通的连接只能处理一两层;递归 CTE 能处理任意深度,而且不需要事先知道有多深。
如何避免 SQLite 递归 CTE 出现无限循环?
确保递归查询有终止条件:一个最终会返回零行的 WHERE 子句,或者一个设了上限的计数器。对于有环的图,用一列记录已经走过的路径,并排除已经在路径中的行。作为保险,再给外层查询加上 LIMIT,这样失控的递归就不会占满内存。