规则执行上下文串号排查:并发请求下变量互相覆盖?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 | 方法内创建局部变量 | 方法级 | 无 | 简单、请求量小 |
| ThreadLocal | ThreadLocal.withInitial | 线程级 | 需主动 remove | Web 请求,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() 被调用。漏掉任何一个路径,都可能积累残留数据。
三、检查异步执行
如果你的规则执行链路里用了 @Async、CompletableFuture、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
公众号:服务端技术精选
评论