一行注解防死循环:MyBatis 递归深度限制(无需 level 字段)

在开发后台管理系统时,我们经常需要处理组织架构、菜单权限、评论回复等树形数据。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 自动递归调用子查询

整个过程对业务代码完全透明!

🔧 三、代码实现(简洁优雅版)

  1. 自定义注解 @TreeDepth
java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TreeDepth {
    int max() default 5; // 最大递归深度,默认5层
}

💡 使用方式:加在 被 引用的方法上。

  1. 深度上下文管理器 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;
    }
}

✅ 线程安全|自动清理|逻辑清晰

  1. 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 保证深度计数不泄露

异常安全,不影响正常查询

  1. 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 层剪枝方案,性能更优。

🛠 六、可选增强(按需扩展)

  1. 支持自定义起始层级
java 复制代码
@TreeDepth(max = 5, startFrom = 1) // 根算第1层
  1. 添加日志告警
java 复制代码
if (TreeDepthContext.isExceeded()) {
    log.warn("⚠️ Tree depth limit exceeded! max={}, current={}", max, depth);
    return Collections.emptyList();
}
  1. 抛异常代替静默返回(调试用)
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 字段 本文方案:内存深度跟踪 ✅

只需三步:

  1. 在子查询方法上加 @TreeDepth(max = N)
  2. 注册 TreeDepthInterceptor
  3. 保持原有 MyBatis 映射不变

即可获得一个安全、稳定、可复用的树形查询防护体系。

相关推荐
q***o3762 小时前
Spring Boot环境配置
java·spring boot·后端
oMcLin2 小时前
如何在SUSE Linux Enterprise Server 15 SP4上通过配置并优化ZFS存储池,提升文件存储与数据备份的效率?
java·linux·运维
TaiKuLaHa2 小时前
Spring Bean的生命周期
java·后端·spring
刀法如飞3 小时前
开箱即用的 DDD(领域驱动设计)工程脚手架,基于 Spring Boot 4.0.1 和 Java 21
java·spring boot·mysql·spring·设计模式·intellij-idea
我是苏苏3 小时前
Web开发:C#通过ProcessStartInfo动态调用执行Python脚本
java·服务器·前端
JavaGuide3 小时前
SpringBoot 官宣停止维护 3.2.x~3.4.x!
java·后端
tkevinjd4 小时前
动态代理
java
Knight_AL4 小时前
Spring 事务管理:为什么内部方法调用事务不生效以及如何解决
java·后端·spring