外观
从 800 QPS 到 6000:Java 25 虚拟线程改造秒杀接口实录
结论先行:虚拟线程不是银弹,数据库保护必须先到位
我在 Java 25 上把秒杀订单详情接口从串行调用改成虚拟线程并行聚合后,压测峰值从约 800 QPS 提升到 6000 QPS,P99 从 920ms 降到 180ms。真正决定上限的不是“创建了多少虚拟线程”,而是 Tomcat 是否切换到虚拟线程处理请求、下游调用是否并行、数据库连接池和信号量是否把并发放大量控制在安全水位。下面这套方案的核心是:Tomcat 每请求一个虚拟线程,业务层用结构化并发并行拉取库存、价格和推荐数据,数据库侧由 HikariCP 最大连接数与热点 SKU 信号量共同兜底。
秒杀接口的典型问题很集中:请求进来后依次查询库存、会员价、优惠券、推荐商品,大部分时间都耗在网络 I/O 等待上。传统平台线程模型下,Tomcat 工作线程被阻塞在 Redis、RPC 或数据库调用上,200 个线程很快被占满;盲目把线程数调到 1000,又会带来上下文切换、内存占用和数据库连接风暴。Java 21 正式发布虚拟线程后,很多团队以为“直接开启就能线性扩容”,但我的实测结论是:虚拟线程只解决阻塞时代价高的问题,不自动解决下游容量问题。
1
Tomcat 请求层
虚拟线程 per request不再占用平台线程高并发连接低成本承载
2
业务聚合层
StructuredTaskScope 并行扇出库存服务价格服务推荐服务
3
保护与数据层
Semaphore 热点资源限流HikariCP 连接池水位MySQL / Redis
为什么选虚拟线程,而不是响应式栈或更大的线程池
我评估过三条路线:继续扩大平台线程池、改成 WebFlux 响应式接口、使用 Java 25 虚拟线程。扩大线程池改动最小,但在 8C16G 容器里,几百个平台线程遇到下游抖动时,请求队列和数据库连接会一起失控;WebFlux 性能模型优秀,可存量代码里大量使用阻塞式 MyBatis、同步 HTTP Client 和 ThreadLocal 上下文,全链路改造风险很高。
虚拟线程方案更适合这次场景:保留熟悉的同步编程模型,让阻塞 I/O 在底层自动卸载,少量载体线程就能承载大量并发请求;配合 Java 25 的结构化并发 API,库存、价格、推荐三个子任务的生命周期、超时和异常传播也更清晰。它的代价同样明确,虚拟线程可以轻松上万,但数据库连接、Redis 连接、下游线程池不会跟着无限扩展,所以必须显式限流。
Tomcat 开启虚拟线程:请求处理模型先切换
示例基于 Spring Boot 3.5 与 Java 25。只在业务代码里 Thread.ofVirtual() 手动起线程还不够,Tomcat 仍可能用平台线程接收和处理请求。关键配置如下:
yaml
server:
tomcat:
threads:
# 虚拟线程模型下不再依赖庞大的平台线程池
max: 200
min-spare: 10
shutdown: graceful
spring:
threads:
virtual:
# 让 Tomcat 请求处理与 @Async 任务默认使用虚拟线程
enabled: true
datasource:
hikari:
jdbc-url: jdbc:mysql://127.0.0.1:3306/seckill?useServerPrepStmts=true
username: root
password: root
# 数据库硬保护:连接数按 MySQL 实例容量评估,不是越大越好
maximum-pool-size: 40
minimum-idle: 10
connection-timeout: 1000
validation-timeout: 1000可以加一个临时过滤器确认线程模型:
```java` @Component public class ThreadModelFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
Thread current = Thread.currentThread();
// 压测时观察日志,应看到 virtual=true
System.out.printf("thread=%s, virtual=%s%n",
current.getName(), current.isVirtual());
chain.doFilter(request, response);
}
}
这里有个容易误判的点:虚拟线程名通常是类似 `""` 或 `VirtualThread[#123]/runnable` 的形式,不要靠线程名前缀判断,应该使用 `Thread.isVirtual()`。
## 并行聚合库存、价格与推荐数据
原接口是三段串行调用:库存 35ms、价格 45ms、推荐 80ms,再加上订单基础信息,总耗时接近 170ms;下游一旦抖动,P99 会被最慢的推荐调用拖到 900ms 以上。改造后三个 I/O 任务并行,整体耗时接近最慢的一项,而不是三者相加。
```java
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
import org.springframework.stereotype.Service;
@Service
public class SeckillOrderAggregateService {
private final InventoryClient inventoryClient;
private final PriceClient priceClient;
private final RecommendClient recommendClient;
public SeckillOrderAggregateService(InventoryClient inventoryClient,
PriceClient priceClient,
RecommendClient recommendClient) {
this.inventoryClient = inventoryClient;
this.priceClient = recommendClient(priceClient);
this.recommendClient = recommendClient;
}
public SeckillOrderDetail aggregate(Long userId, Long skuId) {
// 整体超时必须短于 Tomcat 与网关超时,避免请求堆积
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.ShutdownOnFailure::new,
policy -> policy.withTimeout(Duration.ofMillis(200)))) {
Subtask<InventoryInfo> inventory =
scope.fork(() -> inventoryClient.getInventory(skuId));
Subtask<PriceInfo> price =
scope.fork(() -> priceClient.getMemberPrice(userId, skuId));
// 推荐数据允许降级时,不建议和核心链路使用同一个失败策略
Subtask<RecommendInfo> recommend =
scope.fork(() -> recommendClient.listRecommend(skuId));
scope.join();
scope.throwIfFailed(ex -> new BizException("订单聚合失败", ex));
return new SeckillOrderDetail(
inventory.get(),
price.get(),
recommend.get()
);
} catch (BizException e) {
throw e;
} catch (Exception e) {
// 结构化并发超时会取消尚未完成的子任务
throw new BizException("秒杀订单查询超时", e);
}
}
private PriceClient recommendClient(PriceClient priceClient) {
return priceClient;
}
}推荐属于非核心信息,生产版本我没有让它失败就拖垮整个订单接口,而是拆成独立的降级 scope:库存和价格任何一个失败立即失败;推荐超时则返回默认列表。这个取舍很重要,秒杀详情页首先要保证可下单信息准确,推荐位可以空,但不能因为推荐服务抖动放大核心链路故障。
连接池水位与信号量:别让虚拟线程打爆数据库
虚拟线程最容易踩的坑就在这里。请求线程从 200 扩到几千后,如果每个请求都同步获取数据库连接,而 HikariCP 只有 40 个连接,多余请求会在 getConnection() 处排队。队列本身能削峰,但如果下游 SQL 变慢,1 秒连接获取超时会集中爆发,接口线程继续堆积,最终表现为整个服务“活着但不可用”。
我的做法是两层保护:第一层由 HikariCP 控制数据库真实并发,maximumPoolSize=40 对应压测环境 MySQL 可承受的活跃查询数;第二层在热点 SKU 查询和库存扣减入口加信号量,避免缓存失效时大量请求穿透到同一条数据库记录。
java
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Component;
@Component
public class HotSkuInventoryGuard {
// 单实例热点扣减并发上限,按数据库行锁能力与压测结果设置
private static final int MAX_HOT_SKU_CONCURRENCY = 20;
private final Semaphore semaphore = new Semaphore(MAX_HOT_SKU_CONCURRENCY);
public <T> T execute(Long skuId, java.util.function.Supplier<T> action) {
boolean acquired = false;
try {
// 必须使用 tryAcquire,不能无限阻塞
acquired = semaphore.tryAcquire(50, TimeUnit.MILLISECONDS);
if (!acquired) {
// 快速失败比排队等待更适合秒杀场景
throw new BizException("当前下单人数过多,请稍后重试");
}
return action.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BizException("请求被中断");
} finally {
if (acquired) {
semaphore.release();
}
}
}
}Mapper 侧还要避免慢查询占住连接:
java
@Mapper
public interface InventoryMapper {
@Select("""
SELECT sku_id, available_stock, version
FROM seckill_inventory
WHERE sku_id = #{skuId}
""")
InventoryPO selectBySkuId(Long skuId);
@Update("""
UPDATE seckill_inventory
SET available_stock = available_stock - #{num},
version = version + 1
WHERE sku_id = #{skuId}
AND available_stock >= #{num}
AND version = #{version}
""")
int deductStock(@Param("skuId") Long skuId,
@Param("num") int num,
@Param("version") int version);
}注意单机 Semaphore 只能保护单 JVM,多实例部署时总并发上限是“实例数 × 单实例许可数”。如果库存扣减是强一致热点,我更倾向于 Redis 预扣库存加 Lua 脚本、MQ 异步落库;数据库信号量只作为最后一道本机防线,而不是分布式限流方案。
平台线程峰值 QPS200 工作线程接近打满
820
虚拟线程峰值 QPSDB 与下游成为瓶颈
6100
P99 延迟单位 ms,由 920ms 降低
180
数据库连接峰值被连接池硬上限约束
40
实测数据与瓶颈变化
压测使用相同 8C16G 容器、40 个数据库连接、三个下游模拟服务。平台线程模型在 200 工作线程附近达到约 820 QPS,继续加压后 Tomcat 等待队列变长,P99 快速恶化。虚拟线程模型下,请求不再被平台线程数卡住,峰值到 6100 QPS 左右,此时瓶颈转移到数据库连接获取、Redis CPU 和推荐服务线程池。
这组数据给我的启发是,虚拟线程提升的是“阻塞等待时的并发承载能力”,不是让 SQL、锁竞争和下游容量消失。开启后如果没有监控连接池等待时间、SQL 耗时和信号量拒绝数,团队很容易把故障误判成“虚拟线程不稳定”。
- 1开启 Tomcat 虚拟线程Spring Boot 3.5+ 配置 virtual threads=true验证请求线程名与载体线程数
- 2并行聚合改造用 StructuredTaskScope 替换串行 RPC设置超时与取消策略
- 3数据库保护HikariCP 水位调参并加信号量限制热点 SKU 并发穿透
- 4压测与观测阶梯加压观察连接数、队列与 P99避免只看平均耗时
避坑总结:这些问题我建议上线前逐项检查
第一,不要只开业务虚拟线程而忘记 Tomcat。现象是压测时 QPS 仍受 server.tomcat.threads.max 限制,线程 dump 里请求处理线程仍是平台线程;修正方式是开启 spring.threads.virtual.enabled=true,并用过滤器或 Actuator 指标确认。
第二,不要把 synchronized 包在长时间 I/O 或远程调用外面。虚拟线程进入 synchronized 阻塞时可能产生 pinning,载体线程被占住后吞吐会异常下降。这个坑我排查了整整一下午,最后通过线程 dump 中的 MONITOR 与 carrier 占用关系才定位。修正方式是把 synchronized 改成 ReentrantLock,或缩小同步范围,绝不在锁内访问数据库和 HTTP 接口。
第三,连接池不是越大越好。数据库连接数超过 CPU 与磁盘能承受的范围后,行锁竞争和上下文切换会让 SQL 平均耗时上升。应固定 HikariCP 上限,打开 hikaricp.connections.pending、hikaricp.connections.usage 等指标,结合慢查询与 P99 调整。
第四,所有限流入口都要使用带超时的 tryAcquire。无限等待信号量或连接,只会把拒绝从入口转移到队列,最终拖垮服务。秒杀场景更适合快速失败、排队页或稍后重试,而不是让用户请求在服务端悬挂几十秒。
第五,ThreadLocal 缓存要控制对象大小。虚拟线程让每个请求拥有独立线程变得很便宜,但如果 ThreadLocal 里存放大对象、用户权限快照或追踪上下文,请求量上来后会增加堆压力。追踪信息建议继续使用 Micrometer Tracing 这类显式上下文传播方案。
这次改造的价值不只是吞吐数字,更在于保留同步代码风格的同时,把容量边界设计清楚:虚拟线程负责高效等待,结构化并发负责并行与取消,连接池和信号量负责保护数据库。本文首发于开发者日记,后续我会继续记录 Java 25 在高并发接口中的 pinning 定位、池化组件适配和可观测性细节。 ===CONTENT===
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。