在开发后台管理系统时,我们经常需要处理组织架构、菜单权限、评论回复等树形数据。MyBatis 的 标签配合懒加载,能轻松实现无限级递归查询。
但一旦数据存在环路(比如 A 的子节点是 B,B 的子节点又是 A),程序就会陷入死循环,甚至导致栈溢出或数据库连接耗尽!
更麻烦的是:你的表里根本没有 level 字段,无法通过 SQL 条件直接限制层级。
那么问题来了:
如何在不修改表结构的前提下,安全地控制 MyBatis 递归查询的最大深度?
答案是:利用 MyBatis 拦截器 + 自定义注解 + 线程局部变量(ThreadLocal),在应用层实现"递归深度熔断"机制。
本文将手把手教你实现一个通用、零侵入、高可靠的解决方案!
🎯 一、核心思想:在内存中跟踪递归深度
由于没有 level 字段,我们不能靠 SQL 剪枝,但可以:
在每次执行被注解标记的 Mapper 方法前,记录当前递归深度
如果深度超过阈值,直接返回空列表,跳过 SQL 执行
利用 ThreadLocal 保证多线程安全
通过 try-finally 确保深度计数正确回退
✅ 这不是改写 SQL,而是主动短路(Short-Circuit)递归调用链。
🧩 二、整体架构图
否
是
是
否
调用 selectRoots
是否带 @TreeDepth 注解?
正常执行
当前深度 >= 最大深度?
返回空列表
深度+1 → 执行SQL → 深度-1
MyBatis 自动递归调用子查询
整个过程对业务代码完全透明!
🔧 三、代码实现(简洁优雅版)
- 自定义注解 @TreeDepth
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TreeDepth {
int max() default 5; // 最大递归深度,默认5层
}
💡 使用方式:加在 被 引用的方法上。
- 深度上下文管理器 TreeDepthContext
java
public class TreeDepthContext {
private static final ThreadLocal<Integer> CURRENT_DEPTH = new ThreadLocal<>();
private static final ThreadLocal<Integer> MAX_DEPTH = new ThreadLocal<>();
public static void enter(int max) {
int depth = CURRENT_DEPTH.get() == null ? 0 : CURRENT_DEPTH.get() + 1;
CURRENT_DEPTH.set(depth);
MAX_DEPTH.set(max);
}
public static void exit() {
Integer depth = CURRENT_DEPTH.get();
if (depth == null || depth <= 0) {
CURRENT_DEPTH.remove();
MAX_DEPTH.remove();
} else {
CURRENT_DEPTH.set(depth - 1);
}
}
public static boolean isExceeded() {
Integer depth = CURRENT_DEPTH.get();
Integer max = MAX_DEPTH.get();
return depth != null && max != null && depth >= max;
}
}
✅ 线程安全|自动清理|逻辑清晰
- MyBatis 拦截器 TreeDepthInterceptor
java
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class TreeDepthInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
TreeDepth ann = findTreeDepthAnnotation(ms);
// 不是树查询,直接放行
if (ann == null) {
return invocation.proceed();
}
// 超过最大深度?直接返回空,避免死循环!
if (TreeDepthContext.isExceeded()) {
return Collections.emptyList();
}
// 进入下一层
TreeDepthContext.enter(ann.max());
try {
return invocation.proceed(); // 执行原查询
} finally {
TreeDepthContext.exit(); // 必须退出,保证计数准确
}
}
// 从 MappedStatement 反射获取方法上的注解
private TreeDepth findTreeDepthAnnotation(MappedStatement ms) {
String fullMethodName = ms.getId(); // 如: com.example.OrgMapper.selectChildren
int lastDot = fullMethodName.lastIndexOf('.');
String className = fullMethodName.substring(0, lastDot);
String methodName = fullMethodName.substring(lastDot + 1);
try {
Class<?> mapperClass = Class.forName(className);
for (Method method : mapperClass.getDeclaredMethods()) {
if (method.getName().equals(methodName)) {
return method.getAnnotation(TreeDepth.class);
}
}
} catch (Exception ignored) {
// 安静失败,不影响主流程
}
return null;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可扩展配置
}
}
✨ 代码亮点:
使用 Collections.emptyList() 避免创建新对象
try-finally 保证深度计数不泄露
异常安全,不影响正常查询
- Mapper 接口 & XML(无需改动!)
java
public interface OrgMapper {
List<OrgNode> selectRoots(); // 根节点,不加注解
@TreeDepth(max = 4) // ← 关键!限制最多4层子节点
List<OrgNode> selectChildrenByParentId(@Param("parentId") Long parentId);
}
xml
<resultMap id="OrgTree" type="OrgNode">
<id property="id" column="id"/>
<result property="name" column="name"/>
<collection property="children"
select="selectChildrenByParentId"
column="id"/>
</resultMap>
🎉 业务代码一行都不用改,只需加个注解!
🔄 四、执行流程演示
假设 max = 3,查询过程如下:
调用层级 方法 当前深度 是否执行 SQL
| 调用层级 | 方法 | 当前深度 | 是否执行 SQL |
|---|---|---|---|
| 1 | selectRoots() |
0 | ✅ 是 |
| 2 | selectChildren(...) |
1 | ✅ 是 |
| 3 | selectChildren(...) |
2 | ✅ 是 |
| 4 | selectChildren(...) |
3 | ✅ 是 |
| 5 | selectChildren(...) |
4 | ❌ 否(返回空列表) |
🔒 即使数据存在 A → B → A 的环路,也会在第 5 次调用时被截断,彻底杜绝死循环!
✅ 五、方案优势 vs 局限
✅ 优势
无需修改数据库表结构(不要求 level 字段)
零侵入业务代码,仅需加注解
自动防护死循环和栈溢出
线程安全,适用于高并发场景
通用性强,适用于所有 MyBatis 树形查询
⚠️ 局限
无法利用数据库索引优化(因不改 SQL)
仅适用于 递归加载场景
深度从 0 开始计数(根为第 0 层)
💡 小贴士:若你未来能加 level 字段,可升级为 SQL 层剪枝方案,性能更优。
🛠 六、可选增强(按需扩展)
- 支持自定义起始层级
java
@TreeDepth(max = 5, startFrom = 1) // 根算第1层
- 添加日志告警
java
if (TreeDepthContext.isExceeded()) {
log.warn("⚠️ Tree depth limit exceeded! max={}, current={}", max, depth);
return Collections.emptyList();
}
- 抛异常代替静默返回(调试用)
java
throw new IllegalStateException("Recursive query depth limit exceeded: " + max);
📦 七、注册拦截器(Spring Boot)
java
@Configuration
public class MyBatisConfig {
@Bean
public TreeDepthInterceptor treeDepthInterceptor() {
return new TreeDepthInterceptor();
}
@Bean
public ConfigurationCustomizer mybatisCustomizer() {
return config -> config.addInterceptor(treeDepthInterceptor());
}
}
或通过 mybatis-config.xml 注册插件。
🎯 八、总结
| 场景 | 推荐方案 |
|---|---|
表中有 level 字段 |
拦截器改写 SQL(性能最优) |
表中无 level 字段 |
本文方案:内存深度跟踪 ✅ |
只需三步:
- 在子查询方法上加 @TreeDepth(max = N)
- 注册 TreeDepthInterceptor
- 保持原有 MyBatis 映射不变
即可获得一个安全、稳定、可复用的树形查询防护体系。