虚拟线程落地实录:商品聚合接口改造、pinning 排查与连接池真相

2026-10-17 0 189

去年我们接手了一个老电商项目,商品详情页的接口 P99 一直是 800 毫秒左右,遇到大促高峰能飘到 2 秒开外。这个接口做的事情很典型:查商品主信息、查 SKU 列表、查店铺信息、查几条热门评论,四件事并行发出去,然后组装成一个 VO 返回。上线之初就用线程池 + CompletableFuture 写着,五年了也没出过什么大事故,就是慢。

JDK 21 正式发布之后,团队里关于虚拟线程的讨论就没停过。我一开始挺乐观的——一个纯 IO 密集的聚合接口,几乎是为虚拟线程量身定做的场景。真正改下来,一周时间上线,性能数字确实变好看了,但中间踩的坑比预期多不少,有几个还差点让我放弃。

这篇记录就是把这整个过程写清楚。JDK 版本是 21.0.5,Spring Boot 3.3,MySQL 8.0.35,HikariCP 5.1.0。

原来那套写法的问题在哪

老代码大概长这样(简化版本):

private final ExecutorService pool = new ThreadPoolExecutor(
    20, 200, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(500),
    new ThreadFactoryBuilder().setNameFormat("detail-pool-%d").build(),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

public ProductDetailVO getDetail(Long productId) {
    CompletableFuture<Product> productFuture = CompletableFuture.supplyAsync(
        () -> productService.findById(productId), pool);
    CompletableFuture<List<Sku>> skuFuture = CompletableFuture.supplyAsync(
        () -> skuService.listByProduct(productId), pool);
    CompletableFuture<Shop> shopFuture = CompletableFuture.supplyAsync(
        () -> shopService.findByProduct(productId), pool);
    CompletableFuture<List<Comment>> commentFuture = CompletableFuture.supplyAsync(
        () -> commentService.topComment(productId), pool);

    try {
        CompletableFuture.allOf(productFuture, skuFuture, shopFuture, commentFuture)
            .get(800, TimeUnit.MILLISECONDS);
    } catch (TimeoutException e) {
        // 超时之后怎么办?这里其实什么也没做
    } catch (Exception e) {
        throw new BizException("查询商品失败");
    }

    ProductDetailVO vo = new ProductDetailVO();
    // 一堆 getNow / join 组装
    return vo;
}

这段代码有几个很隐蔽的问题,正确性上勉强过得去,但延迟表现很难做好。

第一个问题是线程池大小和 Tomcat 线程数不匹配。Tomcat 默认最大 200 个线程,而我们的业务线程池核心只有 20,最大 200,队列 500。高峰期 Tomcat 层 200 个请求一起进来,每个请求都要往业务池投 4 个任务,一共 800 个任务。业务池线程数撑到 200 就暂停扩展了,剩下 600 个任务全堵在队列里。请求响应时间就被这个队列长度拖长。

第二个问题是 CallerRunsPolicy。队列满了之后,Tomcat 线程会自己跑任务而不是拒绝。看着挺安全,实际上是把 Tomcat 线程也拖进了业务池的节奏里,并发能力进一步恶化。这个策略当年选的时候就是图省事,能起来就行。

第三个问题是超时处理形同虚设。800 毫秒超时之后我们只 catch 了异常,四个 future 还在继续跑,日志里也看不出是谁慢,只能靠 APM 猜。

虚拟线程版的改写

JDK 21 里虚拟线程已经是正式特性了,不需要加 preview 参数。写法上有两种:直接用 Executors.newVirtualThreadPerTaskExecutor() 提交任务,或者用 StructuredTaskScope 做结构化并发。

我最初写的是简单版本:

private final ExecutorService vThreadPool =
    Executors.newVirtualThreadPerTaskExecutor();

public ProductDetailVO getDetail(Long productId) {
    Future<Product> productFuture =
        vThreadPool.submit(() -> productService.findById(productId));
    Future<List<Sku>> skuFuture =
        vThreadPool.submit(() -> skuService.listByProduct(productId));
    Future<Shop> shopFuture =
        vThreadPool.submit(() -> shopService.findByProduct(productId));
    Future<List<Comment>> commentFuture =
        vThreadPool.submit(() -> commentService.topComment(productId));

    try {
        Product p = productFuture.get(800, TimeUnit.MILLISECONDS);
        // ...
    } catch (Exception e) {
        throw new BizException("查询商品失败");
    }
}

代码是简单了,但超时之后四个 Future 不会被取消。Future.cancel(true) 对虚拟线程也不像对面线程那么可靠——如果任务已经跑进 JDBC 阻塞调用里,interrupt 是收不到的。这时候需要的是结构化并发。

StructuredTaskScope 在 JDK 21 到 JDK 24 之间一直是 preview,需要加 --enable-preview 编译运行。到了 JDK 25 这个 API 才正式转正(JEP 505)。我们生产环境用的是 JDK 21,所以加了 preview 参数,这点要注意,preview 特性在跨大版本升级时会有破坏性变更。

用 StructuredTaskScope.ShutdownOnFailure 重写:

public ProductDetailVO getDetail(Long productId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Subtask<Product> productTask =
            scope.fork(() -> productService.findById(productId));
        Subtask<List<Sku>> skuTask =
            scope.fork(() -> skuService.listByProduct(productId));
        Subtask<Shop> shopTask =
            scope.fork(() -> shopService.findByProduct(productId));
        Subtask<List<Comment>> commentTask =
            scope.fork(() -> commentService.topComment(productId));

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

        ProductDetailVO vo = new ProductDetailVO();
        vo.setProduct(productTask.get());
        vo.setSkus(skuTask.get());
        vo.setShop(shopTask.get());
        vo.setTopComments(commentTask.get());
        return vo;
    }
}

这里有几个细节值得说。

ShutdownOnFailure 的语义是:任意一个子任务抛异常,其它还没完成的子任务会被取消。joinUntil 是带超时的 join,超时之后返回,throwIfFailed 会把子任务里的异常重新抛出来,如果超时则抛 TimeoutException。

try-with-resources 是必须的。StructuredTaskScope 实现了 AutoCloseable,close() 时会确保所有子任务已经被处理完(要么正常结束,要么被取消),并且会等它们真正退出。这是结构化并发的核心承诺——离开这个方法时,不会再有它派生出去的线程还在跑。

还有一点,fork 出去的子任务是可以继承作用域的本地变量的(继承 StructuredTaskScope),但网络请求、数据库连接这些资源是共享的,这里要单独考虑。

坑一:连接池立刻变成新瓶颈

改完代码之后先本地压测。500 并发打进来,Tomcat 层面不改色,虚拟线程瞬间创建了 500 个,任务全部进入业务逻辑。但等我盯着 HikariCP 的监控看,问题就来了——DB 查询开始出现大量等待。

我们生产库的 HikariCP 配置如下:

spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.minimum-idle=10

30 个物理连接。500 个虚拟线程里,同时能拿到连接的只有 30 个,剩下 470 个全部卡在 getConnection 上,最长要等 30 秒才会抛连接超时异常。之前用线程池的时候为什么没暴露这个问题?因为线程池本身的核心线程只有 20,最多扩到 200 还得排队,实际上同一时刻最多只有 200 个任务在抢连接,压力没那么大。虚拟线程把这个限流打爆了。

更隐蔽的是,连接池等待发生在 JDBC 层,是阻塞调用。虚拟线程在这个阻塞里会被卸载,CPU 占用很低,看起来一切正常。唯一能看出异常的是接口 P99 从 800 毫秒飘到了 1900 毫秒,而 CPU 使用率不到 30%。

处理方式我们试过三种。

第一种是直接调大 maximumPoolSize,从 30 加到了 100。有用,但需要注意 MySQL 的 max_connections 是共用的,你这边调到 100,别的服务那边就少了。而且 HikariCP 官方文档一直强调 pool size 不是越大越好,物理连接过多会让数据库侧的上下文切换和锁竞争变严重。

第二种是加信号量限流。在进入业务逻辑之前就用 Semaphore 限住并发数,保证同时在业务逻辑里的虚拟线程不超过物理连接能承载的数量:

private final Semaphore dbGate = new Semaphore(30);

public ProductDetailVO getDetail(Long productId) throws Exception {
    dbGate.acquire();
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // ...
    } finally {
        dbGate.release();
    }
}

这个方案的缺点是整个请求期间都占着一个许可,而实际上四个子查询每个只需要一段很短的时间。并发粒度不够细。

第三种方案最朴实——维持 maximumPoolSize = 30,把 connectionTimeout 从 30 秒降到 2 秒。让等不到连接的请求尽快失败,触发上层的重试或降级逻辑。这个方案对可用性的影响最可控。我们最后用的是这个,同时把业务池的并发控制在 200 左右。

有一点需要澄清:网上有种说法是”虚拟线程可以让连接池变小”,这完全是反过来的。虚拟线程能做的是让 IO 等待不再占用宝贵的 OS 线程,但物理连接的数量是数据库侧的硬约束,一个都省不了。真正的变化是——压力的瓶颈从应用线程池挪到了连接池,以前你可能注意不到连接池,现在它会跑到最前面。

坑二:synchronized 引发的 pinning

这个坑差点让我们把整个改造回滚掉。

压测跑到一半,同事说接口偶发性卡顿,有时一个请求会挂到 5 秒以上才返回,没有任何错误日志。我去看 JFR 的虚拟线程事件,发现一堆 jdk.VirtualThreadPinned 的告警。

虚拟线程虽然轻量,但它仍然需要一个载体线程(carrier thread,本质是 OS 线程)来运行。当虚拟线程执行到某些”无法卸载”的操作时,它会 pin 住载体线程,让载体线程跟着一起阻塞。最典型的就是 synchronized 块。如果这个 synchronized 块里还有 IO 操作,那就等于回到了老时代——一个线程干一件事。

检测方法:启动加 -Djdk.tracePinnedThreads=full。这个参数在 JDK 21 和 22 里能用,输出会告诉你哪一行代码把虚拟线程 pin 住了,堆栈打印得很清楚。

我们排查下来发现两个地方在犯这个问题。

第一个是 slf4j 的老版本 logback 里有个同步的 Appender 内部实现。每次业务逻辑打日志,虚拟线程都会在 synchronized 块里短暂阻塞。这个本身时间很短,但高频调用下累积效果很明显。

第二个是我们自己写的某个缓存组件里,用了 synchronized 保护本地缓存的 get-or-load 逻辑。这个逻辑包括一次 Redis 调用。synchronized 里套网络 IO,典型的灾难写法,之前用老线程池的时候问题不明显,因为并发不高的时候 synchronized 争抢很快就过去了。

修起来其实不难。第一种情况升级 logback 到最新版就能缓解。第二种情况把 synchronized 换成 ReentrantLock,或者更彻底地改用 Caffeine 缓存,它有内建的原子加载语义,不需要手写锁。

JDK 24 之后,JEP 491 生效,synchronized 不再 pin 住载体线程了,这个问题从根本上被解决。我们当时还是 JDK 21,只能靠代码侧绕开。升级 JDK 完全是个更省事的办法,但生产环境的升级往往没那么自由。

坑三:ThreadLocal 开始变得危险

虚拟线程数量可以轻松达到几万个,甚至几十万。如果每个虚拟线程都持有 ThreadLocal,内存使用量是线性上升的。

我们在项目里用 ThreadLocal 干了三件事:请求级用户信息缓存、traceId 上下文、以及一个 MyBatis 的 SQL 会话上下文。第三个是框架自己管理的,问题不大。前两个是我们自己写的 ThreadLocal 存储,在没有显式 remove 的情况下,虚拟线程结束之后这些数据会成为垃圾,但问题是——虚拟线程在 JDK 21 里不会被池化,每次请求都是新建的。所以你需要确保的是在请求结束的时候主动 remove,而不是依赖线程复用。

更需要注意的一个场景:StructuredTaskScope 里 fork 出去的子任务会继承父虚拟线程的 inheritableThreadLocals。如果父线程的 ThreadLocal 里存了一个很大的对象,这个对象会被复制到每个 fork 出去的子任务里,内存直接翻好几倍。

我们在 traceId 那部分没出问题,因为 traceId 字符串本身很小,复制也无所谓。用户信息那部分就翻车了——我们缓存的是一个完整 User 对象,含有权限列表、菜单树,光 JSON 序列化出来就几十 KB。500 并发 4 个子任务,就是 500 * 4 * 几十 KB,内存瞬间过百兆。

处理方式是把用户信息从 ThreadLocal 换成请求作用域的外部缓存(用 Redis 或者一次请求内共享的对象),只把 user id 放进 ThreadLocal,需要的时候再去查。

另外 JDK 21 引入的 ScopedValue 是专门为这个问题设计的(也是 preview 状态,JDK 25 转正),它在虚拟线程下比 ThreadLocal 更合适。不过我们当时没敢用 preview 特性,改用普通对象传递的方式绕过了。

坑四:监控指标集体失灵

改造上线的第二天早上,运维找我,问我是不是把 Tomcat 的线程数调小了。因为他看到监控里的 jvm.threads.live 从 200 多降到了 20 多。

其实是虚拟线程不占 OS 线程,所以 JVM 线程数自然就降下来了。原来的线程池监控、Tomcat 并发监控,全都不再能反映真实压力。

替代方案有几个方向。Tomcat 层面要看的是 request 处理时间,这个 Spring Boot 3.2+ 的 Micrometer 已经能采到。运行中的虚拟线程数可以通过 ManagementFactory.getThreadMXBean() 的 getVirtualThreadCount 方法拿到(JDK 21 加了相关 API),采样之后上报就行。业务层的并发我们用的是 Resilience4j 的信号量指标,改造之前就在,现在反而成了最有用的观测点。

还有一点要注意:虚拟线程的默认载体线程池大小是 CPU 核心数(即 jdk.virtualThreadScheduler.parallelism),但最大会扩展到 256(jdk.virtualThreadScheduler.maxPoolSize)。如果你的应用里有大量的计算密集操作,可能会导致载体线程池被占满,其它虚拟线程即使能跑也要等。这个在监控上要看 ForkJoinPool 的队列长度。我们是纯 IO 场景,没遇到,但同部门另一个做图片处理的团队撞上了。

Tomcat 线程池怎么换

补充一下服务层的配置。Spring Boot 3.2 之后,加一行 yml 就能让 Tomcat 用虚拟线程处理请求:

spring:
  threads:
    virtual:
      enabled: true

这个配置会自动把 Tomcat 的 ProtocolHandler 换成基于虚拟线程的 Executor。如果你不用 Spring Boot 3.2+ 或者想更精细的控制,也可以手动配:

@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() {
    return protocolHandler -> protocolHandler.setExecutor(
        Executors.newVirtualThreadPerTaskExecutor()
    );
}

换完之后 Tomcat 那层的 maxThreads 配置就失效了,因为不再有固定线程数这个概念。真正的限流点要放在连接池、信号量或者网关层。

最终的数据

改造前后我们做了同一套压测,参数是 500 并发,持续 10 分钟,机型是 4 核 8G 的阿里云 Serverless 集群(数据库是 RDS MySQL 8.0)。

改造前:P50 是 210 毫秒,P95 是 550 毫秒,P99 是 810 毫秒,QPS 峰值约 220,业务线程池在高峰期基本满负荷,Tomcat 线程数稳定在 200。

改造后,连接池保持 30,加 200 并发信号量限流:P50 是 105 毫秒,P95 是 240 毫秒,P99 是 380 毫秒,QPS 峰值约 480。CPU 使用率从 45% 降到 38%。

数字看起来没那么惊艳,是因为限流卡在了 200。如果不加限流,QPS 能到 800 左右,但 P99 会飘到 1.5 秒。我们最终选择的是前者——在稳定的延迟下拿到最大的吞吐,比盲目的峰值 QPS 有价值得多。

有一点意外收获:改造之后 Tomcat 的请求处理拒绝率从之前的 0.3% 降到了 0。之前高峰期隔三差五会有几个请求在 Tomcat 层被拒绝,现在没有这个现象了,因为 Tomcat 那层不再有”线程池满”这个概念。

哪些接口不适合改

项目里我们也顺手把几个 CPU 密集的接口一起改了,结果发现虚拟线程并不是万能的。

一个是商品搜索的排序接口。它做的是一次大数据量的内存排序,纯 CPU 运算,每个请求要跑 200 毫秒左右。用虚拟线程之后没有任何变化,因为虚拟线程的调度依赖 ForkJoinPool,CPU 密集任务的载体线程数上限就是 CPU 核数,跟直接用固定线程池跑没区别。

另一个是图片处理接口。它调的是第三方 SDK,内部有大量的 synchronized,pinning 问题严重,改完之后 P99 反而变差了。我们最后回滚了这个接口,保持原来的写法。

结论很简单:虚拟线程真的是为 IO 阻塞设计的。CPU 密集型任务、或者内部有大量 synchronized 的老库,改造收益可能是负的。评估的时候要具体看接口的等待时间占比,等待时间超过总时间的 70% 才值得改。

另外就是改造过程中一定要盯紧连接池、关注 pinning、处理好 ThreadLocal。这四件事里任何一个没做对,虚拟线程带来的收益都会被吃掉。

最后一句话:虚拟线程是个很好的工具,但它解决的是”线程不够用”的问题,不是”性能不够好”的问题。如果瓶颈本身就在数据库或者网络带宽,那再多线程也没用。

虚拟线程落地实录:商品聚合接口改造、pinning 排查与连接池真相
收藏 (0) 打赏

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

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

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

淘吗网 java 虚拟线程落地实录:商品聚合接口改造、pinning 排查与连接池真相 https://www.taomawang.com/server/java/2953.html

常见问题

相关文章

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

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