ScopedValue 替换 ThreadLocal 实战:订单导出接口的串号事故复盘

2026-09-10 0 279

客服把截图甩进群里的时候是凌晨一点四十。两个不同商户的导出记录里,出现了对方的订单号。第一反应是缓存串了 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();
    }
}

拦截器在请求进来时 setfinallyclear,看起来无懈可击。业务读取的地方是这么用的:

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 删掉,只留 getbinding,让编译器帮你找出所有不合理的调用点。接着逐个处理跨边界的地方,选择显式传参还是 Carrier 传播。最后把那套 null 兜底逻辑清理干净。

别一次性全量切,挑一个并发不高的接口先跑两周。ScopedValue 的行为跟 ThreadLocal 差别不小,尤其是”出了作用域就取不到”这条,很容易在定时任务或者延迟队列里踩雷——这些地方往往没有明确的调用入口,你没办法自然地圈出一个作用域。

回到最开始那个事故,最后修复其实只改了三行:删掉那套 null 兜底,给 supplyAsync 传一个专用 Executor,再把手动设的 ThreadLocal 换成作用域绑定。但那三行背后的问题——上下文到底该绑定在线程上,还是绑定在一次调用上——值得多想一会儿。

ScopedValue 替换 ThreadLocal 实战:订单导出接口的串号事故复盘
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java ScopedValue 替换 ThreadLocal 实战:订单导出接口的串号事故复盘 https://www.taomawang.com/server/java/2741.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务