网关从 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 分钟:
| 配置 | QPS | P95 延迟 | 峰值线程数 | CPU |
|---|---|---|---|---|
| 3.2.5 + Tomcat 200 平台线程 | 1150 | 412 ms | 203 | 58% |
| 3.3.4 + Tomcat 200 平台线程 | 1178 | 405 ms | 204 | 57% |
| 3.3.4 + 虚拟线程 | 1890 | 268 ms | 1.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 次平均):
| 方式 | 启动到可服务 | 类加载耗时 |
|---|---|---|
| 无 CDS | 3620 ms | 1180 ms |
| CDS | 2410 ms | 390 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 这些框架组件用上虚拟线程,但你自己的代码里那些池化、同步、线程本地变量的习惯,得自己改。这部分工作量比加一行配置大得多。