1. 递归查询到底是什么?先解决“为什么要折腾它”
一提到 MySQL 里的递归查询,很多人的第一反应是“我又不遍历文件夹,用不上”,或者“树形结构不就是父子ID嘛,查一次不够就再查一次,写几个循环就搞定了”。但等你真正碰到组织架构树、多级商品分类、BOM物料清单、评论盖楼这类数据时,才会发现“再查几次”这件事听着简单,做起来全是泪。
我先说个结论性的话:MySQL 是在 8.0 版本才正式支持递归查询的,核心语法是 WITH RECURSIVE 公用表表达式。在 8.0 之前,你想在 SQL 里完成“从一个节点出发,把它下面所有层级的子孙节点全找出来”这件事,基本只有三条路——写存储过程循环、拼几十层自连接、或者在应用层查完一层再去查下一层。这三条路各有各的坑,等会我会逐个拆。
那递归查询到底解决什么问题?我用一句大白话说:它让数据库自己可以“根据一条查询规则反复执行,直到查无可查”,最终把一棵树、一条链、或者任何有层级关系的数据一次性返回给你。比如你给我一个部门ID,我不仅能查出它的直接子部门,还能把它下面第五层、第八层的孙部门全捞出来,而且不需要知道你总共嵌套了多少层。
有经验的兄弟可能已经发现了,这里的关键词是“不需要知道层数”。这正是传统写法最大的死穴——你能写三层自连接,但业务方突然加了个六级分类,你怎么办?拉着十一层 JOIN 硬写?那不是工程,那是行为艺术。递归查询的价值就在这里,一次定义,递归跑到底,不管树有多深,只要不撞上系统保护上限,它就能给你生推出来。
这篇文章我会从原理、语法、实操案例、性能优化到老版本兼容方案,把 MySQL 递归查询这条线彻底讲透。不管你是刚入门的小白,还是被树形查询折腾过的老手,都可以对照自己手头的场景直接拿去用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. WITH RECURSIVE 的语法拆解:锚点成员和递归成员到底怎么配合
先看完整语法框架,这是 MySQL 官方文档里递归 CTE 的基础结构:
sql复制WITH RECURSIVE cte_name AS (
-- 锚点成员:非递归的初始查询,跑一次,产生第一行结果
SELECT ...
FROM ...
WHERE ...
UNION ALL
-- 递归成员:引用 cte_name 自身,反复执行直到不再产生新行
SELECT ...
FROM cte_name
JOIN ...
WHERE ...
)
SELECT * FROM cte_name;
很多第一次接触的人看到这段会懵:这玩意儿怎么自己引用自己?数据库不会死循环吗?我们分开来看。
2.1 锚点成员:递归的“种子”
锚点成员(Anchor Member)是递归的起点,它决定了你从哪开始查。这一部分只执行一次,结果作为结果集的初始内容。
拿组织架构举例,你要找“技术部”下面的所有子部门,那锚点成员就是:
sql复制SELECT id, name, parent_id, 1 AS depth
FROM department
WHERE id = 技术部ID;
这个查询把起点部门本身捞出来,并给它标记一个初始深度。
2.2 递归成员:每一轮迭代的“发动机”
递归成员是核心,它必须引用 CTE 名字本身(上面的 cte_name),在每一轮执行时,拿上一轮刚产生的新行作为输入,再去查它的下一层。
sql复制SELECT d.id, d.name, d.parent_id, cte.depth + 1
FROM cte
JOIN department d ON d.parent_id = cte.id;
注意这里的 JOIN 方向。它是拿 cte 上一轮的结果去 JOIN department 表,找出 parent_id 等于上一轮 id 的那些行。也就是说,每一轮迭代都会把当前已知节点的直接子节点拉出来,然后深度加 1。
我给你模拟一下整个执行过程,假设技术部下面有前端组、后端组,后端组下面又有网关组:
- 第 1 轮(锚点执行):结果集只有一行——技术部,depth=1。
- 第 2 轮(第一次递归迭代):拿技术部的 id,查出前端组、后端组,depth=2。
- 第 3 轮(第二次递归迭代):拿前端组和后端组的 id 去查,查出网关组,depth=3。
- 第 4 轮:拿网关组的 id 去查,查不到子部门,产生 0 行,迭代结束。
整个过程自然终止。这就是递归查询最优雅的地方——它不需要你指定“到底迭代几次”,查不出新东西了,它自己就停下来,然后返回所有累积的行。
2.3 UNION ALL 和 UNION 的区别:决定了你去不去重
语法里还有一个容易忽略但影响巨大的细节:连接锚点成员和递归成员是用 UNION ALL 还是 UNION。
UNION ALL:保留所有行,不去重。树形结构下如果数据没有环路,用 UNION ALL 是绝对正确的。UNION:会对结果去重。代价是每次迭代都要做一次去重操作,性能差很多。
但如果你处理的数据有环路(比如 A 的父节点是 B,B 的父节点又是 A),用 UNION ALL 就会陷入无限循环,这时用 UNION 能在一定程度上利用去重机制阻止死循环,但我不建议你把 UNION 当成防环路的保命手段,后面我会专门讲怎么用深度字段控制循环。
2.4 一个小例子先跑通
如果上面你还有一点懵,可以先看一个最简单、最能验证语法的例子——生成一段数字序列:
sql复制WITH RECURSIVE seq AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM seq WHERE n < 10
)
SELECT * FROM seq;
这个查询会输出 1 到 10。原理很简单:锚点先输出 1;递归成员每次拿上一轮的 n 加 1,只要 n 小于 10 就继续迭代。当 n 等于 10 时,下一轮 n+1=11 不满足 WHERE 条件,自然结束。
你可以在自己的 MySQL 8.0 环境里跑一下,两秒就能理解整个执行机制。理解了这段,后面所有复杂的树形递归都只是在这个框架上套业务逻辑而已。
3. 一个完整的组织架构树实战:从根节点查所有子孙
前面语法讲得再明白,不如一个真实场景来得痛。这个案例我建议你从头到尾亲手过一遍,我当年就是靠这个案例彻底弄懂递归的。
3.1 先建表造数据
假设我们有一张部门表,结构非常简单:
sql复制CREATE TABLE department (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
parent_id INT NULL,
sort_order INT DEFAULT 0,
KEY idx_parent_id (parent_id),
CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES department(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里我故意建了 parent_id 上的索引和外键,因为递归查询每一轮都要用 parent_id 去查子节点,没有索引的递归查询,在数据量稍微大一点的时候就是灾难,后面性能优化章节我会再强调。
插入几条有层级的数据:
sql复制INSERT INTO department (id, name, parent_id, sort_order) VALUES
(1, '总公司', NULL, 1),
(2, '技术部', 1, 1),
(3, '产品部', 1, 2),
(4, '前端组', 2, 1),
(5, '后端组', 2, 2),
(6, '网关组', 5, 1),
(7, '用户增长组', 3, 1);
这样我们就有了一个三层半的小树:总公司下面有技术部和产品部,技术部下面有前端组、后端组,后端组又挂着网关组。
3.2 查询总公司下面的全部子孙部门
需求:你要给一个“全公司组织架构树”页面提供数据,传一个根节点 ID=1,一次性查出它下面所有层级的部门,每行都带上自己所在的层级。
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 1 AS depth, CAST(id AS CHAR(200)) AS path
FROM department
WHERE id = 1
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.depth + 1, CONCAT(dt.path, ',', d.id)
FROM dept_tree dt
JOIN department d ON d.parent_id = dt.id
)
SELECT id, name, parent_id, depth, path
FROM dept_tree
ORDER BY path;
执行结果:
| id | name | parent_id | depth | path |
|---|---|---|---|---|
| 1 | 总公司 | NULL | 1 | 1 |
| 2 | 技术部 | 1 | 2 | 1,2 |
| 4 | 前端组 | 2 | 3 | 1,2,4 |
| 5 | 后端组 | 2 | 3 | 1,2,5 |
| 6 | 网关组 | 5 | 4 | 1,2,5,6 |
| 3 | 产品部 | 1 | 2 | 1,3 |
| 7 | 用户增长组 | 3 | 3 | 1,3,7 |
这就是我们熟悉的按层级顺序排列的组织架构数据。关键点有两个:
第一,我没有直接使用原始表名去递归,而是给 CTE 起了别名 dept_tree,然后用 dept_tree.depth + 1 累加深度。每一轮迭代中,dept_tree 代表的是“截止上一轮产生的所有结果集合”。
第二,path 字段是我用 CONCAT 拼出来的路径列,它记录了从根节点到当前节点的完整 ID 链路。这个字段用处非常大,它能让你在查询结果出来后直接按整棵树的深度优先顺序排序,也方便你在树上做“面包屑导航”。如果你把 CAST(id AS CHAR(200)) 去掉直接拼,MySQL 会报类型转换错误,所以这里我故意转成了字符型。
3.3 查询某个节点向上找所有祖先
有的场景反过来——你拿到一个子节点的 ID(比如网关组 ID=6),要找出它的完整上级链路:网关组 → 后端组 → 技术部 → 总公司。这种“向上递归”就是把 JOIN 的方向反转一下:
sql复制WITH RECURSIVE parent_chain AS (
SELECT id, name, parent_id, 1 AS depth
FROM department
WHERE id = 6
UNION ALL
SELECT d.id, d.name, d.parent_id, pc.depth + 1
FROM parent_chain pc
JOIN department d ON pc.parent_id = d.id
)
SELECT id, name, parent_id, depth
FROM parent_chain
ORDER BY depth DESC;
注意这里递归成员的 JOIN 条件变成了 pc.parent_id = d.id,意思是“拿当前行的父节点 ID,去部门表里找对应的那行”。每迭代一次就向上跳一级,直到某行的 parent_id 为 NULL,JOIN 查不到任何行,循环结束。
结果就是:
| id | name | parent_id | depth |
|---|---|---|---|
| 1 | 总公司 | NULL | 3 |
| 2 | 技术部 | 1 | 2 |
| 5 | 后端组 | 2 | 1 |
| 6 | 网关组 | 5 | 1 |
最后用 ORDER BY depth DESC 把顺序反过来,就得到了我们熟悉的“根 → 叶”排列。这个查询在做权限继承、审批流上级查找、分类归属判断时经常用到。
3.4 指定节点向下查,但不要根节点
实际开发里还有一种常见需求:我要查“技术部”这个节点下面的所有子部门,但不想带上技术部自己。实现方式很简单,把 WHERE 条件从锚点里的 ID 改成锚点查询出来的结果过滤,比如把锚点成员的初始 depth 从 0 开始:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 0 AS depth
FROM department
WHERE id = 2
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.depth + 1
FROM dept_tree dt
JOIN department d ON d.parent_id = dt.id
)
SELECT id, name, parent_id, depth
FROM dept_tree
WHERE depth > 0
ORDER BY depth, id;
原理很简单:锚点查出技术部时 depth 标为 0,它的子部门递归一次 depth 变成 1,最后在外层把它过滤掉。这种写法比锚点里直接写 parent_id = 2 更通用,因为你是从“节点自身”出发,往外扩展,而不是从“它爹”出发往下找。
4. 那些你一定会踩的坑:死循环、深度限制、排序失效和性能坠落
我在实际项目里用递归查询踩过的坑,没有一个是在官方示例里能直接看到的。下面这几条是最容易炸的,我按严重程度排个序。
4.1 数据环导致死循环:比数据库崩溃更可怕的是 CPU 打满
前面说过,递归查询会在“查不出新行”时自动终止。但如果数据里有环路,这个终止条件永远不会触发,查询就会无限迭代下去,直到 MySQL 抛出 cte_max_recursion_depth 超出限制的报错。
环路长什么样?就是有两条数据互相指:A 的 parent_id = B,B 的 parent_id = A。这在真实业务里并不罕见——比如部门调整时数据迁移出错、手工在管理后台改层级改乱了、导入脏数据没做校验,都可能导致环路。
最稳妥的防环方案是控制递归深度。用 SEARCH 子句确实可以控制,但 MySQL 8.0 目前对递归 CTE 的 SEARCH 子句支持还不完善,我验证过的可靠办法是在 CTE 里加一个深度字段,然后在递归成员的 JOIN 条件后加 WHERE depth < 50 这种硬限制:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 1 AS depth
FROM department
WHERE id = 1
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.depth + 1
FROM dept_tree dt
JOIN department d ON d.parent_id = dt.id
WHERE dt.depth < 50
)
SELECT * FROM dept_tree;
这样即使数据出现环路,最多跑 50 层就会停下来,不会无限循环。深度上限按你的业务实际情况设,普通组织架构 20 层以内完全够用,设 50 已经是极度保守了。
但你也要明白,深度限制只是“止损”,它不能帮你确认数据是否有环。真正要根除问题,得从源头上保证数据的正确性:写入时做链路校验、定期跑一次检查脚本、或者在应用层使用锁表来避免并发修改造成环路。我自己的做法是在部门管理后台的“保存”接口里,用一段递归 CTE 把当前节点的所有祖先查出来,如果新 parent_id 在祖先列表里,直接拒绝保存,这就是应用层的防环方案。
4.2 cte_max_recursion_depth 超出限制:到底改多少合适?
上面那种死循环如果没有深度保护,MySQL 默认会在跑满 1000 次迭代后抛出这个错误:
code复制Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.
很多人第一反应是直接把这个参数改大。我这里必须泼一盆冷水:先确认你的查询是对的,再考虑改参数。如果你是因为数据环而死循环,把限制改成 1000000 只会让数据库先崩溃,而不是让你的业务跑得更欢。
如果数据本身没问题,确实需要超过 1000 层的递归(比如你处理深层级的 BOM、基因分类树、多级标签体系),可以在会话级别临时改:
sql复制SET SESSION cte_max_recursion_depth = 1000000;
或者用单个查询的方式:
sql复制WITH RECURSIVE dept_tree AS (...)
SELECT * FROM dept_tree;
注意 cte_max_recursion_depth 没法直接在 SQL 语句里写成变量,你必须在执行查询前用 SET 语句设置。另外这个参数同时受 max_execution_time 的影响,所以更稳的姿势是把两个一起考虑:超时和深度双保险。
4.3 递归成员里不能用聚合、窗口函数,ORDER BY 和 LIMIT 也不生效
这条非常容易踩,因为乍一看“递归成员也是 SELECT 嘛,那我能不能在里面做个 GROUP BY 或者 ORDER BY LIMIT?”答案是:不能。
MySQL 官方明确规定,递归成员中不允许使用聚合函数、窗口函数、GROUP BY、ORDER BY、LIMIT、DISTINCT 等操作。为什么?因为递归的每一次迭代结果都会作为下一轮迭代的输入,如果你在递归成员里对这些结果做排序和跳过,迭代的语义就变了,MySQL 优化器没法保证正确性。
比如下面这个写法是会直接报错的:
sql复制WITH RECURSIVE dept_tree AS (
SELECT id, name, parent_id, 1 AS depth
FROM department WHERE id = 1
UNION ALL
SELECT d.id, d.name, d.parent_id, dt.depth + 1
FROM dept_tree dt
JOIN department d ON d.parent_id = dt.id
ORDER BY d.name -- 错误!递归成员不允许 ORDER BY
LIMIT 10 -- 错误!递归成员不允许 LIMIT
)
SELECT * FROM dept_tree;
正确的做法是把所有排序、分组、分页都放到递归 CTE 的外层去处理。递归 CTE 只负责“把该捞的数据捞全”,后续的加工交给最外层的 SELECT。
4.4 递归查询性能坠落:索引、数据量、迭代次数
递归查询在数据量小的表上表现非常优雅,但一旦表变大、层级变深,性能问题会成倍放大。我用真实项目说话:一张 50 万行的部门表,树深度最大 12 层,在只有主键索引、没有 parent_id 索引的情况下,递归一次要跑 4 秒多;加上 parent_id 索引后,直接降到 200 毫秒。
原因非常简单:递归每迭代一轮,都要拿当前结果集去 JOIN 原表。如果 JOIN 的关联字段没有索引,MySQL 就要对原表做全表扫描。递归 10 层,就是 10 次全表扫描。你可以想象一下。
所以索引是第一优先级:
sql复制ALTER TABLE department ADD INDEX idx_parent_id (parent_id);
还有一点容易被忽略:递归出来的结果集会完整保存在临时表里。如果查询结果有几万行,临时表的内存占用、排序、物化都会消耗资源。尤其在你最后外层还做了 ORDER BY 或者 GROUP BY 的时候,MySQL 可能还会把临时表再复制一份去排序。所以实际项目里,建议不要在一个递归查询里把整棵树的全部节点都查出来,该用 WHERE 限制范围就限制范围。
我举一个 BOM 物料清单的案例:假设一棵产品树下挂了 5000 个物料节点,你从产品根节点出发一次性全量递归,然后在外层做聚合计算,这种查询在报表里如果每秒钟触发几十次,数据库肯定吃不消。我当时用的方案是内存缓存 + 快照表:物料树的完整结构每天凌晨算一次,写进一张扁平化快照表,业务查询直接读快照,而不是每次实时递归。实时递归只用在单点变更后的局部重算,数据量小,性能完全可控。
5. MySQL 5.7 及更早版本怎么办?三种替代方案的选型对比
如果你的生产环境还在用 MySQL 5.7(说实话,这种情况一点都不少见),那很遗憾,以上所有 WITH RECURSIVE 语法都是不能用的。但业务需求不会因为版本老就消失。我在这节里把 5.7 环境下我实测过的三种替代方案都列出来,并且把各自的适用场景讲清楚。
5.1 存储过程循环 + 临时表:动态层数的兜底方案
思路很简单:建一张临时表,然后用循环逐层查询,把每次查到的子节点追加到临时表里。用 LOOP 语句控制循环,当某一次查询结果为空时退出。
一个查所有子部门的存储过程模板:
sql复制DELIMITER //
CREATE PROCEDURE get_child_departments(IN root_id INT)
BEGIN
DECLARE max_depth INT DEFAULT 100;
DECLARE current_depth INT DEFAULT 1;
DECLARE affected_rows INT DEFAULT 1;
CREATE TEMPORARY TABLE temp_dept (
id INT PRIMARY KEY,
name VARCHAR(50),
parent_id INT,
depth INT
) ENGINE=MEMORY;
INSERT INTO temp_dept
SELECT id, name, parent_id, 1
FROM department
WHERE id = root_id;
WHILE current_depth < max_depth AND affected_rows > 0 DO
INSERT INTO temp_dept (id, name, parent_id, depth)
SELECT d.id, d.name, d.parent_id, current_depth + 1
FROM department d
JOIN temp_dept t ON d.parent_id = t.id
WHERE t.depth = current_depth
AND NOT EXISTS (SELECT 1 FROM temp_dept t2 WHERE t2.id = d.id);
SET affected_rows = ROW_COUNT();
SET current_depth = current_depth + 1;
END WHILE;
SELECT * FROM temp_dept ORDER BY depth, id;
DROP TEMPORARY TABLE temp_dept;
END //
DELIMITER ;
这套方案在当前业务场景下最需要记住的坑是:为什么我在每一轮插入子节点时都要额外加一个 NOT EXISTS 去重?因为如果数据有从上往下的重复引用(例如两个父节点指向同一个子节点),你会在临时表里插入重复行。如果加了主键约束,重复行会导致插入报错;若不加约束,则结果集会膨胀。去重条件可以用 id 判断。另外,我选用 ENGINE=MEMORY 临时表是为了加速,但内存临时表在数据量大时会转成磁盘临时表,反而更慢,所以数据量超过几十万行时建议直接用默认的磁盘临时表。
这个方案的优点是标准语法,5.7 完全兼容;可读性好,逻辑清晰,可以任意添加自定义逻辑。缺点是每一个树查询都要写一个独立的存储过程,代码重复度高;临时表在并发场景下会有内存压力;性能上没有专门的递归优化器,比 8.0 的递归 CTE 并不占优势。
5.2 自连接固定层数:只适用于层级明确且不会变的场景
如果你的业务层级是固定而且很浅的,比如最多到三级分类,那完全不需要写循环。直接自连接 N 次即可:
sql复制SELECT
l1.id AS id_l1, l1.name AS name_l1,
l2.id AS id_l2, l2.name AS name_l2,
l3.id AS id_l3, l3.name AS name_l3
FROM department l1
LEFT JOIN department l2 ON l2.parent_id = l1.id
LEFT JOIN department l3 ON l3.parent_id = l2.id
WHERE l1.id = 1;
这种写法执行计划简单,CPU 消耗低,适合那种“业务方拍胸脯说永远只有三级”的场景。但你要做好心理准备,只要需求方哪天说“我们加一个第四级”,你的 SQL 就得改一遍,顺便前端树组件也要跟着升级。是选“简单高效但不灵活”,还是选“一次写好随便加层级”,这个权衡只能在具体项目里做判断。
5.3 扁平化路径字段:用空间换时间的长久方案
这个方法被很多人忽略,但它是我在生产环境最推崇的“老版本 MySQL 树形查询”解决方案。
实现方式也很直白:在部门表里加一个字段 path,每次保存部门时,在应用层把它的完整祖先链 ID 拼接好存进去。比如网关组这一行会存成 1,2,5,6。
这样查询某个部门下面的所有子孙就非常简单:
sql复制SELECT *
FROM department
WHERE path LIKE '1,2,%'
OR path = '1,2';
你会发现这个 SQL 根本不需要递归,也不需要 JOIN,它是直接按前缀匹配字符串来捞整棵子树。优点是性能极好(前提是 path 字段有前缀索引)、读取无压力、写法简单。缺点是写入时需要在应用层维护 path 字段,而且如果部门发生移动(parent_id 变了),所有子孙节点的 path 都要跟着更新,更新成本可能非常高。
我在自己的项目里采用的是这套方案的变种:临时表快照 + 定期刷新。业务高峰期完全不更新树结构,只在每天晚上用存储过程统一重建 path 字段并刷新到快照表,白天所有读取全部走快照。牺牲一点实时性,换来的是无需在 OLTP 写路径上做高频数据处理。
5.4 三种方案怎么选?
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 存储过程循环 | 层级动态、不固定 | 语法兼容、逻辑清晰 | 代码重复、性能一般 |
| 自连接固定层数 | 层级少且固定 | 性能好、简单 | 改层级要改 SQL |
| 扁平化 path | 读多写少、层级深 | 查询最快 | 写维护复杂、移动成本高 |
结合我自己项目里的实际经验,如果你还在 5.7 上且不打算升级,树形数据的“写少读多”场景直接用扁平化 path 方案是最省心的;如果层级复杂且经常变动,存储过程循环是兜底选项。真心建议早做升级到 8.0 的规划,不仅仅是为了递归 CTE,而是 8.0 整体的性能、窗口函数、数据字典、原子 DDL 都有巨大提升。
6. 实战总结:我的几条递归查询使用经验
聊完了语法、案例、坑和旧版替代方案,最后我再分享几条在真实项目中沉淀下来的经验判断。这些不是教科书写法,是踩坑之后形成的习惯。
第一条,能用递归 CTE 的,不要在主查询里做多次递归。 我就见过有人在一条查询里写了三个 WITH RECURSIVE,又是查组织架构,又是查权限继承,结果一个接口跑了 800 毫秒。后来我把两次递归合并成一次,通过 UNION ALL 把不同递归结果集拼接,再在外层用 CASE WHEN 区分类型,接口直接降到 80 毫秒。递归与递归之间的结果复用,比你想的更重要。
第二条,查询结果一定要有合理的排序。 树形数据如果没有排序,返回的可能是层级交错、同一层级内顺序也是乱的。我的习惯是每个递归 CTE 里都通过字符串 path 字段来保证深度优先顺序,或者在外层用一个 sort_order 字段排序。order by 放在最外层,因为递归成员里不能排序。
第三条,警惕递归 CTE 与外层 WHERE 条件的执行顺序。 很多新手以为在外层加 WHERE 能减少递归量,比如“只查技术部门下面的男员工”,但实际上递归 CTE 会先把所有技术部子孙部门全查出来,再在外层过滤。如果你只在最外层过滤,性能并不会因为过滤条件减少。正确的做法是把这个过滤条件尽量下沉到锚点成员或递归成员里,让每一轮递归都只保留目标数据。
第四条,如果数据量真的很大(超百万行),不要硬用递归。 任何一个递归查询,本质上都是“逐层扫描”,迭代 N 层就要访问 N 次表。百万行数据、12 层树,最坏情况下的扫描行数会非常可观。这时候优先考虑扁平化 path 方案、嵌套集模型(Nested Set)或闭包表(Closure Table)。递归 CTE 是工具,不是银弹。
第五条,给递归查询设置超时和深度上限是生产必备。 即使你对数据质量有绝对信心——我见过太多“绝对不会有环”的数据最终出现了环。所以我的每个递归查询都会带深度保护,有些还会配合 SET max_execution_time 防止异常占用数据库资源。
关于 MySQL 递归查询,能讲的细节其实还有很多,比如它和 MySQL 8.0 中窗口函数配合做树内排名、用它实现 Calendar 日期维度展开、用它处理图关系中的最短路径问题。这些方向等以后有机会再单独展开。如果你在实际项目里遇到了递归查询相关的报错或者性能问题,拿上面这些排查思路过一遍,基本能定位到方向。希望这篇东西能让你少踩几个我踩过的坑,至少在被业务方问“能不能帮我查一下这个部门下面所有层级的子部门”的时候,你脑子里已经有了不止一条路。
