Java 21虚拟线程实战:把电商活动页的并发查询从60秒压到3秒

2026-07-02 0 551

上个月公司搞了个限时秒杀活动,活动页需要同时拉取商品信息、库存、用户优惠券、物流预估、推荐列表等十几个维度的数据。上线第一版的时候,页面加载竟然要将近一分钟,用户投诉电话直接打爆了客服。排查下来发现瓶颈不在数据库,也不在下游服务,而是我们自己的Java服务在并发编排上写了太多锅碗瓢盆的等待逻辑。后来切到Java 21的虚拟线程,响应时间直接从60秒降到了3秒以内。趁这次经历还热乎,把整个过程记下来。

原来的写法是怎么把性能吃光的

先描述一下活动页的逻辑:前端发一个请求过来,后台要调用大概20个独立的下游接口,每个接口返回时间在200毫秒到800毫秒之间。这些接口之间没有依赖关系,理论上可以全部并发调用,最慢的那个返回了整体结果也就出来了。

问题出在我们当时的实现方式上。团队习惯用CompletableFuture配合一个固定大小的线程池来做并发编排,大概长这样:

                
// 旧版本代码
ExecutorService executor = Executors.newFixedThreadPool(10);

List<CompletableFuture<Object>> futures = tasks.stream()
    .map(task -> CompletableFuture.supplyAsync(() -> callDownstream(task), executor))
    .toList();

CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
                
            

线程池大小设成了10,表面上看20个任务分两批执行,每批等最慢的那个800毫秒,加起来也就1.6秒。但实际跑起来根本不是这么回事。下游接口偶尔会出现超时重试,某些任务一卡就是三四秒,把线程池里的线程全部占满,后面的任务只能干等。更要命的是,线程池满了之后,连Tomcat的请求处理线程都被拖住,整个服务的吞吐量断崖式下跌。

我们也试过把线程池调到50甚至100,但平台线程的创建和切换开销太大了,内存也扛不住。调大之后线程上下文切换的CPU消耗反而让响应时间变得更长。这是个典型的「线程池两难」问题。

虚拟线程解决的根本问题

Java 21正式发布的虚拟线程,最核心的概念就是把线程的调度从操作系统层面移到了JVM层面。一个虚拟线程在等待IO(比如等待下游接口返回)的时候,底层的载体线程(也就是平台线程)会被释放去执行其他虚拟线程。这样几万个虚拟线程可以同时跑在几十个平台线程上,而不会互相阻塞。

这意味着我们可以给每个下游调用都分配一个独立的虚拟线程,20个任务就创建20个虚拟线程,它们几乎同时发出请求,然后全部进入等待状态。载体线程的数量很少,切换成本也低,不会出现线程池耗尽的问题。再也不用纠结线程池大小设多少了。

迁移到虚拟线程的代码改动小到让人不敢相信。Spring Boot 3.2已经内置了对虚拟线程的支持,只需要在配置文件里加一行:

                
spring.threads.virtual.enabled = true
                
            

然后原来用线程池的地方全部换成虚拟线程执行器:

                
// 新版本代码
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

List<Future<Object>> futures = tasks.stream()
    .map(task -> executor.submit(() -> callDownstream(task)))
    .toList();

for (Future<Object> future : futures) {
    future.get(); // 这里阻塞的是虚拟线程,不消耗平台线程
}
                
            

核心变化就是把newFixedThreadPool换成了newVirtualThreadPerTaskExecutor。其他业务代码一行没改。部署上去之后第一次压测,20个并发任务场景下的响应时间从之前的60秒降到了2.8秒,基本等于最慢那个下游接口的响应时间。

更进一步的优化:StructuredTaskScope

单纯换成虚拟线程已经解决了主要问题,但Java 21还提供了一个更适合这种场景的工具:StructuredTaskScope。它能让我们用结构化的方式管理一组并发任务,并且支持在第一个任务失败时立即取消其他任务,或者在所有任务完成后统一收集结果。

我们活动页的场景用StructuredTaskScope改写后,代码逻辑更清晰:

                
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    // 同时提交所有下游调用
    Subtask<ProductInfo> productTask = scope.fork(() -> fetchProductInfo());
    Subtask<InventoryInfo> inventoryTask = scope.fork(() -> fetchInventory());
    Subtask<CouponInfo> couponTask = scope.fork(() -> fetchCoupons());
    Subtask<LogisticsInfo> logisticsTask = scope.fork(() -> fetchLogistics());
    Subtask<RecommendInfo> recommendTask = scope.fork(() -> fetchRecommendations());
    // ... 其他十几个任务

    scope.join();           // 等待所有任务完成
    scope.throwIfFailed();  // 任意一个失败就抛异常

    // 所有结果都成功拿到,组装返回
    return new ActivityPageResult(
        productTask.get(),
        inventoryTask.get(),
        couponTask.get(),
        logisticsTask.get(),
        recommendTask.get()
    );
}
                
            

这种写法有几个好处:

  1. 生命周期自动管理。try-with-resources确保所有子任务在代码块结束时都已完成或被取消,不会出现线程泄漏。
  2. 失败快速传播。任何一个子任务失败,整个scope会立刻感知并取消其他还在跑的任务,避免做无用功。
  3. 可读性高。以前用CompletableFuture编排十几个任务,代码缩进能超过四层,现在扁平化的结构谁都能一眼看懂。

数据库连接池的坑:虚拟线程放大了一直存在的问题

换上虚拟线程之后,性能确实暴涨,但测试环境跑着跑着突然开始报无法获取数据库连接。查了半天才发现,虚拟线程把并发量提上去之后,数据库连接池的默认大小(比如HikariCP默认10个连接)成了新的瓶颈。

以前用10个平台线程的时候,同时最多也就10个数据库查询在跑,连接池刚好够用。现在虚拟线程一口气发出20个甚至更多请求,如果每个请求都要查数据库,连接池瞬间就被打满。

我们的解决方案分了三步:

  • 增大连接池。把HikariCP的maximumPoolSize从10调到30。这一步最简单,但不能无脑加大,得看数据库的承受能力。
  • 引入本地缓存。那些跟用户无关的公共数据(比如商品基础信息、物流模板),在服务启动时预热到Caffeine本地缓存里,减少数据库访问。
  • 拆分数据源。把高频查询的表和低频查询的表分到不同的连接池,避免互相挤占资源。

调整完之后这个错误就没再出现过。但也算学到一课:虚拟线程不会创造新的瓶颈,但会把旧系统里原本被平台线程数量掩盖的短板全部暴露出来。

线程局部变量的兼容问题

另一个在实际迁移中碰到的问题是ThreadLocal。我们项目里用ThreadLocal传递请求上下文(用户ID、traceId之类)。虚拟线程跟平台线程不同,它的生命周期很短,用完就回收,但ThreadLocal的值如果没清理,可能会被下一个复用同一个载体线程的虚拟线程读到。

虚拟线程的载体线程是固定的几个,所以一个虚拟线程在执行过程中可能会切换载体线程。这意味着ThreadLocal的值不一定能在整个虚拟线程生命周期里保持一致。

解决办法是用Java 21同期引入的ScopedValue来替代ThreadLocal

                
// 旧的ThreadLocal方式
private static final ThreadLocal<String> traceIdHolder = new ThreadLocal();

// 新的ScopedValue方式
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

ScopedValue.where(TRACE_ID, currentTraceId).run(() -> {
    // 在这个代码块里,TRACE_ID的值始终是currentTraceId
    // 不管虚拟线程切换到哪个载体线程
    doBusinessLogic();
});
                
            

ScopedValue是绑定到代码块的,不是绑定到线程的,完美适配虚拟线程的调度模型。而且它的语义更明确,不会出现忘记清理的情况。

压测数据对比

上线前我们在同样配置的机器上做了三组对比测试,每组模拟500个并发用户访问活动页:

方案 平均响应时间 P99响应时间 最大并发线程数 CPU使用率
固定线程池(大小10) 34.2秒 61.8秒 10 18%
固定线程池(大小100) 12.5秒 28.3秒 100 47%
虚拟线程(无限制) 1.9秒 2.7秒 约500个虚拟线程 22%
虚拟线程+ScopedValue 1.8秒 2.6秒 约500个虚拟线程 21%

虚拟线程方案不仅在响应时间上碾压了线程池方案,CPU使用率反而更低。原因很简单:线程池方案里平台线程大量时间花在等待IO上,白白占用CPU调度资源。虚拟线程把等待时间从CPU上剥离了出去,真正干活的时候才占用载体线程。

什么时候不适合用虚拟线程

虚拟线程也不是万能药。有两种场景需要警惕:

  • 纯CPU密集型任务。如果任务一直在做计算,没有IO等待,虚拟线程的优势完全发挥不出来。这种情况下用传统线程池更合适,因为虚拟线程的调度反而成了额外开销。
  • 频繁synchronized的代码块。虚拟线程在执行synchronized块时会把底下的载体线程一起锁住,导致其他虚拟线程无法使用这个载体线程。如果大量虚拟线程争同一个锁,性能反而可能比平台线程更差。这种情况需要把synchronized替换为ReentrantLock

好在我们活动页的场景是典型的IO密集型,而且代码里几乎没有显式加锁的地方,所以迁移过程没有触发这些问题。

迁移清单:一步步来,别一把梭

从这次经验出发,我整理了一个虚拟线程迁移的实操步骤,供参考:

  1. 确认运行环境。Java 21以上,Spring Boot 3.2以上。如果是旧版本Spring Boot,也可以手动配置虚拟线程,但用内置开关最省心。
  2. 找出IO密集型任务。把那些大部分时间花在等待数据库、下游接口、消息队列的地方圈出来,这些是迁移收益最大的。
  3. 先把线程池换成虚拟线程执行器。这一步风险最低,改完就有效果。
  4. 排查ThreadLocal。全局搜索项目里的ThreadLocal,逐个评估是否需要迁移到ScopedValue
  5. 压测观察连接池。数据库连接池、Redis连接池、HttpClient连接池,全部检查一遍,该加大的加大。
  6. 处理synchronized。如果性能不如预期,排查是否有synchronized竞争,有的话换成ReentrantLock

写在最后

这次活动页的优化经历让我对虚拟线程有了更具体的认识。它不是什么黑科技,只是在底层把线程调度这件事重新做了一遍,但带来的影响波及了整个并发编程模型。以前写Java并发代码总要掂量线程池大小、任务队列长度、拒绝策略,脑子里得时刻绷着一根资源管理的弦。虚拟线程出现之后,并发编程一下子变简单了——有多少任务就开多少虚拟线程,把精力集中在业务逻辑上。

当然,虚拟线程替代不了分布式架构、缓存策略这些更高层面的设计,但在单服务内部,它把以前需要精心调优才能压出来的性能,变成了一件开箱即用的事情。如果你的项目还在用传统的线程池扛高并发,强烈建议在下一个迭代窗口里试试这条路,改造成本比想象中低得多。

Java 21虚拟线程实战:把电商活动页的并发查询从60秒压到3秒
收藏 (0) 打赏

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

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

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

淘吗网 java Java 21虚拟线程实战:把电商活动页的并发查询从60秒压到3秒 https://www.taomawang.com/server/java/2309.html

常见问题

相关文章

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

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