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, Label
FROM base_permission
WHERE Id IN (1, 2)
UNION ALL
-- 递归查询:向上查找父节点
SELECT p.Id, p.ParentId, p.Name, p.Label
FROM base_permission p
INNER 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=10
Id=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=10
Id=2, ParentId=20
Id=10, ParentId=30
Id=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=10
Id=2, ParentId=20
Id=10, ParentId=30
Id=20, ParentId=40
Id=30, ParentId=NULL
Id=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, Label
FROM base_permission
WHERE Id IN (1, 2)
UNION ALL
SELECT p.Id, p.ParentId, p.Name, p.Label
FROM base_permission p
INNER JOIN MenuTree mt ON p.Id = mt.ParentId
)
SELECT * FROM MenuTree;
3.2 向下递归(查找后代)
如果需要从父节点查找所有子节点,只需调整 INNER JOIN
的条件:
WITH RECURSIVE MenuTree AS (
SELECT Id, ParentId, Name, Label
FROM base_permission
WHERE Id = 30 -- 从根节点开始
UNION ALL
SELECT p.Id, p.ParentId, p.Name, p.Label
FROM base_permission p
INNER 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, Label
FROM base_permission
WHERE Id IN (1, 2, 3)
UNION ALL
SELECT p.Id, p.ParentId, p.Name, p.Label
FROM base_permission p
INNER 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 和 Setter
public 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 permssionId
FROM base_permission
</select>
<!-- 递归查询 -->
<select id="selectMenuTree" parameterType="list" resultType="com.example.entity.Permission">
WITH RECURSIVE MenuTree AS (
SELECT Id, ParentId, Name, Label
FROM base_permission
WHERE Id IN
<foreach collection="list" item="id" open="(" separator="," close=")">
#{id}
</foreach>
UNION ALL
SELECT p.Id, p.ParentId, p.Name, p.Label
FROM base_permission p
INNER 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. 查询所有 permssionId
List<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, Label
FROM base_permission
WHERE Id IN (1, 2)
UNION ALL
SELECT DISTINCT p.Id, p.ParentId, p.Name, p.Label
FROM base_permission p
INNER JOIN (SELECT DISTINCT ParentId FROM MenuTree WHERE ParentId IS NOT NULL) mt
ON 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 应用,全面讲解了递归查询的方方面面。希望这篇文章能帮助你在实际开发中更好地使用递归查询,解决复杂的业务需求。