SQL 递归查询(Recursive Common Table Expression, Recursive CTE)是数据库开发中一个强大而灵活的工具,尤其在处理树形结构或图结构数据时表现出色。本文将从程序员的角度,详细讲解 SQL 递归查询的原理、实现方式、性能优化、MyBatis 中的应用,以及实际案例和注意事项。
1. 什么是 SQL 递归查询?
1.1 背景与需求
在数据库开发中,经常会遇到需要处理层次结构数据的场景,例如:
- 组织结构:公司部门之间的上下级关系。
 - 权限系统:权限树(如菜单、角色权限)。
 - 家谱:家族成员之间的亲属关系。
 - 图结构:社交网络中的朋友关系、路径搜索。
 
这些数据通常以树形结构或图结构存储在数据库中,表中包含一个字段(如 ParentId)来表示记录之间的父子关系。例如:
| Id | ParentId | Name | Label | 
|---|---|---|---|
| 1 | 10 | 小明 | 权限A | 
| 2 | 20 | 小红 | 权限B | 
| 10 | 30 | 小明爸 | 权限C | 
| 20 | 40 | 小红妈 | 权限D | 
| 30 | NULL | 小明爷爷 | 权限E | 
| 40 | NULL | 小红外婆 | 权限F | 
在这个表中,Id 是记录的唯一标识,ParentId 指向父记录的 Id,NULL 表示没有父记录(即根节点)。如果我们需要从某些节点(例如 Id=1 和 Id=2)开始,向上查找所有父节点(包括父节点的父节点),传统 SQL 查询(例如多次 JOIN)会非常复杂。这时,递归查询就派上用场了。
1.2 递归查询的定义
SQL 递归查询是通过递归公共表表达式(Recursive Common Table Expression, Recursive CTE)实现的。WITH RECURSIVE 是 SQL 标准语法,允许开发者定义一个临时结果集(CTE),并通过递归的方式不断扩展这个结果集,直到满足终止条件。
递归查询通常用于:
- 查找树形结构中的所有祖先或后代。
 - 计算图中的路径或距离。
 - 构建层次结构的完整视图。
 
1.3 支持递归查询的数据库
- PostgreSQL:从 8.4 版本开始支持 
WITH RECURSIVE。 - MySQL:从 8.0 版本开始支持(5.x 版本不支持)。
 - Oracle:从 11g 版本开始支持 
WITH RECURSIVE,但更常用CONNECT BY语法。 - SQL Server:从 2005 版本开始支持。
 - SQLite:从 3.8.3 版本开始支持。
 
如果数据库不支持 WITH RECURSIVE,可以考虑使用存储过程或在应用层实现递归逻辑。
2. SQL 递归查询的原理
2.1 递归查询的结构
一个典型的递归查询由以下部分组成:
- 基础查询(Base Case):定义递归的起点,即初始结果集。
 - 递归查询(Recursive Case):定义如何基于当前结果集查找新记录。
 - 终止条件:当递归查询不再产生新记录时,递归停止。
 - 最终查询:从递归结果集中选择最终输出。
 
以下是一个简单的递归查询示例:
WITH RECURSIVE MenuTree AS (-- 基础查询:选择起点节点SELECT Id, ParentId, Name, LabelFROM base_permissionWHERE Id IN (1, 2)UNION ALL-- 递归查询:向上查找父节点SELECT p.Id, p.ParentId, p.Name, p.LabelFROM base_permission pINNER JOIN MenuTree mt ON p.Id = mt.ParentId)SELECT * FROM MenuTree;
2.2 递归查询的执行过程
递归查询的执行过程可以分为以下步骤:
步骤 1:执行基础查询
- 基础查询是递归的起点,生成初始结果集。
 - 在上述示例中,
WHERE Id IN (1, 2)找到Id=1和Id=2的记录:Id=1, ParentId=10, Name="小明", Label="权限A"Id=2, ParentId=20, Name="小红", Label="权限B"
 - 这些记录被放入 
MenuTree临时结果集。 
步骤 2:执行递归查询(第一次迭代)
- 递归查询基于当前 
MenuTree的内容,查找新记录。 - 当前 
MenuTree包含:Id=1, ParentId=10Id=2, ParentId=20
 INNER JOIN查找p.Id = mt.ParentId,即p.Id IN (10, 20):- 找到 
Id=10, ParentId=30和Id=20, ParentId=40。 
- 找到 
 - 使用 
UNION ALL将这些新记录追加到MenuTree:MenuTree现在包含:[Id=1, Id=2, Id=10, Id=20]。
 
步骤 3:执行递归查询(第二次迭代)
- 当前 
MenuTree包含:Id=1, ParentId=10Id=2, ParentId=20Id=10, ParentId=30Id=20, ParentId=40
 INNER JOIN查找p.Id IN (10, 20, 30, 40):p.Id=10和p.Id=20已经加入MenuTree,不会产生新记录。p.Id=30找到Id=30, ParentId=NULL。p.Id=40找到Id=40, ParentId=NULL。
- 追加到 
MenuTree:[Id=1, Id=2, Id=10, Id=20, Id=30, Id=40]。 
步骤 4:执行递归查询(第三次迭代)
- 当前 
MenuTree包含:Id=1, ParentId=10Id=2, ParentId=20Id=10, ParentId=30Id=20, ParentId=40Id=30, ParentId=NULLId=40, ParentId=NULL
 INNER JOIN查找p.Id IN (10, 20, 30, 40, NULL, NULL):p.Id=10, 20, 30, 40已经加入MenuTree。p.Id=NULL无法匹配(NULL不参与比较)。
- 没有新记录产生,递归停止。
 
步骤 5:执行最终查询
SELECT * FROM MenuTree返回MenuTree中的所有记录:Id=1, ParentId=10, Name="小明", Label="权限A"Id=2, ParentId=20, Name="小红", Label="权限B"Id=10, ParentId=30, Name="小明爸", Label="权限C"Id=20, ParentId=40, Name="小红妈", Label="权限D"Id=30, ParentId=NULL, Name="小明爷爷", Label="权限E"Id=40, ParentId=NULL, Name="小红外婆", Label="权限F"
2.3 递归查询的执行次数
- 基础查询:执行 1 次。
 - 递归查询:执行次数等于树的最大深度(
D)减 1。- 在上述示例中,树的最大深度是 3(
Id=1 -> Id=10 -> Id=30),递归查询执行 2 次。 
 - 在上述示例中,树的最大深度是 3(
 - 最终查询:执行 1 次。
 - 总执行次数:
1 + (D-1) + 1 = D + 1次。 
3. 递归查询的实现方式
3.1 向上递归(查找祖先)
上述示例是向上递归,即从子节点查找所有父节点。SQL 如下:
WITH RECURSIVE MenuTree AS (SELECT Id, ParentId, Name, LabelFROM base_permissionWHERE Id IN (1, 2)UNION ALLSELECT p.Id, p.ParentId, p.Name, p.LabelFROM base_permission pINNER JOIN MenuTree mt ON p.Id = mt.ParentId)SELECT * FROM MenuTree;
3.2 向下递归(查找后代)
如果需要从父节点查找所有子节点,只需调整 INNER JOIN 的条件:
WITH RECURSIVE MenuTree AS (SELECT Id, ParentId, Name, LabelFROM base_permissionWHERE Id = 30 -- 从根节点开始UNION ALLSELECT p.Id, p.ParentId, p.Name, p.LabelFROM base_permission pINNER JOIN MenuTree mt ON p.ParentId = mt.Id)SELECT * FROM MenuTree;
- 变化:
p.ParentId = mt.Id表示查找base_permission中ParentId等于MenuTree中Id的记录,即查找子节点。 
3.3 处理多条记录的输入
如果起点是多条记录(例如 Id IN (1, 2, ...)),可以使用 IN 条件:
WITH RECURSIVE MenuTree AS (SELECT Id, ParentId, Name, LabelFROM base_permissionWHERE Id IN (1, 2, 3)UNION ALLSELECT p.Id, p.ParentId, p.Name, p.LabelFROM base_permission pINNER JOIN MenuTree mt ON p.Id = mt.ParentId)SELECT * FROM MenuTree;
4. MyBatis 中实现递归查询
MyBatis 是一个流行的 Java 持久化框架,支持通过 XML 或注解定义 SQL 语句。以下是如何在 MyBatis 中实现上述递归查询。
4.1 需求分析
假设我们需要:
- 查询 
base_permission表中的所有Id(作为递归的起点)。 - 使用这些 
Id进行递归查询,查找所有父权限。 
4.2 实体类
public class Permission {private Long id;private Long parentId;private String name;private String label;// Getter 和 Setterpublic Long getId() { return id; }public void setId(Long id) { this.id = id; }public Long getParentId() { return parentId; }public void setParentId(Long parentId) { this.parentId = parentId; }public String getName() { return name; }public void setName(String name) { this.name = name; }public String getLabel() { return label; }public void setLabel(String label) { this.label = label; }}
4.3 Mapper 接口
public interface PermissionMapper {List<Long> selectPermissionIds();List<Permission> selectMenuTree(List<Long> permissionIds);}
4.4 XML 映射文件
<mapper namespace="com.example.mapper.PermissionMapper"><!-- 查询所有 permssionId --><select id="selectPermissionIds" resultType="long">SELECT Id AS permssionIdFROM base_permission</select><!-- 递归查询 --><select id="selectMenuTree" parameterType="list" resultType="com.example.entity.Permission">WITH RECURSIVE MenuTree AS (SELECT Id, ParentId, Name, LabelFROM base_permissionWHERE Id IN<foreach collection="list" item="id" open="(" separator="," close=")">#{id}</foreach>UNION ALLSELECT p.Id, p.ParentId, p.Name, p.LabelFROM base_permission pINNER JOIN MenuTree mt ON p.Id = mt.ParentId)SELECT * FROM MenuTree</select></mapper>
4.5 调用代码
SqlSession session = sqlSessionFactory.openSession();try {PermissionMapper mapper = session.getMapper(PermissionMapper.class);// 1. 查询所有 permssionIdList<Long> permissionIds = mapper.selectPermissionIds();if (permissionIds == null || permissionIds.isEmpty()) {System.out.println("No permission IDs found.");return;}// 2. 使用 permssionId 列表执行递归查询List<Permission> menuTree = mapper.selectMenuTree(permissionIds);for (Permission permission : menuTree) {System.out.println("ID: " + permission.getId() + ", Name: " + permission.getName());}} finally {session.close();}
4.6 执行过程
- 第一步:
selectPermissionIds:- 执行 
SELECT Id AS permssionId FROM base_permission。 - 返回 
[1, 2, 10, 20, 30, 40]。 
 - 执行 
 - 第二步:
selectMenuTree:<foreach>生成WHERE Id IN (1, 2, 10, 20, 30, 40)。- 数据库执行递归查询:
- 基础查询:找到 
Id=1, 2, 10, 20, 30, 40。 - 递归查询:向上查找父节点(例如 
Id=1的ParentId=10已经包含在起点中,无需进一步递归)。 
 - 基础查询:找到 
 - 最终返回所有记录。
 
 
5. 递归查询的性能分析
5.1 时间复杂度
- 基础查询:执行 1 次,时间复杂度为 
O(N),其中N是base_permission表的记录数。 - 递归查询:执行 
D-1次(D是树的最大深度),每次迭代需要扫描base_permission表,时间复杂度为O(D * N)。 - 总时间复杂度:
O(D * N)。 
5.2 空间复杂度
MenuTree临时结果集存储所有记录,空间复杂度为O(M),其中M是最终结果集的记录数。
5.3 重复匹配问题
在递归查询中,MenuTree 中的记录会不断增加,INNER JOIN 每次迭代都会尝试匹配所有 ParentId:
- 重复匹配:
ParentId可能被多次匹配(例如ParentId=10在多次迭代中出现)。 - 数据库行为:数据库不会显式记录“已比对过的记录”,但由于 
Id是唯一的,同一个Id只会加入MenuTree一次。 
优化重复匹配
- 去重 
ParentId:WITH RECURSIVE MenuTree AS (SELECT Id, ParentId, Name, LabelFROM base_permissionWHERE Id IN (1, 2)UNION ALLSELECT DISTINCT p.Id, p.ParentId, p.Name, p.LabelFROM base_permission pINNER JOIN (SELECT DISTINCT ParentId FROM MenuTree WHERE ParentId IS NOT NULL) mtON p.Id = mt.ParentId)SELECT * FROM MenuTree;
 - 索引:确保 
Id和ParentId有索引,加速INNER JOIN。 
6. 实际案例:权限树查询
6.1 场景描述
在一个权限管理系统中,base_permission 表存储了权限的层次结构。用户可能有多个权限(permssionId),我们需要查找这些权限及其所有父权限。
6.2 实现代码
已在上文给出,这里不再赘述。
6.3 结果分析
- 输入:
permssionId1=1, permssionId2=2。 - 输出:
Id=1, ParentId=10, Name="小明", Label="权限A"Id=2, ParentId=20, Name="小红", Label="权限B"Id=10, ParentId=30, Name="小明爸", Label="权限C"Id=20, ParentId=40, Name="小红妈", Label="权限D"Id=30, ParentId=NULL, Name="小明爷爷", Label="权限E"Id=40, ParentId=NULL, Name="小红外婆", Label="权限F"
 
7. 注意事项与优化建议
7.1 数据库支持
- 确保数据库支持 
WITH RECURSIVE(MySQL 8.0+、PostgreSQL、SQL Server 等)。 - 如果不支持,可以使用存储过程或在 Java 代码中实现递归。
 
7.2 性能优化
- 索引:为 
Id和ParentId创建索引。 - 去重:使用 
DISTINCT避免重复记录。 - 分批处理:如果起点记录过多,分批执行递归查询。
 
7.3 异常处理
- 空结果:如果 
permssionId列表为空,IN ()会导致语法错误,需在 MyBatis 中添加条件:<if test="list != null and list.size > 0"><foreach collection="list" item="id" open="(" separator="," close=")">#{id}</foreach></if>
 
8. 总结
SQL 递归查询是处理层次结构数据的强大工具,通过 WITH RECURSIVE 可以轻松实现树形结构的遍历。本文从原理、实现、性能分析到 MyBatis 应用,全面讲解了递归查询的方方面面。希望这篇文章能帮助你在实际开发中更好地使用递归查询,解决复杂的业务需求。
