Java 21虚拟线程落地:订单详情聚合接口并发改造与压测复盘

2026-11-01 0 862

订单详情接口原来是这样写的:查库存、查价格、查风控,三个下游服务串行调用。每个下游平均 100 到 200 毫秒,接口 P95 经常飘到 800 毫秒以上。业务方一直以为是数据库慢,后来看链路才发现,大部分时间都耗在等下游 HTTP 响应上。

这次改造没有动业务语义,只做一件事:把串行调用改成并发调用,并用 Java 21 的虚拟线程承接阻塞 IO。文章里会放关键代码、Spring Boot 3 配置、测试方式、压测记录,以及几个实际踩过的坑。

一、改造前的接口形态

简化后的业务模型如下:

public record Stock(String skuId, int available) {}
public record Price(String skuId, long cents) {}
public record Risk(long userId, boolean passed) {}
public record OrderDetail(Stock stock, Price price, Risk risk) {}

public interface StockClient {
    Stock get(String skuId);
}

public interface PriceClient {
    Price get(String skuId);
}

public interface RiskClient {
    Risk check(long userId);
}

原来的聚合逻辑是串行调用:

public OrderDetail query(long userId, String skuId) {
    Stock stock = stockClient.get(skuId);
    Price price = priceClient.get(skuId);
    Risk risk = riskClient.check(userId);
    return new OrderDetail(stock, price, risk);
}

如果三个下游各 150 毫秒,理论耗时就是 450 毫秒,再叠加网络抖动和重试,P95 很容易过 800 毫秒。这个接口的问题不是 CPU 算不过来,而是线程一直在等 IO。

二、为什么用虚拟线程,而不是继续加线程池

传统平台线程池也能做并发,但线程池大小和下游延迟绑得很死。比如 Tomcat 最大线程数 200,业务线程池再固定 200,一旦下游变慢,线程很快被占满,后续请求只能排队。继续加线程数也不是不行,但平台线程的内存和上下文切换成本会逐渐变得难看。

虚拟线程适合这种场景:任务里有大量阻塞 IO,线程大部分时间在等待,CPU 并不是瓶颈。虚拟线程在阻塞时会让出载体线程,JVM 可以用少量载体线程调度大量虚拟线程。注意,它不会让下游变快,也不会提升数据库连接池容量,它解决的是“等待成本”问题。

三、虚拟线程版本的聚合服务

先给一个不依赖 preview 特性的版本,核心是 Executors.newVirtualThreadPerTaskExecutor()。每个任务创建一个虚拟线程,不池化虚拟线程本身。

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;

public class OrderDetailService {

    private final StockClient stockClient;
    private final PriceClient priceClient;
    private final RiskClient riskClient;
    private final ExecutorService ioExecutor;

    public OrderDetailService(StockClient stockClient,
                              PriceClient priceClient,
                              RiskClient riskClient,
                              ExecutorService ioExecutor) {
        this.stockClient = stockClient;
        this.priceClient = priceClient;
        this.riskClient = riskClient;
        this.ioExecutor = ioExecutor;
    }

    public OrderDetail query(long userId, String skuId) throws Exception {
        CompletableFuture<Stock> stockFuture = CompletableFuture.supplyAsync(
                () -> stockClient.get(skuId), ioExecutor);

        CompletableFuture<Price> priceFuture = CompletableFuture.supplyAsync(
                () -> priceClient.get(skuId), ioExecutor);

        CompletableFuture<Risk> riskFuture = CompletableFuture.supplyAsync(
                () -> riskClient.check(userId), ioExecutor);

        try {
            CompletableFuture.allOf(stockFuture, priceFuture, riskFuture)
                    .orTimeout(800, TimeUnit.MILLISECONDS)
                    .join();
        } catch (CompletionException ex) {
            stockFuture.cancel(true);
            priceFuture.cancel(true);
            riskFuture.cancel(true);
            throw new OrderQueryException("订单详情聚合失败", ex.getCause());
        }

        return new OrderDetail(
                stockFuture.join(),
                priceFuture.join(),
                riskFuture.join()
        );
    }
}

这里的超时不是装饰品。虚拟线程只负责调度,不会自动给下游调用加超时。HTTP 客户端本身要设连接超时和读取超时,聚合层再用 orTimeout 兜底。超时后主动 cancel,避免慢请求继续占着下游连接。

public class OrderQueryException extends RuntimeException {
    public OrderQueryException(String message, Throwable cause) {
        super(message, cause);
    }
}

四、Spring Boot 3 里的配置方式

Spring Boot 3.2 之后可以通过配置让请求处理线程使用虚拟线程:

spring.threads.virtual.enabled=true

但这个开关主要影响 Web 容器请求处理线程,不会自动把你业务里自定义的线程池替换掉。聚合服务里的下游调用执行器还是要自己声明。

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@Configuration
public class OrderConfig {

    @Bean(destroyMethod = "close")
    public ExecutorService orderIoExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }

    @Bean
    public OrderDetailService orderDetailService(
            StockClient stockClient,
            PriceClient priceClient,
            RiskClient riskClient,
            @Qualifier("orderIoExecutor") ExecutorService orderIoExecutor) {
        return new OrderDetailService(stockClient, priceClient, riskClient, orderIoExecutor);
    }
}

Controller 不需要特殊处理:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderDetailService orderDetailService;

    public OrderController(OrderDetailService orderDetailService) {
        this.orderDetailService = orderDetailService;
    }

    @GetMapping("/{skuId}")
    public OrderDetail detail(@PathVariable String skuId,
                              @RequestParam long userId) throws Exception {
        return orderDetailService.query(userId, skuId);
    }
}

五、用 StructuredTaskScope 写更直观的并发聚合

JDK 21 里还有一个适合结构化并发的写法:StructuredTaskScope。它把“拆子任务、等结果、失败传播、取消兄弟任务”放在一个作用域里,代码比手动组合 Future 更清楚。JDK 21 下它属于 preview 特性,编译和运行需要加 --enable-preview;如果团队暂时不方便开 preview,上面的 CompletableFuture 版本也能落地。

import java.util.concurrent.StructuredTaskScope;

public OrderDetail queryWithScope(long userId, String skuId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var stock = scope.fork(() -> stockClient.get(skuId));
        var price = scope.fork(() -> priceClient.get(skuId));
        var risk = scope.fork(() -> riskClient.check(userId));

        scope.join();
        scope.throwIfFailed();

        return new OrderDetail(stock.get(), price.get(), risk.get());
    }
}

这段代码的重点不是语法,而是行为:任何一个子任务失败,作用域会取消其他子任务;作用域退出时,子任务不会继续散落在后台。对于订单聚合这种“缺一个数据就不能返回”的场景,这种失败传播很实用。

六、测试:先验证是不是真的并发了

不要只看接口通不通,要验证三个下游是否并发执行。下面用虚拟线程执行器加模拟延迟做一个小测试。三个下游各等 100 毫秒,如果串行执行会接近 300 毫秒,并发执行应该明显低于这个值。

import org.junit.jupiter.api.Test;

import java.util.concurrent.Executors;
import java.util.concurrent.locks.LockSupport;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class OrderDetailServiceTest {

    @Test
    void should_call_downstream_in_parallel() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            OrderDetailService service = new OrderDetailService(
                    skuId -> {
                        LockSupport.parkNanos(100_000_000L);
                        return new Stock(skuId, 3);
                    },
                    skuId -> {
                        LockSupport.parkNanos(100_000_000L);
                        return new Price(skuId, 9900);
                    },
                    userId -> {
                        LockSupport.parkNanos(100_000_000L);
                        return new Risk(userId, true);
                    },
                    executor
            );

            long start = System.nanoTime();
            OrderDetail detail = service.query(1L, "SKU-1");
            long costMs = (System.nanoTime() - start) / 1_000_000;

            assertTrue(costMs < 250, "cost=" + costMs);
            assertEquals(3, detail.stock().available());
            assertEquals(9900, detail.price().cents());
            assertTrue(detail.risk().passed());
        }
    }
}

这个测试不是为了追求一个绝对时间,而是防止后续有人把并发调用又改回串行。CI 里可以适当放宽阈值,避免机器抖动导致误报。

七、一次压测记录

压测环境是 4C8G,JDK 21,Spring Boot 3.2,下游用固定延迟的 mock 服务模拟,每个请求会查库存、价格、风控三个接口,单次下游延迟约 120 毫秒。数据库连接池没有调整,重点只看聚合接口的线程等待模型变化。

  • 旧方案:固定 200 平台线程的线程池,CompletableFuture 并发调用,Future.get 聚合。QPS 大约 420,P95 约 760 毫秒。
  • 新方案:虚拟线程执行器,CompletableFuture 并发调用,800 毫秒超时兜底。QPS 大约 950,P95 约 430 毫秒。
  • CPU 没有打满,下游 mock 服务和连接池先成为瓶颈。虚拟线程不是无限扩容按钮,下游容量、HTTP 连接池、数据库连接池仍然要单独限流。

这组数据只能说明趋势:在这个 IO 等待占比较高的接口上,虚拟线程减少了线程池排队带来的尾延迟。如果下游本身只能扛 300 QPS,虚拟线程把入口 QPS 拉到 900,只会把压力更快地传给下游。所以上线前一定要配合信号量、连接池上限和熔断。

八、几个实际踩过的坑

1. 不要用固定大小线程池去“池化”虚拟线程

比如 Executors.newFixedThreadPool(200, Thread.ofVirtual().factory()) 这种写法看起来用了虚拟线程,实际还是把并发限制在 200,失去了每任务一虚拟线程的意义。直接用 newVirtualThreadPerTaskExecutor(),需要限制的是下游并发,不是虚拟线程数量。

2. JDK 21 下注意 synchronized 钉住载体线程

虚拟线程在 synchronized 块里发生阻塞时,可能钉住载体线程。JDK 21 上如果虚拟线程里有长时间持有 synchronized 再调下游的代码,建议换成 ReentrantLock。新版本 JDK 在这块有改进,但落地时不能假设运行环境一定是最新版。

private final ReentrantLock lock = new ReentrantLock();

public void update() {
    lock.lock();
    try {
        // 这里如果有阻塞 IO,ReentrantLock 比 synchronized 更稳妥
    } finally {
        lock.unlock();
    }
}

3. ThreadLocal 和 MDC 要手动处理

虚拟线程数量可能很多,每个线程都复制一份 ThreadLocal 会增加内存开销。日志 MDC 也不会自动从请求线程传到业务执行器里。可以在提交任务时包装 Supplier,把 traceId 显式带过去,或者用统一的上下文传递工具,不要默认 MDC 会自己飞过去。

4. 虚拟线程不等于下游无限并发

这是最容易出事故的一点。入口并发上来后,库存、价格、风控服务的连接池可能瞬间被打满。可以在客户端层加信号量:

private final Semaphore stockPermits = new Semaphore(64);

public Stock get(String skuId) {
    stockPermits.acquireUninterruptibly();
    try {
        return doGet(skuId);
    } finally {
        stockPermits.release();
    }
}

这里限制的是对单个下游的并发调用数,不是虚拟线程数。具体值要按下游容量和压测结果来定,不要拍脑袋。

5. 超时要分层

HTTP 连接超时、读取超时、聚合层总超时、前端网关超时,要分开设置。只在聚合层加 orTimeout 不够,因为底层连接可能还卡着。比较稳的做法是:客户端超时略小于聚合层超时,聚合层超时略小于网关超时,确保最外层先返回兜底结果或错误,而不是请求一直挂着。

九、这次改造的结论

虚拟线程适合替换“大量阻塞等待”的业务线程池,尤其是聚合查询、HTTP 调用、消息拉取这类 IO 密集型场景。它不能替代限流、连接池、熔断和超时设计。实际落地时,先确认接口是不是在等 IO,再用小流量灰度验证尾延迟,最后把下游并发限制补齐。只改一个执行器就指望接口脱胎换骨,通常会在压测时被下游服务教做人。

如果项目还在 JDK 17,不必为了虚拟线程强行升级;如果已经在 JDK 21 LTS 上,这种订单聚合接口是一个比较温和的试点场景。改造范围小,收益容易观测,出了问题也容易回滚。

Java 21虚拟线程落地:订单详情聚合接口并发改造与压测复盘
收藏 (0) 打赏

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

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

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

淘吗网 java Java 21虚拟线程落地:订单详情聚合接口并发改造与压测复盘 https://www.taomawang.com/server/java/2989.html

常见问题

相关文章

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

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