MySQL的递归查询我前前后后用过不少次,从最早在存储过程里写循环一条条查,到后来换成WITH RECURSIVE一条SQL搞定整棵树,这个转变确实让我省了不少心。如果你也遇到过“查组织架构要写半天代码”“菜单树查出来还得在Java里拼半天层级”这类问题,这篇文章应该能帮你把递归查询这块彻底吃透。我会从语法原理、实战用法、性能优化到踩坑记录都过一遍,不管你是刚接触MySQL的新手,还是已经在业务里被树形结构折磨过的老手,都能找到能直接抄的解决方案。
1. 递归查询到底是什么,它解决了什么问题
1.1 没有递归查询时我们怎么处理树形结构
先回忆一个场景:一张部门表,id、parent_id、name三个字段,典型的邻接表结构。要查出某个部门下面所有子部门,最笨的办法是先在应用层查出第一层子部门,然后遍历每个子部门再查一次,循环往复直到查不到子节点为止。这种写法本身没问题,但问题也摆在明面上。
一是SQL查询次数不可控。树的深度有多少层,就要执行多少次查询,如果每层有多个节点,那N+1问题会直接放大成N×M问题。二是应用层代码特别啰嗦。你得维护一个待遍历队列,写递归函数或者while循环,再加上去重、排序、组装树形结构,光这些逻辑就能写上百行。三是事务边界不好控制。如果中途某次查询失败,你很难说清楚数据一致性到底有没有被破坏。
这也是为什么很多老项目里的树形结构查询代码极其难维护,新接手的人看着那一堆循环和递归函数头皮发麻。而WITH RECURSIVE这种公共表表达式(CTE,Common Table Expression)的递归写法,本质上是把“反复查询同一张表并不断把结果集喂回给下一次查询”这件事,放到了数据库引擎内部去完成。我们只需要描述清楚两件事:从哪一行开始,以及每一步如何从当前结果推导出下一步结果。
1.2 WITH RECURSIVE 的语法拆解
MySQL从8.0版本开始支持WITH RECURSIVE,8.0之前只能用存储过程或者多层JOIN硬凑。它的基础语法长这样:
WITH RECURSIVE cte_name AS ( -- 锚点成员:初始结果集 SELECT ... UNION ALL -- 递归成员:引用cte_name自身,生成下一批数据 SELECT ... ) SELECT * FROM cte_name;这里有几个关键点必须搞清楚。UNION ALL和UNION是有区别的,前者保留全部重复行,后者会去重。递归查询里绝大多数情况下我们用的是UNION ALL,因为树形结构里每个节点在递归过程中只会被访问一次,不太容易出现重复行让你非去重不可的情况,而UNION的去重代价在递归场景下会明显拖慢速度。
递归成员的查询里必须引用cte_name自身,这是递归得以继续的原因。而且每次递归产生的数据会继续作为下一轮的输入,直到某次查询返回空结果集,递归就会自动终止。这个过程类似一个循环:给定初始集合,不断扩展集合,直到不再有新数据。
一个最小的例子,用数字序列来理解:
WITH RECURSIVE seq AS ( SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n < 10 ) SELECT * FROM seq;第一轮seq里只有1,递归成员取出n=1生成2,第二轮拿2生成3……一直到n=10时不满足n < 10,递归终止,最终得到1到10的数字序列。这个例子虽然简单,但它是理解一切递归查询的基础——锚点成员负责“点火”,递归成员负责“持续加柴”,条件负责“熄火”。
1.3 一个小例子快速入门
拿组织架构举例。假设有一张dept表,id、parent_id、name,根部门的parent_id为NULL。要查出id=1这个部门下面所有的子部门,可以这么写:
WITH RECURSIVE dept_tree AS ( SELECT id, parent_id, name, 0 AS depth FROM dept WHERE id = 1 UNION ALL SELECT d.id, d.parent_id, d.name, dt.depth + 1 FROM dept d INNER JOIN dept_tree dt ON d.parent_id = dt.id ) SELECT * FROM dept_tree;第一轮选出了根部门,深度是0;第二轮把parent_id=1的部门全部关联出来,深度变成1;接着把parent_id等于这批部门id的再关联出来,深度继续加1。只要某层没有任何子部门能关联上,递归自然停住。
这个写法里有个细节值得注意:递归成员里INNER JOIN dept_tree dt ON d.parent_id = dt.id,这个JOIN就是递归的“桥梁”。它把上一次的结果集dept_tree作为驱动表,去原表dept里找孩子。理解了这行JOIN,你就理解了八成递归查询的写法。
2. 从零开始:一张组织架构表搞定递归查询
2.1 表设计与测试数据准备
实际操作之前,先建一张标准的分层表,顺便加上索引,避免后面排查问题时分不清是数据问题还是SQL问题。
CREATE TABLE dept ( id INT PRIMARY KEY, parent_id INT NULL, name VARCHAR(50) NOT NULL, sort_no INT DEFAULT 0, KEY idx_parent_id (parent_id), KEY idx_sort_no (sort_no) ); INSERT INTO dept VALUES (1, NULL, '总公司', 1), (2, 1, '华东分部', 1), (3, 1, '华南分部', 2), (4, 2, '上海分公司', 1), (5, 2, '杭州分公司', 2), (6, 3, '广州分公司', 1), (7, 4, '浦东支公司', 1), (8, 5, '滨江支公司', 1);parent_id上建索引是必须的,因为递归成员里每次都要用d.parent_id = dt.id这个条件去扫描,没有索引就是全表扫描乘以递归深度,数据量一大直接爆炸。我见过不少递归查询慢的案例,最后排查下来都是缺这个索引。
2.2 向上递归与向下递归
向下递归就是前面那个写法,从根往下找所有子孙节点。向上递归则反过来,从某个子节点出发,不断找id = parent_id的上一级,一直追到根。两种方向语法几乎对称,差别只在JOIN的方向。
-- 向上递归:从id=7出发,找到所有上级直到根 WITH RECURSIVE dept_ancestors AS ( SELECT id, parent_id, name, 0 AS depth FROM dept WHERE id = 7 UNION ALL SELECT d.id, d.parent_id, d.name, da.depth + 1 FROM dept d INNER JOIN dept_ancestors da ON d.id = da.parent_id ) SELECT * FROM dept_ancestors;注意这里递归成员里的JOIN条件变成了d.id = da.parent_id,含义是“当前结果集里的parent_id对应的那条上级记录”。向下是找孩子,向上是找爸爸,理解了这个方向感,后面不管换什么业务表都能套。
向上递归实际用得也不少,典型场景是权限判断:一个用户属于某个子部门,需要判断他有没有权限操作某条数据,而权限挂在上级部门上,那就得从用户的部门一路向上找权限。这种场景如果用应用层循环,每层一个查询,接口响应时间就上去了,一条递归SQL顶回去,逻辑清晰而且只查一次库。
2.3 路径拼接与层级深度控制
很多业务不只要查出子孙节点,还得知道每个节点的完整路径和层级。比如树形菜单要展示“总公司 / 华东分部 / 上海分公司 / 浦东支公司”这种面包屑,或者列表里要按层级缩进显示。MySQL里可以用CONCAT配合递归自动拼路径。
WITH RECURSIVE dept_tree AS ( SELECT id, parent_id, name, 0 AS depth, CAST(name AS CHAR(500)) AS path FROM dept WHERE id = 1 UNION ALL SELECT d.id, d.parent_id, d.name, dt.depth + 1, CONCAT(dt.path, ' / ', d.name) FROM dept d INNER JOIN dept_tree dt ON d.parent_id = dt.id ) SELECT id, name, depth, path FROM dept_tree;这里有一个比较容易踩的坑:递归成员里path字段的类型必须和锚点成员一致,否则MySQL会报Illegal mix of collations之类的错误。锚点里先CAST(name AS CHAR(500))就是提前把字段类型和长度固定下来,避免递归过程中字段类型推断不一致。
还有一个小细节,路径会随着递归深度变长,所以CHAR(500)是给路径字段留足余量,如果树特别深、路径特别长,就把长度调大。但也不能无脑调大,过长的字段会造成内存和排序开销增加。
3. 递归查询的性能优化与避坑指南
3.1 为什么递归越查越慢
递归查询慢的根源,往往不是递归本身,而是每一轮递归都要重新扫描一遍表。拿前面的组织架构查询来说,每层递归都会执行一次INNER JOIN dept d ON d.parent_id = dt.id,如果parent_id上没有索引,MySQL只能对dept表做全表扫描。假设表有10万行,树深度是5层,总共就是50万次行扫描,性能自然好不到哪里去。
更隐蔽的一个问题是,如果每次递归生成很多中间行,递归过程中临时表会不断膨胀。CTE递归的中间结果默认是存在内存临时表里的,如果超过tmp_table_size的限制,会溢出到磁盘,形成磁盘临时表,那速度会慢到让你怀疑数据库是不是卡死了。
所以在生产环境里用递归查询,我一般会先看两样东西:一是EXPLAIN里递归成员有没有走索引,二是SHOW STATUS LIKE 'Created_tmp_disk_tables'有没有在递归执行期间暴涨。这两样搞清楚了,慢的原因基本就定位到了。
3.2 优化手段:索引、深度限制、CTE物化
优化手段我按有效程度排个序。
第一个必须做的是在递归关联字段上建索引。parent_id这种字段在邻接表设计里天然就是高频查询字段,不管你用什么方式查树形结构,这个索引都该建。对递归查询来说,索引意味着每一轮JOIN从“全表扫”变成“点查”,性能提升是最直接的。建索引的语句很简单:ALTER TABLE dept ADD INDEX idx_parent_id (parent_id);
第二个是限制递归深度。MySQL 8.0里可以通过cte_max_recursion_depth系统变量控制最大递归深度,默认是1000。这个值不光是防御机制,也是性能保护。一旦数据结构出现异常变成环,递归就会无限循环下去,直到打爆临时表资源。把深度限制设成一个合理值,比如100,既能覆盖绝大多数业务树的深度,又能及时终止异常递归。
SET SESSION cte_max_recursion_depth = 100;第三个是评估CTE物化策略。MySQL 8.0里CTE默认可能会被物化多次,如果递归成员里引用同一个CTE的多个地方,代价会成倍上涨。通常情况下我们不会在递归成员里多次引用CTE,但如果你看到执行计划里出现重复的物化操作,可以检查一下是不是SQL写法导致CTE被展开了多份。
3.3 与其他方案的对比:邻接表、闭包表、路径枚举
递归查询不是解决树形结构的唯一方式,而且也不是所有场景的最优解。我做过一次对比,把各方案的适用场景理清楚了。
邻接表(就是我们一直在用的id + parent_id)配合递归查询,是最通用的方案,优点是表结构清晰、增删改方便,缺点是查询全部子孙依赖递归或者多次查询。数据量几万行的组织架构、菜单、分类,用起来完全没问题。
闭包表(Closure Table)是单独存一张“祖先-后代”关系表的方案,每次增删都要同步维护关系数据,写入更重,但查询任意层级关系都只需要一次普通查询,不需要递归。适合查询极其频繁、树结构相对稳定、写操作少的数据,比如权限关系、分类树。
路径枚举(Path Enumeration)是把完整路径存在一个字段里,比如/1/2/4/,查询子节点用LIKE '/1/2/4/%',写起来简单,但修改节点位置时需要批量更新路径,而且索引对LIKE前缀匹配不友好。适合树结构几乎不变、查询模式很固定的场景。
我的建议是:如果不确定该用哪种,默认选邻接表配合递归查询。等真的到了闭包表或路径枚举的场景,比如树深度很大、查询频率极高、写入很少,再考虑换方案,不要一开始就过度设计。
4. 生产环境中的常见问题与排查套路
4.1 死循环与报错ERROR 3636
先说一个我确实踩过的坑:数据里有脏数据,某个节点的parent_id指向了自己,或者两个节点互相指来指去,形成环。这时候递归查询会无限循环,MySQL会报ERROR 3636: Recursive query aborted after 1000 iterations。
这个报错的含义就是触发了递归深度上限,MySQL保护性地终止了查询。发现这个报错,第一反应不要是调大cte_max_recursion_depth,因为调大了也只是延迟了资源耗尽的时间点。正确的做法是去检查数据,把环找出来。
定位环的SQL可以这样写:
-- 找出parent_id指向自己节点的脏数据 SELECT * FROM dept WHERE id = parent_id; -- 两节点互相指向 SELECT a.id, a.parent_id, b.id, b.parent_id FROM dept a JOIN dept b ON a.parent_id = b.id AND b.parent_id = a.id WHERE a.id < b.id;更复杂的三节点以上成环,可以即席构造一个递归查询,在递归过程中记录已经访问过的节点路径,然后检查路径里是否出现重复节点。但多数生产环境的环,都是前面两种情况造成的,先查这两种就差不多了。
4.2 数据里出现环怎么办
如果查出来了确实有环,比如id=4的部门parent_id指向了id=6,而id=6的parent_id又指向id=4,那么递归查询从id=4出发时就会无限循环。这时候除了修数据,还有一个防线:在递归查询里主动加一个“不能返回到已访问节点”的约束。
用一个visited字段来记录路径上已经访问过的节点id集合,然后限制新节点的id不能已经出现在路径里:
WITH RECURSIVE dept_tree AS ( SELECT id, parent_id, name, CAST(id AS CHAR(500)) AS visited FROM dept WHERE id = 1 UNION ALL SELECT d.id, d.parent_id, d.name, CONCAT(dt.visited, ',', d.id) FROM dept d INNER JOIN dept_tree dt ON d.parent_id = dt.id WHERE FIND_IN_SET(d.id, dt.visited) = 0 ) SELECT * FROM dept_tree;第四行给visited字段赋了初始值,记录从根开始的路径节点集合。递归成员里WHERE FIND_IN_SET(d.id, dt.visited) = 0就是防环的“保险丝”:如果新节点id已经在当前路径上出现过,就跳过它,不参与下一轮递归。这种方式虽然会多消耗一点字符串拼接的开销,但能在数据层面存在脏数据时保证查询不会死循环。生产环境树形数据如果来源复杂,我建议直接把这个防环逻辑写进递归SQL里,一劳永逸。
4.3 递归结果排序与分页的坑
递归查询的结果顺序不是按你直觉来的。默认情况下,每一轮递归产生的行会按轮次依次追加,同一轮内部不保证顺序。想按层级和sort_no排序,最稳妥的做法是在递归结束后的外层SELECT里统一排序。
WITH RECURSIVE dept_tree AS (...) SELECT * FROM dept_tree ORDER BY depth, sort_no;有人可能会尝试在递归成员里写ORDER BY,这没有意义,因为内层排序不会影响最终结果,反而可能破坏递归的逻辑顺序。正确思路是:递归只负责“把数据捞全”,排序交给外层。
分页和递归是另一个容易让人疑惑的地方。如果你在递归CTE外层直接LIMIT 10 OFFSET 0,每轮递归都会把整棵树完整生成出来,然后才轮到外层分页截取10行。这意味着分页的页码越深,递归耗费的资源越大,但至少结果是正确的。需要注意的坑是:如果依赖递归过程中自然的行序来分页,跳页后顺序会完全对不上,所以分页之前必须先把ORDER BY放到外层,保证先有稳定顺序再分页。
还有一种更激进的做法是每轮递归都先过滤再递归,但这需要业务上有明确的剪枝条件,实现复杂度明显更高,并不适合所有树形查询。
5. 实战案例:菜单权限树的递归实现
5.1 场景与需求说明
以菜单权限树为例,很多后台管理系统的左侧菜单是两级或者三级的树形结构,菜单表通常是id、parent_id、menu_name、menu_type、sort_no。需求是把用户有权限看到的菜单全部查出来,并且在前端渲染成树。传统写法是查全量菜单后在应用层过滤权限再拼树,数据量大时效率低,代码也绕。
用递归查询可以很好地解决:先查出用户有权限的菜单ID集合,再从根菜单出发递归,每层递归中只保留权限集合内的菜单。这样数据库返回的天然就是一颗完整的、已经过滤好权限的树行集合,应用层只需要按parent_id组装即可。
5.2 完整SQL与过程说明
假设有权限的菜单集已经保存在一张临时表或子查询user_menu里,包含menu_id。完整SQL可以这样写:
WITH RECURSIVE menu_tree AS ( SELECT m.id, m.parent_id, m.menu_name, m.sort_no, 0 AS depth FROM menu m WHERE m.parent_id IS NULL AND m.id IN (SELECT menu_id FROM user_menu) UNION ALL SELECT m.id, m.parent_id, m.menu_name, m.sort_no, mt.depth + 1 FROM menu m INNER JOIN menu_tree mt ON m.parent_id = mt.id INNER JOIN user_menu um ON m.id = um.menu_id ) SELECT * FROM menu_tree ORDER BY depth, sort_no;锚点成员选出用户有权限的顶级菜单,递归成员向下扩展时再用INNER JOIN user_menu把没有权限的分支直接剪掉。这里有个很关键的点:锚点如果查不到任何顶级菜单,整个CTE就空了,递归不会启动,输出结果也会是空集,这是符合预期的行为,不需要额外处理。
另外可以看到menu_tree的递归成员里同时引用了原始表menu和权限表user_menu。只要user_menu数据量可控,这里的子查询可以安全使用。user_menu如果本身是从关联表实时计算出来的,建议先提前物化成临时表,否则每一轮递归都会重新执行一次权限计算查询,性能会显著下降。
5.3 应用层如何配合递归查询
SQL查出的是扁平的行集合,每行带parent_id,前端需要的树形嵌套结构还需要在应用层组一下。这个组装逻辑本身不复杂,但要写得稳,核心是按id建立索引映射后一次性挂接。
如果用的是Java,大致逻辑是遍历一遍结果集,把所有节点放入Map<Integer, Node>,然后第二次遍历,把每个节点挂到parent_id对应的父节点的children列表下。这属于O(n)的组装,没什么性能压力,关键点在于:根节点条件应该是parent_id为空或者父节点在Map里不存在,避免出现孤儿节点在树里丢失。
实测下来,一条递归SQL加上几十行组装代码,就能替代过去那种“多次查询加递归函数拼树”的老方案。接口响应时间从几百毫秒降到几十毫秒,差的不是一点半点。
6. 性能对比实测:递归查询 vs 应用层递归
6.1 测试环境与用例设计
为了验证递归查询到底值不值得用,我专门在自己的测试环境里做了一个对比实验。表结构和前面dept表一致,数据量造了5万行左右,树深度大概6到8层。对比两种取数方式:一种是应用层递归,先查根部门,再逐层查询子部门,每层批量用WHERE parent_id IN (...)去查;另一种就是一条WITH RECURSIVE查询。两者取出的最终数据集一致。
测试环境是MySQL 8.0的默认配置,没有针对这个场景单独调优,查询的parent_id索引在两棵方案下都同样存在。应用层递归用Java实现,开了连接池,每层查询复用同一连接。
6.2 数据结果与分析
每组查询都重复了多次取平均值,大致结果如下:
| 方案 | 查询次数 | 耗时(毫秒) | 网络与代码复杂度 |
|---|---|---|---|
| 应用层递归 | 6到8次 | 80到120 | 需要写递归函数,处理临时结果集,分页与排序麻烦 |
| WITH RECURSIVE | 1次 | 15到25 | 一条SQL,外层排序方便,应用代码大量简化 |
第二轮测试加大数据量到20万行时,应用层递归因为每次查询的IN条件里的id数量变多,每次查询的网络开销和处理时间同步上升,耗时增长到300毫秒左右,而递归查询只到了60毫秒上下。
这个结果其实不意外。数据库引擎内部的递归是在服务端内存中完成的,不需要反复在应用层和数据库之间传输中间结果,省掉了大量网络往返和ORM封装开销。但也要说清楚边界:递归查询的优势集中在“遍历树”这种一次查询就能算完的场景,如果树结构还需要在应用层做很多特殊业务处理,比如每个节点都要再关联其他业务数据,那应用层递归反而更灵活。
6.3 什么场景下不要用它
递归查询也不是银弹。我遇到过一个场景:一张分类表数据量超过百万,而且树的层级深,用户要求前端展开某个大分类时要秒出数据。这种情况下即使递归查询走了索引,单次递归遍历出来的行数也可能到十几万行,再加上临时表的读写压力,性能会很难看。当时我们改用了闭包表方案,把常用层级关系预计算好存在单独的关系表里,查询直接从关系表拿结果,性能才稳定下来。
判断标准就一条:如果某棵树的查询频度远高于写入频度,而且树结构不会频繁变动,闭包表比递归查询更合适。反过来,要是树结构经常增删改,闭包表的维护成本会让你头疼,这时邻接表配合递归才是对的。
最后一点个人体会
写递归查询的SQL,最大的门槛不是语法本身,而是思维方式的转换。你需要把自己从“一批数据一批数据地捞”的循环思维,切换到“描述一个集合如何不断扩展”的声明式思维。一旦过了这个坎,很多复杂业务都会突然变简单,比如菜单权限、组织架构、评论盖楼、商品分类,甚至是一张无限级分销关系表,都能用同一套递归模板去解。我在实际项目里只要确认了“这是树形结构”并且“一次要拿完整棵树”,就会优先考虑WITH RECURSIVE。如果你也想在自己项目里试试,建议先从小数据量的内部管理系统开始,加上cte_max_recursion_depth这类保护参数,再慢慢推广到核心业务。聊到最后再送一个小技巧:给所有递归查询统一加上depth字段做层级标记,不仅是排查问题方便,后续做权限过滤、数据导出、树形渲染都会省非常多事。