去年三月,我把 Spring Boot 3.2 的 Tomcat 线程池换成了虚拟线程
当时看到 Spring Boot 3.2 正式支持虚拟线程(spring.threads.virtual.enabled=true),我连夜把公司一个订单查询服务给改了。那个服务是典型的 IO 密集型,每次请求要调三次外部 API(ERP、WMS、促销系统),平均响应 800ms,Tomcat 默认 200 线程,高峰期线程池直接打满,队列堆积到 2000+。
换虚拟线程那天,配置只改了一行。压测环境用 wrk 打了 60 秒,线程池模式下 QPS 稳定在 3200,虚拟线程直接冲到 6800,吞吐量翻倍。内存方面,虚拟线程不是万能的,每个虚拟线程的栈默认 1MB(实际分配按需,但 overhead 依然存在),我 200 并发下观察堆外内存大概多了 300MB,能接受。于是上线。
诡异的事发生在第三天
凌晨大促流量进来,监控面板上 QPS 到了 9000,一切正常。但是数据库连接池 HikariCP 最大连接数 50,突然出现大量 Connection is not available, request timed out。查了代码,原来是某次查询里用了 Thread.sleep(500) 模拟外部延迟——在虚拟线程里,这 500ms 不会占用 Tomcat 线程,但会占住一个 JDBC 连接。以前 200 个 Tomcat 线程最多同时占 200 个连接(还有超时),现在虚拟线程可以轻松创建几万个,它们同时去拿连接,HikariCP 的 50 个连接根本不够用,队列里全是等连接的虚拟线程。
这不是连接池不够大的问题。我把 maximumPoolSize 调到 200,数据库 CPU 直接飙到 90%,慢查询变多,反而更糟。最后被迫在 DAO 层加了 Semaphore 限制并发数据库操作数量为 40。后来看 Spring 官方文档也提到,虚拟线程 + JDBC 连接池要重新设计隔离级别,但没有任何框架能自动帮你解决。
对比实验:到底什么场景该用虚拟线程
我做了三组对比测试,同一台 8C16G 的机器,同一个 jar 包,分别用三种模式:A(Tomcat 200 线程)、B(虚拟线程)、C(虚拟线程 + 信号量限制 DB 并发)。请求分两种:纯 IO(调外部 API,无数据库)和混合 IO(30% 查 MySQL)。
| 场景 | 模式 | QPS | P99 延迟 | 内存峰值 (堆+堆外) |
|---|---|---|---|---|
| 纯外部 IO 调用 | A | 3100 | 450ms | 1.2GB |
| 纯外部 IO 调用 | B | 6800 | 220ms | 1.5GB |
| 混合 IO(30% DB) | A | 2600 | 520ms | 1.3GB |
| 混合 IO(30% DB) | B | 3600 | 800ms | 1.6GB |
| 混合 IO(30% DB) | C | 3400 | 320ms | 1.7GB |
数据很明显:纯外部 IO 场景下,虚拟线程是碾压级的。一旦涉及数据库连接池这种有上限的共享资源,虚拟线程的高并发反而变成放大器,你会看到连接池饥饿、线程上下文切换开销(虽然虚拟线程切换轻量,但几万个同时在等锁,调度器也忙不过来),以及 GC 压力增大——因为每个虚拟线程的任务都持有部分业务数据,堆里对象存活时间变长,我用 JDK 21 的 ZGC 还是能看到 GC 频率提升 20%。
我最后的方案:不是二选一
我现在保留虚拟线程,但只给那些不访问数据库、不访问 Redis 连接池的接口用(比如单纯的第三方 API 聚合服务)。而所有掺了 DB 访问的业务,我回退到了 Tomcat 线程池,但把线程数从 200 调到了 400,同时给每个查询加了 hystrix 超时(现在用 resilience4j)。这个组合跑了半年,QPS 虽然后没有虚拟线程那个峰值高(混合场景 3400 vs 3600),但 P99 稳定在 300ms 以内,没有出现过一次连接池超时。
如果你也想用虚拟线程,我的建议是:先画清你的请求链路里有哪些“有限资源”。连接池、分布式锁、甚至是 synchronized 代码块,都会成为瓶颈。别被“十万虚拟线程”的宣传冲昏头脑,IO 密集型不等于无脑切换。另外,记得升级到 Spring Boot 3.3.4 以上,早期版本对虚拟线程的 MBean 统计有泄漏问题,我们生产环境试过 3.2.0,跑了俩星期,/jolokia 监控到的线程数只增不减。
最后说句大实话:虚拟线程是 Java 并发的一次进步,但 Spring Boot 默认给你开的自动化配置,是为了让你 happy path 顺畅,不是替你解决架构问题。真正稳的方案,还是像我一样自己写一个 ThreadPoolTaskExecutor Bean,然后对每种接口单独声明 @Async 放在哪个执行器上。这样虽然看起来不酷,但凌晨三点被报警电话叫醒的时候,你会感谢自己当初的保守。