递归CTE实战:用SQL搞定树形结构查询,告别“写死”代码

简介: 组织架构、商品分类、菜单权限、BOM清单——树形结构查询是日常开发中高频出现的需求。很多开发者的做法是“写死层级”或“循环查库”,代码又臭又长,性能还差。递归CTE是解决这类问题的标准写法,但很多人一看到WITH RECURSIVE就觉得头大。本文从三个真实场景出发,手把手教读者写出能直接用的递归CTE,并讲清楚执行机制和性能陷阱。

大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!

你有没有写过这样的代码:查组织架构,先查顶层部门,再循环查子部门,每层写一个for循环。部门深度是5层就写5个循环,深度不确定就写while循环不断查库。

这种写法,代码难看、性能差、还容易出bug。每次看到这种代码我都想问一句:为什么不用递归CTE?

递归CTE是SQL标准中处理树形结构的官方解法,MySQL 8.0+、PostgreSQL、SQL Server都原生支持。树形结构查询有好几种写法,每种写法各有优劣:

写法 优点 缺点
循环查库 直观,容易理解 代码冗长,性能差,N次查询
递归CTE 标准解法,一次查询,支持任意深度 语法稍复杂,需注意性能
闭包表 查询极快 维护成本高,适合读多写少
路径枚举 查询快,易于理解 更新路径成本高

递归CTE可能是树形查询的最优解——语法简单清晰、一次搞定、不需要额外维护表结构。我把三个最常用的场景拆开讲,看完就能上手。

一、先搞清楚递归CTE是什么

递归CTE(Recursive Common Table Expression)说白了就是一个能“自己调用自己”的临时查询结果集。

它由两部分组成:

  • 锚点(Anchor) :告诉数据库“从哪开始找”。比如查部门树的时候,先找到最顶层的那个部门——WHERE parent_id IS NULL。
  • 递归成员(Recursive Member) :告诉数据库“怎么继续往下找”。比如通过当前层的id去关联子部门的parent_id——ON d.parent_id = dt.id。

两部分用UNION ALL连接起来。数据库执行的时候,先跑锚点拿到第一层结果,然后拿着这些结果去跑递归成员,拿到第二层,再拿第二层去跑第三层……直到再也找不到新数据为止。

整个流程可以理解成:从起点出发,每次拿当前查到的结果去查下一层,一层层往下展开,直到触底为止。 就像玩一个“点开一个文件夹,自动展开它里面所有子文件夹”的游戏,只不过这个游戏是数据库帮你自动完成的。

二、场景一:遍历组织架构树——最基础的用法

假设有一张部门表department,字段为id、name、parent_id。要查出某个部门及其所有下级:

WITH RECURSIVE dept_tree AS (

   -- 锚点:从根部门开始

   SELECT id, name, parent_id, 0 AS depth

   FROM department

   WHERE id = 1   -- 指定根节点ID

   

   UNION ALL

   

   -- 递归:找子部门

   SELECT d.id, d.name, d.parent_id, dt.depth + 1

   FROM department d

   INNER JOIN dept_tree dt ON d.parent_id = dt.id

)

SELECT * FROM dept_tree ORDER BY depth, id;

depth字段记录层级深度,方便在应用层做缩进展示。ORDER BY depth保证了父级在子级之前输出。

为什么这比循环查库好? 一次查询搞定所有层级,不用在应用层写循环,不用反复建立数据库连接,性能提升肉眼可见。

三、场景二:面包屑导航——从叶子节点往上找

电商网站的商品分类、博客的评论嵌套、系统的菜单路径——这些都需要“从当前位置追溯到根”。

比如给一个商品分类ID,生成“首页 > 手机 > 智能手机 > iPhone 15”这样的面包屑:

WITH RECURSIVE breadcrumb AS (

   -- 锚点:从当前分类开始

   SELECT id, name, parent_id, 1 AS level

   FROM category

   WHERE id = 123   -- 指定当前分类ID

   

   UNION ALL

   

   -- 递归:往上找父级

   SELECT c.id, c.name, c.parent_id, b.level + 1

   FROM category c

   INNER JOIN breadcrumb b ON c.id = b.parent_id

)

SELECT * FROM breadcrumb ORDER BY level DESC;

ORDER BY level DESC让根节点排在前面,直接就能渲染面包屑。一层SQL搞定原本需要递归查询或多次查库才能完成的事情。

四、场景三:带路径的完整子树——行政区域查询

有时候不仅要查出所有下级节点,还要知道每个节点的完整路径。比如查询某个省下的所有市、区、街道:

WITH RECURSIVE region_tree AS (

   -- 锚点:从指定省份开始

   SELECT id, name, parent_id, 0 AS depth,

          CAST(name AS CHAR(1000)) AS path

   FROM region

   WHERE id = 440000   -- 广东省

   

   UNION ALL

   

   -- 递归:找下级行政区

   SELECT r.id, r.name, r.parent_id, rt.depth + 1,

          CONCAT(rt.path, ' > ', r.name)

   FROM region r

   INNER JOIN region_tree rt ON r.parent_id = rt.id

)

SELECT * FROM region_tree ORDER BY depth, id;

path字段用CAST指定了足够长度,CONCAT逐层拼接,最终得到类似“广东省 > 广州市 > 天河区”的完整路径。

这个模式在电商的SPU-SKU层级、BOM物料清单、组织权限树等场景中都非常实用。

五、递归CTE的性能陷阱与避坑指南

递归CTE虽强,但用不好也会踩坑。

陷阱1:缺少索引,每层全表扫描

递归查询的每一层都会执行一次JOIN。如果parent_id没有索引,每一层都要扫描全表,性能急剧下降。

解法:确保parent_id上有索引。对于频繁查询树形结构的表,这是必须的。

陷阱2:锚点写错,查不出数据

锚点必须明确指定根节点。用WHERE parent_id IS NULL或WHERE id = 1都可以,但要确保和业务数据一致。如果业务中用parent_id = 0表示根节点,锚点就要写成WHERE parent_id = 0。

陷阱3:数据中存在环,导致无限递归

如果数据中出现A.parent_id = B.id且B.parent_id = A.id这样的环,递归CTE会无限执行下去。

解法:限制递归深度。在递归成员中添加WHERE dt.depth < 20这样的条件,或在最外层加LIMIT。大多数数据库也提供了MAXRECURSION选项来控制最大迭代次数。

陷阱4:大数据量下性能下降

当树形结构数据量极大(百万级节点)且查询频繁时,递归CTE可能成为性能瓶颈。

解法:考虑用闭包表(Closure Table)或路径枚举(Path Enumeration)方案来替代递归CTE。这两种方案以空间换时间,适合读多写少的场景。

六、总结

递归CTE是处理树形结构查询的标准写法,三个核心场景覆盖了大部分日常需求:

  • 从根到叶:遍历组织架构、商品分类——WHERE parent_id IS NULL往下找
  • 从叶到根:面包屑导航、权限继承——WHERE id = 当前节点往上找
  • 带路径的子树:行政区域、BOM清单——用CONCAT拼接完整路径

递归CTE的语法看起来有点吓人,但拆开看就是“锚点 + UNION ALL + 递归关联”三部分。写的时候记住三件事:锚点写对、索引建好、防环做好,剩下的就是多练。

小耶在手,SQL 不愁

还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~

相关文章
|
4月前
|
SQL Oracle 关系型数据库
MySQL迁移到国产数据库实战指南:以金仓为例
本文详解MySQL迁至国产金仓KingbaseES的实战经验:涵盖兼容性评估、官方工具(KDMS/KDTS/KFS)使用、高频语法差异(自增主键、字符串处理、日期函数、Upsert等)、数据迁移技巧及性能调优要点,助你少踩坑、高效落地。
686 1
|
4月前
|
人工智能 Cloud Native 关系型数据库
MySQL 8.4 LTS来了!从8.0到8.4,DBA必须知道的5个核心变化
MySQL 8.0社区版将于2026年结束生命周期,8.4 LTS作为首个长期支持版本,提供5年超长支持周期(至2031年)。本文从InnoDB并行查询、Redo Log动态容量、默认认证插件变更、参数默认值调整、云原生适配五个维度,梳理DBA升级前必须掌握的核心变化,并提供升级检查清单。
|
4月前
|
SQL 人工智能 自然语言处理
Vibe Coding 是什么?当“感觉编程”遇上数据库
Vibe Coding是2026年编程圈最火的概念之一,指开发者通过自然语言描述“感觉”或“意图”,由AI自动生成代码、调试、优化。本文从Vibe Coding的起源讲起,分析它如何改变数据库开发方式:从手写SQL到自然语言查询、从人工调索引到AI推荐、从经验运维到智能诊断。探讨这项趋势对DBA职业的影响,并给出拥抱变化的实用建议。技术会变,但人的判断力、审美和业务理解才是长期竞争力。
|
3月前
|
人工智能 BI API
2026 最新版通义千问付费版全功能介绍
通义千问付费体系含Pro会员、API按量付费、企业团队版三大模块,基于Qwen3.7旗舰模型,全面升级算力、文件处理、多模态与商用权限。覆盖个人创作、开发者集成及政企数字化场景,实现从日常辅助到AI生产底座的全链路赋能。(239字)
|
2月前
|
存储 消息中间件 SQL
Redis大Key优化完全指南:三种类型、五种拆分策略、一套渐进式方案
大key是Redis最隐蔽的性能杀手——它不会直接报错,只会让你半夜收到延迟告警、主从断开、请求超时。本文从大key的三种类型出发,拆解String、Hash、Set、ZSet、List五类数据结构的拆分策略,提供渐进式拆分的完整方案,并给出数据结构选型的“防患于未然”建议,帮助读者从“发现大key”走向“根治大key”。
|
2月前
|
存储 架构师 数据库
从DBA到数据架构师:技术债务管理是分水岭
“架构债”在业务快速迭代中被不断放大,最终成为系统稳定性的定时炸弹。本文从数据架构的视角出发,拆解数据库技术债务的四种典型类型,提供识别、评估和偿还的完整方法论,帮助读者从“数据库管理员”升级为“数据架构师”。
|
2月前
|
SQL 缓存 NoSQL
Redis缓存三大坑:穿透、击穿、雪崩,一次讲透
缓存穿透、击穿、雪崩,名字像兄弟但成因解法完全不同。本文深入讲解三种问题的原理、实现细节与隐藏的坑,覆盖布隆过滤器、互斥锁、逻辑过期、过期随机化等解法。
|
2月前
|
SQL JSON 移动开发
SQL派生表优化实战:从物化机制到LATERAL JOIN的完整进阶
很多人只知道“子查询改JOIN就快了”,但不知道为什么,也不知道什么时候该改、什么时候不该改。本文从派生表的物化机制出发,拆解临时表膨胀、索引失效的根因,通过真实案例对比派生表、CTE、LATERAL JOIN三种写法的性能差异,帮助读者从“知道现象”升级到“理解原理”。
|
2月前
|
存储 固态存储 关系型数据库
从月账单5万到3.5万:云数据库成本优化的完整复盘
上云本应是降本增效,但很多企业上云之后,账单反而越滚越大。实例规格买高了、历史数据堆在SSD上、测试环境没人关、过期快照没清理——每一笔费用都在悄悄累积。本文从云账单的三大“黑洞”出发,拆解云成本失控的根因,给出实例降配、冷热数据分层、僵尸资源清理三条可落地的优化路径,帮助DBA和运维工程师用数据驱动成本优化,让每一分钱都花在刀刃上。
|
2月前
|
SQL 运维 监控
慢查询日志的“高级用法”:从找慢SQL到做容量规划
慢查询日志是DBA最熟悉的工具,但大多数人只用它来找“跑得慢的SQL”。如果只做到这一步,你只用了慢查询日志20%的价值——剩下的80%是建立性能基线、预测容量瓶颈、评估优化效果、发现潜在风险。本文从慢查询日志的进阶用法出发,讲解如何通过持续记录慢查询建立性能基线、如何通过慢查询趋势预测容量瓶颈、如何将慢查询日志从“故障排查工具”升级为“容量规划工具”,帮助读者从“出了问题再查”升级到“看着趋势主动调整”。