Administrator
发布于 2024-10-05 / 3649 阅读
96

Spring Boot 3.3 新特性与虚拟线程深度集成

网关从 3.2 升到 3.3,我把虚拟线程打开了

国庆前最后一周,我把内部 API 网关从 Spring Boot 3.2.5 升到了 3.3.4。这次升级的动机不是安全补丁,而是 3.3 里那几个终于能用的东西:虚拟线程的正式支持、CDS 启动优化,还有服务连接抽象。

网关是个典型的 IO 密集型服务——一次请求要在内部调 3 到 7 个下游,自己几乎不做什么计算。之前压测的瓶颈一直卡在线程池上,刚好适合试虚拟线程。

虚拟线程:一行配置,但别急着高兴

3.3 里的开启方式就一行:

# application.yml
spring:
  threads:
    virtual:
      enabled: true

这个开关会同时影响四处:Tomcat 的请求处理线程、@Async 的任务执行器、任务调度器,以及 Spring MVC 的异步请求。不需要自己写 Executor bean,也不需要配 ProtocolHandler

验证是否生效,我加了个 Controller 打日志:

@GetMapping("/probe")
public String probe() {
    Thread t = Thread.currentThread();
    return STR."\{t.getName()} virtual=\{t.isVirtual()} daemon=\{t.isDaemon()}";
}
$ curl -s localhost:8080/probe
VirtualThread[#92]/runnable@ForkJoinPool-1-worker-1 virtual=true daemon=true

确认生效后跑压测。用同一套 JMeter 脚本,4C8G 容器,压 3 分钟:

配置QPSP95 延迟峰值线程数CPU
3.2.5 + Tomcat 200 平台线程1150412 ms20358%
3.3.4 + Tomcat 200 平台线程1178405 ms20457%
3.3.4 + 虚拟线程1890268 ms1.2 万83%

QPS 涨了 60%,但没有宣传里那种十倍的效果。原因很快查到了:瓶颈从线程池转移到了连接池

第一个坑:HttpClient 连接池还是老尺寸

虚拟线程让并发请求数从 200 涨到 1.2 万,但我们用的 Apache HttpClient5 连接池配置是老参数:

// 升级前的配置,maxConnPerRoute 默认只有 5
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200);
cm.setDefaultMaxPerRoute(5);   // 每路由只有 5 条连接

5 条连接意味着 1.2 万个虚拟线程全堵在等连接上。JFR 里 jdk.VirtualThreadPinned 没多少,但 jdk.JavaMonitorEnter 事件巨多,堆栈全指向连接池的 lease()

改成这样之后 QPS 直接跳到 3100:

@Bean
CloseableHttpClient httpClient() {
    var cm = PoolingHttpClientConnectionManagerBuilder.create()
            .setMaxConnPerRoute(500)
            .setMaxConnTotal(2000)
            .setDefaultConnectionConfig(ConnectionConfig.custom()
                    .setConnectTimeout(Timeout.ofSeconds(2))
                    .setSocketTimeout(Timeout.ofSeconds(10))
                    .build())
            .build();
    return HttpClients.custom()
            .setConnectionManager(cm)
            .evictIdleConnections(TimeValue.ofSeconds(30))
            .build();
}

虚拟线程几乎所有教程都在讲"线程不再稀缺",但没人提醒你:下游的连接数才是新瓶颈。开虚拟线程之前,先把连接池、数据库池、Redis 池的大小盘一遍。

第二个坑:pinning

网关的鉴权过滤器里有一段用了 synchronized 包裹的本地缓存更新。JDK 21 里虚拟线程在 synchronized 块里会被钉在载体线程上(pinning),如果这个块里发生了阻塞 IO,载体线程就白占着。

用 JFR 抓:

$ java -XX:StartFlightRecording:filename=vt.jfr,settings=profile,duration=180s -jar gateway.jar
$ jfr summary vt.jfr | grep -i pinned
 jdk.VirtualThreadPinned    184203 events
$ jfr print --events jdk.VirtualThreadPinned vt.jfr | grep -A2 "Stack Trace" | head -30

18 万次钉住,堆栈指向 AuthFilter.doFilter。改成 ReentrantLock 后降到 0:

// 之前
public Token get(String key) {
    synchronized (this) {
        return cache.computeIfAbsent(key, this::loadFromRedis);
    }
}

// 之后
private final ReentrantLock lock = new ReentrantLock();
public Token get(String key) {
    Token t = cache.get(key);
    if (t != null) return t;
    lock.lock();
    try {
        return cache.computeIfAbsent(key, this::loadFromRedis);
    } finally {
        lock.unlock();
    }
}

顺带一提,JEP 444 说 JDK 21 的 pinning 问题会在后续版本解决(JEP 491 已经在 JDK 23 里作为预览出现了),但在那之前,synchronized 里别放 IO 是硬规矩。

第三个坑:别给虚拟线程池配队列

有同事习惯性地给 @Async 配线程池参数,配成这样是反效果:

// 错误:虚拟线程不需要池化,也别加队列
spring:
  task:
    execution:
      pool:
        max-size: 500
        queue-capacity: 10000

虚拟线程的用法是一个任务一个线程,池化和队列都是给昂贵资源设计的。正确做法是直接用 Executors.newVirtualThreadPerTaskExecutor(),或者 3.3 里 spring.threads.virtual.enabled=true 就行,别再去配 pool。

CDS:启动从 3.6 秒到 2.4 秒

3.3 加了类数据共享的开箱支持。原理是把类加载和链接的结果存成一个归档文件,下次启动直接内存映射,省掉重复的解析工作。

构建归档,Spring Boot 3.3 的插件提供了目标:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <cds>
            <enabled>true</enabled>
        </cds>
    </configuration>
</plugin>

或者手动跑一遍,原理是一样的:先让应用把类加载完但不启动 Web 容器,然后 dump 归档。

$ java -Dspring.context.exit-on-refresh=true \
       -XX:SharedArchiveFile=app.cds -jar gateway.jar
$ java -XX:SharedArchiveFile=app.cds -Xshare:auto -jar gateway.jar

实测数据(4C8G 容器,冷启动,取 5 次平均):

方式启动到可服务类加载耗时
无 CDS3620 ms1180 ms
CDS2410 ms390 ms

省下 1.2 秒,其中 790ms 是类加载。这个数字对我们的价值在于 K8s 的就绪探针和滚动发布:8 个 Pod 滚动更新,每个省 1.2 秒,加上逐个启动本身就有间隔,整体发布时间从 52 秒缩到 34 秒。

有个限制要记住:CDS 归档和 classpath 强绑定。改了任何一个依赖,归档就得重建。我们在 CI 里加了缓存 key,用依赖树的哈希做判断,哈希变了才重新生成归档。

服务连接抽象:本地终于不用手起环境了

这个特性 3.1 就有,3.3 完善了不少。我们的集成测试以前要起四个 Docker 容器,靠一个 docker-compose-test.yml 和一堆 @DynamicPropertySource 手工对齐端口,改一次配置就得同步改测试代码。

现在的做法是写一个 compose 文件,加上测试依赖:

# compose.yaml,放在 src/test/ 下
services:
  redis:
    image: 'redis:7.4-alpine'
    ports: ['6379']
  postgres:
    image: 'postgres:16-alpine'
    environment:
      - 'POSTGRES_PASSWORD=test'
      - 'POSTGRES_DB=testdb'
    ports: ['5432']
@SpringBootTest
@Testcontainers(disabledWithoutDocker = true)
class CacheIntegrationTest {   // 不用写任何连接配置
    @Autowired StringRedisTemplate redis;
}

Spring Boot 会读 compose 文件,把容器的端口自动映射成 spring.data.redis.host/port 这些属性,测试代码里一行连接配置都不用写。更关键的是 3.3 支持在 @ConfigurationProperties 里直接注入连接信息,写自定义服务连接也方便。

唯一的不满:它在 CI 里启动容器需要时间,我们把集成测试的本地模式改成用 Testcontainers 复用容器,一轮跑下来从 6 分 20 秒降到 3 分 10 秒。

小结

这次升级最值钱的是虚拟线程带来的 QPS 提升,但前提是把连接池一起调了。CDS 属于低成本高收益,改几行配置拿到 1.2 秒。服务连接抽象解决的是开发体验问题,短期看不出收益,长期省人力。

还有一点体会:Spring Boot 3.3 对虚拟线程的支持是"框架层面"的,它解决的是让 Tomcat、@Async 这些框架组件用上虚拟线程,但你自己的代码里那些池化、同步、线程本地变量的习惯,得自己改。这部分工作量比加一行配置大得多。

参考