订单详情接口原来是这样写的:查库存、查价格、查风控,三个下游服务串行调用。每个下游平均 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 上,这种订单聚合接口是一个比较温和的试点场景。改造范围小,收益容易观测,出了问题也容易回滚。

