跳到主内容

从 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 异步落库;数据库信号量只作为最后一道本机防线,而不是分布式限流方案。

压测关键指标对比(相同 8C16G 环境)
平台线程峰值 QPS200 工作线程接近打满
820
虚拟线程峰值 QPSDB 与下游成为瓶颈
6100
P99 延迟单位 ms,由 920ms 降低
180
数据库连接峰值被连接池硬上限约束
40

实测数据与瓶颈变化 ​

压测使用相同 8C16G 容器、40 个数据库连接、三个下游模拟服务。平台线程模型在 200 工作线程附近达到约 820 QPS,继续加压后 Tomcat 等待队列变长,P99 快速恶化。虚拟线程模型下,请求不再被平台线程数卡住,峰值到 6100 QPS 左右,此时瓶颈转移到数据库连接获取、Redis CPU 和推荐服务线程池。

这组数据给我的启发是,虚拟线程提升的是“阻塞等待时的并发承载能力”,不是让 SQL、锁竞争和下游容量消失。开启后如果没有监控连接池等待时间、SQL 耗时和信号量拒绝数,团队很容易把故障误判成“虚拟线程不稳定”。

落地步骤与验证节奏
  1. 1
    开启 Tomcat 虚拟线程
    Spring Boot 3.5+ 配置 virtual threads=true
    验证请求线程名与载体线程数
  2. 2
    并行聚合改造
    用 StructuredTaskScope 替换串行 RPC
    设置超时与取消策略
  3. 3
    数据库保护
    HikariCP 水位调参并加信号量
    限制热点 SKU 并发穿透
  4. 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===

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。