客服把截图甩进群里的时候是凌晨一点四十。两个不同商户的导出记录里,出现了对方的订单号。第一反应是缓存串了 key,翻遍 Redis 前缀没看出问题;接着怀疑主从延迟,查了一个多小时,慢查询日志干干净净。
最后定位到的是这么一行代码:UserContextHolder.get() 在某个子线程里返回了 null。
这事儿的根子不在缓存,也不在数据库,而在于我们用错了 ThreadLocal 的适用场景。这篇把整个排查和改造过程写下来,包括后来换到 ScopedValue 之后踩的新坑。
出问题的代码长什么样
我们的上下文容器是这类写法,估计很多项目里都有:
public class UserContextHolder {
private static final ThreadLocal<UserContext> HOLDER = new ThreadLocal<>();
public static void set(UserContext ctx) {
HOLDER.set(ctx);
}
public static UserContext get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}
拦截器在请求进来时 set,finally 里 clear,看起来无懈可击。业务读取的地方是这么用的:
public List<Order> pageByCurrentUser(ExportQuery query) {
UserContext ctx = UserContextHolder.get();
if (ctx == null) {
// 早期留下的兜底,当时觉得"查不到就全量"
return orderMapper.pageAll(query);
}
return orderMapper.pageByUser(query, ctx.userId(), ctx.dataScope());
}
导出接口本身原来是串行的,后来为了压 QPS,改成了并行拉三个下游:
@PostMapping("/order/export")
public ExportResult export(@RequestBody ExportQuery query) throws Exception {
CompletableFuture<List<Order>> orders =
CompletableFuture.supplyAsync(() -> orderService.pageByCurrentUser(query));
CompletableFuture<List<Refund>> refunds =
CompletableFuture.supplyAsync(() -> refundService.byCurrentUser(query));
CompletableFuture<List<Invoice>> invoices =
CompletableFuture.supplyAsync(() -> invoiceService.byCurrentUser(query));
CompletableFuture.allOf(orders, refunds, invoices).join();
return new ExportResult(orders.join(), refunds.join(), invoices.join());
}
问题就出在 supplyAsync 没传 Executor。它默认走 ForkJoinPool.commonPool(),也就是一批平台线程。这些线程跟处理 HTTP 请求的那条线程没有任何关系,ThreadLocal 里自然是空的——于是兜底逻辑被触发,数据权限的 SQL 条件被整个丢掉,三个下游各自返回了全量数据。
那为什么会”串号”而不是简单返回空列表?因为 commonPool 的线程是复用的。如果某条异常路径没走到 clear(),上一个请求的 UserContext 会残留在池子里的某条线程上,下一个请求的子任务读到的就是别人的身份。这比读到 null 更吓人,因为它不报错,安静地给你一份别人的数据。
ThreadLocal 的天生局限
把锅推给虚拟线程是不对的,这事在平台线程时代一样会发生。ThreadLocal 的名字已经把局限写在脸上了——它绑定的是线程,而业务上我们真正需要的是绑定一次调用。
只要调用链跨出当前线程,绑定就断了。而跨线程这件事在现代 Java 里越来越常见:CompletableFuture、@Async、自定义线程池、并行流、消息消费里的线程池回调。每多一条异步路径,就要多一次手动传递,漏一次就是一个潜在的线上事故。
另一个隐患是清理。set 和 clear 必须严格配对,异常路径、提前 return、过滤器顺序调整,任何一个环节出错都会留下脏数据。而且这种错误在测试环境很难复现,因为并发压力不够。
ScopedValue 换了个思路
ScopedValue 是 JDK 21 引入预览、JDK 25 正式定稿的 API,不在预览列表里了。它的核心区别是:没有 set,只有”在某个作用域内绑定”。值随着闭包执行而生效,闭包结束自动失效。
写法是这样:
static final ScopedValue<UserContext> CURRENT = ScopedValue.newInstance();
// 绑定并执行
ScopedValue.where(CURRENT, ctx).run(() -> handleRequest(query));
// 需要返回值
ExportResult result = ScopedValue.where(CURRENT, ctx).call(() -> doExport(query));
读的时候直接用 CURRENT.get(),判断是否绑定用 CURRENT.isBound()。
几个必须记住的约束:值一旦绑定就不能在作用域内修改,也没有 remove 可以忘记调用;出了闭包再去 get() 会直接抛异常;同一个线程可以嵌套绑定同一个 key,内层覆盖外层,退出内层后自动恢复。
动手改造
第一步:把可变容器改成只读访问器
public final class CurrentUser {
public static final ScopedValue<UserContext> KEY = ScopedValue.newInstance();
private CurrentUser() {
}
public static boolean bound() {
return KEY.isBound();
}
public static UserContext get() {
if (!KEY.isBound()) {
throw new IllegalStateException(
"调用链不在用户作用域内,检查是否脱离了 ScopedValue.where(...).call(...) 的边界");
}
return KEY.get();
}
public static ScopedValue.Carrier binding(UserContext ctx) {
return ScopedValue.where(KEY, ctx);
}
}
注意 get() 里的处理:不再返回 null,而是直接抛异常。原来那个”查不到就查全量”的兜底必须删掉——它把上下文丢失这种严重错误降级成了一次静默的数据越权,危害远大于抛异常。
第二步:过滤器把整个请求包进作用域
@Component
public class UserContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
UserContext ctx = UserContext.fromHeaders(request);
try {
ScopedValue.where(CurrentUser.KEY, ctx).call(() -> {
chain.doFilter(request, response);
return null;
});
} catch (IOException | ServletException | RuntimeException e) {
throw e;
} catch (Exception e) {
throw new ServletException(e);
}
}
}
相比原来 set/clear 成对出现的写法,这里没有清理动作需要维护。业务代码再怎么提前 return、抛异常,作用域都会正常退出。
第三步:异步任务显式带上下文过边界
这一步是重点,也是最容易想当然的地方。ScopedValue 不会自动跨越普通线程池的边界。往 ExecutorService.submit() 里扔任务,子任务看到的仍然是未绑定状态。想让它跟着走,要么用结构化并发,要么手动传播 Carrier。
@PostMapping("/order/export")
public ExportResult export(@RequestBody ExportQuery query) throws Exception {
// 在当前作用域内捕获,得到一个不可变的 Carrier
ScopedValue.Carrier carrier = CurrentUser.binding(CurrentUser.get());
List<CompletableFuture<List<Order>>> futures = new ArrayList<>();
for (int i = 0; i < shardCount; i++) {
int shard = i;
futures.add(CompletableFuture.supplyAsync(() -> {
try {
return carrier.call(() -> orderService.pageByShard(shard, query));
} catch (Exception e) {
throw new CompletionException(e);
}
}, exportExecutor));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
List<Order> all = futures.stream()
.flatMap(f -> f.join().stream())
.toList();
return new ExportResult(all);
}
carrier 是个不可变对象,可以安全地传到任意线程,在任何线程上重新建立绑定。
第四步:能上结构化并发的地方就别手写 Carrier
如果项目允许开启预览特性,用 StructuredTaskScope 会让代码干净很多,因为它创建的子线程会自动继承父线程当前的 ScopedValue 绑定,不需要手动传 Carrier:
// JDK 25 下仍需 --enable-preview
try (var scope = StructuredTaskScope.open()) {
var orders = scope.fork(() -> orderService.pageByCurrentUser(query));
var refunds = scope.fork(() -> refundService.byCurrentUser(query));
var invoices = scope.fork(() -> invoiceService.byCurrentUser(query));
scope.join();
return new ExportResult(orders.get(), refunds.get(), invoices.get());
}
三个 fork 出来的子任务里直接写 CurrentUser.get() 就行,不用做任何传递。这也是结构化并发和 ScopedValue 被放在一起设计的原因——它们本来就是配套的。
改造过程中真踩到的坑
MDC 失效是最先撞上的。日志框架的 MDC 内部用的是 ThreadLocal,切到 ScopedValue 之后,过滤器里的绑定对 MDC 没有任何影响。我们的做法是在过滤器里手动把 userId 放进 MDC,并用 try-finally 保证清除:
try (MDC.MDCCloseable ignored = MDC.putCloseable("uid", ctx.userId())) {
ScopedValue.where(CurrentUser.KEY, ctx).call(() -> {
chain.doFilter(request, response);
return null;
});
}
但要注意,子线程里的日志还是拿不到这个 userId。如果排查问题时依赖日志串联,得在传播 Carrier 的同时也把 MDC 一起带过去,或者改用结构化并发配合专门的日志上下文方案。
第二个坑是 @Async 和 @Scheduled。这些方法执行在 Spring 自己的线程池上,ScopedValue 完全不可见。我们的选择是把 UserContext 作为方法参数显式传进去,而不是搞一套 TaskDecorator 自动传播。参数传参虽然啰嗦,但调用方一眼能看出”这个方法依赖上下文”,比隐式的线程绑定更好排查。
第三个坑是那些”先存起来,等会儿再读”的代码。有一处是在方法开头把 CurrentUser.get() 存进局部变量,本意是省几次查找,结果这个变量被 lambda 捕获后在别的线程里用。改成 ScopedValue 之后,这种写法会在编译期或运行期直接暴露出来——因为作用域外 get() 会抛异常。这类代码大概有七八处,改的时候才发现原来都在裸奔。
关于性能,我压过一版:500 并发、持续 10 分钟,P99 和原版本没有可观测差异。写入和读取都是常数级操作,瓶颈根本不在这里。真正省下来的是虚拟线程场景下的内存——ThreadLocal 会让每个虚拟线程都持有一份 ThreadLocalMap,虽然 JVM 做了惰性初始化的优化,但在十万级虚拟线程的压测里,省掉这部分还是很可观的。
什么情况下我不建议动
如果上下文只在一个线程里从头用到尾,没有异步、没有线程池回调,那 ThreadLocal 挺好的,没必要为了上新特性而折腾。它成熟、简单、所有框架都支持。
还有一类是生命周期本来就跟着线程走的场景,比如连接池或缓冲区里绑定线程相关状态的标记,这种天然属于线程本地,ScopedValue 也不适合。
ScopedValue 解决的是”沿调用树向下传递不可变上下文”这个问题,它没打算、也不应该取代所有 ThreadLocal 用法。
迁移时建议的执行顺序
先梳理所有会跨线程的路径,把 @Async、CompletableFuture、线程池提交、消息监听器这些地方全部列出来,这一步花的时间比写代码多得多。然后改造上下文容器,把 set 删掉,只留 get 和 binding,让编译器帮你找出所有不合理的调用点。接着逐个处理跨边界的地方,选择显式传参还是 Carrier 传播。最后把那套 null 兜底逻辑清理干净。
别一次性全量切,挑一个并发不高的接口先跑两周。ScopedValue 的行为跟 ThreadLocal 差别不小,尤其是”出了作用域就取不到”这条,很容易在定时任务或者延迟队列里踩雷——这些地方往往没有明确的调用入口,你没办法自然地圈出一个作用域。
回到最开始那个事故,最后修复其实只改了三行:删掉那套 null 兜底,给 supplyAsync 传一个专用 Executor,再把手动设的 ThreadLocal 换成作用域绑定。但那三行背后的问题——上下文到底该绑定在线程上,还是绑定在一次调用上——值得多想一会儿。

