规则执行上下文串号排查:并发请求下变量互相覆盖?ThreadLocal 精准隔离 + 清理钩子!

公司的风控系统出了个诡异的 bug。用户 A 在页面上看到自己被拒绝了,但查日志发现规则里引用的是用户 B 的订单金额。两个人完全不相干,数据却串了。排查了两天,最后定位到 QLExpress 的 DefaultContext 被当成了单例 Bean,所有请求共享同一个 context 对象,并发请求下变量互相覆盖。

这种 bug 的特征很典型——低并发时一切正常,压测或者高峰期才出现,而且数据是"偶尔串"而不是"一直错"。如果你问 QA 能不能复现,他们的回答多半是"有时候能有时候不能"。这就是并发问题的经典症状。

今天聊聊怎么在规则引擎的场景下,用 ThreadLocal 把上下文彻底隔离开,再加一个清理钩子防止内存泄露。


问题怎么发生的

先还原一下事故现场。很多人在用 QLExpress 的时候,为了省事会把 DefaultContext 注入成 Spring Bean:

@Component
public class RuleService {
    // ❌ 单例 Bean!所有请求共享
    private final DefaultContext<String, Object> context = new DefaultContext<>();

    public Object execute(String rule, Map<String, Object> params) {
        // 线程 A 刚 put 完,线程 B 又 put 了一遍,把 A 的覆盖了
        params.forEach(context::put);
        return runner.execute(rule, context, ...);
    }
}

RuleService 是单例 Bean,它的 context 字段也是单例。请求 A 先往 context 里放了 userId=A,请求 B 紧跟着放了 userId=B,然后请求 A 执行规则的时候读到的就是 B 的用户 ID。

并发问题不一定是线程安全问题——即使 DefaultContext 本身是线程安全的(它确实是),逻辑上你也可能读到别人放进去的变量。因为问题不在 put 操作本身,而在于 put 和 execute 之间没有原子性保障。


ThreadLocal 隔离:一个线程一个 context

既然问题出在 context 对象被共享,那解法就是把 context 变成线程级别的——每个请求一个独立的 context,用完销毁。

@Component
public class RuleService {
    // ✅ 每个线程独立的 context
    private final ThreadLocal<DefaultContext<String, Object>> contextHolder =
            ThreadLocal.withInitial(DefaultContext::new);

    public Object execute(String rule, Map<String, Object> params) {
        DefaultContext<String, Object> ctx = contextHolder.get();
        try {
            ctx.clear(); // 先清空上一轮残留的数据
            params.forEach(ctx::put);
            return runner.execute(rule, ctx, ...);
        } finally {
            // 及时清理,防止内存泄露
            ctx.clear();
        }
    }
}

三个关键操作:

ThreadLocal.withInitial(DefaultContext::new) —— 每个线程第一次调用 get() 时,自动创建一个全新的 context。线程之间互不干扰。

ctx.clear() 在 try 块开头 —— 清空上一轮的数据。Tomcat 的线程是复用的,一个线程处理完请求 A 后会接着处理请求 B。如果不清理,请求 B 可能读到请求 A 留下的变量。

ctx.clear() 在 finally 块 —— 确保即使执行过程中抛异常了,context 也会被清空。


别忘了清理 try-finally 清不掉的 ThreadLocal

上面的 ctx.clear() 能清空 context 里的变量,但 ThreadLocal 本身的值还持有这个 context 的引用。如果线程一直活着(Tomcat 线程池),ThreadLocal 里的值就一直不会被 GC。

更严重的是线程池场景:线程被归还到线程池时,ThreadLocal 里的值还在。下一次这个线程被分配来处理另一个请求,如果不先清理,就可能串数据。

所以需要一个在请求结束时自动清理 ThreadLocal 的机制。Spring 提供了 RequestContextHolder 的拦截器模式,可以注册一个回调:

@Component
public class ContextCleanupFilter extends OncePerRequestFilter {
    private final ThreadLocal<?> contextHolder;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) {
        try {
            chain.doFilter(request, response);
        } finally {
            contextHolder.remove(); // 请求结束,移除 ThreadLocal
        }
    }
}

remove()clear() 不一样——clear() 是清 context 里的变量,context 对象还在 ThreadLocal 里。remove() 是把 ThreadLocal 的整个条目删掉,线程和 context 对象之间的引用彻底断开,GC 可以回收。

另一个常见的坑:异步方法里拿不到 ThreadLocal。 如果你的规则执行是在 @Async 方法里跑的,异步线程和请求线程是两个不同的线程,ThreadLocal 不共享。这时需要用 InheritableThreadLocal 或者显式地把参数传进去。


方案对比:三种隔离方式

方式实现隔离粒度内存泄露风险适用场景
每次 new Context方法内创建局部变量方法级简单、请求量小
ThreadLocalThreadLocal.withInitial线程级需主动 removeWeb 请求,Tomcat 线程池
请求属性request.setAttribute请求级无(请求结束即释放)仅在 Controller 层可用

推荐 Web 场景用 ThreadLocal + Filter 清理钩子。如果只是简单工具类,没有线程池复用的问题,每次 new 一个 Context 是最简单的。


排查串号问题的三板斧

如果你已经遇到了"偶尔串数据"的问题,可以从三个方向入手:

一、检查 context 是不是单例

context.put() 的地方打断点或者加日志,打印 this 引用的 hashCode:

log.info("context hashCode={}, thread={}",
         System.identityHashCode(context),
         Thread.currentThread().getName());

如果多个并发请求打印出来的 hashCode 一样,那就是在共享同一个 context。

二、检查 ThreadLocal 有没有 remove

在所有可能退出请求的地方——正常返回、异常返回、过滤器链——确保 ThreadLocal.remove() 被调用。漏掉任何一个路径,都可能积累残留数据。

三、检查异步执行

如果你的规则执行链路里用了 @AsyncCompletableFuture、MQ 消费者,检查 context 传参是否正确。不要依赖 ThreadLocal 跨线程传递。


总结

并发下的数据串号问题,根本原因是可变状态被共享。在规则引擎的场景里,这个可变状态就是 DefaultContext

解法很直接:

ThreadLocal 隔离 —— 每个线程一个独立的 context,线程之间互不干扰。get() 时自动创建,用完清空。

清理钩子 —— Filter 的 finally 块里 ThreadLocal.remove(),请求结束彻底清掉引用。

拒绝单例 context —— 永远不要把 context 注入成 Spring 单例 Bean,它不是为共享设计的。

这三条做到位,再高并发也不会出现用户 A 看到用户 B 数据的事故。


有用的话转给还在把 DefaultContext 当单例 Bean 用的同事。


标题:规则执行上下文串号排查:并发请求下变量互相覆盖?ThreadLocal 精准隔离 + 清理钩子!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/09/1780755491576.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消