虚拟线程迁移翻车记:一个聚合接口从 800ms 到 120ms,中间修了三个坑

2026-10-05 0 157

先交代一下这个接口是什么。电商中台的「商品详情聚合」,前端一个请求过来,后端要同时去调库存、价格、促销、评价、推荐、物流时效、店铺信息、会员优惠这八个下游服务,把结果拼成一个 JSON 返回。

这个接口 QPS 峰值在 1200 左右,平均响应 800ms,P99 能到 3.5 秒。运维那边一直有意见,加机器加了三次,还是压不下去。根子在哪呢?每个下游服务平均 150ms 到 400ms,八个串行跑就得 2 秒往上。代码里已经用了 CompletableFuture,但线程池是个固定大小 64 的池子,一旦并发上来,任务全堆在队列里排队,响应时间就跟着一起飙。

去年年底项目组决定把 JDK 从 17 升到 21,顺手把这块换成虚拟线程。这件事看起来简单——把 Executors.newFixedThreadPool(64) 换成 Executors.newVirtualThreadPerTaskExecutor() 不就完了吗。

结果就是这篇文章的由来。

原来的方案和它的纠结

先说说原来的代码长什么样,方便你对比。

public class ProductDetailService {

    private static final ExecutorService POOL = new ThreadPoolExecutor(
            64, 64,
            60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(2000),
            new ThreadFactoryBuilder().setNameFormat("agg-%d").build(),
            new ThreadPoolExecutor.CallerRunsPolicy()
    );

    public ProductDetailVO detail(long skuId, long userId) {
        CompletableFuture<StockInfo> stock = CompletableFuture.supplyAsync(
                () -> stockClient.query(skuId), POOL);
        CompletableFuture<PriceInfo> price = CompletableFuture.supplyAsync(
                () -> priceClient.query(skuId), POOL);
        CompletableFuture<PromotionInfo> promo = CompletableFuture.supplyAsync(
                () -> promoClient.query(skuId, userId), POOL);
        // ... 其余五个

        CompletableFuture.allOf(stock, price, promo, review, rec, delivery, shop, member)
                .orTimeout(3000, TimeUnit.MILLISECONDS)
                .join();

        return assemble(
                stock.join(), price.join(), promo.join(), review.join(),
                rec.join(), delivery.join(), shop.join(), member.join());
    }
}

线程池大小定 64 这个数字,当时组里争论了一下午。定小了并发压不上去,定大了下游被打挂,定 64 是个凭着经验拍的数,谁也说不出准确的依据。这就是固定线程池的经典困境——线程数量必须在「够用」和「不把下游打挂」之间做一个别扭的平衡,而这个平衡点会随着流量、下游性能、机器配置不断漂移。

虚拟线程出现之前,这个问题无解。你只能不断手工调参,然后监控告警,再手工调参。

第一次尝试:无脑替换

第一版改法是真的简单,把线程池常量换掉:

private static final ExecutorService POOL =
        Executors.newVirtualThreadPerTaskExecutor();

其余代码一行没动。本地跑了一下,同一个接口,单机 QPS 从 900 冲到了 2400,P99 从 3.5 秒降到 400ms。看起来一战成名。

上线灰度之后第三天,接到告警:库存服务那边反馈,我们这个应用打过去的请求把他们的连接池耗光了。

第一个坑:线程池换掉了,下游压力也换掉了

这个坑其实很符合逻辑,但一开始我确实没想透。

以前 64 个线程,意味着对下游的并发压力有天然上限——最多 64 个在途请求。现在虚拟线程来一个请求起一个,6000 个请求并发过来,对下游就是 6000 个在途请求。下游服务的小连接池(通常是 50 到 100)瞬间被击穿。

虚拟线程解决的是「本地线程不够用」的问题,它不解决「下游扛不住」的问题。换虚拟线程之后,限流这一层的责任必须单独扛起来,不能再指望线程池大小帮你兜。

修复方式有好几种,我最后用的组合是:

  1. 给每个下游客户端配一个 Semaphore,把在途请求数卡死在合理范围内。
  2. HTTP 客户端本身也换成能感知虚拟线程的版本,用独立的连接池做限制。
  3. 给关键下游加本地缓存,把能挡掉的流量提前挡掉。

Semaphore 那部分代码大概长这样:

public class StockClient {

    private static final Semaphore GATE = new Semaphore(120);

    public StockInfo query(long skuId) {
        try {
            GATE.acquire();
            try {
                return doHttpCall(skuId);
            } finally {
                GATE.release();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("interrupted", e);
        }
    }
}

120 这个数字怎么来的?拿 P99 响应时间(320ms)乘以期望 QPS(400),再留一点余量。这是经典的利特尔法则,比凭感觉拍数字靠谱多了。每个下游各自算一遍,基本上能定出一个不把对方打挂、也不浪费自己吞吐的窗口。

加了这层之后,下游的投诉没有了。但接着出第二个问题。

第二个坑:pinning

加了限流之后,下游不崩了,但接口的响应时间反而变差了——P99 从 400ms 涨回了 1.2 秒,而且日志里出现了大量「carrier thread 长时间占用」的警告。JDK 21 里会打这种日志:

WARNING: A virtual thread has been pinned to a carrier thread. ...

这就是虚拟线程的 pinning 问题。

虚拟线程的运行靠的是「载波线程」(carrier thread),数量等于 CPU 核数。虚拟线程在遇到阻塞操作时,会从载波线程上「卸载」下来,让出载波线程去执行别的虚拟线程。听起来很美,但有两类操作会让虚拟线程无法卸载:

  • 在 synchronized 块里发生阻塞
  • 调用 JNI 本地方法,或进入某些 native 帧

一旦被 pin 住,虚拟线程就一直占着载波线程不放。载波线程总共就那么几条,被 pin 住的多了,后面的虚拟线程全部排队。这一下就把虚拟线程的优势全抹掉了,甚至不如固定线程池。

怎么定位是哪里在 pin?JDK 提供了工具。用 jcmd 打一个线程 dump,专门关注虚拟线程:

jcmd <pid> Thread.dump_to_file -format=json /tmp/vthreads.json

打开 json,在里面搜 "pinned",能看到被 pin 住的虚拟线程栈。我当时那份 dump 里,罪魁祸首明明白白地出现了两次:

at com.example.cache.LocalCache.get(LocalCache.java:87)
    - locked <0x00000007c0a1b2c0>
at com.example.client.RetryTemplate.execute(RetryTemplate.java:42)
    - locked <0x0000000712e0f0a8>

一个是本地缓存用了 synchronized 方法,另一个是重试模板里用 synchronized 包了一段 sleep 逻辑。

这就是我一点没防备的地方。项目里大量的 synchronized 是历史遗留的,平时不会有任何问题,换成虚拟线程之后就变成了性能杀手。而且从代码 review 的角度看,它们长得跟正常代码没区别,肉眼根本发现不了。

修 pinning 的正确姿势

修法其实很简单——把 synchronized 换成 ReentrantLock。ReentrantLock 在 JDK 21 里已经针对虚拟线程做了适配,遇到阻塞时会正确卸载,不会 pin 载波线程。

改之前的本地缓存:

// 旧:会 pin
public synchronized Product get(long id) {
    Product cached = map.get(id);
    if (cached != null) return cached;
    Product loaded = loader.load(id);  // 这里会阻塞
    map.put(id, loaded);
    return loaded;
}

改之后:

private final ReentrantLock lock = new ReentrantLock();

public Product get(long id) {
    Product cached = map.get(id);
    if (cached != null) return cached;

    lock.lock();
    try {
        // 双重检查,防止并发加载
        cached = map.get(id);
        if (cached != null) return cached;

        Product loaded = loader.load(id);
        map.put(id, loaded);
        return loaded;
    } finally {
        lock.unlock();
    }
}

改完之后再打一次 dump,pinning 数量从四位数降到了个位数。剩下那几个,是 native 层的锁,比如某些第三方 SDK 里用了老版本 Netty 或者 JDBC 驱动,那个就只能等对方升级。

顺带说一句,JDK 24 已经把 synchronized 的 pinning 问题基本修掉了(JEP 491),如果你的项目能上到 24 或者 25,原来的 synchronized 大部分场景就不需要改了。但升级 JDK 本身也是有成本的事,根据自己情况决定。

第三个坑:ThreadLocal 内存

改完 pinning,接口 P99 回到了 260ms,已经能接受了。但上线一周后,发现应用的内存曲线开始有规律地周期性上涨——每 24 小时左右会涨到 1.5G,然后触发一次 GC 又掉回去。

这事跟虚拟线程有关系,但很容易被忽略。

项目里有一个 TraceContext 类,用 ThreadLocal 存当前请求的 traceId,做日志链路追踪用。原来是 64 个线程,一个线程一个 ThreadLocal 副本,内存开销可以忽略。换成虚拟线程之后,每个请求起一个虚拟线程,ThreadLocal 也跟着每个请求创建一份。虽然虚拟线程结束之后 ThreadLocal 会被回收,但回收时机不确定,堆上短时间能堆几万个 Map.Entry,GC 压力就上来了。

解决办法是 JDK 21 引入的 ScopedValue。它的语义比 ThreadLocal 更清晰——值只在某个作用域内有效,作用域结束立即回收,不需要手动 remove,也不会被遗忘。

// 旧:ThreadLocal
public class TraceContext {
    private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

    public static void set(String id) { TRACE_ID.set(id); }
    public static String get() { return TRACE_ID.get(); }
    public static void clear() { TRACE_ID.remove(); }
}

// 新:ScopedValue
public class TraceContext {
    public static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
}

用的时候稍微改一下调用方式:

public ProductDetailVO detail(long skuId) {
    String traceId = generateTraceId();

    return ScopedValue.where(TraceContext.TRACE_ID, traceId)
            .call(() -> doDetail(skuId));
}

在作用域内部,任何地方调 TraceContext.TRACE_ID.get() 都能拿到值。作用域退出后自动失效,不需要显式清理,内存曲线也不再周期性上涨了。

ScopedValue 目前在 JDK 21 到 25 里都是预览特性,需要加 --enable-preview 才能用。如果不想开预览,退一步的做法还是用 ThreadLocal,但要在每个虚拟线程结束的 finally 里调用 remove(),而且要用 ThreadLocal.withInitial() 保证不会漏掉初始值。

结构化并发:把八个任务收拢在一个作用域里

上面那几个问题都修完之后,代码其实还是有点乱——每个下游调用散落在方法体里,try/catch 分散各处分不清,超时处理和控制流纠缠在一起。这时候我决定上结构化并发。

结构化并发的核心思想很简单:一组同时启动的并发任务,应该作为一个整体被管理。要么全部成功,要么整体取消。任何一个任务失败,其余任务自动被取消,不会出现「一个成功一个失败」这种半拉子状态。

JDK 21 到 25 里这个 API 叫 StructuredTaskScope,在 JDK 25 里已经是正式功能(如果你用的版本还带 preview 标记,编译和运行时加 --enable-preview 即可)。用它重写这个聚合接口:

import java.util.concurrent.StructuredTaskScope;

public ProductDetailVO detail(long skuId, long userId) {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

        var stock    = scope.fork(() -> stockClient.query(skuId));
        var price    = scope.fork(() -> priceClient.query(skuId));
        var promo    = scope.fork(() -> promoClient.query(skuId, userId));
        var review   = scope.fork(() -> reviewClient.query(skuId));
        var rec      = scope.fork(() -> recClient.query(skuId, userId));
        var delivery = scope.fork(() -> deliveryClient.query(skuId));
        var shop     = scope.fork(() -> shopClient.query(skuId));
        var member   = scope.fork(() -> memberClient.query(userId));

        scope.joinUntil(Instant.now().plusMillis(800))
             .throwIfFailed();

        return assemble(
                stock.get(), price.get(), promo.get(), review.get(),
                rec.get(), delivery.get(), shop.get(), member.get());

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("aggregation interrupted", e);
    } catch (ExecutionException | TimeoutException e) {
        throw new RuntimeException("aggregation failed", e);
    }
}

跟 CompletableFuture 版本相比,这段代码的变化很实在:

  • 没有 ExecutorService 需要传递,也没有线程池参数需要纠结。scope.fork() 默认就在虚拟线程上运行。
  • 超时通过 joinUntil 表达,就是一个明确的截止时间。之前 CompletableFuture 的 orTimeout 只作用于 allOf 那个 future,子任务还在后台跑,容易造成资源泄漏。
  • 任何一个 fork 失败,throwIfFailed 会立即抛出,其余 fork 会被自动取消,不留悬空任务。
  • try-with-resources 保证作用域退出时所有子任务都已经结束,不存在「方法返回了任务还在跑」这种状态。

最后这一条,是结构化并发最核心的价值。并发编程里最难查的 Bug 之一就是「任务泄漏」——某个子任务在方法返回之后还在跑,访问了已经被释放的资源,报了一个莫名其妙的 NPE。结构化并发从语言层面堵住了这条路。

压测数据对比

同一台机器(4C8G 容器,JDK 21.0.5),同一批测试数据,三套方案各跑 10 分钟。

指标 固定线程池(64) 虚拟线程(无脑替换) 虚拟线程(修复后)
平均响应 820ms 失败率高,数据不可用 128ms
P99 响应 3520ms – 286ms
最大 QPS 920 – 2650
单请求内存增量 约 2KB – 约 1.1KB
下游连接错误率 0 3.2% 0

单请求内存增量那一列其实出乎我意料。虚拟线程的栈初始就几百字节,比固定线程池里每个 512KB 到 1MB 的栈小得多。虽然虚拟线程数量多,但总内存反而更省。

什么时候不该用虚拟线程

说了这么多好处,得泼点冷水。虚拟线程不是万能药,有几类场景我建议先别碰。

CPU 密集型任务。虚拟线程的优势在于「大量的阻塞等待」,如果你的任务不需要等 IO,一直吃 CPU,那虚拟线程跟固定线程池没区别——载波线程就那么几条,多出来的虚拟线程都得排队。CPU 密集场景该用多少线程还是多少线程,Runtime.getRuntime().availableProcessors() 加一,别用虚拟线程。

依赖老的 JNI 库或 synchronized 密集的代码。如果你的项目深度绑定了某个老版本的 JDBC 驱动、老版本的 Netty、老版本的日志框架,里面到处是 synchronized 和 native 调用,换虚拟线程会踩一脚 pinning。JDK 24 之前这个问题比较严重,24 之后稍有改善但不是完全消失。

少量长任务的应用。比如一个定时任务,一天跑一次,每次处理 20 分钟。这种场景本来就没多少并发,虚拟线程的调度开销反而多了一层。老实用普通线程就行。

线程数不多但每个任务很重的场景。虚拟线程的调度器是 ForkJoinPool,它在调度海量轻任务时非常高效,但如果你就开 20 个线程每个跑一小时,用虚拟线程是杀鸡用牛刀。

我的判断框架

总结一下我自己的经验——判断一个场景该不该用虚拟线程,先问三个问题:

  1. 任务的瓶颈是 IO 等待还是 CPU 计算?IO 等待占主导,可以上;CPU 计算占主导,别上。
  2. 相同时刻的活动任务数会不会随流量大幅波动?会波动,虚拟线程好处大;基本恒定,好处小。
  3. 代码链路里有没有未适配虚拟线程的老库?有就要先评估,不然 pinning 会抵消大部分收益。

三个问题都答「是」,虚拟线程几乎肯定能给你带来好处。有一个答「否」,就得慎重。

最后

这次迁移从立项到全量上线,前后花了六周。其中前两周到处踩坑,中间三周定位和修复,最后一周做压测和文档。回头看,真正值得说的其实不是「虚拟线程有多快」——这个结论现在网上到处都是。值得说的是虚拟线程带来的这些坑,以及每个坑背后对应的工程判断。世界不是被单个技术改变的,是被一整套配合这个技术的工程实践改变的。

把这个过程写下来,希望能给正在做或者准备做这件事的人一点参考。如果你踩到了我都没想到的坑,欢迎交流。

虚拟线程迁移翻车记:一个聚合接口从 800ms 到 120ms,中间修了三个坑
收藏 (0) 打赏

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

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

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

淘吗网 java 虚拟线程迁移翻车记:一个聚合接口从 800ms 到 120ms,中间修了三个坑 https://www.taomawang.com/server/java/2890.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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